@storystick/next 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +31 -0
  3. package/README.md +522 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/cache-tags.d.ts +2 -0
  6. package/dist/cache-tags.js +12 -0
  7. package/dist/clean.d.ts +1 -0
  8. package/dist/clean.js +1 -0
  9. package/dist/code-version.d.ts +29 -0
  10. package/dist/code-version.js +48 -0
  11. package/dist/file.d.ts +1 -0
  12. package/dist/file.js +1 -0
  13. package/dist/i18n-seo.d.ts +1 -0
  14. package/dist/i18n-seo.js +1 -0
  15. package/dist/image.d.ts +11 -0
  16. package/dist/image.js +22 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +3 -0
  19. package/dist/index.react-server.d.ts +3 -0
  20. package/dist/index.react-server.js +12 -0
  21. package/dist/link.d.ts +10 -0
  22. package/dist/link.js +8 -0
  23. package/dist/preview/index.d.ts +29 -0
  24. package/dist/preview/index.js +24 -0
  25. package/dist/preview/internal.d.ts +61 -0
  26. package/dist/preview/internal.js +97 -0
  27. package/dist/preview/preview-request-identity.d.ts +4 -0
  28. package/dist/preview/preview-request-identity.js +63 -0
  29. package/dist/preview/provenance-config.d.ts +42 -0
  30. package/dist/preview/provenance-config.js +132 -0
  31. package/dist/preview/provenance-frame-builder.d.ts +6 -0
  32. package/dist/preview/provenance-frame-builder.js +10 -0
  33. package/dist/preview/provenance-response.d.ts +5 -0
  34. package/dist/preview/provenance-response.js +9 -0
  35. package/dist/preview/provenance-runtime-adapter.d.ts +9 -0
  36. package/dist/preview/provenance-runtime-adapter.js +42 -0
  37. package/dist/preview/render-frame-provenance.d.ts +23 -0
  38. package/dist/preview/render-frame-provenance.js +47 -0
  39. package/dist/preview/route-authoring.d.ts +3 -0
  40. package/dist/preview/route-authoring.js +80 -0
  41. package/dist/preview/route-context.d.ts +3 -0
  42. package/dist/preview/route-context.js +38 -0
  43. package/dist/preview/route-disable.d.ts +7 -0
  44. package/dist/preview/route-disable.js +63 -0
  45. package/dist/preview/route-document-provenance.d.ts +23 -0
  46. package/dist/preview/route-document-provenance.js +46 -0
  47. package/dist/preview/route-errors.d.ts +1 -0
  48. package/dist/preview/route-errors.js +1 -0
  49. package/dist/preview/route-handler.d.ts +36 -0
  50. package/dist/preview/route-handler.js +139 -0
  51. package/dist/preview/route-initial.d.ts +9 -0
  52. package/dist/preview/route-initial.js +141 -0
  53. package/dist/preview/route-provenance-runtime.d.ts +34 -0
  54. package/dist/preview/route-provenance-runtime.js +90 -0
  55. package/dist/preview/route-token.d.ts +6 -0
  56. package/dist/preview/route-token.js +46 -0
  57. package/dist/preview/server-preview-internals.d.ts +3 -0
  58. package/dist/preview/server-preview-internals.js +3 -0
  59. package/dist/preview/slice-locale.d.ts +5 -0
  60. package/dist/preview/slice-locale.js +15 -0
  61. package/dist/preview-error-surface.d.ts +7 -0
  62. package/dist/preview-error-surface.js +24 -0
  63. package/dist/preview-exit.d.ts +7 -0
  64. package/dist/preview-exit.js +71 -0
  65. package/dist/preview-metadata.d.ts +15 -0
  66. package/dist/preview-metadata.js +13 -0
  67. package/dist/preview-mode-latch.d.ts +133 -0
  68. package/dist/preview-mode-latch.js +231 -0
  69. package/dist/preview-nav-location.d.ts +12 -0
  70. package/dist/preview-nav-location.js +149 -0
  71. package/dist/preview-nav.d.ts +94 -0
  72. package/dist/preview-nav.js +523 -0
  73. package/dist/preview-refresh-coordinator.d.ts +26 -0
  74. package/dist/preview-refresh-coordinator.js +93 -0
  75. package/dist/preview-render-error.d.ts +11 -0
  76. package/dist/preview-render-error.js +36 -0
  77. package/dist/preview-render-recovery.d.ts +9 -0
  78. package/dist/preview-render-recovery.js +42 -0
  79. package/dist/preview-session-recovery.d.ts +1 -0
  80. package/dist/preview-session-recovery.js +1 -0
  81. package/dist/preview-standalone-host.d.ts +3 -0
  82. package/dist/preview-standalone-host.js +55 -0
  83. package/dist/preview-text.d.ts +2 -0
  84. package/dist/preview-text.js +1 -0
  85. package/dist/preview-view-bootstrap.d.ts +5 -0
  86. package/dist/preview-view-bootstrap.js +13 -0
  87. package/dist/public-delivery.d.ts +6 -0
  88. package/dist/public-delivery.js +7 -0
  89. package/dist/query-fetch-options.d.ts +7 -0
  90. package/dist/query-fetch-options.js +193 -0
  91. package/dist/query.d.ts +2 -0
  92. package/dist/query.js +1 -0
  93. package/dist/rendering.d.ts +33 -0
  94. package/dist/rendering.js +42 -0
  95. package/dist/revalidate.d.ts +12 -0
  96. package/dist/revalidate.js +33 -0
  97. package/dist/rich-text.d.ts +2 -0
  98. package/dist/rich-text.js +1 -0
  99. package/dist/server-runtime.d.ts +208 -0
  100. package/dist/server-runtime.js +1584 -0
  101. package/dist/server.d.ts +8 -0
  102. package/dist/server.js +6 -0
  103. package/dist/slice-zone.d.ts +2 -0
  104. package/dist/slice-zone.js +1 -0
  105. package/dist/storystick-component-types.d.ts +26 -0
  106. package/dist/storystick-component-types.js +1 -0
  107. package/dist/storystick-component.d.ts +10 -0
  108. package/dist/storystick-component.js +45 -0
  109. package/dist/storystick-error-recovery.d.ts +12 -0
  110. package/dist/storystick-error-recovery.js +11 -0
  111. package/dist/storystick-preview-runtime.d.ts +30 -0
  112. package/dist/storystick-preview-runtime.js +561 -0
  113. package/dist/storystick.d.ts +24 -0
  114. package/dist/storystick.js +23 -0
  115. package/dist/testing.d.ts +9 -0
  116. package/dist/testing.js +40 -0
  117. package/dist/video.d.ts +1 -0
  118. package/dist/video.js +1 -0
  119. package/package.json +128 -3
  120. package/recipes/README.md +91 -0
  121. package/recipes/resource-mirror.ts +49 -0
@@ -0,0 +1,1584 @@
1
+ import { PUBLIC_RENDER_CHALLENGE_HEADER, isPublicRenderChallenge } from '@storystick/types/public-delivery';
2
+ import { fetchProjectLocaleSettings } from '@storystick/client/adapter';
3
+ import { createPublicRenderReceipt, createPublicSiteManifest } from '@storystick/client';
4
+ import { publishedLocaleRoutes as readPublishedLocaleRoutes } from '@storystick/client/adapter';
5
+ import { pathnameForPreviewDraft, previewDocumentSeo, localeContextForPreviewDraft } from '@storystick/preview/server/draft-route';
6
+ import { PREVIEW_CONTEXT_SUPERSEDED_DIGEST } from './preview-render-error.js';
7
+ import { mapPreviewSessionError } from '@storystick/preview/session-subscriber';
8
+ /** Next server reads, optional CMS routing, and static route inventory. */
9
+ import 'server-only';
10
+ import { attachDocumentLocaleRouting, normalizePublicLocaleOrigins, publishedLocaleHrefs } from '@storystick/client/locale-routing';
11
+ import { cache } from 'react';
12
+ import { bindSiteQuerySchema, previewRuntimeForSchema, bindPreviewRuntimeToReads, previewRuntimeForReads, bindPreviewRuntimeToSidecar } from './preview/provenance-runtime-adapter.js';
13
+ import { unstable_buildPreviewRouteDocumentFrameWithRuntime } from './preview/route-document-provenance.js';
14
+ import { prepareOrdinaryPreviewRead } from '@storystick/preview/server/ordinary-read-provenance';
15
+ import { createRouteHandler } from './preview/route-handler.js';
16
+ import { storystickAlternates } from './i18n-seo.js';
17
+ import { stripPreviewProvenance } from './clean.js';
18
+ import { STORYSTICK_PREVIEW_ROBOTS } from './preview-metadata.js';
19
+ import { readNativeSliceLocale } from './preview/slice-locale.js';
20
+ import { draftMode, cookies, headers } from 'next/headers';
21
+ import { previewControlClient } from '@storystick/preview/server/control-client';
22
+ import { isPreviewReadContext } from '@storystick/types/preview-query-targeting';
23
+ import { isPreviewProjectionGenerationSignal } from '@storystick/preview/framework/generation-signal';
24
+ import { normalizePathname, pathnameToSegments, StorystickError, } from '@storystick/client';
25
+ import { createHttpTransportV2, fetchCurrentSchema, resolveInfra, } from '@storystick/client/adapter';
26
+ import { createInternalClient, prepareContentValues, assertExactQueryResult, getInternalClientRuntime, getPreviewReadObservation, previewAssetDependenciesFromValue, } from '@storystick/client/internal';
27
+ import { publicPathnameForLocale, } from '@storystick/types/internal/locale-routing';
28
+ import { readPreviewProvenanceRouteRender, registerPreviewSliceScopeAdapter, } from '@storystick/react/internal/preview-provenance';
29
+ import { resolvePreviewGrantAccess, } from '@storystick/preview/server/extract-token';
30
+ import { exchangePreviewGrantForServer, PreviewGrantExchangeError, classifyPreviewGrantError, } from '@storystick/preview/server/grant';
31
+ import { resolveStorystickApplicationBuildDigest, resolveStorystickApplicationBuildId, resolveStorystickServerCodeVersion } from './code-version.js';
32
+ import { PREVIEW_SESSION_QUERY_PARAM, readPreviewDraftDocumentId, previewDraftHref, readPreviewSessionIdFromSearch, } from '@storystick/preview/identity';
33
+ import { attachPreviewFrameDependencies, } from '@storystick/preview/provenance';
34
+ import { attachPreviewRouteRenderContext, collectPreviewRouteDocumentSliceTypes, createPreviewReadIdentityTransport, resolvePreviewProvenanceConfig, } from './preview/server-preview-internals.js';
35
+ import { createPreviewReadLifecycle } from '@storystick/preview/server/read-lifecycle';
36
+ import { runNativeSlice } from './preview/slice-locale.js';
37
+ import { buildStorystickSitemapShards, STORYSTICK_SITEMAP_MAX_URLS, } from './i18n-seo.js';
38
+ import { appendSearchParamsToRedirect } from './preview-session-recovery.js';
39
+ import { withNextQueryOptions, deploymentScopedFetch } from './query-fetch-options.js';
40
+ // Each createSite owns a distinct immutable schema; no request state is stored.
41
+ const staticSiteSchemas = new WeakSet();
42
+ function deploymentId() {
43
+ return process.env.VERCEL_DEPLOYMENT_ID ?? process.env.STORYSTICK_DEPLOYMENT_ID;
44
+ }
45
+ function createNextHttpTransport(options) {
46
+ return createHttpTransportV2({ ...options, fetchImpl: deploymentScopedFetch(deploymentId()) });
47
+ }
48
+ export { buildStorystickSitemap, buildStorystickSitemapShards, STORYSTICK_SITEMAP_MAX_URLS, storystickAlternates, } from './i18n-seo.js';
49
+ export { appendSearchParamsToRedirect, } from './preview-session-recovery.js';
50
+ registerPreviewSliceScopeAdapter(runNativeSlice);
51
+ /** Internal schema for control-plane-only helpers such as route resolution. */
52
+ const CONTROL_QUERY_SCHEMA = Object.freeze({
53
+ contractVersion: 2,
54
+ fingerprint: 'query-schema.v2.control-only',
55
+ stories: Object.freeze({}),
56
+ });
57
+ const readPreviewRawReadCache = cache((_schema) => new Map());
58
+ const requestRenderSlot = cache(() => ({}));
59
+ function previewRequestSelectionKey(selection) {
60
+ return JSON.stringify({
61
+ sessionId: selection.sessionId,
62
+ });
63
+ }
64
+ function bindRequestPreviewSelection(selection) {
65
+ const slot = requestRenderSlot();
66
+ const key = previewRequestSelectionKey(selection);
67
+ if (slot.previewRequest === undefined) {
68
+ slot.previewRequest = selection;
69
+ slot.previewRequestKey = key;
70
+ return selection;
71
+ }
72
+ if (slot.previewRequestKey !== key) {
73
+ throw new StorystickError('Two incompatible Preview selectors were bound in one request.', {
74
+ code: 'storystick/PREVIEW_CONTEXT_CONFLICT',
75
+ category: 'validation',
76
+ recoverable: true,
77
+ retryable: false,
78
+ });
79
+ }
80
+ return slot.previewRequest;
81
+ }
82
+ function readRequestPreviewSelection() {
83
+ return requestRenderSlot().previewRequest;
84
+ }
85
+ function localeContextKey(context) {
86
+ return JSON.stringify({
87
+ locale: context.locale,
88
+ defaultLocale: context.defaultLocale,
89
+ locales: context.locales,
90
+ localeRouting: context.localeRouting,
91
+ publicOrigins: context.publicOrigins,
92
+ synthetic: context.synthetic,
93
+ });
94
+ }
95
+ function bindRequestLocaleContext(context) {
96
+ const slot = requestRenderSlot();
97
+ const key = localeContextKey(context);
98
+ if (slot.binding === undefined) {
99
+ slot.key = key;
100
+ slot.binding = Promise.resolve(context);
101
+ return slot.binding;
102
+ }
103
+ if (slot.key !== key) {
104
+ throw mixedLocaleRequest('Two incompatible locale routes were bound in one request.');
105
+ }
106
+ return slot.binding;
107
+ }
108
+ async function readRequestLocaleContext() {
109
+ const binding = requestRenderSlot().binding;
110
+ if (binding === undefined) {
111
+ throw new Error('StorystickError: LOCALE_REQUEST_CONTEXT_MISSING — call resolvePage({ pathname }) before locale-aware reads.');
112
+ }
113
+ return binding;
114
+ }
115
+ function readBoundRequestLocaleContext() {
116
+ return requestRenderSlot().binding;
117
+ }
118
+ const readProjectLocaleSettings = cache(async (requestContext) => {
119
+ // A resolved page already carries the project's routing settings. Keep
120
+ // subsequent layout/client reads on that same request snapshot.
121
+ const bound = readBoundRequestLocaleContext();
122
+ if (bound)
123
+ return bound;
124
+ const projectId = readProjectId();
125
+ const apiKey = readApiKey();
126
+ const infra = await resolveServerInfra(projectId);
127
+ return fetchProjectLocaleSettings(createNextHttpTransport({
128
+ projectId, endpoint: infra.endpoint, apiKey, applicationBuildId: resolveStorystickApplicationBuildId(),
129
+ }), requestContext);
130
+ });
131
+ // ─────────────────────────────────────────────
132
+ // StorystickError re-export (ADR-091 §Consequences > @storystick/next)
133
+ // ─────────────────────────────────────────────
134
+ //
135
+ // The class itself moved to `@storystick/client` in Lane A. We re-export
136
+ // it from this server entry so existing user code that imported it from
137
+ // `@storystick/next` keeps working source-compat. `instanceof` checks
138
+ // across both import paths resolve to the same class identity.
139
+ export { StorystickError } from '@storystick/client';
140
+ const PREVIEW_SESSION_ENDED_ERROR_CODES = new Set([
141
+ 'storystick/PREVIEW_GRANT_EXPIRED',
142
+ 'storystick/PREVIEW_GRANT_MISSING',
143
+ 'storystick/PREVIEW_GRANT_REVOKED',
144
+ 'storystick/PREVIEW_ACCESS_REVOKED',
145
+ 'storystick/PREVIEW_CONTEXT_INVALID',
146
+ 'storystick/PREVIEW_GRANT_FORBIDDEN',
147
+ ]);
148
+ /**
149
+ * Natural cookie expiry can arrive as EXPIRED while the cookie is still in
150
+ * the request jar, or MISSING after the browser evicts it while Draft Mode
151
+ * remains enabled. Both are the same user-visible ended-session state.
152
+ */
153
+ export function isPreviewSessionEndedError(error) {
154
+ if (typeof error !== 'object' || error === null)
155
+ return false;
156
+ const code = error.code;
157
+ return typeof code === 'string' && PREVIEW_SESSION_ENDED_ERROR_CODES.has(code);
158
+ }
159
+ export { buildPreviewProvenanceFrame, } from './preview/provenance-frame-builder.js';
160
+ export { STORYSTICK_PREVIEW_ROBOTS } from './preview-metadata.js';
161
+ export { readPreviewProvenanceSessionId, resolvePreviewProvenanceRouteIdentity, } from './preview/preview-request-identity.js';
162
+ export { stripPreviewProvenance } from './clean.js';
163
+ // ─────────────────────────────────────────────
164
+ // Env-var helpers
165
+ // ─────────────────────────────────────────────
166
+ function readRequiredEnv(name, hint) {
167
+ const value = process.env[name];
168
+ if (!value || value.length === 0) {
169
+ throw new Error(`StorystickError: No ${name} found.\n${hint}`);
170
+ }
171
+ return value;
172
+ }
173
+ function readApiKey() {
174
+ return readRequiredEnv('STORYSTICK_API_KEY', 'Add it to your .env.local (ss_sk_live_* for server-side reads):\n\n' +
175
+ ' STORYSTICK_API_KEY=ss_sk_live_...\n');
176
+ }
177
+ function readProjectId() {
178
+ return readRequiredEnv('STORYSTICK_PROJECT_ID', 'Add it to your .env.local — it is the 8-char shortId visible in your\n' +
179
+ 'Hub project URL at /p/<label>-<shortId>:\n\n' +
180
+ ' STORYSTICK_PROJECT_ID=a3k9m2x7\n' +
181
+ ' STORYSTICK_API_KEY=ss_sk_live_...\n');
182
+ }
183
+ /**
184
+ * Resolve the gateway endpoint + Convex RPC URL for `projectId`. ADR-131
185
+ * C9 — delegates to `resolveInfra` which auto-derives the `.convex.site` /
186
+ * `.convex.cloud` pair from one gateway round-trip. Per-URL env escape
187
+ * hatches (`STORYSTICK_ENDPOINT`, `STORYSTICK_CONVEX_URL`) are honoured
188
+ * (undocumented in customer `.env.example`) for self-host overrides.
189
+ */
190
+ async function resolveServerInfra(projectId) {
191
+ const directEndpoint = process.env.STORYSTICK_ENDPOINT;
192
+ const directConvex = process.env.STORYSTICK_CONVEX_URL;
193
+ return resolveInfra(projectId, {
194
+ endpoint: directEndpoint && directEndpoint.length > 0 ? directEndpoint : undefined,
195
+ convexUrl: directConvex && directConvex.length > 0 ? directConvex : undefined,
196
+ });
197
+ }
198
+ /**
199
+ * Build a Storystick client bound to the current Next.js request.
200
+ *
201
+ * Behavior:
202
+ * - Production traffic (`draftMode().isEnabled === false`): returns
203
+ * a configured client that mints JWTs from `STORYSTICK_API_KEY`.
204
+ * `cookies()` is NEVER called, so Next's static prerender / ISR
205
+ * cache is preserved for non-editor traffic.
206
+ * - Editor traffic (`draftMode().isEnabled === true`, `__prerender_bypass`
207
+ * latched for the browser session): resolves the latest signed admission.
208
+ * A closed admission returns public data, even while Draft Mode remains on.
209
+ * An active admission resolves a short-lived
210
+ * preview JWT from that grant and wires
211
+ * `requestContext` so the transport sends `Authorization: Bearer <jwt>`,
212
+ * emits `X-Storystick-Preview: 1`, and forces `cache: 'no-store'`.
213
+ * - An explicit Preview selector with no addressed grant throws a recoverable
214
+ * auth error. Native Draft Mode alone may belong to another local project
215
+ * and does not activate Storystick Preview. Editor traffic
216
+ * with a present-but-malformed/expired cookie throws the existing
217
+ * recoverable auth error during extraction (CSO-PREVIEW-15). In both
218
+ * cases, the preview request never reaches the API-key mint path.
219
+ */
220
+ async function createServerClientForLocale(querySchema, locale, previewRequest, localeRouting, primaryPage = false) {
221
+ const nativePreview = process.env.NODE_ENV !== 'production' || !!process.env.STORYSTICK_LANE_ID
222
+ ? Promise.resolve(true) : draftMode().then(mode => mode.isEnabled);
223
+ const projectId = readProjectId();
224
+ const apiKey = readApiKey();
225
+ const infra = await resolveServerInfra(projectId);
226
+ const requestPreviewSelection = previewRequest === undefined
227
+ ? readRequestPreviewSelection()
228
+ : bindRequestPreviewSelection(previewRequest);
229
+ // Ordinary links and server layouts inherit the admission. The view remains
230
+ // optional, for route observations and explicit draft destinations only.
231
+ const hasExplicitPreviewSelector = requestPreviewSelection !== undefined;
232
+ const endpoint = infra.endpoint;
233
+ const previewEnabled = hasExplicitPreviewSelector || await nativePreview;
234
+ if (!previewEnabled)
235
+ return createNextServerClient({
236
+ querySchema, primaryPage, localeRouting, projectId, apiKey, endpoint, locale,
237
+ });
238
+ // Step 2 — editor traffic (prod: gated by draftMode above; dev: always
239
+ // reaches here). Read the addressed PreviewGrant cookie. Enabled alongside
240
+ // `__prerender_bypass` by the activation handler in prod;
241
+ // in dev the bypass cookie was dropped at the iframe boundary but the
242
+ // Storystick cookie traversed via the loopback CHIPS carve-out
243
+ // (commit 4b866ec, validated by spike cookie-iframe-chips-http-localhost.spec.js).
244
+ const routePathname = requestPreviewSelection?.routePathname ?? '';
245
+ const requestSessionId = requestPreviewSelection?.sessionId ?? null;
246
+ const requestOrigin = await deriveRequestOrigin();
247
+ const fields = await resolveCurrentPreviewGrantAccess({
248
+ projectId,
249
+ requestOrigin,
250
+ endpoint,
251
+ apiKey,
252
+ applicationBuildId: resolveStorystickApplicationBuildId(),
253
+ });
254
+ if (!fields) {
255
+ if (hasExplicitPreviewSelector && fields !== null) {
256
+ throw new StorystickError('Preview mode is active but the addressed PreviewGrant is missing.', {
257
+ code: 'storystick/PREVIEW_GRANT_MISSING',
258
+ category: 'auth',
259
+ recoverable: true,
260
+ retryable: false,
261
+ });
262
+ }
263
+ // Development traffic reaches this branch without a draftMode gate so
264
+ // normal local page loads can legitimately have no preview cookie.
265
+ // Present-but-malformed / expired grants still throw in the resolver.
266
+ return createNextServerClient({
267
+ querySchema,
268
+ primaryPage,
269
+ localeRouting,
270
+ projectId,
271
+ apiKey,
272
+ endpoint,
273
+ locale,
274
+ });
275
+ }
276
+ // Layouts and pages share the admitted perspective; a page may also identify its view.
277
+ const [session, readContext] = await Promise.all([
278
+ requestSessionId ? readPreviewSessionForRequest(fields, projectId, requestSessionId) : null,
279
+ readPreviewContextForRequest(fields.token, infra.endpoint),
280
+ ]);
281
+ const previewScope = readContext.perspective;
282
+ const releaseId = previewScope.kind === 'release' ? previewScope.releaseId : null;
283
+ const variantId = previewScope.kind === 'variant' ? previewScope.variantId : null;
284
+ const codeVersion = resolveStorystickServerCodeVersion();
285
+ const observationId = readPreviewObservationId(readContext.contextId, readContext.revision);
286
+ const observationFetchUrl = `${infra.endpoint.replace(/\/$/, '')}/api/preview/control`;
287
+ const admittedSessionId = session ? requestSessionId : null;
288
+ const isPreviewContextSuperseded = async () => {
289
+ const current = await readPreviewContext(fields.token, infra.endpoint);
290
+ return current.contextId === readContext.contextId && current.revision > readContext.revision;
291
+ };
292
+ const enqueueObservedDocs = admittedSessionId
293
+ ? getObservedDocsBatcher(observationFetchUrl, fields.token, projectId, admittedSessionId, requestOrigin + routePathname, codeVersion)
294
+ : undefined;
295
+ return createNextServerClient({
296
+ querySchema,
297
+ primaryPage,
298
+ localeRouting,
299
+ projectId,
300
+ apiKey,
301
+ endpoint,
302
+ locale,
303
+ isPreviewContextSuperseded,
304
+ requestContext: {
305
+ previewToken: fields.token,
306
+ previewReadContext: readContext,
307
+ previewSessionId: admittedSessionId ?? undefined,
308
+ previewQueryConsumer: 'server-render',
309
+ variantId,
310
+ releaseId,
311
+ previewCodeVersion: codeVersion,
312
+ previewObservationId: observationId,
313
+ onAccessedContent: enqueueObservedDocs,
314
+ refreshPreviewToken: async (oldToken) => {
315
+ // Access credentials are disposable. The durable grant is the
316
+ // refresh authority, so an RSC can recover on demand even though it
317
+ // cannot persist the refreshed access JWT back into the HttpOnly
318
+ // cookie during rendering.
319
+ void oldToken;
320
+ if (!requestOrigin) {
321
+ // Fail closed when we cannot derive the origin from headers.
322
+ // Falling back to the cookie's `previewOrigin` would let a
323
+ // leaked cookie self-attest (codex Lane C blocker). The
324
+ // transport will throw `PREVIEW_TOKEN_EXPIRED` on undefined.
325
+ return undefined;
326
+ }
327
+ try {
328
+ const exchange = await exchangePreviewGrantForServer({
329
+ endpoint,
330
+ apiKey,
331
+ applicationBuildId: resolveStorystickApplicationBuildId(),
332
+ grantToken: fields.grantToken,
333
+ requestOrigin,
334
+ contentShapeCapability: fields.contentShapeCapability,
335
+ });
336
+ if (exchange.projectId !== fields.projectId ||
337
+ exchange.previewOrigin !== fields.previewOrigin ||
338
+ (exchange.grantId !== undefined &&
339
+ exchange.grantId !== fields.grantId) ||
340
+ (exchange.grantExpiresAt !== undefined &&
341
+ exchange.grantExpiresAt <= Date.now())) {
342
+ throw new StorystickError('Preview grant exchange returned incompatible bindings.', {
343
+ code: 'storystick/PREVIEW_GRANT_BINDING_MISMATCH',
344
+ category: 'auth',
345
+ recoverable: true,
346
+ retryable: false,
347
+ });
348
+ }
349
+ return exchange.token;
350
+ }
351
+ catch (err) {
352
+ // Refresh failed (Central rejected, network error, etc.).
353
+ // Preserve the preview-specific taxonomy here instead of
354
+ // collapsing every case to PREVIEW_TOKEN_EXPIRED. The
355
+ // transport still never falls back to the API-key mint cache
356
+ // on preview requests (audit P1-1 invariant).
357
+ if (err instanceof PreviewGrantExchangeError) {
358
+ throw buildPreviewGrantStorystickError(err);
359
+ }
360
+ throw err;
361
+ }
362
+ },
363
+ },
364
+ });
365
+ }
366
+ export async function createServerClient(querySchema) {
367
+ const binding = readBoundRequestLocaleContext();
368
+ const context = binding ? await binding : undefined;
369
+ return createServerClientForLocale(querySchema, context?.synthetic ? undefined : context?.locale, undefined, context);
370
+ }
371
+ async function createNextServerClient(input) {
372
+ // Route resolution returns and attaches its own locale settings. Reading
373
+ // them first adds a serialized request with no information the route needs.
374
+ // Explicit domain routing still supplies settings through input.localeRouting.
375
+ const localeRouting = input.localeRouting ?? (input.primaryPage !== true && previewRuntimeForSchema(input.querySchema)
376
+ ? await readProjectLocaleSettings(input.requestContext)
377
+ : undefined);
378
+ const requestContext = input.requestContext?.previewToken &&
379
+ (previewRuntimeForSchema(input.querySchema) || resolvePreviewProvenanceConfig().enabled)
380
+ ? {
381
+ ...input.requestContext,
382
+ unstable_previewProvenanceReadLifecycle: createPreviewReadLifecycle({
383
+ primaryPage: input.primaryPage,
384
+ projectId: input.projectId,
385
+ endpoint: input.endpoint,
386
+ runtime: previewRuntimeForSchema(input.querySchema),
387
+ previewObservationId: input.requestContext.previewObservationId,
388
+ variantId: input.requestContext.variantId,
389
+ releaseId: input.requestContext.releaseId,
390
+ previewCodeVersion: input.requestContext.previewCodeVersion,
391
+ rawReadCache: readPreviewRawReadCache(input.querySchema),
392
+ }),
393
+ }
394
+ : input.requestContext;
395
+ return createInternalClient({
396
+ schema: input.querySchema,
397
+ localeRouting,
398
+ projectId: input.projectId,
399
+ apiKey: input.apiKey,
400
+ endpoint: input.endpoint,
401
+ defaults: input.locale ? { locale: input.locale } : undefined,
402
+ requestContext,
403
+ transport: withNextQueryOptions(input.projectId, localeGuardTransport(previewRuntimeForSchema(input.querySchema) ? undefined : input.locale, createPreviewReadIdentityTransport(createNextHttpTransport({
404
+ projectId: input.projectId,
405
+ endpoint: input.endpoint,
406
+ deliveryEndpoint: process.env.STORYSTICK_DELIVERY_ENDPOINT,
407
+ apiKey: input.apiKey,
408
+ applicationBuildId: resolveStorystickApplicationBuildId(),
409
+ }))), input.isPreviewContextSuperseded, staticSiteSchemas.has(input.querySchema)),
410
+ });
411
+ }
412
+ function localeGuardTransport(locale, transport) {
413
+ if (!transport)
414
+ throw new Error('StorystickError: missing Next transport.');
415
+ if (!locale)
416
+ return transport;
417
+ return {
418
+ async request(input) {
419
+ const query = input.udfPath === 'queries:queryV2' &&
420
+ input.args.query && typeof input.args.query === 'object'
421
+ ? input.args.query
422
+ : undefined;
423
+ const requested = query?.locale?.code;
424
+ if (requested !== undefined && requested !== locale) {
425
+ throw mixedLocaleRequest(`Request locale "${requested}" conflicts with bound locale "${locale}".`);
426
+ }
427
+ return transport.request(input);
428
+ },
429
+ };
430
+ }
431
+ function mixedLocaleRequest(message) {
432
+ return new StorystickError(message, {
433
+ code: 'storystick/MIXED_LOCALE_REQUEST',
434
+ category: 'validation',
435
+ recoverable: true,
436
+ retryable: false,
437
+ });
438
+ }
439
+ function buildPreviewGrantStorystickError(err) {
440
+ const publicError = classifyPreviewGrantError(err, 'exchange');
441
+ return new StorystickError(`${publicError.code} — ${publicError.message}`, {
442
+ code: `storystick/${publicError.code}`,
443
+ ref: publicError.upstreamRef,
444
+ category: publicError.category,
445
+ recoverable: publicError.recoverable,
446
+ retryable: publicError.retryable,
447
+ payload: {
448
+ operation: 'preview-grant-access',
449
+ upstreamCode: publicError.upstreamCode,
450
+ },
451
+ });
452
+ }
453
+ const resolveCurrentPreviewGrantAccessCached = cache(async (projectId, requestOrigin, endpoint, apiKey, applicationBuildId) => {
454
+ const cookieStore = (await cookies());
455
+ return resolvePreviewGrantAccess(cookieStore, {
456
+ projectId,
457
+ requestOrigin,
458
+ endpoint,
459
+ apiKey,
460
+ applicationBuildId,
461
+ });
462
+ });
463
+ function resolveCurrentPreviewGrantAccess(options) {
464
+ return resolveCurrentPreviewGrantAccessCached(options.projectId, options.requestOrigin, options.endpoint, options.apiKey, options.applicationBuildId);
465
+ }
466
+ /**
467
+ * Derive the current request's origin from the headers store. Mirrors
468
+ * what `request.nextUrl.origin` provides inside a route handler:
469
+ * combines the forwarded protocol with the host header.
470
+ *
471
+ * `next/headers::headers()` is request-scoped (Dynamic API), the same
472
+ * way `cookies()` is — calling it under `draftMode().isEnabled === true`
473
+ * is safe (the route is already dynamically re-rendered for editor
474
+ * traffic; the dynamic-API tax was already paid by `cookies()`).
475
+ *
476
+ * Returns the empty string when no host header is present (e.g., outside
477
+ * a Next request context, or behind a misconfigured proxy that strips
478
+ * `Host` + `X-Forwarded-Host`). The refresh callback fails closed in
479
+ * that case rather than self-attesting from the cookie's `previewOrigin`.
480
+ */
481
+ async function deriveRequestOrigin() {
482
+ try {
483
+ const h = await headers();
484
+ const host = h.get('x-forwarded-host') ?? h.get('host');
485
+ const proto = h.get('x-forwarded-proto') ?? 'https';
486
+ if (host)
487
+ return `${proto}://${host}`;
488
+ }
489
+ catch {
490
+ // headers() can throw outside a request context — fall through.
491
+ }
492
+ return '';
493
+ }
494
+ // ─────────────────────────────────────────────
495
+ // ADR-131 §D3.1b — first-render observation tap helpers
496
+ // ─────────────────────────────────────────────
497
+ /**
498
+ * Resolve the customer's deploy version. Drives Layer 1 cache
499
+ * invalidation for route-scoped document observations; on `codeVersion`
500
+ * drift the central row discards previously-observed document ids rather
501
+ * than threading stale observations into the current route.
502
+ *
503
+ * Precedence:
504
+ * 1. `STORYSTICK_CODE_VERSION` — explicit operator override.
505
+ * 2. `NEXT_BUILD_ID` — Next.js writes this on `next build`.
506
+ * 3. `VERCEL_GIT_COMMIT_SHA` — Vercel-specific fallback.
507
+ * 4. `'dev-unknown'` — last-resort marker so the orchestrator
508
+ * sees a stable key during `next dev` (no cache invalidation
509
+ * across edits is fine in dev — observations refresh on
510
+ * navigate anyway).
511
+ */
512
+ const OBSERVATION_IDLE_FLUSH_MS = 250;
513
+ const OBSERVATION_MAX_FLUSH_MS = 1_000;
514
+ /**
515
+ * Request-scoped observation coalescer. The SDK transport still reports
516
+ * every successful preview read, but the Next adapter sends one bounded
517
+ * `recordAccessedDocumentIds` mutation per render/session/route burst.
518
+ */
519
+ const getObservedDocsBatcher = cache((observationFetchUrl, previewToken, projectId, sessionId, routeUrl, codeVersion) => {
520
+ const pendingDocumentIds = new Set();
521
+ const pendingStoryTypes = new Set();
522
+ let pendingObservationId;
523
+ let pendingRenderWatermark;
524
+ let idleTimer;
525
+ let maxTimer;
526
+ const clearTimers = () => {
527
+ if (idleTimer !== undefined)
528
+ clearTimeout(idleTimer);
529
+ if (maxTimer !== undefined)
530
+ clearTimeout(maxTimer);
531
+ idleTimer = undefined;
532
+ maxTimer = undefined;
533
+ };
534
+ const flush = () => {
535
+ clearTimers();
536
+ const documentIds = Array.from(pendingDocumentIds);
537
+ const storyTypes = Array.from(pendingStoryTypes);
538
+ const observationId = pendingObservationId;
539
+ const renderWatermark = pendingRenderWatermark;
540
+ pendingDocumentIds.clear();
541
+ pendingStoryTypes.clear();
542
+ pendingObservationId = undefined;
543
+ pendingRenderWatermark = undefined;
544
+ if ((documentIds.length === 0 && storyTypes.length === 0) ||
545
+ observationId === undefined ||
546
+ renderWatermark === undefined)
547
+ return;
548
+ // Fire-and-forget. Observation is a fallback signal; render latency
549
+ // must never be coupled to Convex write pressure.
550
+ void postObservedDocs({
551
+ observationFetchUrl,
552
+ previewToken,
553
+ projectId,
554
+ sessionId,
555
+ routeUrl,
556
+ codeVersion,
557
+ documentIds,
558
+ storyTypes,
559
+ observationId,
560
+ renderWatermark,
561
+ });
562
+ };
563
+ return (observation) => {
564
+ // codeVersion + observationId are the compatibility fence. A callback
565
+ // crossing render identities is dropped instead of contaminating the
566
+ // current render projection.
567
+ if (observation.codeVersion !== codeVersion ||
568
+ observation.observationId.length === 0)
569
+ return;
570
+ if (pendingObservationId !== undefined &&
571
+ pendingObservationId !== observation.observationId) {
572
+ flush();
573
+ }
574
+ pendingObservationId = observation.observationId;
575
+ pendingRenderWatermark =
576
+ pendingRenderWatermark === undefined
577
+ ? observation.renderWatermark
578
+ : Math.min(pendingRenderWatermark, observation.renderWatermark);
579
+ for (const id of observation.documentIds) {
580
+ if (typeof id === 'string' && id.length > 0)
581
+ pendingDocumentIds.add(id);
582
+ }
583
+ for (const storyType of observation.storyTypes) {
584
+ if (typeof storyType === 'string' && storyType.length > 0) {
585
+ pendingStoryTypes.add(storyType);
586
+ }
587
+ }
588
+ if (pendingDocumentIds.size === 0 && pendingStoryTypes.size === 0)
589
+ return;
590
+ if (idleTimer !== undefined)
591
+ clearTimeout(idleTimer);
592
+ idleTimer = setObservationTimer(flush, OBSERVATION_IDLE_FLUSH_MS);
593
+ maxTimer ??= setObservationTimer(flush, OBSERVATION_MAX_FLUSH_MS);
594
+ };
595
+ });
596
+ function setObservationTimer(callback, delayMs) {
597
+ const timer = setTimeout(callback, delayMs);
598
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
599
+ const unref = timer.unref;
600
+ if (typeof unref === 'function')
601
+ unref.call(timer);
602
+ }
603
+ return timer;
604
+ }
605
+ /**
606
+ * Best-effort Preview dependency observation through the public gateway.
607
+ * The gateway verifies the SDK token; the native mutation rechecks the grant.
608
+ * These are rendering dependencies, never financial usage evidence.
609
+ *
610
+ * Failure modes (all silent — the tap is fire-and-forget by contract):
611
+ * - Network: `fetch` rejects. Swallowed.
612
+ * - 401/403: stale cookie. The user re-enters Studio preview; the
613
+ * next preview's tap will succeed.
614
+ * - 429: per-session rate limit. Studio uses the most recent
615
+ * successful row until the bucket replenishes.
616
+ *
617
+ * NEVER throws. The `try/catch` is paranoid: a thrown sync error from
618
+ * `fetch` (URL malformed, etc.) would otherwise unwind into the
619
+ * customer's RSC tree (the tap was synchronously invoked from the
620
+ * transport).
621
+ */
622
+ async function postObservedDocs(input) {
623
+ try {
624
+ // Hash on the customer side so observation state never carries the
625
+ // raw URL (PII hygiene + stable Convex index key shape). The
626
+ // orchestrator hashes the same routeUrl with the same SHA-256 to
627
+ // look up rows.
628
+ const routeHash = await sha256Hex(input.routeUrl);
629
+ const payload = JSON.stringify({
630
+ operation: 'observe',
631
+ input: {
632
+ projectId: input.projectId,
633
+ sessionId: input.sessionId,
634
+ routeHash,
635
+ codeVersion: input.codeVersion,
636
+ documentIds: input.documentIds,
637
+ storyTypes: input.storyTypes,
638
+ observationId: input.observationId,
639
+ renderWatermark: input.renderWatermark,
640
+ },
641
+ });
642
+ await fetch(input.observationFetchUrl, {
643
+ method: 'POST',
644
+ redirect: 'error',
645
+ headers: {
646
+ 'Content-Type': 'application/json',
647
+ Authorization: `Bearer ${input.previewToken}`,
648
+ },
649
+ body: payload,
650
+ cache: 'no-store',
651
+ });
652
+ }
653
+ catch {
654
+ // Fire-and-forget. The tap MUST NOT propagate failures back into
655
+ // the RSC tree; if observation fails, the next preview's tap is
656
+ // a clean retry.
657
+ }
658
+ }
659
+ function createPreviewObservationId() {
660
+ if (typeof globalThis.crypto !== 'undefined' &&
661
+ typeof globalThis.crypto.randomUUID === 'function') {
662
+ return globalThis.crypto.randomUUID();
663
+ }
664
+ return `obs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
665
+ }
666
+ // The arguments are semantic React-cache keys: one RSC request may construct
667
+ // several clients, but reads from different context revisions must never be
668
+ // grouped under apparently compatible frame metadata. A view is not required.
669
+ const readPreviewObservationId = cache((_contextId, _contextRevision) => createPreviewObservationId());
670
+ /**
671
+ * SHA-256 hex digest of a string. Lives here rather than imported from
672
+ * a local helper to keep the server adapter independent from Studio
673
+ * internals.
674
+ */
675
+ async function sha256Hex(input) {
676
+ const bytes = new TextEncoder().encode(input);
677
+ const buf = new ArrayBuffer(bytes.byteLength);
678
+ new Uint8Array(buf).set(bytes);
679
+ const digest = await crypto.subtle.digest('SHA-256', buf);
680
+ return Array.from(new Uint8Array(digest))
681
+ .map((b) => b.toString(16).padStart(2, '0'))
682
+ .join('');
683
+ }
684
+ // ─────────────────────────────────────────────
685
+ // Published-route inventory for static params and sitemaps. Build-time reads
686
+ // deliberately omit request cookies and Preview state.
687
+ async function buildBuildTimeClient(querySchema = CONTROL_QUERY_SCHEMA) {
688
+ const projectId = readProjectId();
689
+ const apiKey = readApiKey();
690
+ const infra = await resolveServerInfra(projectId);
691
+ return createInternalClient({
692
+ schema: querySchema,
693
+ projectId,
694
+ apiKey,
695
+ endpoint: infra.endpoint,
696
+ applicationBuildId: resolveStorystickApplicationBuildId(),
697
+ // No requestContext — build time has no viewer, no preview, no
698
+ // request scope. Production-snapshot reads only.
699
+ transport: createNextHttpTransport({ projectId, endpoint: infra.endpoint, apiKey,
700
+ applicationBuildId: resolveStorystickApplicationBuildId() }),
701
+ });
702
+ }
703
+ async function publishedLocaleRoutes(storyTypes) {
704
+ return readPublishedLocaleRoutes(await buildBuildTimeClient(), storyTypes);
705
+ }
706
+ async function sitemap(options) {
707
+ const routes = await publishedLocaleRoutes(options.storyTypes);
708
+ const shardSize = options.shardSize ?? STORYSTICK_SITEMAP_MAX_URLS;
709
+ const shards = buildStorystickSitemapShards(routes, options, shardSize);
710
+ if (options.id === undefined && shards.length > 1) {
711
+ throw new StorystickError(`This project needs ${shards.length} sitemap files. Export generateSitemaps() with site.sitemapIds(), then pass its id to site.sitemap().`, {
712
+ code: 'storystick/QUERY_INVALID',
713
+ category: 'validation',
714
+ recoverable: true,
715
+ retryable: false,
716
+ });
717
+ }
718
+ const id = options.id ?? 0;
719
+ if (!Number.isSafeInteger(id) || id < 0 || id >= shards.length) {
720
+ throw new RangeError(`Unknown sitemap shard id: ${id}`);
721
+ }
722
+ return shards[id];
723
+ }
724
+ async function sitemapIds(options) {
725
+ const routes = await publishedLocaleRoutes(options.storyTypes);
726
+ const shardSize = options.shardSize ?? STORYSTICK_SITEMAP_MAX_URLS;
727
+ if (!Number.isSafeInteger(shardSize) || shardSize < 1) {
728
+ throw new RangeError('sitemap shardSize must be a positive integer');
729
+ }
730
+ const indexableCount = routes.filter((route) => (!options.locale || route.locale === options.locale) && route.seo.indexable && route.seo.canonicalUrl === undefined).length;
731
+ return Array.from({ length: Math.max(1, Math.ceil(indexableCount / shardSize)) }, (_, id) => ({ id }));
732
+ }
733
+ /** Published paths for the optional `[[...pathname]]` route convention. */
734
+ async function routes(storyTypes) {
735
+ const entries = await publishedLocaleRoutes(storyTypes);
736
+ return entries.map((entry) => {
737
+ const pathname = normalizePathname(entry.publicPathname);
738
+ return {
739
+ pathname: pathname.length === 0 ? undefined : pathnameToSegments(pathname),
740
+ };
741
+ });
742
+ }
743
+ // ─────────────────────────────────────────────
744
+ // resolvePage — ADR-094 §1 Next façade
745
+ // ─────────────────────────────────────────────
746
+ import { resolveStorystickPage } from '@storystick/client/adapter';
747
+ const resolveStorystickPageCached = cache(async (cacheKey, previewSessionId, previewRoutePathname, querySchema) => {
748
+ const input = JSON.parse(cacheKey);
749
+ // The site manifest must code authored values before the client hydrates
750
+ // bindings and mapped zones. Coding the prepared document leaves their
751
+ // already-autonomous values pointing at the original, unmarked content.
752
+ const client = await createServerClientForLocale(querySchema ?? CONTROL_QUERY_SCHEMA, undefined, previewRoutePathname === undefined
753
+ ? undefined
754
+ : {
755
+ sessionId: previewSessionId ?? null,
756
+ routePathname: previewRoutePathname,
757
+ }, input.publicOrigins ? { ...await readProjectLocaleSettings(), publicOrigins: input.publicOrigins } : undefined, true);
758
+ return resolveStorystickPage(client, input);
759
+ });
760
+ function serializeResolvePageSearchParams(searchParams) {
761
+ const serialized = new URLSearchParams();
762
+ if (searchParams === undefined)
763
+ return '';
764
+ for (const [key, value] of Object.entries(searchParams)) {
765
+ if (typeof value === 'string') {
766
+ serialized.append(key, value);
767
+ continue;
768
+ }
769
+ if (Array.isArray(value)) {
770
+ for (const item of value)
771
+ serialized.append(key, item);
772
+ }
773
+ }
774
+ return serialized.toString();
775
+ }
776
+ function previewRequestFromResolvePage(searchParams, publicPathname) {
777
+ if (searchParams === undefined)
778
+ return undefined;
779
+ const serialized = serializeResolvePageSearchParams(searchParams);
780
+ const browserPathname = publicPathname.length === 0
781
+ ? '/'
782
+ : `/${publicPathname}`;
783
+ const rawSessionId = searchParams[PREVIEW_SESSION_QUERY_PARAM];
784
+ const draftDocumentId = readPreviewDraftDocumentId(serialized);
785
+ if (rawSessionId === undefined) {
786
+ if (draftDocumentId)
787
+ throw new StorystickPreviewDraftError('A document destination requires a Preview view.');
788
+ return undefined;
789
+ }
790
+ // Duplicate selectors are ambiguous and fail closed. A normal Next page
791
+ // receives the single selector left by the activation redirect.
792
+ const sessionId = typeof rawSessionId === 'string'
793
+ ? readPreviewSessionIdFromSearch(`${PREVIEW_SESSION_QUERY_PARAM}=${encodeURIComponent(rawSessionId)}`)
794
+ : null;
795
+ if (!sessionId)
796
+ throw new StorystickError('The Preview view selector is ambiguous or invalid.', {
797
+ code: 'storystick/PREVIEW_CONTEXT_INVALID', category: 'validation', recoverable: true, retryable: false,
798
+ });
799
+ return {
800
+ sessionId,
801
+ routePathname: serialized.length === 0
802
+ ? browserPathname
803
+ : `${browserPathname}?${serialized}`,
804
+ };
805
+ }
806
+ export async function resolvePage(input) {
807
+ const publicPathname = normalizePathname(input.pathname);
808
+ const pageSelection = previewRequestFromResolvePage(input.searchParams, publicPathname);
809
+ const previewRequest = pageSelection ? bindRequestPreviewSelection(pageSelection) : readRequestPreviewSelection();
810
+ const previewDraft = await resolvePreviewDraftForResolvedPage(previewRequest);
811
+ if (previewDraft?.localeSettings) {
812
+ const localeContext = localeContextForPreviewDraft(input.locale ? publicPathnameForLocale(input.pathname, input.locale, previewDraft.localeSettings) : input.pathname, previewDraft.localeSettings);
813
+ if (localeContext) {
814
+ if (input.publicOrigins) {
815
+ localeContext.publicOrigins = input.publicOrigins;
816
+ if (input.publicOrigins[localeContext.locale])
817
+ localeContext.publicPathname = '/' + localeContext.pathname;
818
+ }
819
+ const rawInitialLocale = previewDraft.document._locale;
820
+ const initialLocale = isRecord(rawInitialLocale)
821
+ ? rawInitialLocale.code
822
+ : undefined;
823
+ const localizedPreviewDraft = previewDraft.target?.kind === 'template' ||
824
+ initialLocale === localeContext.locale
825
+ ? previewDraft
826
+ : await resolvePreviewDraftPayload(localeContext.locale, previewRequest?.sessionId, previewRequest?.routePathname);
827
+ if (localizedPreviewDraft) {
828
+ await bindRequestLocaleContext(localeContext);
829
+ const draft = localizedPreviewDraft.document;
830
+ const canonicalPathname = pathnameForPreviewDraft(draft);
831
+ // A persisted address replaces the preview-only destination. The same
832
+ // route resolver remains responsible for rendering the document there.
833
+ if (readPreviewDraftDocumentId(serializeResolvePageSearchParams(input.searchParams)) &&
834
+ typeof draft.resolvedPathname === 'string') {
835
+ const destination = input.publicOrigins?.[localeContext.locale]
836
+ ? '/' + canonicalPathname
837
+ : publicPathnameForLocale(canonicalPathname, localeContext.locale, previewDraft.localeSettings);
838
+ return {
839
+ type: 'redirect', permanent: false,
840
+ redirectTo: previewDraftHref(appendSearchParamsToRedirect(destination, input.searchParams ?? {}), null),
841
+ };
842
+ }
843
+ // The draft payload resolves the destination, just like a pathname
844
+ // lookup. Read document content through the same query lifecycle as
845
+ // addressed pages so render values carry scope, receipts and page identity.
846
+ const content = localizedPreviewDraft.target?.kind === 'document' && input.querySchema
847
+ ? await readPreviewDraftPageContent(draft, input.querySchema, localeContext, previewRequest)
848
+ : draft;
849
+ const { data, document } = await prepareResolvedPageRoute({
850
+ declaration: input.data,
851
+ querySchema: input.querySchema,
852
+ document: content,
853
+ storyType: typeof draft.storyType === 'string' ? draft.storyType : 'page',
854
+ localeContext,
855
+ previewRequest,
856
+ sessionId: previewRequest?.sessionId,
857
+ });
858
+ const rawDraftLocale = draft._locale;
859
+ const draftLocale = isRecord(rawDraftLocale) ? rawDraftLocale : {};
860
+ return {
861
+ type: 'document',
862
+ document,
863
+ storyType: typeof draft.storyType === 'string' ? draft.storyType : 'page',
864
+ canonicalPathname,
865
+ publicPathname: localeContext.publicPathname,
866
+ localeContext,
867
+ primaryLocale: localizedPreviewDraft.routeAlternates?.primaryLocale ??
868
+ (typeof draftLocale.primaryLocale === 'string'
869
+ ? draftLocale.primaryLocale
870
+ : localeContext.defaultLocale),
871
+ alternates: localizedPreviewDraft.routeAlternates?.alternates ?? {},
872
+ seo: previewDocumentSeo(draft),
873
+ ...(data === undefined ? {} : { data }),
874
+ };
875
+ }
876
+ }
877
+ }
878
+ if (previewRequest && readPreviewDraftDocumentId(previewRequest.routePathname.split('?')[1] ?? '')) {
879
+ return { type: 'notFound' };
880
+ }
881
+ const route = await resolveStorystickPageCached(stableStringify({
882
+ pathname: publicPathname,
883
+ locale: input.locale,
884
+ publicOrigins: input.publicOrigins,
885
+ ...(input.storyTypes ? { storyTypes: [...input.storyTypes] } : {}),
886
+ }), previewRequest?.sessionId, previewRequest?.routePathname, input.querySchema);
887
+ if (route.type === 'redirect') {
888
+ return input.searchParams === undefined
889
+ ? route
890
+ : {
891
+ ...route,
892
+ redirectTo: appendSearchParamsToRedirect(route.redirectTo, input.searchParams),
893
+ };
894
+ }
895
+ if (route.type === 'notFound' && route.localeContext) {
896
+ await bindRequestLocaleContext(route.localeContext);
897
+ return route;
898
+ }
899
+ if (route.type !== 'document')
900
+ return route;
901
+ await bindRequestLocaleContext(route.localeContext);
902
+ const { data, document } = await prepareResolvedPageRoute({
903
+ declaration: input.data,
904
+ querySchema: input.querySchema,
905
+ document: route.document,
906
+ storyType: route.storyType,
907
+ localeContext: route.localeContext,
908
+ previewRequest,
909
+ });
910
+ return {
911
+ ...route,
912
+ ...(data === undefined ? {} : { data }),
913
+ document,
914
+ };
915
+ }
916
+ async function readPreviewDraftPageContent(destination, querySchema, localeContext, previewRequest) {
917
+ const client = await createServerClientForLocale(querySchema, localeContext.locale, previewRequest, localeContext, { documentId: destination.documentId, locale: localeContext.locale, publicPathname: localeContext.publicPathname });
918
+ const query = {
919
+ version: 2,
920
+ target: { kind: 'documents', storyType: destination.storyType },
921
+ selection: { mode: 'all' },
922
+ cardinality: 'one',
923
+ address: { kind: 'id', value: destination.documentId },
924
+ // Destination resolution remains exact-locale. Rendering composes its
925
+ // inherited fields, just like any other addressed document read.
926
+ locale: { code: localeContext.locale, fallback: 'configured' },
927
+ generatedSchemaFingerprint: querySchema.fingerprint,
928
+ };
929
+ const result = await getInternalClientRuntime(client).executionHost.executeCompiled(query, stableStringify(query));
930
+ assertExactQueryResult(result);
931
+ if (!result.data || result.data.documentId !== destination.documentId || result.data.storyType !== destination.storyType) {
932
+ throw new StorystickPreviewDraftError('The Preview draft destination changed before its content could be read.');
933
+ }
934
+ return result.data;
935
+ }
936
+ async function prepareResolvedPageRoute(input) {
937
+ // Locale routing must be attached before content values are prepared: the
938
+ // preparation pass captures this document as each value's immutable origin.
939
+ // Public and Preview draft routes deliberately share this exact boundary.
940
+ attachDocumentLocaleRouting(input.document, input.localeContext);
941
+ const [data, document] = await Promise.all([
942
+ resolveDeclaredPageData({
943
+ declaration: input.declaration,
944
+ querySchema: input.querySchema,
945
+ document: input.document,
946
+ storyType: input.storyType,
947
+ localeContext: input.localeContext,
948
+ previewRequest: input.previewRequest,
949
+ }),
950
+ resolvePageRouteDocumentForRender({
951
+ document: input.document,
952
+ sessionId: input.sessionId,
953
+ pathname: input.localeContext.publicPathname,
954
+ querySchema: input.querySchema,
955
+ }),
956
+ ]);
957
+ return { data, document };
958
+ }
959
+ async function resolveDeclaredPageData(input) {
960
+ if (input.declaration === undefined)
961
+ return undefined;
962
+ const entries = Object.entries(input.declaration);
963
+ if (entries.length === 0)
964
+ return {};
965
+ if (!input.querySchema) {
966
+ throw new TypeError('storystick/QUERY_INVALID: resolvePage data requires querySchema.');
967
+ }
968
+ const querySchema = input.querySchema;
969
+ let sliceTypes;
970
+ const getSliceTypes = () => {
971
+ sliceTypes ??= collectPreviewRouteDocumentSliceTypes(input.document);
972
+ return sliceTypes;
973
+ };
974
+ const resolved = await Promise.all(entries.map(async ([key, declaration]) => {
975
+ if (!pageDataDeclarationMatchesRoute({
976
+ declaration,
977
+ storyType: input.storyType,
978
+ sliceTypes: getSliceTypes,
979
+ })) {
980
+ return [key, []];
981
+ }
982
+ const rows = await readStorystickList({
983
+ querySchema,
984
+ storyType: declaration.query,
985
+ first: declaration.first,
986
+ localeContext: input.localeContext,
987
+ previewRequest: input.previewRequest,
988
+ });
989
+ return [key, rows];
990
+ }));
991
+ return Object.fromEntries(resolved);
992
+ }
993
+ function pageDataDeclarationMatchesRoute(input) {
994
+ const conditions = input.declaration.for;
995
+ if (conditions === undefined || conditions.length === 0)
996
+ return true;
997
+ for (const condition of conditions) {
998
+ if (typeof condition === 'string') {
999
+ if (input.sliceTypes().has(condition))
1000
+ return true;
1001
+ continue;
1002
+ }
1003
+ if (condition.storyType === input.storyType)
1004
+ return true;
1005
+ }
1006
+ return false;
1007
+ }
1008
+ async function readStorystickList(input) {
1009
+ const context = input.localeContext ??
1010
+ (readBoundRequestLocaleContext() ? await readRequestLocaleContext() : undefined);
1011
+ return readGeneratedListCached(input.querySchema, input.storyType, input.first, context?.synthetic ? undefined : context?.locale, input.previewRequest?.sessionId, input.previewRequest?.routePathname, context?.publicOrigins ? stableStringify(context) : undefined);
1012
+ }
1013
+ const readGeneratedListCached = cache(async (querySchema, storyType, requestedFirst, locale, previewSessionId, previewRoutePathname, routingKey) => {
1014
+ if (!Object.prototype.hasOwnProperty.call(querySchema.stories, storyType)) {
1015
+ throw new TypeError(`storystick/QUERY_INVALID: Unknown document story ${JSON.stringify(storyType)}.`);
1016
+ }
1017
+ const client = await createServerClientForLocale(querySchema, locale, previewRoutePathname === undefined
1018
+ ? undefined
1019
+ : {
1020
+ sessionId: previewSessionId ?? null,
1021
+ routePathname: previewRoutePathname,
1022
+ }, routingKey ? JSON.parse(routingKey) : undefined);
1023
+ const maximum = requestedFirst === undefined ? 10_000 : Math.min(10_000, requestedFirst);
1024
+ if (!Number.isSafeInteger(maximum) || maximum < 1) {
1025
+ throw new TypeError('storystick/QUERY_INVALID: page data first must be a positive integer.');
1026
+ }
1027
+ const documents = [];
1028
+ const cursors = new Set();
1029
+ let cursor;
1030
+ for (let page = 0; page < 100 && documents.length < maximum; page += 1) {
1031
+ let query = client.query(storyType).selectAll();
1032
+ if (locale)
1033
+ query = query.locale(locale);
1034
+ const result = await query.page({
1035
+ first: Math.min(100, maximum - documents.length),
1036
+ ...(cursor ? { after: cursor } : {}),
1037
+ }).execute();
1038
+ assertExactQueryResult(result);
1039
+ documents.push(...result.data);
1040
+ const next = result.pageInfo.endCursor;
1041
+ if (!result.pageInfo.hasNextPage)
1042
+ return documents;
1043
+ if (!next || cursors.has(next)) {
1044
+ throw new StorystickError('Query pagination returned a repeated cursor.', {
1045
+ code: 'storystick/QUERY_CURSOR_INVALID',
1046
+ category: 'system',
1047
+ retryable: false,
1048
+ });
1049
+ }
1050
+ cursors.add(next);
1051
+ cursor = next;
1052
+ }
1053
+ if (documents.length >= maximum)
1054
+ return documents;
1055
+ throw new StorystickError('Query pagination exceeded the 100-page safety bound.', {
1056
+ code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false,
1057
+ });
1058
+ });
1059
+ function stableStringify(value) {
1060
+ if (value === null || typeof value !== 'object')
1061
+ return JSON.stringify(value);
1062
+ if (Array.isArray(value)) {
1063
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
1064
+ }
1065
+ const record = value;
1066
+ return `{${Object.keys(record)
1067
+ .filter((key) => record[key] !== undefined)
1068
+ .sort()
1069
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
1070
+ .join(',')}}`;
1071
+ }
1072
+ /**
1073
+ * Resolve the render-time Source Graph closure for a route body.
1074
+ *
1075
+ * `resolvePage()` can attach identity for the route document itself, but app
1076
+ * renderers often load additional QueryResults afterwards (related posts,
1077
+ * customers, authors, tags, etc.). This helper is the server-side seam that
1078
+ * combines the route document and the actual render context before rendering.
1079
+ * It is a no-op outside Preview V2.
1080
+ */
1081
+ export async function resolveStorystickRouteRender({ document, context, pathname, }) {
1082
+ const runtime = previewRuntimeForReads(document, context);
1083
+ const config = runtime?.config.resolveSemanticConfig() ?? resolvePreviewProvenanceConfig();
1084
+ if (!config.enabled)
1085
+ return { document, context };
1086
+ const existingRender = readPreviewProvenanceRouteRender(document);
1087
+ if (existingRender === null && pathname === undefined) {
1088
+ return { document, context };
1089
+ }
1090
+ const manifest = runtime ? await runtime.config.resolveSemanticManifest() : {};
1091
+ const publicSidecar = existingRender?.frame.publicSidecar ?? null;
1092
+ const sourceDocument = stripPreviewRouteRenderMetadata(document);
1093
+ const frame = await unstable_buildPreviewRouteDocumentFrameWithRuntime({
1094
+ document: sourceDocument,
1095
+ context,
1096
+ pathname: normalizeRoutePathname(pathname ?? pathnameFromRouteHash(existingRender?.frame.routeHash) ?? '/'),
1097
+ manifest,
1098
+ routeHash: existingRender?.frame.routeHash,
1099
+ sessionId: existingRender?.frame.sessionId,
1100
+ codeVersion: publicSidecar?.codeVersion,
1101
+ renderId: publicSidecar?.renderId,
1102
+ generation: publicSidecar?.generation,
1103
+ fixture: config.fixture,
1104
+ }, runtime);
1105
+ if (frame === null)
1106
+ return { document, context };
1107
+ if (runtime)
1108
+ bindPreviewRuntimeToSidecar(frame.publicSidecar, runtime);
1109
+ const renderContext = attachPreviewRouteRenderContext((frame.codedContext ?? context), frame);
1110
+ return {
1111
+ document: attachPreviewRouteRenderContext(sourceDocument, frame),
1112
+ context: renderContext,
1113
+ };
1114
+ }
1115
+ async function resolvePageRouteDocumentForRender({ document, sessionId, pathname, querySchema, }) {
1116
+ // A generated manifest enables the capability, not Preview for public traffic.
1117
+ // Gate before planning/noStore/cookie reads so native public pages retain ISR.
1118
+ if (!sessionId && !getPreviewReadObservation(document)?.readContext)
1119
+ return document;
1120
+ const siteRuntime = querySchema ? previewRuntimeForSchema(querySchema) : undefined;
1121
+ bindPreviewRuntimeToReads(document, siteRuntime);
1122
+ // The page also owns the manifest for session-bound template payloads,
1123
+ // which have no document-query observation.
1124
+ const runtime = previewRuntimeForReads(document) ?? siteRuntime;
1125
+ return prepareContentValues(runtime ? await prepareOrdinaryPreviewRead(document, runtime, {
1126
+ sessionId, pathname: normalizeRoutePathname(pathname),
1127
+ }) : document, true, true);
1128
+ }
1129
+ function stripPreviewRouteRenderMetadata(document) {
1130
+ if (!isRecord(document))
1131
+ return document;
1132
+ const { __ssPreviewProvenance: _previewProvenance, __ssPreviewRouteFrame: _previewRouteFrame, __ssPreviewRouteRender: _previewRouteRender, ...rest } = document;
1133
+ return rest;
1134
+ }
1135
+ function pathnameFromRouteHash(routeHash) {
1136
+ if (routeHash === undefined)
1137
+ return null;
1138
+ if (!routeHash.startsWith('route:'))
1139
+ return null;
1140
+ const pathname = routeHash.slice('route:'.length);
1141
+ return pathname.length > 0 ? pathname : '/';
1142
+ }
1143
+ function normalizeRoutePathname(pathname) {
1144
+ const canonical = normalizePathname(pathname);
1145
+ return canonical.length === 0 ? '/' : `/${canonical}`;
1146
+ }
1147
+ async function resolvePreviewDraftForResolvedPage(previewRequest) {
1148
+ if (previewRequest !== undefined) {
1149
+ if (!previewRequest.sessionId)
1150
+ return null;
1151
+ return resolvePreviewDraftPayload(undefined, previewRequest.sessionId, previewRequest.routePathname);
1152
+ }
1153
+ return null;
1154
+ }
1155
+ function isRecord(value) {
1156
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1157
+ }
1158
+ // ─────────────────────────────────────────────
1159
+ // resolvePreviewDraft + isPreviewDraftActive — ADR-099 D2
1160
+ // ─────────────────────────────────────────────
1161
+ //
1162
+ // Customer's root layout calls `resolvePreviewDraft()` and substitutes
1163
+ // `children` with `<Renderer document={draft}/>` when a draft is active.
1164
+ // The fast path (no `_ss_session` selector) bails before any
1165
+ // Convex call, preserving prod traffic at zero cost.
1166
+ //
1167
+ // `isPreviewDraftActive()` is the customer guard helper for nested
1168
+ // Server Components: `if (await isPreviewDraftActive()) return null`
1169
+ // short-circuits `notFound()`/`redirect()` calls in the discarded
1170
+ // page subtree. Pre-ship spike (§5.10) determines whether this is a
1171
+ // hard requirement (mixed/red OUTCOME) or a cost-optimization (green
1172
+ // OUTCOME — saves a Convex call in nested SCs that fetch draft data).
1173
+ //
1174
+ // React `cache()` scopes both reads to the current request. The session
1175
+ // guard and the draft payload fetch are cached independently: callers
1176
+ // that only need `isPreviewDraftActive()` avoid fetching document-shaped
1177
+ // content, while root layouts call `resolvePreviewDraft()` directly.
1178
+ /**
1179
+ * Visible-failure subclass for preview-draft resolution failures
1180
+ * (CSO-PREVIEW-1 invariant — never silent prod fallback when the editor
1181
+ * is targeting a draft). Categorized as `'system'` to flow through the
1182
+ * existing canonical category union; the subclass identity (via
1183
+ * `instanceof`) is the discriminator for customer error.tsx handlers
1184
+ * that want to render a draft-specific message.
1185
+ */
1186
+ export class StorystickPreviewDraftError extends StorystickError {
1187
+ constructor(message) {
1188
+ super(message, { category: 'system' });
1189
+ }
1190
+ }
1191
+ async function readPreviewReadState(token, endpoint, includeProjectionCheckpoint = false) {
1192
+ const result = await previewControlClient(endpoint, token).context(includeProjectionCheckpoint ? { includeProjectionCheckpoint: true } : {});
1193
+ if (!isRecord(result) || result.status !== 'active' || !isPreviewReadContext(result.context)) {
1194
+ throw new StorystickError('This Preview context is no longer available.', {
1195
+ code: 'storystick/PREVIEW_CONTEXT_INVALID', category: 'auth', recoverable: true, retryable: false,
1196
+ });
1197
+ }
1198
+ const checkpoint = result.projectionCheckpoint;
1199
+ return {
1200
+ context: result.context,
1201
+ ...(isPreviewProjectionGenerationSignal(checkpoint) ? { projectionCheckpoint: {
1202
+ generation: checkpoint.generation, status: checkpoint.status,
1203
+ ...(checkpoint.projectedThroughSeq === undefined ? {} : { projectedThroughSeq: checkpoint.projectedThroughSeq }),
1204
+ ...(checkpoint.projectionHash === undefined ? {} : { projectionHash: checkpoint.projectionHash }),
1205
+ ...(checkpoint.lensHash === undefined ? {} : { lensHash: checkpoint.lensHash }),
1206
+ } } : {}),
1207
+ };
1208
+ }
1209
+ const readPreviewStateForRequest = cache((token, deploymentUrl) => readPreviewReadState(token, deploymentUrl, true));
1210
+ async function readPreviewContextForRequest(token, deploymentUrl) {
1211
+ return (await readPreviewStateForRequest(token, deploymentUrl)).context;
1212
+ }
1213
+ async function readPreviewContext(token, deploymentUrl) {
1214
+ return (await readPreviewReadState(token, deploymentUrl)).context;
1215
+ }
1216
+ /** @internal The existing server mount attests the same request as its page.
1217
+ * No cookies or infrastructure reads are added to public production renders. */
1218
+ export const readPreviewServerRenderReceipt = cache(async (projectOverride) => {
1219
+ if (process.env.NODE_ENV === 'production' && !process.env.STORYSTICK_LANE_ID &&
1220
+ !(await draftMode()).isEnabled)
1221
+ return null;
1222
+ const projectId = projectOverride ?? readProjectId();
1223
+ const apiKey = readApiKey();
1224
+ const infra = await resolveServerInfra(projectId);
1225
+ const fields = await resolveCurrentPreviewGrantAccess({
1226
+ projectId, apiKey, endpoint: infra.endpoint,
1227
+ requestOrigin: await deriveRequestOrigin(),
1228
+ applicationBuildId: resolveStorystickApplicationBuildId(),
1229
+ });
1230
+ if (!fields)
1231
+ return null;
1232
+ const { context: readContext, projectionCheckpoint } = await readPreviewStateForRequest(fields.token, infra.endpoint);
1233
+ return {
1234
+ readContext,
1235
+ renderId: readPreviewObservationId(readContext.contextId, readContext.revision),
1236
+ ...(projectionCheckpoint ? { projectionCheckpoint } : {}),
1237
+ };
1238
+ });
1239
+ function parsePreviewDraftTarget(value, fallbackDraftDocumentId) {
1240
+ if (value && typeof value === 'object') {
1241
+ const target = value;
1242
+ if ((target.kind === 'document' || target.kind === 'template') &&
1243
+ typeof target.id === 'string') {
1244
+ return { kind: target.kind, id: target.id };
1245
+ }
1246
+ }
1247
+ return fallbackDraftDocumentId
1248
+ ? { kind: 'document', id: fallbackDraftDocumentId }
1249
+ : null;
1250
+ }
1251
+ async function fetchPreviewSessionRow(fields, projectId, sessionId) {
1252
+ const infra = await resolveServerInfra(projectId);
1253
+ const client = previewControlClient(infra.endpoint, fields.token);
1254
+ let raw;
1255
+ try {
1256
+ raw = await client.session({ sessionId, mode: 'routing-only' });
1257
+ }
1258
+ catch (error) {
1259
+ const { code } = mapPreviewSessionError(error);
1260
+ // Missing routing metadata cannot supply a perspective. The separate
1261
+ // context read still validates this exact view and fails closed if absent.
1262
+ if (code === 'PREVIEW_ADMISSION_SUPERSEDED' || code === 'PREVIEW_VIEW_UNAVAILABLE')
1263
+ return null;
1264
+ // A new Studio view may load while the origin still carries another
1265
+ // admission's cookie. Keep the server read closed, but let the existing
1266
+ // whole-render recovery retry once AFTER the browser authenticates this
1267
+ // exact view. Otherwise Next labels this admission race a customer error
1268
+ // and Studio abandons the healthy replacement as soon as auth becomes ready.
1269
+ if (code === 'PREVIEW_GRANT_FORBIDDEN' && error instanceof Error) {
1270
+ Object.assign(error, { digest: PREVIEW_CONTEXT_SUPERSEDED_DIGEST });
1271
+ }
1272
+ throw error;
1273
+ }
1274
+ if (!raw || typeof raw !== 'object')
1275
+ return null;
1276
+ const row = raw;
1277
+ const draftDocumentId = typeof row.draftDocumentId === 'string'
1278
+ ? row.draftDocumentId
1279
+ : null;
1280
+ const draftTarget = parsePreviewDraftTarget(row.draftTarget, draftDocumentId);
1281
+ return {
1282
+ sessionId: String(row.sessionId ?? ''),
1283
+ previewedDocumentId: String(row.previewedDocumentId ?? ''),
1284
+ pathname: String(row.pathname ?? ''),
1285
+ activeVariantId: typeof row.activeVariantId === 'string' ? row.activeVariantId : null,
1286
+ activeReleaseId: typeof row.activeReleaseId === 'string' ? row.activeReleaseId : null,
1287
+ parentOrigin: String(row.parentOrigin ?? ''),
1288
+ previewOrigin: typeof row.previewOrigin === 'string' ? row.previewOrigin : undefined,
1289
+ contentRevision: typeof row.contentRevision === 'number' ? row.contentRevision : 0,
1290
+ draftDocumentId,
1291
+ draftTarget,
1292
+ };
1293
+ }
1294
+ /**
1295
+ * Fetch the preview session row. Cached per-request via React.cache so
1296
+ * `resolvePreviewDraft()` and `isPreviewDraftActive()` share the same
1297
+ * round-trip when both are called in the same RSC render.
1298
+ *
1299
+ * Returns `null` when:
1300
+ * - The `_ss_session` selector is absent (prod traffic fast path).
1301
+ *
1302
+ * Throws / propagates when preview state is present but invalid or when
1303
+ * the underlying query fails — e.g. malformed cookie,
1304
+ * `PREVIEW_PROJECT_MISMATCH`, or `PREVIEW_GRANT_EXPIRED`. The editor must see the
1305
+ * failure, never silently fall back to prod content.
1306
+ */
1307
+ const readPreviewSessionForRequest = cache(fetchPreviewSessionRow);
1308
+ const _fetchPreviewSession = cache(async (previewSessionId) => {
1309
+ const sessionId = previewSessionId;
1310
+ if (!sessionId)
1311
+ return null;
1312
+ const projectId = readProjectId();
1313
+ const [infra, requestOrigin] = await Promise.all([
1314
+ resolveServerInfra(projectId),
1315
+ deriveRequestOrigin(),
1316
+ ]);
1317
+ const fields = await resolveCurrentPreviewGrantAccess({
1318
+ projectId,
1319
+ requestOrigin,
1320
+ endpoint: infra.endpoint,
1321
+ apiKey: readApiKey(),
1322
+ applicationBuildId: resolveStorystickApplicationBuildId(),
1323
+ });
1324
+ if (!fields)
1325
+ return null;
1326
+ return readPreviewSessionForRequest(fields, projectId, sessionId);
1327
+ });
1328
+ const _fetchPreviewDraftPayload = cache(async (previewSessionId, locale, draftDocumentId) => {
1329
+ const sessionId = previewSessionId;
1330
+ if (!sessionId)
1331
+ return null;
1332
+ const projectId = readProjectId();
1333
+ const [infra, requestOrigin] = await Promise.all([
1334
+ resolveServerInfra(projectId),
1335
+ deriveRequestOrigin(),
1336
+ ]);
1337
+ const fields = await resolveCurrentPreviewGrantAccess({
1338
+ projectId,
1339
+ requestOrigin,
1340
+ endpoint: infra.endpoint,
1341
+ apiKey: readApiKey(),
1342
+ applicationBuildId: resolveStorystickApplicationBuildId(),
1343
+ });
1344
+ if (!fields)
1345
+ return null;
1346
+ // Resolve the admitted context and routing metadata together. An ordinary
1347
+ // addressed page has no draft payload; do not add a serialized empty read.
1348
+ const [readContext, session] = await Promise.all([
1349
+ readPreviewContextForRequest(fields.token, infra.endpoint),
1350
+ draftDocumentId ? null : readPreviewSessionForRequest(fields, projectId, sessionId),
1351
+ ]);
1352
+ if (!draftDocumentId && !session?.draftTarget)
1353
+ return null;
1354
+ const client = previewControlClient(infra.endpoint, fields.token);
1355
+ try {
1356
+ const payload = await client.draft({
1357
+ sessionId,
1358
+ ...(draftDocumentId ? { draftDocumentId } : {}),
1359
+ ...(locale === undefined ? {} : { locale }),
1360
+ contextId: readContext.contextId,
1361
+ revision: readContext.revision,
1362
+ });
1363
+ return { payload, session };
1364
+ }
1365
+ catch (cause) {
1366
+ const { code } = mapPreviewSessionError(cause);
1367
+ if (code === 'PREVIEW_ADMISSION_SUPERSEDED' || code === 'PREVIEW_VIEW_UNAVAILABLE')
1368
+ return null;
1369
+ const detail = cause instanceof Error ? cause.message : String(cause);
1370
+ throw new StorystickPreviewDraftError(`Preview draft payload could not be resolved. ${detail}`);
1371
+ }
1372
+ });
1373
+ /**
1374
+ * Resolve an explicitly selected draft in a native page request. Independent
1375
+ * layouts use site.client() and keep their own content; they do not substitute
1376
+ * their children. Template prototypes use the same document-shaped payload.
1377
+ *
1378
+ * @returns The draft `QueryResult` when Cas A is active and the doc is
1379
+ * routable. `null` otherwise (prod traffic, Tier 1 path, doc not in Cas
1380
+ * A scope).
1381
+ *
1382
+ * @throws `StorystickPreviewDraftError` when the row references a draft
1383
+ * target that cannot be resolved (deleted / stale / cross-tenant
1384
+ * session). CSO-PREVIEW-1: the editor must see a visible failure, never
1385
+ * silent prod fallback.
1386
+ *
1387
+ * Wrapped in `React.cache()` — repeated calls within one RSC render
1388
+ * share the same Promise.
1389
+ */
1390
+ async function resolvePreviewDraftPayload(locale, previewSessionId, previewRoutePathname) {
1391
+ const requestedDocumentId = previewRoutePathname === undefined
1392
+ ? null
1393
+ : readPreviewDraftDocumentId(new URL(previewRoutePathname, 'https://preview.invalid').search);
1394
+ const result = await _fetchPreviewDraftPayload(previewSessionId, locale, requestedDocumentId);
1395
+ const raw = result?.payload;
1396
+ const session = result?.session;
1397
+ if (!raw || (!session && !requestedDocumentId))
1398
+ return null;
1399
+ if (raw.isRoutable !== true)
1400
+ return null; // Cas A scope only (defense)
1401
+ const settings = raw.__ssPreviewLocaleSettings;
1402
+ const routeAlternates = raw.__ssPreviewRouteAlternates;
1403
+ const { __ssPreviewLocaleSettings: _localeSettings, __ssPreviewRouteAlternates: _routeAlternates, ...doc } = raw;
1404
+ // The draft payload is fetched outside the provenance transport, so it
1405
+ // carries no read identity of its own. Tag it with its session target and
1406
+ // the schema-derived assets carried by `_assets`. Without the latter, an
1407
+ // asset replacement advances only `asset:<id>` while this direct draft path
1408
+ // keeps watching the document/template clock and therefore stays stale
1409
+ // until an unrelated content edit happens.
1410
+ const target = requestedDocumentId
1411
+ ? { kind: 'document', id: requestedDocumentId }
1412
+ : session?.draftTarget ?? null;
1413
+ const targetDependencies = target?.kind === 'template'
1414
+ ? [{ kind: 'template', templateId: target.id }]
1415
+ : target?.kind === 'document'
1416
+ ? [
1417
+ {
1418
+ kind: 'document',
1419
+ documentId: target.id,
1420
+ reason: 'scope-document',
1421
+ },
1422
+ ]
1423
+ : [];
1424
+ attachPreviewFrameDependencies(doc, [
1425
+ ...targetDependencies,
1426
+ ...previewAssetDependenciesFromValue(doc),
1427
+ ]);
1428
+ return {
1429
+ document: doc,
1430
+ target,
1431
+ ...(isRecord(routeAlternates) &&
1432
+ typeof routeAlternates.primaryLocale === 'string' &&
1433
+ isRecord(routeAlternates.alternates)
1434
+ ? {
1435
+ routeAlternates: {
1436
+ primaryLocale: routeAlternates.primaryLocale,
1437
+ alternates: Object.fromEntries(Object.entries(routeAlternates.alternates).filter((entry) => typeof entry[1] === 'string')),
1438
+ },
1439
+ }
1440
+ : {}),
1441
+ ...(isRecord(settings)
1442
+ ? {
1443
+ localeSettings: settings,
1444
+ }
1445
+ : {}),
1446
+ };
1447
+ }
1448
+ const resolvePreviewDraftForLocale = cache(async (locale, previewSessionId) => (await resolvePreviewDraftPayload(locale, previewSessionId))?.document ?? null);
1449
+ export function resolvePreviewDraft(options) {
1450
+ return resolvePreviewDraftForLocale(options.locale, options.sessionId);
1451
+ }
1452
+ /** Advanced draft guard for an explicitly selected view. Prefer site.resolvePage for CMS routes. */
1453
+ export const isPreviewDraftActive = cache(async (sessionId) => {
1454
+ const session = await _fetchPreviewSession(sessionId);
1455
+ return session?.draftTarget != null;
1456
+ });
1457
+ /** Immutable configuration; sessions and request data are resolved per render. */
1458
+ export function createSite(options) {
1459
+ const schema = bindSiteQuerySchema(options.definition.querySchema, options.definition.previewProvenanceEditableTextPathManifest);
1460
+ if (options.publicRendering === 'static') {
1461
+ const id = deploymentId();
1462
+ if (!id || !/^[A-Za-z0-9:_-]{1,255}$/.test(id)) {
1463
+ throw new TypeError('Static public rendering requires a unique VERCEL_DEPLOYMENT_ID or STORYSTICK_DEPLOYMENT_ID per compilation (1–255 URL-safe characters).');
1464
+ }
1465
+ staticSiteSchemas.add(schema);
1466
+ }
1467
+ const storyTypes = Object.freeze([...(options.storyTypes ?? Object.entries(schema.stories)
1468
+ .filter(([, story]) => story.addresses.includes('pathname'))
1469
+ .map(([name]) => name))]);
1470
+ const publicOrigins = options.publicOrigins ? normalizePublicLocaleOrigins(options.publicOrigins) : undefined;
1471
+ const domainLocale = options.domainLocale;
1472
+ if (domainLocale && !publicOrigins?.[domainLocale])
1473
+ throw new TypeError('domainLocale must have a configured public origin.');
1474
+ const defaultLocale = options.locale ?? domainLocale;
1475
+ const configuredSiteUrl = options.siteUrl?.toString();
1476
+ for (const name of storyTypes) {
1477
+ if (!schema.stories[name]?.addresses.includes('pathname'))
1478
+ throw new TypeError(`Story "${name}" does not have a pathname address.`);
1479
+ }
1480
+ const resolve = (input) => resolvePage({ ...input, ...(domainLocale ? { locale: input.locale ?? domainLocale } : {}), publicOrigins, querySchema: schema, storyTypes: [...storyTypes] });
1481
+ const client = async (input = {}) => {
1482
+ const sliceLocale = await readNativeSliceLocale();
1483
+ const explicitLocale = input.locale ?? sliceLocale ?? defaultLocale;
1484
+ const defaults = explicitLocale === undefined || publicOrigins ? await readProjectLocaleSettings() : undefined;
1485
+ const locale = explicitLocale ?? (defaults?.synthetic ? undefined : defaults?.defaultLocale);
1486
+ return createServerClientForLocale(schema, locale, undefined, defaults ? { ...defaults, ...(publicOrigins ? { publicOrigins } : {}) } : undefined);
1487
+ };
1488
+ const siteUrl = () => {
1489
+ const value = configuredSiteUrl ?? process.env.STORYSTICK_SITE_URL;
1490
+ if (!value)
1491
+ throw new TypeError('Set siteUrl in createSite, or STORYSTICK_SITE_URL, for canonical URLs and sitemaps.');
1492
+ const url = new URL(value);
1493
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
1494
+ throw new TypeError('Storystick siteUrl must be an absolute HTTP(S) URL without credentials, search or hash.');
1495
+ }
1496
+ return url;
1497
+ };
1498
+ return Object.freeze({
1499
+ client,
1500
+ async renderReceipt(input) {
1501
+ if ((await draftMode()).isEnabled)
1502
+ return null;
1503
+ const buildId = resolveStorystickApplicationBuildId();
1504
+ if (!buildId)
1505
+ throw new TypeError('Public render receipts require an application build ID.');
1506
+ const token = options.publicRendering === 'request' ? (await headers()).get(PUBLIC_RENDER_CHALLENGE_HEADER) : undefined;
1507
+ return createPublicRenderReceipt({ ...input, projectId: readProjectId(), buildId,
1508
+ ...(isPublicRenderChallenge(token) ? { requestToken: token } : {}),
1509
+ deploymentId: input.deploymentId ?? deploymentId() });
1510
+ },
1511
+ async publicationManifest(input = {}) {
1512
+ const buildId = resolveStorystickApplicationBuildId();
1513
+ const resolvedDeploymentId = input.deploymentId ?? deploymentId();
1514
+ if (!buildId || !resolvedDeploymentId)
1515
+ throw new TypeError('Publication manifests require build and deployment IDs.');
1516
+ const routes = await readPublishedLocaleRoutes(await client(), storyTypes);
1517
+ return createPublicSiteManifest({ projectId: readProjectId(), buildId, deploymentId: resolvedDeploymentId, results: [routes],
1518
+ sitemapPath: input.sitemapPath ?? '/sitemap.xml',
1519
+ routes: routes.filter(route => !domainLocale || route.locale === domainLocale).map(route => ({
1520
+ pathname: domainLocale ? '/' + route.pathname.replace(/^\/+/, '') : route.publicPathname,
1521
+ indexable: route.seo.indexable && route.seo.canonicalUrl === undefined,
1522
+ })) });
1523
+ },
1524
+ /** Project default locale, or the explicit locale configured for this site. */
1525
+ async locale() {
1526
+ return defaultLocale ?? (await readProjectLocaleSettings()).defaultLocale;
1527
+ },
1528
+ async resolvePage(input) {
1529
+ if (!('params' in input))
1530
+ return resolve(input);
1531
+ const params = await input.params;
1532
+ // Keep public ISR static: only Preview and redirects need searchParams.
1533
+ const preview = process.env.NODE_ENV !== 'production' ||
1534
+ !!process.env.STORYSTICK_LANE_ID || (await draftMode()).isEnabled;
1535
+ const searchParams = preview ? await input.searchParams : undefined;
1536
+ const route = await resolve({ pathname: params.pathname, searchParams });
1537
+ return route.type === 'redirect' ? { ...route,
1538
+ redirectTo: appendSearchParamsToRedirect(route.redirectTo, searchParams ?? await input.searchParams ?? {}),
1539
+ } : route;
1540
+ },
1541
+ params: async () => domainLocale
1542
+ ? (await publishedLocaleRoutes(storyTypes)).filter(entry => entry.locale === domainLocale).map(entry => ({
1543
+ pathname: normalizePathname(entry.pathname) ? pathnameToSegments(entry.pathname) : undefined,
1544
+ }))
1545
+ : routes(storyTypes),
1546
+ /** Only real published translations; no prefix substitution in language switches. */
1547
+ alternates(route) {
1548
+ return route.type === 'document' ? publishedLocaleHrefs(route.alternates, {
1549
+ ...route.localeContext, ...(publicOrigins ? { publicOrigins } : {}),
1550
+ }) : {};
1551
+ },
1552
+ async metadata(route, metadata = {}) {
1553
+ const contextualClient = await client();
1554
+ const preview = !!getInternalClientRuntime(contextualClient)?.requestContext?.previewToken;
1555
+ if (route.type !== 'document')
1556
+ return preview ? { robots: { ...STORYSTICK_PREVIEW_ROBOTS } } : {};
1557
+ return stripPreviewProvenance({
1558
+ ...metadata,
1559
+ alternates: { ...metadata.alternates, ...storystickAlternates(route, { siteUrl: siteUrl(), publicOrigins }) },
1560
+ robots: preview ? { ...STORYSTICK_PREVIEW_ROBOTS } : !route.seo.indexable ? { index: false } : metadata.robots,
1561
+ });
1562
+ },
1563
+ handlers: Object.freeze(createRouteHandler({
1564
+ ...options.routes,
1565
+ migrationReadback: async () => {
1566
+ const artifactId = resolveStorystickApplicationBuildId();
1567
+ const artifactDigest = resolveStorystickApplicationBuildDigest();
1568
+ if (!artifactId || !artifactDigest)
1569
+ return null;
1570
+ const current = await fetchCurrentSchema(await createServerClientForLocale(schema, undefined), { cache: 'no-store' });
1571
+ return {
1572
+ projectId: readProjectId(),
1573
+ artifactId,
1574
+ artifactDigest,
1575
+ schemaVersion: current?.version ?? null,
1576
+ querySchemaFingerprint: schema.fingerprint,
1577
+ publication: { version: 2, rendering: options.publicRendering ?? 'unqualified', receipts: options.publicRendering === 'request' },
1578
+ };
1579
+ },
1580
+ })),
1581
+ sitemap: (input = {}) => sitemap({ ...input, siteUrl: siteUrl(), publicOrigins, locale: domainLocale, storyTypes }),
1582
+ sitemapIds: (input = {}) => sitemapIds({ ...input, locale: domainLocale, storyTypes }),
1583
+ });
1584
+ }