From cb1bf73212411f233ce47fdea5cb03832060342a Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Fri, 12 Jun 2026 15:53:03 +0100 Subject: [PATCH 1/8] feat(cli-build): thread projectId through build options to vite entries plugin --- .../src/actions/build/__tests__/getViteConfig.test.ts | 1 + .../@sanity/cli-build/src/actions/build/getViteConfig.ts | 9 ++++++++- .../actions/build/vite/plugin-sanity-build-entries.ts | 1 + .../@sanity/cli/src/actions/build/buildStaticFiles.ts | 3 +++ packages/@sanity/cli/src/actions/build/buildStudio.ts | 1 + 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts b/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts index 9a7e23fbe6..dc15e99753 100644 --- a/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts +++ b/packages/@sanity/cli-build/src/actions/build/__tests__/getViteConfig.test.ts @@ -323,6 +323,7 @@ describe('#getViteConfig', () => { basePath: '/', cwd: mockTestCwd, isApp: undefined, + projectId: undefined, }) // Vendor entries become additional Rolldown inputs of the single build. diff --git a/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts b/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts index 6e26a438d5..08aed04473 100644 --- a/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts +++ b/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts @@ -79,6 +79,12 @@ interface ViteOptions { */ outputDir?: string + /** + * Sanity project ID, threaded through to the build-entries plugin so the + * early-auth probe script can be injected into production HTML. + */ + projectId?: string + /** * Schema extraction configuration */ @@ -108,6 +114,7 @@ export async function getViteConfig(options: ViteOptions): Promise minify, mode, outputDir, + projectId, reactCompiler, schemaExtraction, server, @@ -160,7 +167,7 @@ export async function getViteConfig(options: ViteOptions): Promise ...(reactCompiler ? [babel({presets: [reactCompilerPreset(reactCompiler)]})] : []), sanityFaviconsPlugin({customFaviconsPath, defaultFaviconsPath, staticUrlPath: staticPath}), sanityRuntimeRewritePlugin(), - sanityBuildEntries({autoUpdates, basePath, cwd, isApp}), + sanityBuildEntries({autoUpdates, basePath, cwd, isApp, projectId}), // Add schema extraction when enabled ...(schemaExtraction?.enabled ? [ diff --git a/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts b/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts index f91e07f45f..d861fbe127 100644 --- a/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts +++ b/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts @@ -32,6 +32,7 @@ export function sanityBuildEntries(options: { basePath: string cwd: string isApp?: boolean + projectId?: string }): Plugin { const {autoUpdates, basePath, cwd, isApp} = options diff --git a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts index d51dde32ec..d162b4980e 100644 --- a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts +++ b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts @@ -40,6 +40,7 @@ interface StaticBuildOptions { isApp?: boolean minify?: boolean profile?: boolean + projectId?: string reactCompiler?: ReactCompilerConfig schemaExtraction?: CliConfig['schemaExtraction'] sourceMap?: boolean @@ -63,6 +64,7 @@ export async function buildStaticFiles( isApp, minify = true, outputDir, + projectId, reactCompiler, schemaExtraction, sourceMap = false, @@ -97,6 +99,7 @@ export async function buildStaticFiles( minify, mode, outputDir, + projectId, reactCompiler, schemaExtraction, sourceMap, diff --git a/packages/@sanity/cli/src/actions/build/buildStudio.ts b/packages/@sanity/cli/src/actions/build/buildStudio.ts index f127ad67ae..08aab9d90d 100644 --- a/packages/@sanity/cli/src/actions/build/buildStudio.ts +++ b/packages/@sanity/cli/src/actions/build/buildStudio.ts @@ -293,6 +293,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise cwd: workDir, minify, outputDir, + projectId, reactCompiler, schemaExtraction, sourceMap, From 8bebcbf6103b231eed8a60a4c209a48e466a91ae Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Fri, 12 Jun 2026 15:59:07 +0100 Subject: [PATCH 2/8] feat(cli-build): inject early-auth probe script into production build html --- .../decorateIndexWithEarlyAuthScript.test.ts | 253 ++++++++++++++++++ .../build/decorateIndexWithEarlyAuthScript.ts | 55 ++++ .../build/vite/plugin-sanity-build-entries.ts | 32 ++- 3 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts create mode 100644 packages/@sanity/cli-build/src/actions/build/decorateIndexWithEarlyAuthScript.ts diff --git a/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts new file mode 100644 index 0000000000..79bcda29fa --- /dev/null +++ b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts @@ -0,0 +1,253 @@ +import {JSDOM} from 'jsdom' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {decorateIndexWithEarlyAuthScript} from '../decorateIndexWithEarlyAuthScript' + +const mockIsStaging = vi.hoisted(() => vi.fn<() => boolean>()) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isStaging: mockIsStaging, + } +}) + +const sampleHtml = '' +const projectId = 'testproject123' + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('decorateIndexWithEarlyAuthScript', () => { + test('returns template unchanged when projectId is undefined', () => { + mockIsStaging.mockReturnValue(false) + expect(decorateIndexWithEarlyAuthScript(sampleHtml, undefined)).toBe(sampleHtml) + }) + + test('returns template unchanged when projectId is empty string', () => { + mockIsStaging.mockReturnValue(false) + expect(decorateIndexWithEarlyAuthScript(sampleHtml, '')).toBe(sampleHtml) + }) + + test('returns template unchanged when projectId sanitizes to empty', () => { + mockIsStaging.mockReturnValue(false) + expect(decorateIndexWithEarlyAuthScript(sampleHtml, '!!!')).toBe(sampleHtml) + }) + + test('returns template unchanged when template is empty string', () => { + mockIsStaging.mockReturnValue(false) + expect(decorateIndexWithEarlyAuthScript('', projectId)).toBe('') + }) + + test('uses api.sanity.io when isStaging() returns false', () => { + mockIsStaging.mockReturnValue(false) + const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + expect(result).toContain('api.sanity.io') + expect(result).not.toContain('api.sanity.work') + }) + + test('uses api.sanity.io when SANITY_INTERNAL_ENV is unset (default)', () => { + mockIsStaging.mockReturnValue(false) + const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + expect(result).toContain('api.sanity.io') + }) + + test('uses api.sanity.work when isStaging() returns true', () => { + mockIsStaging.mockReturnValue(true) + const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + expect(result).toContain('api.sanity.work') + expect(result).not.toContain('api.sanity.io') + }) + + test('injected script is the first child of ', () => { + mockIsStaging.mockReturnValue(false) + const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + const earlyAuthIdx = result.indexOf('__sanityEarlyAuthInit') + const appScriptIdx = result.indexOf('src="app.js"') + expect(earlyAuthIdx).toBeGreaterThan(-1) + expect(earlyAuthIdx).toBeLessThan(appScriptIdx) + }) + + test('preserves attributes', () => { + mockIsStaging.mockReturnValue(false) + const html = '' + const result = decorateIndexWithEarlyAuthScript(html, projectId) + expect(result).toContain('\n`) +} diff --git a/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts b/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts index d861fbe127..bcfa6b7d26 100644 --- a/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts +++ b/packages/@sanity/cli-build/src/actions/build/vite/plugin-sanity-build-entries.ts @@ -3,6 +3,7 @@ import {type ChunkMetadata, type Plugin} from 'vite' import {type AutoUpdatesBuildConfig} from '../autoUpdates.js' import {createVendorImportMapFromBundle} from '../createVendorImportMapFromBundle.js' import {decorateIndexWithBridgeScript} from '../decorateIndexWithBridgeScript.js' +import {decorateIndexWithEarlyAuthScript} from '../decorateIndexWithEarlyAuthScript.js' import {decorateIndexWithStagingScript} from '../decorateIndexWithStagingScript.js' import {renderDocument} from '../renderDocument.js' @@ -34,7 +35,7 @@ export function sanityBuildEntries(options: { isApp?: boolean projectId?: string }): Plugin { - const {autoUpdates, basePath, cwd, isApp} = options + const {autoUpdates, basePath, cwd, isApp, projectId} = options return { apply: 'build', @@ -104,20 +105,23 @@ export function sanityBuildEntries(options: { this.emitFile({ fileName: 'index.html', - source: decorateIndexWithStagingScript( - decorateIndexWithBridgeScript( - await renderDocument({ - autoUpdatesCssUrls: autoUpdates?.cssUrls, - importMap, - isApp, - props: { - basePath, - css, - entryPath, - }, - studioRootPath: cwd, - }), + source: decorateIndexWithEarlyAuthScript( + decorateIndexWithStagingScript( + decorateIndexWithBridgeScript( + await renderDocument({ + autoUpdatesCssUrls: autoUpdates?.cssUrls, + importMap, + isApp, + props: { + basePath, + css, + entryPath, + }, + studioRootPath: cwd, + }), + ), ), + projectId, ), type: 'asset', }) From 19fd0423196dbf54010fbb669ec36a5186e682c3 Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:16:54 +0000 Subject: [PATCH 3/8] chore: update auto-generated changeset for PR #1294 --- .changeset/pr-1294.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pr-1294.md diff --git a/.changeset/pr-1294.md b/.changeset/pr-1294.md new file mode 100644 index 0000000000..aab6d87dc8 --- /dev/null +++ b/.changeset/pr-1294.md @@ -0,0 +1,7 @@ + +--- +'@sanity/cli-build': minor +'@sanity/cli': minor +--- + +feat(cli-build): inject early-auth probe script during studio build \ No newline at end of file From 1f5e77a36900effe106a269bc6f0c69f6141f166 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Fri, 12 Jun 2026 17:04:03 +0100 Subject: [PATCH 4/8] refactor(cli-build): author early-auth probe as real module transformed at build time --- .../decorateIndexWithEarlyAuthScript.test.ts | 250 +++++++++++++++--- .../build/decorateIndexWithEarlyAuthScript.ts | 114 +++++--- .../src/actions/build/earlyAuthProbeScript.ts | 94 +++++++ .../build/vite/plugin-sanity-build-entries.ts | 2 +- 4 files changed, 388 insertions(+), 72 deletions(-) create mode 100644 packages/@sanity/cli-build/src/actions/build/earlyAuthProbeScript.ts diff --git a/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts index 79bcda29fa..027308de62 100644 --- a/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts +++ b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts @@ -1,7 +1,8 @@ import {JSDOM} from 'jsdom' -import {afterEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {decorateIndexWithEarlyAuthScript} from '../decorateIndexWithEarlyAuthScript' +import {__sanityEarlyAuthInit} from '../earlyAuthProbeScript' const mockIsStaging = vi.hoisted(() => vi.fn<() => boolean>()) @@ -21,100 +22,112 @@ afterEach(() => { }) describe('decorateIndexWithEarlyAuthScript', () => { - test('returns template unchanged when projectId is undefined', () => { + test('returns template unchanged when projectId is undefined', async () => { mockIsStaging.mockReturnValue(false) - expect(decorateIndexWithEarlyAuthScript(sampleHtml, undefined)).toBe(sampleHtml) + expect(await decorateIndexWithEarlyAuthScript(sampleHtml, undefined)).toBe(sampleHtml) }) - test('returns template unchanged when projectId is empty string', () => { + test('returns template unchanged when projectId is empty string', async () => { mockIsStaging.mockReturnValue(false) - expect(decorateIndexWithEarlyAuthScript(sampleHtml, '')).toBe(sampleHtml) + expect(await decorateIndexWithEarlyAuthScript(sampleHtml, '')).toBe(sampleHtml) }) - test('returns template unchanged when projectId sanitizes to empty', () => { + test('skips (returns template unchanged) when projectId fails the validity check', async () => { mockIsStaging.mockReturnValue(false) - expect(decorateIndexWithEarlyAuthScript(sampleHtml, '!!!')).toBe(sampleHtml) + expect(await decorateIndexWithEarlyAuthScript(sampleHtml, '!!!')).toBe(sampleHtml) + expect(await decorateIndexWithEarlyAuthScript(sampleHtml, 'my-proj!')).toBe(sampleHtml) }) - test('returns template unchanged when template is empty string', () => { + test('returns template unchanged when template is empty string', async () => { mockIsStaging.mockReturnValue(false) - expect(decorateIndexWithEarlyAuthScript('', projectId)).toBe('') + expect(await decorateIndexWithEarlyAuthScript('', projectId)).toBe('') }) - test('uses api.sanity.io when isStaging() returns false', () => { + test('uses api.sanity.io when isStaging() returns false', async () => { mockIsStaging.mockReturnValue(false) - const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + const result = await decorateIndexWithEarlyAuthScript(sampleHtml, projectId) expect(result).toContain('api.sanity.io') expect(result).not.toContain('api.sanity.work') }) - test('uses api.sanity.io when SANITY_INTERNAL_ENV is unset (default)', () => { + test('uses api.sanity.io when SANITY_INTERNAL_ENV is unset (default)', async () => { mockIsStaging.mockReturnValue(false) - const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + const result = await decorateIndexWithEarlyAuthScript(sampleHtml, projectId) expect(result).toContain('api.sanity.io') }) - test('uses api.sanity.work when isStaging() returns true', () => { + test('uses api.sanity.work when isStaging() returns true', async () => { mockIsStaging.mockReturnValue(true) - const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + const result = await decorateIndexWithEarlyAuthScript(sampleHtml, projectId) expect(result).toContain('api.sanity.work') expect(result).not.toContain('api.sanity.io') }) - test('injected script is the first child of ', () => { + test('injected script is the first child of ', async () => { mockIsStaging.mockReturnValue(false) - const result = decorateIndexWithEarlyAuthScript(sampleHtml, projectId) + const result = await decorateIndexWithEarlyAuthScript(sampleHtml, projectId) const earlyAuthIdx = result.indexOf('__sanityEarlyAuthInit') const appScriptIdx = result.indexOf('src="app.js"') expect(earlyAuthIdx).toBeGreaterThan(-1) expect(earlyAuthIdx).toBeLessThan(appScriptIdx) }) - test('preserves attributes', () => { + test('preserves attributes', async () => { mockIsStaging.mockReturnValue(false) const html = '' - const result = decorateIndexWithEarlyAuthScript(html, projectId) + const result = await decorateIndexWithEarlyAuthScript(html, projectId) expect(result).toContain('\n`) } + +let cachedProbeSource: Promise | undefined + +/** + * Reads the sibling probe module, transforms it to module-free inlinable JS, + * and memoizes the result. The transform runs once regardless of how many + * times the decorator is called. + */ +function loadProbeSource(): Promise { + cachedProbeSource = cachedProbeSource ?? readAndTransformProbeSource() + return cachedProbeSource +} + +async function readAndTransformProbeSource(): Promise { + // In vitest the sibling module is the `.ts` source; in the published dist it + // is the SWC-compiled `.js`. Resolve relative to this module's own URL and + // try the `.ts` first, falling back to the `.js`. + const candidateFilenames = ['earlyAuthProbeScript.ts', 'earlyAuthProbeScript.js'] + + const resolved = candidateFilenames + .map((filename) => { + const candidatePath = fileURLToPath(new URL(filename, import.meta.url)) + try { + return {filename, source: readFileSync(candidatePath, 'utf8')} + } catch { + return null + } + }) + .find((candidate) => candidate !== null) + + if (!resolved) { + throw new Error( + `Failed to locate early-auth probe module (tried ${candidateFilenames.join(', ')})`, + ) + } + + const transformed = await transformWithEsbuild(resolved.source, resolved.filename, { + minify: false, + sourcemap: false, + target: 'es2017', + }) + + // The transform preserves ESM `export` on the probe declaration. Strip it so + // the function is a bare declaration suitable for an inline `) +} - const script = - probeSource + - `\ntry {\n __sanityEarlyAuthInit(${JSON.stringify(projectId)}, ${JSON.stringify(apiHost)})\n} catch (initError) {}` +/** + * Builds the call to the inlined probe, passing every configuration value as a + * `JSON.stringify`'d literal so the decorator stays the single source of truth. + * Wrapped in its own `try/catch` so a probe failure can never abort HTML parse. + */ +function buildProbeInvocation(projectId: string, apiHost: string): string { + const probeArguments = [ + projectId, + apiHost, + EARLY_AUTH_API_VERSION, + EARLY_AUTH_REQUEST_TAG, + EARLY_AUTH_TOKEN_STORAGE_PREFIX, + ] + .map((value) => JSON.stringify(value)) + .join(', ') + + return `try {\n __sanityEarlyAuthInit(${probeArguments})\n} catch (initError) {}` +} - return template.replace(/]*)>/, `\n`) +/** + * Inserts `markup` immediately after the opening `` tag so it runs before + * any existing head content. The capture group preserves the tag's attributes + * (e.g. ``). + */ +function injectAsFirstHeadChild(template: string, markup: string): string { + return template.replace(HEAD_OPEN_TAG_PATTERN, `\n${markup}`) } let cachedProbeSource: Promise | undefined diff --git a/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeConstants.ts b/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeConstants.ts new file mode 100644 index 0000000000..a2e0f1e65b --- /dev/null +++ b/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeConstants.ts @@ -0,0 +1,20 @@ +// Configuration values for the browser-side early-auth probe. +// +// These are the single source of truth for the values the decorator passes into +// the inlined probe (`earlyAuthProbeScript.ts`). The probe stays self-contained +// (zero imports) by receiving them as arguments rather than reading them here. +// +// Each value mirrors a constant in the sanity monorepo's auth store and must +// stay in sync with it (packages/sanity/src/core/store/authStore/constants.ts): +// - EARLY_AUTH_API_VERSION <-> AUTH_API_VERSION +// - EARLY_AUTH_TOKEN_STORAGE_PREFIX <-> AUTH_TOKEN_STORAGE_PREFIX +// - EARLY_AUTH_REQUEST_TAG <-> requestTagPrefix + the probe's own suffix + +/** API version segment in the probe's `/users/me` request URL. */ +export const EARLY_AUTH_API_VERSION = 'v2026-05-04' + +/** Prefix for the localStorage key holding a per-project auth token. */ +export const EARLY_AUTH_TOKEN_STORAGE_PREFIX = '__studio_auth_token_' + +/** Request tag identifying the probe in API logs and metrics. */ +export const EARLY_AUTH_REQUEST_TAG = 'sanity.studio.auth.early-probe' diff --git a/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeScript.ts b/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeScript.ts index 7396a24ade..b2ba2a8e69 100644 --- a/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeScript.ts +++ b/packages/@sanity/cli-build/src/actions/build/earlyAuthProbeScript.ts @@ -9,10 +9,9 @@ // CRITICAL: keep this file fully self-contained — zero value imports. The // transform step inlines the output verbatim (it transforms, it does not // bundle), so a value import would ship a broken ESM `import` statement into -// the HTML. -// -// The `v2026-05-04` path segment below must stay in sync with AUTH_API_VERSION -// in the sanity monorepo (packages/sanity/src/core/store/authStore/constants.ts). +// the HTML. Configuration values (API version, request tag, storage-key +// prefix) are therefore passed in as arguments by the decorator, which owns +// them in earlyAuthProbeConstants.ts. interface EarlyAuthOk { type: 'ok' @@ -39,27 +38,33 @@ interface EarlyAuthState { token: string | null } -export function __sanityEarlyAuthInit(projectId: string, apiHost: string): void { - try { - const storageKey = '__studio_auth_token_' + projectId +const UNAUTHORIZED_STATUS = 401 - let token: string | null = null - try { - const raw = localStorage.getItem(storageKey) - if (raw) { - const parsed = JSON.parse(raw) - token = parsed && parsed.token ? parsed.token : null - } - } catch { - token = null +function readStoredToken(storageKey: string): string | null { + try { + const raw = localStorage.getItem(storageKey) + if (!raw) { + return null } + const parsed = JSON.parse(raw) + return parsed && parsed.token ? parsed.token : null + } catch { + return null + } +} + +export function __sanityEarlyAuthInit( + projectId: string, + apiHost: string, + apiVersion: string, + requestTag: string, + tokenStorageKeyPrefix: string, +): void { + try { + const token = readStoredToken(tokenStorageKeyPrefix + projectId) const requestUrl = - 'https://' + - projectId + - '.' + - apiHost + - '/v2026-05-04/users/me?tag=sanity.studio.auth.early-probe' + 'https://' + projectId + '.' + apiHost + '/' + apiVersion + '/users/me?tag=' + requestTag const requestOptions: RequestInit = token ? {headers: {Authorization: 'Bearer ' + token}} @@ -68,7 +73,7 @@ export function __sanityEarlyAuthInit(projectId: string, apiHost: string): void const startedAt = Date.now() const promise: Promise = fetch(requestUrl, requestOptions).then((response) => { - if (response.status === 401) { + if (response.status === UNAUTHORIZED_STATUS) { return {type: 'unauthenticated'} } if (response.ok) { From ce39d5673b0dd764922b25bfbb73a5b82654bf4b Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Sat, 13 Jun 2026 01:43:57 +0100 Subject: [PATCH 8/8] fix(cli-build): make early-auth injection fail-soft --- .../decorateIndexWithEarlyAuthScript.test.ts | 121 ++++++++++++++++++ .../build/decorateIndexWithEarlyAuthScript.ts | 39 ++++-- 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts index b4d2cafd45..2d66dcb421 100644 --- a/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts +++ b/packages/@sanity/cli-build/src/actions/build/__tests__/decorateIndexWithEarlyAuthScript.test.ts @@ -429,3 +429,124 @@ describe('__sanityEarlyAuthInit (direct module unit tests)', () => { expect(windowStub.__sanityEarlyAuth).toBeUndefined() }) }) + +// These exercise the decorator's fail-soft posture: any failure in the +// load/transform/assemble path returns the template unchanged with a single +// warning, never throwing. Each test isolates the module so the memoized probe +// source cannot leak between cases (or from the happy-path suites above). +async function loadDecoratorWith(mocks: { + readFileSync?: () => string + transformWithOxc?: (code: string, filename: string) => {code: string} +}) { + vi.resetModules() + + vi.doMock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, isStaging: () => false} + }) + + if (mocks.readFileSync) { + vi.doMock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, readFileSync: mocks.readFileSync} + }) + } + + if (mocks.transformWithOxc) { + vi.doMock('vite', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + transformWithOxc: (code: string, filename: string) => + Promise.resolve(mocks.transformWithOxc!(code, filename)), + } + }) + } + + const moduleUnderTest = await import('../decorateIndexWithEarlyAuthScript') + return moduleUnderTest.decorateIndexWithEarlyAuthScript +} + +describe('decorateIndexWithEarlyAuthScript - fail-soft', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('node:fs') + vi.doUnmock('vite') + vi.restoreAllMocks() + }) + + test('probe source unreadable: returns template unchanged and warns', async () => { + const decorate = await loadDecoratorWith({ + readFileSync: () => { + throw new Error('ENOENT: probe source missing') + }, + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await decorate(sampleHtml, projectId) + + expect(result).toBe(sampleHtml) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0][0]).toContain('[sanity early-auth probe]') + }) + + test('transform output retains an import: returns template unchanged and warns', async () => { + const decorate = await loadDecoratorWith({ + transformWithOxc: () => ({ + code: "import {x} from 'y'\nexport function __sanityEarlyAuthInit() {}", + }), + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await decorate(sampleHtml, projectId) + + expect(result).toBe(sampleHtml) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0][0]).toContain('[sanity early-auth probe]') + }) + + test('transform output retains an export: returns template unchanged and warns', async () => { + const decorate = await loadDecoratorWith({ + transformWithOxc: () => ({ + code: 'export const leftover = 1\nfunction __sanityEarlyAuthInit() {}', + }), + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await decorate(sampleHtml, projectId) + + expect(result).toBe(sampleHtml) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + test('no exception escapes the decorator on any failure path', async () => { + const decorate = await loadDecoratorWith({ + readFileSync: () => { + throw new Error('boom') + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(decorate(sampleHtml, projectId)).resolves.toBe(sampleHtml) + }) + + test('a failed load is not memoized: a later call can still succeed', async () => { + let shouldFail = true + const decorate = await loadDecoratorWith({ + readFileSync: () => { + if (shouldFail) { + throw new Error('transient read failure') + } + return 'export function __sanityEarlyAuthInit() {}' + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const firstResult = await decorate(sampleHtml, projectId) + expect(firstResult).toBe(sampleHtml) + + shouldFail = false + const secondResult = await decorate(sampleHtml, projectId) + expect(secondResult).toContain('__sanityEarlyAuthInit') + }) +}) diff --git a/packages/@sanity/cli-build/src/actions/build/decorateIndexWithEarlyAuthScript.ts b/packages/@sanity/cli-build/src/actions/build/decorateIndexWithEarlyAuthScript.ts index a91cf28a68..0ececbf000 100644 --- a/packages/@sanity/cli-build/src/actions/build/decorateIndexWithEarlyAuthScript.ts +++ b/packages/@sanity/cli-build/src/actions/build/decorateIndexWithEarlyAuthScript.ts @@ -19,6 +19,8 @@ const PROJECT_ID_PATTERN = /^[a-zA-Z0-9]+$/ // Captures any `` attributes so they survive the injection. const HEAD_OPEN_TAG_PATTERN = /]*)>/ +const WARNING_PREFIX = '[sanity early-auth probe]' + /** * Decorates the given HTML template with an inline script that fires a * `/users/me` fetch during HTML parse, before the multi-MB module bundle @@ -33,8 +35,11 @@ const HEAD_OPEN_TAG_PATTERN = /]*)>/ * self-contained — see the breadcrumb in `earlyAuthProbeScript.ts`. * * Injected as the first child of `` so it runs before any other scripts. - * Returns the template unchanged when `template` is empty or when `projectId` - * is absent/empty or fails `PROJECT_ID_PATTERN`. + * + * The probe is a progressive enhancement and never fails the build: the + * template is returned unchanged (with a single warning) when it is empty, when + * `projectId` is absent/empty or fails `PROJECT_ID_PATTERN`, or when anything in + * the load/transform/assemble/inject path throws. * * @internal */ @@ -50,12 +55,19 @@ export async function decorateIndexWithEarlyAuthScript( return template } - const apiHost = isStaging() ? 'api.sanity.work' : 'api.sanity.io' + try { + const apiHost = isStaging() ? 'api.sanity.work' : 'api.sanity.io' - const probeSource = await loadProbeSource() - const script = `${probeSource}\n${buildProbeInvocation(projectId, apiHost)}` + const probeSource = await loadProbeSource() + const script = `${probeSource}\n${buildProbeInvocation(projectId, apiHost)}` - return injectAsFirstHeadChild(template, ``) + return injectAsFirstHeadChild(template, ``) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + // eslint-disable-next-line no-console -- no logger in scope here; a single warning is enough + console.warn(`${WARNING_PREFIX} skipped early-auth injection: ${reason}`) + return template + } } /** @@ -91,10 +103,17 @@ let cachedProbeSource: Promise | undefined /** * Reads the sibling probe module, transforms it to module-free inlinable JS, * and memoizes the result. The transform runs once regardless of how many - * times the decorator is called. + * times the decorator is called. A failed load is never cached: the memo is + * cleared on rejection so a transient failure cannot poison later builds in the + * same process. */ function loadProbeSource(): Promise { - cachedProbeSource = cachedProbeSource ?? readAndTransformProbeSource() + cachedProbeSource = + cachedProbeSource ?? + readAndTransformProbeSource().catch((error) => { + cachedProbeSource = undefined + throw error + }) return cachedProbeSource } @@ -130,7 +149,9 @@ async function readAndTransformProbeSource(): Promise { const stripped = transformed.code.replace(/^export function /m, 'function ') // A self-containment regression (a stray module import, or an export the - // strip missed) must fail the build loudly rather than ship broken HTML. + // strip missed) must not ship broken HTML. Throwing here is caught by the + // decorator, which skips injection; CI's happy-path tests catch the + // regression rather than a customer build. if (/^import\s/m.test(stripped)) { throw new Error( 'Early-auth probe transform produced an `import` statement; it must be inlinable',