Skip to content

Commit

Permalink
fix: skew protection (#1658)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Oct 12, 2024
1 parent c79b76a commit 9b693b7
Show file tree
Hide file tree
Showing 18 changed files with 109 additions and 66 deletions.
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

0 comments on commit 9b693b7

Please sign in to comment.