Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: skew protection #1658

Merged
merged 8 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .github/workflows/deploy-docs-bundle-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,32 +63,50 @@ jobs:
filePath: preview.txt
comment_tag: pr_preview

analyze:
needs: ignore
if: needs.ignore.outputs.continue == 1
runs-on: ubuntu-latest
permissions: write-all # required for the pr-preview comment
steps:
# set the ref to a specific branch so that the deployment is scoped to that branch (instead of a headless ref)
- uses: actions/checkout@v4
with:
fetch-depth: 2 # used for turbo-ignore
ref: ${{ github.event.pull_request.head.ref || github.ref_name || github.ref }}

- uses: ./.github/actions/install

- name: Build
id: deploy
run: pnpm vercel-scripts deploy app.buildwithfern.com --token=${{ secrets.VERCEL_TOKEN }} --skip-deploy=true
env:
ANALYZE: 1

- name: Analyze bundle
if: steps.deploy.outputs.deployment_url
run: pnpm --package=nextjs-bundle-analysis dlx report

- name: Upload analysis
if: steps.deploy.outputs.deployment_url
uses: actions/upload-artifact@v4
with:
name: analyze
path: packages/ui/docs-bundle/.next/analyze/

- name: Download base branch bundle stats
uses: dawidd6/action-download-artifact@v2
if: steps.deploy.outputs.deployment_url && success() && github.event.number
if: success() && github.event.number
with:
workflow: nextjs_bundle_analysis.yml
branch: ${{ github.event.pull_request.base.ref }}
path: packages/ui/docs-bundle/.next/analyze/base

# https://infrequently.org/2021/03/the-performance-inequality-gap/
- name: Compare with base branch bundle
if: steps.deploy.outputs.deployment_url && success() && github.event.number
if: success() && github.event.number
run: ls -laR packages/ui/docs-bundle/.next/analyze/base && pnpm --package=nextjs-bundle-analysis dlx compare

- name: Comment PR Bundle Analysis
if: steps.deploy.outputs.deployment_url && github.event_name == 'pull_request'
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v2
with:
filePath: packages/ui/docs-bundle/.next/analyze/__bundle_analysis_comment.txt
Expand Down
18 changes: 13 additions & 5 deletions clis/vercel-scripts/src/utils/deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,16 @@ export class VercelDeployer {
});
}

private async deploy(project: { id: string; name: string }): Promise<Vercel.GetDeploymentResponse> {
let command = `pnpx vercel deploy --yes --prebuilt --token=${this.token} --archive=tgz`;
private async deploy(
project: { id: string; name: string },
opts?: { prebuilt?: boolean },
): Promise<Vercel.GetDeploymentResponse> {
let command = `pnpx vercel deploy --yes --token=${this.token} --archive=tgz`;

if (opts?.prebuilt) {
command += " --prebuilt";
}

if (this.environment === "production") {
command += " --prod --skip-domain";
}
Expand Down Expand Up @@ -121,13 +129,13 @@ export class VercelDeployer {

this.pull(prj);

this.build(prj);

if (skipDeploy) {
// build-only
this.build(prj);
return;
}

const deployment = await this.deploy(prj);
const deployment = await this.deploy(prj, { prebuilt: false });

await this.promote(deployment);

Expand Down
6 changes: 2 additions & 4 deletions packages/ui/app/src/atoms/launchdarkly.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { atom, useAtomValue, useSetAtom } from "jotai";
import * as LDClient from "launchdarkly-js-client-sdk";
import { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
import { useApiRoute } from "../hooks/useApiRoute";
import { useApiRouteSWR } from "../hooks/useApiRouteSWR";

// NOTE do not export this file in any index.ts file so that it can be properly tree-shaken
// otherwise we risk importing launchdarkly-js-client-sdk in all of our bundles
Expand Down Expand Up @@ -85,9 +84,8 @@ export const useLaunchDarklyFlag = (flag: string, equals = true, not = false): b

// since useSWR is cached globally, we can use this hook in multiple components without worrying about multiple requests
function useInitLaunchDarklyClient() {
const route = useApiRoute("/api/fern-docs/integrations/launchdarkly");
const setInfo = useSetAtom(SET_LAUNCH_DARKLY_INFO_ATOM);
useSWR(route, (key): Promise<LaunchDarklyInfo> => fetch(key).then((res) => res.json()), {
useApiRouteSWR<LaunchDarklyInfo>("/api/fern-docs/integrations/launchdarkly", {
onSuccess(data) {
void setInfo(data);
},
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const UnmemoizedHeader = forwardRef<HTMLDivElement, PropsWithChildren<Header.Pro
const colors = useColors();
const openSearchDialog = useOpenSearchDialog();
const isSearchBoxMounted = useAtomValue(SEARCH_BOX_MOUNTED);
const [searchService] = useSearchConfig();
const searchService = useSearchConfig();
const showSearchBar = useAtomValue(SEARCHBAR_PLACEMENT_ATOM) === "HEADER";

const navbarLinksSection = (
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/hooks/useApiRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Getter, useAtomValue } from "jotai";
import urlJoin from "url-join";
import { BASEPATH_ATOM, TRAILING_SLASH_ATOM } from "../atoms";

type FernDocsApiRoute = `/api/fern-docs/${string}`;
export type FernDocsApiRoute = `/api/fern-docs/${string}`;

interface Options {
includeTrailingSlash?: boolean;
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/app/src/hooks/useApiRouteSWR.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import useSWR, { Fetcher, SWRConfiguration, SWRResponse } from "swr";
import useSWRImmutable from "swr/immutable";
import { withSkewProtection } from "../util/withSkewProtection";
import { FernDocsApiRoute, useApiRoute } from "./useApiRoute";

interface Options<T> extends SWRConfiguration<T, Error, Fetcher<T>> {
disabled?: boolean;
request?: RequestInit;
}

function createFetcher<T>(init?: RequestInit): (url: string) => Promise<T> {
return (url: string): Promise<T> => fetch(withSkewProtection(url), init).then((r) => r.json());
}

export function useApiRouteSWR<T>(route: FernDocsApiRoute, options?: Options<T>): SWRResponse<T> {
const key = useApiRoute(route);
return useSWR(options?.disabled ? null : key, createFetcher(options?.request), options);
}

export function useApiRouteSWRImmutable<T>(route: FernDocsApiRoute, options?: Options<T>): SWRResponse<T> {
const key = useApiRoute(route);
return useSWRImmutable(options?.disabled ? null : key, createFetcher(options?.request), options);
}
3 changes: 2 additions & 1 deletion packages/ui/app/src/hooks/useInterceptNextDataHref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { parseRelativeUrl } from "next/dist/shared/lib/router/utils/parse-relati
import { removeTrailingSlash } from "next/dist/shared/lib/router/utils/remove-trailing-slash";
import { Router } from "next/router";
import { useEffect } from "react";
import { withSkewProtection } from "../util/withSkewProtection";

/**
* This function is adapted from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/page-loader.ts
Expand All @@ -25,7 +26,7 @@ function createPageLoaderGetDataHref(basePath: string | undefined): PageLoader["

const getHrefForSlug = (path: string) => {
const dataRoute = getAssetPathFromRoute(removeTrailingSlash(addLocale(path, locale)), ".json");
return addPathPrefix(`/_next/data/${buildId}${dataRoute}${search}`, basePath);
return addPathPrefix(`/_next/data/${buildId}${dataRoute}${withSkewProtection(search)}`, basePath);
};

const toRet = getHrefForSlug(
Expand Down
9 changes: 3 additions & 6 deletions packages/ui/app/src/playground/hooks/useEndpointContext.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { createEndpointContext, type ApiDefinition, type EndpointContext } from "@fern-api/fdr-sdk/api-definition";
import type * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { useMemo } from "react";
import useSWRImmutable from "swr/immutable";
import { useWriteApiDefinitionAtom } from "../../atoms";
import { useApiRoute } from "../../hooks/useApiRoute";
import { useApiRouteSWRImmutable } from "../../hooks/useApiRouteSWR";

interface LoadableEndpointContext {
context: EndpointContext | undefined;
isLoading: boolean;
}

const fetcher = (url: string): Promise<ApiDefinition> => fetch(url).then((res) => res.json());

/**
* This hook leverages SWR to fetch and cache the definition for this endpoint.
* It should be refactored to store the resulting endpoint in a global state, so that it can be shared between components.
*/
export function useEndpointContext(node: FernNavigation.EndpointNode | undefined): LoadableEndpointContext {
const route = useApiRoute(
const { data: apiDefinition, isLoading } = useApiRouteSWRImmutable<ApiDefinition>(
`/api/fern-docs/api-definition/${encodeURIComponent(node?.apiDefinitionId ?? "")}/endpoint/${encodeURIComponent(node?.endpointId ?? "")}`,
{ disabled: node == null },
);
const { data: apiDefinition, isLoading } = useSWRImmutable(node != null ? route : null, fetcher);
const context = useMemo(() => createEndpointContext(node, apiDefinition), [node, apiDefinition]);
useWriteApiDefinitionAtom(apiDefinition);

Expand Down
9 changes: 3 additions & 6 deletions packages/ui/app/src/playground/hooks/useWebSocketContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,23 @@ import { createWebSocketContext } from "@fern-api/fdr-sdk/api-definition";
import type * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { useSetAtom } from "jotai";
import { useEffect, useMemo } from "react";
import useSWRImmutable from "swr/immutable";
import { WRITE_API_DEFINITION_ATOM } from "../../atoms";
import { useApiRoute } from "../../hooks/useApiRoute";
import { useApiRouteSWRImmutable } from "../../hooks/useApiRouteSWR";

interface LoadableWebSocketContext {
context: WebSocketContext | undefined;
isLoading: boolean;
}

const fetcher = (url: string): Promise<ApiDefinition> => fetch(url).then((res) => res.json());

/**
* This hook leverages SWR to fetch and cache the definition for this endpoint.
* It should be refactored to store the resulting endpoint in a global state, so that it can be shared between components.
*/
export function useWebSocketContext(node: FernNavigation.WebSocketNode): LoadableWebSocketContext {
const route = useApiRoute(
const { data: apiDefinition, isLoading } = useApiRouteSWRImmutable<ApiDefinition>(
`/api/fern-docs/api-definition/${encodeURIComponent(node.apiDefinitionId)}/websocket/${encodeURIComponent(node.webSocketId)}`,
{ disabled: node == null },
);
const { data: apiDefinition, isLoading } = useSWRImmutable(route, fetcher);
const context = useMemo(() => createWebSocketContext(node, apiDefinition), [node, apiDefinition]);

const set = useSetAtom(WRITE_API_DEFINITION_ATOM);
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/app/src/search/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const SearchDialog = (): ReactElement | null => {
const isSearchDialogOpen = useIsSearchDialogOpen();
const { isAiChatbotEnabledInPreview } = useFeatureFlags();

const [config] = useSearchConfig();
const config = useSearchConfig();

if (!config.isAvailable) {
if (isSearchDialogOpen) {
Expand Down Expand Up @@ -73,7 +73,7 @@ export const SearchSidebar: React.FC<PropsWithChildren<SearchSidebar.Props>> = (
[activeVersion, sidebar],
);

const [searchConfig] = useSearchConfig();
const searchConfig = useSearchConfig();
const algoliaSearchClient = useAlgoliaSearchClient();
const inputRef = useRef<HTMLInputElement>(null);
const isMobileScreen = useAtomValue(IS_MOBILE_SCREEN_ATOM);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useSearchConfig } from "../../services/useSearchService";

export function useAlgoliaSearchClient(): [SearchClient, index: string] | undefined {
const currentVersionId = useAtomValue(CURRENT_VERSION_ID_ATOM);
const [searchConfig] = useSearchConfig();
const searchConfig = useSearchConfig();

return useMemo(() => {
if (!searchConfig.isAvailable) {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/search/cohere/CohereChatButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useSearchConfig } from "../../services/useSearchService";
import { CohereChatbotModal } from "./CohereChatbotModal";

export function CohereChatButton(): ReactElement | null {
const [config] = useSearchConfig();
const config = useSearchConfig();
const [enabled, setEnabled] = useAtom(COHERE_ASK_AI);

// Close the dialog when the route changes
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/app/src/search/inkeep/useInkeepSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const useInkeepSettings = ():
}
| undefined => {
const theme = useTheme();
const [searchConfig] = useSearchConfig();
const searchConfig = useSearchConfig();

if (!searchConfig.isAvailable || searchConfig.inkeep == null) {
return;
Expand Down
17 changes: 4 additions & 13 deletions packages/ui/app/src/services/useApiKeyInjectionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import type { APIKeyInjectionConfig } from "@fern-ui/fern-docs-auth";
import useSWR from "swr";
import { useApiRoute } from "../hooks/useApiRoute";
import { useApiRouteSWR } from "../hooks/useApiRouteSWR";

const DEFAULT = { enabled: false as const };

export function useApiKeyInjectionConfig(): APIKeyInjectionConfig {
const key = useApiRoute("/api/fern-docs/auth/api-key-injection");
const { data } = useSWR<APIKeyInjectionConfig>(
key,
async (url: string) => {
const res = await fetch(url);
return res.json();
},
{
refreshInterval: (latestData) => (latestData?.enabled ? 1000 * 60 * 5 : 0), // refresh every 5 minutes
},
);
const { data } = useApiRouteSWR<APIKeyInjectionConfig>("/api/fern-docs/auth/api-key-injection", {
refreshInterval: (latestData) => (latestData?.enabled ? 1000 * 60 * 5 : 0), // refresh every 5 minutes
});
return data ?? DEFAULT;
}
18 changes: 5 additions & 13 deletions packages/ui/app/src/services/useSearchService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
/* eslint-disable react-hooks/rules-of-hooks */
import type { SearchConfig } from "@fern-ui/search-utils";
import { useCallback } from "react";
import useSWR, { mutate } from "swr";
import { noop } from "ts-essentials";
import { useIsLocalPreview } from "../contexts/local-preview";
import { useApiRoute } from "../hooks/useApiRoute";
import { useApiRouteSWR } from "../hooks/useApiRouteSWR";

export type SearchCredentials = {
appId: string;
Expand All @@ -25,22 +22,17 @@ export declare namespace SearchService {

export type SearchService = SearchService.Available | SearchService.Unavailable;

export function useSearchConfig(): [SearchConfig, refresh: () => void] {
export function useSearchConfig(): SearchConfig {
const isLocalPreview = useIsLocalPreview();

if (isLocalPreview) {
return [{ isAvailable: false }, noop];
return { isAvailable: false };
}

const key = useApiRoute("/api/fern-docs/search");
const { data } = useSWR<SearchConfig>(key, (url: string) => fetch(url).then((res) => res.json()), {
const { data } = useApiRouteSWR<SearchConfig>("/api/fern-docs/search", {
refreshInterval: 1000 * 60 * 60 * 2, // 2 hours
revalidateOnFocus: false,
});

const refresh = useCallback(() => {
void mutate(key);
}, [key]);

return [data ?? { isAvailable: false }, refresh];
return data ?? { isAvailable: false };
}
2 changes: 1 addition & 1 deletion packages/ui/app/src/sidebar/SidebarSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const SidebarSearchBar: React.FC<SidebarSearchBar.Props> = memo(function
hideKeyboardShortcutHint,
}) {
const openSearchDialog = useOpenSearchDialog();
const [searchService] = useSearchConfig();
const searchService = useSearchConfig();

return (
<button
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/app/src/util/withSkewProtection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const deploymentId = process.env.NEXT_DEPLOYMENT_ID;
export function withSkewProtection(url: string): string {
if (!deploymentId) {
return url;
}

if (url.includes("?")) {
return `${url}&dpl=${deploymentId}`;
} else {
return `${url}?dpl=${deploymentId}`;
}
}
Loading
Loading