diff --git a/package.json b/package.json index 5bf2129..0e95274 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@resvg/resvg-js": "^2.6.2", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.7.6", + "@vercel/global-config": "^1.5.1", "@vercel/otel": "^2.1.3", "@vercel/speed-insights": "^2.0.0", "@vueuse/core": "^14.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea9a1a2..404a697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@vercel/functions': specifier: ^3.7.6 version: 3.7.6(ws@8.21.1) + '@vercel/global-config': + specifier: ^1.5.1 + version: 1.5.1(@opentelemetry/api@1.9.1) '@vercel/otel': specifier: ^2.1.3 version: 2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) @@ -2964,6 +2967,21 @@ packages: ws: optional: true + '@vercel/global-config-fs@0.1.0': + resolution: {integrity: sha512-xxMUwf96H/qKTA4Az+uh3xlVWkwnN3z400P5gex0uUsU8pe99njopYVyKVoUTfu8gN9xbHqaigWaIBr1+lufMg==} + + '@vercel/global-config@1.5.1': + resolution: {integrity: sha512-PUJaoyMGuSLdKnBgYIqeH1i6U3G+5noLvvJLqI8IPP5+P0b2Q5EcBBraFRh6ctNY/yTpb40klyLYyE3X0OPmKQ==} + engines: {node: '>=14.6'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + next: '>=1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + next: + optional: true + '@vercel/nft@1.10.2': resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==} engines: {node: '>=20'} @@ -10135,6 +10153,14 @@ snapshots: optionalDependencies: ws: 8.21.1 + '@vercel/global-config-fs@0.1.0': {} + + '@vercel/global-config@1.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@vercel/global-config-fs': 0.1.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@vercel/nft@1.10.2(rollup@4.62.2)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) diff --git a/server/utils/content.ts b/server/utils/content.ts index 3210f3a..a57c65d 100644 --- a/server/utils/content.ts +++ b/server/utils/content.ts @@ -91,15 +91,28 @@ export function getHeadRef(): string { return headRef } +/** + * The SHA production should currently serve: + * - global config pin if one is set (production only) + * - latest commit touching the content directory via `resolveContentSha()` + */ +export async function resolveProdSha(): Promise { + const { contentDir } = useRuntimeConfig().docs + if (process.env.VERCEL_ENV === 'production') { + const pinned = await getPinnedSha() + if (pinned) return pinned + } + return resolveContentSha(targetBranch(), contentDir) +} + /** * Shared content instance for the lifetime of this server instance, pinned to `headRef`. In production every - * call resolves the latest commit touching the content directory via `resolveContentSha()` — a shared, - * short-TTL cache, not a per-instance timer — and rebuilds when that advances. Previews stay pinned. + * call resolves the current head via `resolveProdSha()` — a shared, short-TTL cache, not a per-instance + * timer — and rebuilds when that advances. Previews stay pinned. */ export async function getProdContent(): Promise { if (['production', 'preview'].includes(process.env.VERCEL_ENV || '')) { - const { contentDir } = useRuntimeConfig().docs - const sha = await resolveContentSha(targetBranch(), contentDir) + const sha = await resolveProdSha() if (sha !== getHeadRef()) { console.log(`[content] head ${getHeadRef()} -> ${sha}`) headRef = sha diff --git a/server/utils/global-config.ts b/server/utils/global-config.ts new file mode 100644 index 0000000..3f52fba --- /dev/null +++ b/server/utils/global-config.ts @@ -0,0 +1,18 @@ +import { get } from '@vercel/global-config' + +/** Key looked up in the connected Global Config store for the pinned production content SHA. */ +const PINNED_SHA_KEY = 'contentSha' + +/** + * The content SHA pinned in Vercel Global Config, if any. + */ +export async function getPinnedSha(): Promise { + if (!process.env.GLOBAL_CONFIG) return undefined + try { + const sha = await get(PINNED_SHA_KEY) + return sha || undefined + } catch (error) { + console.error('[content] failed to read pinned SHA from Global Config', error) + return undefined + } +} diff --git a/test/global-config.test.ts b/test/global-config.test.ts new file mode 100644 index 0000000..3435950 --- /dev/null +++ b/test/global-config.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('@vercel/global-config', () => ({ get })) + +afterEach(() => { + vi.unstubAllEnvs() + get.mockReset() +}) + +describe('getPinnedSha', () => { + it('returns undefined without reading the store when no Global Config is connected', async () => { + vi.stubEnv('GLOBAL_CONFIG', '') + const { getPinnedSha } = await import('../server/utils/global-config') + + await expect(getPinnedSha()).resolves.toBeUndefined() + expect(get).not.toHaveBeenCalled() + }) + + it('returns the pinned SHA when the store has one', async () => { + vi.stubEnv('GLOBAL_CONFIG', 'connection-string') + get.mockResolvedValue('abc123') + const { getPinnedSha } = await import('../server/utils/global-config') + + await expect(getPinnedSha()).resolves.toBe('abc123') + expect(get).toHaveBeenCalledWith('contentSha') + }) + + it('returns undefined when the key is unset', async () => { + vi.stubEnv('GLOBAL_CONFIG', 'connection-string') + get.mockResolvedValue(undefined) + const { getPinnedSha } = await import('../server/utils/global-config') + + await expect(getPinnedSha()).resolves.toBeUndefined() + }) + + it('falls back to undefined when the read fails', async () => { + vi.stubEnv('GLOBAL_CONFIG', 'connection-string') + get.mockRejectedValue(new Error('network error')) + const { getPinnedSha } = await import('../server/utils/global-config') + + await expect(getPinnedSha()).resolves.toBeUndefined() + }) +})