diff --git a/packages/gitbook/src/app/utils.test.ts b/packages/gitbook/src/app/utils.test.ts index e2c55b7ae..f6cf1554b 100644 --- a/packages/gitbook/src/app/utils.test.ts +++ b/packages/gitbook/src/app/utils.test.ts @@ -1,18 +1,58 @@ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; import jwt from 'jsonwebtoken'; +import { + type WorkStore, + workAsyncStorage, +} from 'next/dist/server/app-render/work-async-storage.external'; import rison from 'rison'; import * as realContext from '@/lib/context'; mock.module('server-only', () => ({})); -// Only the lookup is stubbed: mocking the whole module would leak into the other test files, +// Only the lookups are stubbed: mocking the whole module would leak into the other test files, // as `mock.module` replaces it for the entire test process. mock.module('@/lib/context', () => ({ ...realContext, - getBaseContext: (input: unknown) => input, + getBaseContext: (input: BaseContextInput) => ({ + ...input, + dataFetcher: createFakeDataFetcher(input), + }), fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data, - fetchSiteScopeContextByURLLookup: async (_baseContext: unknown, data: unknown) => data, + // Just enough of a site scope for the real merge with a space context. + fetchSiteScopeContextByURLLookup: async ( + _baseContext: unknown, + { revision, ...data }: { siteSpace: string | undefined; revision: string } + ) => ({ + ...data, + revisionId: revision, + siteSpace: { id: data.siteSpace, space: {} }, + linker: 'site-scope-linker', + }), })); + +type BaseContextInput = { siteURLData: { apiToken: string }; pprScope?: string }; + +/** Records the token and scope it was created with, so the composed context can be asserted on. */ +function createFakeDataFetcher(input: BaseContextInput) { + const fetchedWith = { + pprScope: input.pprScope, + tokenScope: getTokenScope(input.siteURLData.apiToken), + }; + + return { + fetchedWith, + getSpace: async (params: { spaceId: string }) => ({ + data: { id: params.spaceId, organization: 'org-id', revision: 'space-revision-id' }, + }), + getRevision: async (params: { revisionId: string }) => ({ + data: { id: params.revisionId, pages: [], fetchedWith }, + }), + }; +} + +function getTokenScope(token: string): string | undefined { + return (jwt.decode(token) as { claims?: { scope?: string } } | null)?.claims?.scope; +} // Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is // stubbed at the network boundary rather than with `mock.module`, which would replace // `@/lib/ppr-token` for the entire test process and break its own test file. @@ -250,18 +290,67 @@ describe('getPPRStaticSiteScopeContext', () => { expect(context).toMatchObject({ apiToken, - revision: 'ppr-revision-id', + revisionId: 'ppr-revision-id', }); }); }); describe('getPPRStaticSiteContext', () => { - it('uses the supplied API token without resolving published content again', async () => { - const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body'); + /** Run `fn` the way Next runs a request: under a work store of its own. */ + function inRequest(fn: () => Promise): Promise { + return workAsyncStorage.run({} as WorkStore, fn); + } - expect(context).toMatchObject({ - apiToken, - revision: 'ppr-revision-id', + type FetchedWith = { pprScope: string; tokenScope: string }; + /** Shape produced by the stubs above. */ + type ComposedContext = { + apiToken: string; + siteSpace: { id: string }; + revisionId: string; + revision: { fetchedWith: FetchedWith }; + dataFetcher: { fetchedWith: FetchedWith }; + }; + + it('resolves the header from its own params', async () => { + const { context } = await getPPRStaticSiteContext( + await getPPRHeaderRouteParams(routeParams), + 'header' + ); + + expect(context).toMatchObject({ siteSpace: 'default-site-space-id' }); + expect(getTokenScope((context as unknown as { apiToken: string }).apiToken)).toBe('site'); + }); + + it('composes the site scope of the layout, the revision of the TOC and its own fetcher', async () => { + await inRequest(async () => { + const { context } = await getPPRStaticSiteContext( + await getPPRPageRouteParams(routeParams), + 'body' + ); + const composed = context as unknown as ComposedContext; + + // Same call as the layout: site token, visited location rather than the defaults. + expect(getTokenScope(composed.apiToken)).toBe('site'); + expect(composed.siteSpace.id).toBe('page-site-space-id'); + // Same fetch as the table of contents. + expect(composed.revisionId).toBe('ppr-revision-id'); + expect(composed.revision.fetchedWith).toEqual({ + pprScope: 'toc', + tokenScope: 'revision', + }); + // Anything else the body reads goes through its own token. + expect(composed.dataFetcher.fetchedWith).toEqual({ + pprScope: 'body', + tokenScope: 'page', + }); }); }); + + it('fails outside a PPR request', async () => { + const pageParams = await inRequest(() => getPPRPageRouteParams(routeParams)); + + await expect(getPPRStaticSiteContext(pageParams, 'body')).rejects.toThrow( + 'outside a PPR request' + ); + }); }); diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index dc33e82fa..618368535 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -14,10 +14,13 @@ import { type SiteURLData, fetchSiteContextByURLLookup, fetchSiteScopeContextByURLLookup, + fetchSpaceContextByIds, getBaseContext, + mergeSiteScopeAndSpaceContext, } from '@/lib/context'; import { getDynamicCustomizationSettings } from '@/lib/customization'; import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token'; +import { createServerContextValue } from '@/lib/server-context'; export type RouteParamMode = 'url-host' | 'url'; @@ -181,12 +184,32 @@ export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData } } +/** + * Raw params of the PPR request, shared with the cached components. They read them from here rather + * than from their props, which would put the site and revision tokens in their cache key. + */ +const pprRequestParams = createServerContextValue('ppr-request-params'); + +function getPPRRequestParams(): PPRRouteLayoutParams { + const params = pprRequestParams.read(); + if (!params) { + throw new Error( + 'PPR component rendered outside a PPR request: the route entry must project its params with getPPRRouteParams first' + ); + } + return params; +} + export function getPPRRouteParams(params: PPRRouteParams): RouteParams; export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams; /** * Project PPR route params for the current page, without PPR-only cache inputs. + * Every route entry goes through here before rendering a cached component, so this is also where + * the raw params are shared with them. */ export function getPPRRouteParams(params: PPRRouteLayoutParams): RouteLayoutParams { + pprRequestParams.provide(params); + const { revisionId, revalidationId, pprDefaults: _, ...routeParams } = params; const siteURLData = getSiteURLDataFromParams(params); @@ -309,9 +332,43 @@ export async function getPPRVisitorAuthClaims( /** * Get the static context for a PPR component. The scope partitions the cache entries and scopes * the tags they emit, so the component and its data are revalidated as one unit. + * + * The context is composed the way the shell is: the site scope of the layout (site token, visited + * location) and the space and revision of the table of contents (revision token). Only the data + * fetcher is the component's own, so what it reads beyond that is narrowed to its scope. The nested + * fetches run inside the cache fill, so the entry is tagged with the data it renders. */ export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScope: PPRCacheScope) { - return getStaticSiteContext(params, { pprScope }); + if (pprScope === 'header') { + // The header renders the default variant, so its site scope is parsed for another location + // than the layout's. Its site fetch is still shared with it: same token, same scope. + return getStaticSiteContext(params, { pprScope }); + } + + const requestParams = getPPRRequestParams(); + const [siteParams, tableOfContentsParams] = await Promise.all([ + getPPRSiteRouteParams(requestParams), + getPPRTableOfContentsRouteParams(requestParams), + ]); + const { baseContext, decoded } = getStaticBaseContext(params, { pprScope }); + const tableOfContents = getStaticBaseContext(tableOfContentsParams, { pprScope: 'toc' }); + + const [{ context: siteScope }, spaceContext] = await Promise.all([ + getStaticSiteScopeContext(siteParams, { pprScope: 'header' }), + fetchSpaceContextByIds(tableOfContents.baseContext, { + space: tableOfContents.siteURLData.space, + shareKey: tableOfContents.siteURLData.shareKey, + changeRequest: tableOfContents.siteURLData.changeRequest, + revision: tableOfContents.siteURLData.revision, + }), + ]); + + return { + context: mergeSiteScopeAndSpaceContext(siteScope, spaceContext, { + dataFetcher: baseContext.dataFetcher, + }), + visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + }; } /** diff --git a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx index b0bce1dbe..fbd773e32 100644 --- a/packages/gitbook/src/components/SitePage/PPRSitePage.tsx +++ b/packages/gitbook/src/components/SitePage/PPRSitePage.tsx @@ -24,6 +24,11 @@ import { // cache key of every data fetcher, so the tags they emit are scoped too and propagate up to the // entry here — making the component and the data it read a single revalidatable unit. No explicit // `cacheTag` is needed, and adding one would only duplicate a propagated tag. +// +// Only the params of the component are in its cache key. The site scope of the layout and the +// revision of the table of contents reach the fill through the server context, so a component of the +// `toc` or `body` scope renders the same site and revision as the shell. The header is the exception: +// it renders the default variant, so it resolves its own. /** * Render the header from cache without carrying a request-scoped data fetcher into the cache key. diff --git a/packages/gitbook/src/lib/context.test.ts b/packages/gitbook/src/lib/context.test.ts index a56b402ca..7587d03e9 100644 --- a/packages/gitbook/src/lib/context.test.ts +++ b/packages/gitbook/src/lib/context.test.ts @@ -11,7 +11,9 @@ import { type GitBookBaseContext, fetchSiteContextByIds, fetchSiteScopeContextByIds, + fetchSpaceContextByIds, filterSectionsAndGroupsWithHiddenSiteSpaces, + mergeSiteScopeAndSpaceContext, } from './context'; import { createLinker } from './links'; @@ -150,3 +152,30 @@ describe('fetchSiteContextByIds', () => { expect(context.revisionId).toBe('space-revision-id'); }); }); + +describe('mergeSiteScopeAndSpaceContext', () => { + it('keeps the supplied data fetcher over the ones of both contexts', async () => { + const siteBaseContext = getBaseContext(); + const spaceBaseContext = getBaseContext(); + const dataFetcher = getBaseContext().dataFetcher; + const [siteScope, spaceContext] = await Promise.all([ + fetchSiteScopeContextByIds(siteBaseContext, { ...ids, revision: 'revision-id' }), + fetchSpaceContextByIds(spaceBaseContext, { + space: 'space-id', + shareKey: undefined, + changeRequest: undefined, + revision: 'revision-id', + }), + ]); + + const context = mergeSiteScopeAndSpaceContext(siteScope, spaceContext, { dataFetcher }); + + expect(context.dataFetcher).toBe(dataFetcher); + expect(context.dataFetcher).not.toBe(siteBaseContext.dataFetcher); + expect(context.dataFetcher).not.toBe(spaceBaseContext.dataFetcher); + expect(context.site.id).toBe('site-id'); + expect(context.revision).toBe(revision as unknown as typeof context.revision); + expect(context.revisionId).toBe('revision-id'); + expect(context.locale).toBe(TranslationLanguage.Fr); + }); +}); diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 0c35df36a..c96ade5fc 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -540,9 +540,26 @@ export async function fetchSiteContextByIds( fetchSpaceContextByIds(baseContext, ids), ]); + return mergeSiteScopeAndSpaceContext(siteScope, spaceContext, { + dataFetcher: baseContext.dataFetcher, + }); +} + +/** + * Merge a site scope with the space context it is rendered with. The data fetcher is explicit: the + * two may have been resolved with different tokens, and the merged context must fetch through + * neither of them by accident. + */ +export function mergeSiteScopeAndSpaceContext( + siteScope: Omit, + spaceContext: GitBookSpaceContext, + options: { dataFetcher: GitBookDataFetcher } +): GitBookSiteContext { return { ...spaceContext, ...siteScope, + dataFetcher: options.dataFetcher, + revisionId: spaceContext.revisionId, locale: siteScope.siteSpace.space.language ?? spaceContext.locale, linker: getLinkerForSiteSpace( siteScope.linker, diff --git a/packages/gitbook/src/lib/ppr-token.test.ts b/packages/gitbook/src/lib/ppr-token.test.ts index 4875f2602..7406cdd4e 100644 --- a/packages/gitbook/src/lib/ppr-token.test.ts +++ b/packages/gitbook/src/lib/ppr-token.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { + type WorkStore, + workAsyncStorage, +} from 'next/dist/server/app-render/work-async-storage.external'; import { PPR_TOKEN_SCOPE, exchangePPRToken } from './ppr-token'; import { DataFetcherError } from '@/lib/data/errors'; @@ -68,8 +72,6 @@ describe('exchangePPRToken', () => { expect((error as DataFetcherError).code).toBe(502); }); - // Request-level memoization is `React.cache`, which is inert outside a render scope and so - // cannot be exercised here. What is asserted instead: each scope is a distinct exchange. it('exchanges each scope separately', async () => { const calls = mockFetch(() => Response.json({ token: 'exchanged' })); @@ -83,4 +85,17 @@ describe('exchangePPRToken', () => { { token: 'shared-token', scope: 'revision' }, ]); }); + + // The memo lives on the server context, keyed on the work store of the request, so it is also + // what a cache fill sees. + it('reuses an exchange within a request', async () => { + const calls = mockFetch(() => Response.json({ token: 'exchanged' })); + + await workAsyncStorage.run({} as WorkStore, async () => { + await exchangePPRToken('shared-token', 'site'); + await exchangePPRToken('shared-token', 'site'); + }); + + expect(calls).toHaveLength(1); + }); }); diff --git a/packages/gitbook/src/lib/ppr-token.ts b/packages/gitbook/src/lib/ppr-token.ts index 854ad134c..fb6a67ba5 100644 --- a/packages/gitbook/src/lib/ppr-token.ts +++ b/packages/gitbook/src/lib/ppr-token.ts @@ -1,8 +1,8 @@ import 'server-only'; -import { cache } from '@/lib/cache'; import type { PPRCacheScope } from '@/lib/cache-tags'; import { DataFetcherError } from '@/lib/data/errors'; import { GITBOOK_EXCHANGE_TOKEN_URL } from '@/lib/env'; +import { serverCache } from '@/lib/server-context'; import { trace } from '@/lib/tracing'; /** @@ -23,9 +23,10 @@ export const PPR_TOKEN_SCOPE: Record = { * narrowed to `scope`. The API only understands the latter, and narrowing is what lets components * sharing a scope share a cache entry: the token is part of their cache key. * - * Memoized per request, but never persisted — an exchanged token is a credential. + * Memoized on the server context, so the cache fills reuse the exchanges of the route entries, but + * never persisted — an exchanged token is a credential. */ -export const exchangePPRToken = cache( +export const exchangePPRToken = serverCache( async (token: string, scope: PPRTokenScope): Promise => { return trace(`exchangePPRToken(${scope})`, async () => { const response = await fetchExchangedToken(token, scope); diff --git a/packages/gitbook/src/lib/server-context.test.ts b/packages/gitbook/src/lib/server-context.test.ts new file mode 100644 index 000000000..8a0475e54 --- /dev/null +++ b/packages/gitbook/src/lib/server-context.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, mock } from 'bun:test'; +import { + type WorkStore, + workAsyncStorage, +} from 'next/dist/server/app-render/work-async-storage.external'; + +mock.module('server-only', () => ({})); + +const { createServerContextValue, serverCache } = await import('./server-context'); + +/** Run `fn` the way Next runs a request: under a work store of its own. */ +function inRequest(fn: () => T): T { + return workAsyncStorage.run({} as WorkStore, fn); +} + +describe('createServerContextValue', () => { + it('provides a value to the rest of the request, first one wins', () => { + const value = createServerContextValue('first-wins'); + + inRequest(() => { + expect(value.read()).toBeUndefined(); + value.provide('first'); + value.provide('second'); + expect(value.read()).toBe('first'); + }); + }); + + it('isolates requests from each other', () => { + const value = createServerContextValue('isolated'); + + inRequest(() => value.provide('a')); + inRequest(() => expect(value.read()).toBeUndefined()); + }); + + it('is inert outside a request', () => { + const value = createServerContextValue('inert'); + + value.provide('a'); + expect(value.read()).toBeUndefined(); + }); +}); + +describe('serverCache', () => { + it('memoizes calls within a request, non-primitive arguments included', () => { + const fn = mock((input: { id: string }) => ({ id: input.id })); + const cached = serverCache(fn); + + inRequest(() => { + const first = cached({ id: 'a' }); + expect(cached({ id: 'a' })).toBe(first); + expect(cached({ id: 'b' })).not.toBe(first); + }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('does not share results across requests', () => { + const fn = mock(() => ({})); + const cached = serverCache(fn); + + const first = inRequest(() => cached()); + const second = inRequest(() => cached()); + expect(second).not.toBe(first); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('does not memoize outside a request', () => { + const fn = mock(() => ({})); + const cached = serverCache(fn); + + expect(cached()).not.toBe(cached()); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('keeps functions with the same source apart', () => { + const a = serverCache(() => 'a'); + const b = serverCache(() => 'b'); + + inRequest(() => { + expect(a()).toBe('a'); + expect(b()).toBe('b'); + }); + }); +}); diff --git a/packages/gitbook/src/lib/server-context.ts b/packages/gitbook/src/lib/server-context.ts new file mode 100644 index 000000000..e02ba97c6 --- /dev/null +++ b/packages/gitbook/src/lib/server-context.ts @@ -0,0 +1,79 @@ +import 'server-only'; +import { workAsyncStorage } from 'next/dist/server/app-render/work-async-storage.external'; +import { identify } from 'object-identity'; + +/** + * Request-scoped values that survive the `'use cache'` boundary. + * + * Next runs a cached function in a clean `AsyncLocalStorage` snapshot: the request store and React's + * per-request cache are gone inside it, and the arguments are the only thing left. The one object it + * restores is the `WorkStore` of the request, so anything keyed on it is visible both to the route + * entries and to the cache fills they trigger, without being part of a cache key. + */ +const stores = new WeakMap>(); + +let cachedFunctions = 0; + +/** + * Store of the current request, or undefined outside a Next render (tests, scripts). + */ +function getServerContextStore(): Map | undefined { + const workStore = workAsyncStorage.getStore(); + if (!workStore) { + return undefined; + } + + let store = stores.get(workStore); + if (!store) { + store = new Map(); + stores.set(workStore, store); + } + return store; +} + +/** + * Declare a value provided once per request. `provide` keeps the first value, so callers that may run + * in any order (a layout, a page, their metadata) can all provide it. + */ +export function createServerContextValue(name: string) { + const key = `value:${name}`; + + return { + provide(value: T): void { + const store = getServerContextStore(); + if (store && !store.has(key)) { + store.set(key, value); + } + }, + read(): T | undefined { + return getServerContextStore()?.get(key) as T | undefined; + }, + }; +} + +/** + * Equivalent to `cache` from `@/lib/cache`, but memoized on the server context: a call made inside a + * cache fill reuses the result of the same call made by the layout. Outside a request it is inert. + */ +export function serverCache( + fn: (...args: Args) => Return +): (...args: Args) => Return { + // `identify` hashes a function by its source, which two distinct functions can share. + const prefix = `cache:${cachedFunctions++}:`; + + return (...args: Args) => { + const store = getServerContextStore(); + if (!store) { + return fn(...args); + } + + const key = prefix + identify(args); + if (store.has(key)) { + return store.get(key) as Return; + } + + const result = fn(...args); + store.set(key, result); + return result; + }; +} diff --git a/packages/gitbook/tests/preload-bun.ts b/packages/gitbook/tests/preload-bun.ts index 3fc846ae2..0b01f9fa8 100644 --- a/packages/gitbook/tests/preload-bun.ts +++ b/packages/gitbook/tests/preload-bun.ts @@ -1,4 +1,5 @@ import { mock } from 'bun:test'; +import { AsyncLocalStorage } from 'node:async_hooks'; /** * Mock the `server-only` module to avoid errors when running tests as it doesn't work well in Bun @@ -6,3 +7,9 @@ import { mock } from 'bun:test'; mock.module('server-only', () => { return {}; }); + +/** + * Next reads `AsyncLocalStorage` from the global it sets up in its node environment, and falls back + * to a fake that throws on `run`. Tests running code under a work store need the real one. + */ +globalThis.AsyncLocalStorage = AsyncLocalStorage; diff --git a/patches/next@16.3.3.patch b/patches/next@16.3.3.patch index 2ea2e63a3..608170758 100644 --- a/patches/next@16.3.3.patch +++ b/patches/next@16.3.3.patch @@ -29,3 +29,57 @@ index d9c2e5e801c91b2edbc0101c56af14c3dda6c42e..0b65253e39cecc67c76e5d69105c56c0 } function getOutlinedModel(response, reference, parentObject, key, map) { var path = reference.split(":"); +diff --git a/dist/esm/server/app-render/work-async-storage-instance.js b/dist/esm/server/app-render/work-async-storage-instance.js +index ff0edc4aa1a2674eb995361a6c3497d9dbe56ee4..b678b282dddfe00f2a6e9c4b0f3b7c92f80a4e25 100644 +--- a/dist/esm/server/app-render/work-async-storage-instance.js ++++ b/dist/esm/server/app-render/work-async-storage-instance.js +@@ -1,4 +1,20 @@ + import { createAsyncLocalStorage } from './async-local-storage'; +-export const workAsyncStorageInstance = createAsyncLocalStorage(); ++// GitBook patch: share one instance per process. OpenNext inlines a copy of this module into its ++// handler while route chunks require another from node_modules. `'use cache'` fills restore the ++// work store through this storage, so app code reading it inside a fill needs every copy to agree. ++// Only a real storage is shared: a fake created before the global is set must not be pinned. ++const sharedInstanceKey = Symbol.for('next.workAsyncStorageInstance'); ++function getWorkAsyncStorageInstance() { ++ const existing = globalThis[sharedInstanceKey]; ++ if (existing) { ++ return existing; ++ } ++ const instance = createAsyncLocalStorage(); ++ if (typeof globalThis.AsyncLocalStorage !== 'undefined') { ++ globalThis[sharedInstanceKey] = instance; ++ } ++ return instance; ++} ++export const workAsyncStorageInstance = getWorkAsyncStorageInstance(); + + //# sourceMappingURL=work-async-storage-instance.js.map +diff --git a/dist/server/app-render/work-async-storage-instance.js b/dist/server/app-render/work-async-storage-instance.js +index 1ea9c61d574f2ed2753b0ed94887adaa6c1ecb92..1edb9c339ea55b930a10c5a446f33ce8e541eb7b 100644 +--- a/dist/server/app-render/work-async-storage-instance.js ++++ b/dist/server/app-render/work-async-storage-instance.js +@@ -9,6 +9,22 @@ Object.defineProperty(exports, "workAsyncStorageInstance", { + } + }); + const _asynclocalstorage = require("./async-local-storage"); +-const workAsyncStorageInstance = (0, _asynclocalstorage.createAsyncLocalStorage)(); ++// GitBook patch: share one instance per process. OpenNext inlines a copy of this module into its ++// handler while route chunks require another from node_modules. `'use cache'` fills restore the ++// work store through this storage, so app code reading it inside a fill needs every copy to agree. ++// Only a real storage is shared: a fake created before the global is set must not be pinned. ++const sharedInstanceKey = Symbol.for("next.workAsyncStorageInstance"); ++function getWorkAsyncStorageInstance() { ++ const existing = globalThis[sharedInstanceKey]; ++ if (existing) { ++ return existing; ++ } ++ const instance = (0, _asynclocalstorage.createAsyncLocalStorage)(); ++ if (typeof globalThis.AsyncLocalStorage !== "undefined") { ++ globalThis[sharedInstanceKey] = instance; ++ } ++ return instance; ++} ++const workAsyncStorageInstance = getWorkAsyncStorageInstance(); + + //# sourceMappingURL=work-async-storage-instance.js.map