@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.
- package/LICENSE +112 -0
- package/PUBLIC-API.md +31 -0
- package/README.md +522 -2
- package/dist/.source-hash +1 -0
- package/dist/cache-tags.d.ts +2 -0
- package/dist/cache-tags.js +12 -0
- package/dist/clean.d.ts +1 -0
- package/dist/clean.js +1 -0
- package/dist/code-version.d.ts +29 -0
- package/dist/code-version.js +48 -0
- package/dist/file.d.ts +1 -0
- package/dist/file.js +1 -0
- package/dist/i18n-seo.d.ts +1 -0
- package/dist/i18n-seo.js +1 -0
- package/dist/image.d.ts +11 -0
- package/dist/image.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.react-server.d.ts +3 -0
- package/dist/index.react-server.js +12 -0
- package/dist/link.d.ts +10 -0
- package/dist/link.js +8 -0
- package/dist/preview/index.d.ts +29 -0
- package/dist/preview/index.js +24 -0
- package/dist/preview/internal.d.ts +61 -0
- package/dist/preview/internal.js +97 -0
- package/dist/preview/preview-request-identity.d.ts +4 -0
- package/dist/preview/preview-request-identity.js +63 -0
- package/dist/preview/provenance-config.d.ts +42 -0
- package/dist/preview/provenance-config.js +132 -0
- package/dist/preview/provenance-frame-builder.d.ts +6 -0
- package/dist/preview/provenance-frame-builder.js +10 -0
- package/dist/preview/provenance-response.d.ts +5 -0
- package/dist/preview/provenance-response.js +9 -0
- package/dist/preview/provenance-runtime-adapter.d.ts +9 -0
- package/dist/preview/provenance-runtime-adapter.js +42 -0
- package/dist/preview/render-frame-provenance.d.ts +23 -0
- package/dist/preview/render-frame-provenance.js +47 -0
- package/dist/preview/route-authoring.d.ts +3 -0
- package/dist/preview/route-authoring.js +80 -0
- package/dist/preview/route-context.d.ts +3 -0
- package/dist/preview/route-context.js +38 -0
- package/dist/preview/route-disable.d.ts +7 -0
- package/dist/preview/route-disable.js +63 -0
- package/dist/preview/route-document-provenance.d.ts +23 -0
- package/dist/preview/route-document-provenance.js +46 -0
- package/dist/preview/route-errors.d.ts +1 -0
- package/dist/preview/route-errors.js +1 -0
- package/dist/preview/route-handler.d.ts +36 -0
- package/dist/preview/route-handler.js +139 -0
- package/dist/preview/route-initial.d.ts +9 -0
- package/dist/preview/route-initial.js +141 -0
- package/dist/preview/route-provenance-runtime.d.ts +34 -0
- package/dist/preview/route-provenance-runtime.js +90 -0
- package/dist/preview/route-token.d.ts +6 -0
- package/dist/preview/route-token.js +46 -0
- package/dist/preview/server-preview-internals.d.ts +3 -0
- package/dist/preview/server-preview-internals.js +3 -0
- package/dist/preview/slice-locale.d.ts +5 -0
- package/dist/preview/slice-locale.js +15 -0
- package/dist/preview-error-surface.d.ts +7 -0
- package/dist/preview-error-surface.js +24 -0
- package/dist/preview-exit.d.ts +7 -0
- package/dist/preview-exit.js +71 -0
- package/dist/preview-metadata.d.ts +15 -0
- package/dist/preview-metadata.js +13 -0
- package/dist/preview-mode-latch.d.ts +133 -0
- package/dist/preview-mode-latch.js +231 -0
- package/dist/preview-nav-location.d.ts +12 -0
- package/dist/preview-nav-location.js +149 -0
- package/dist/preview-nav.d.ts +94 -0
- package/dist/preview-nav.js +523 -0
- package/dist/preview-refresh-coordinator.d.ts +26 -0
- package/dist/preview-refresh-coordinator.js +93 -0
- package/dist/preview-render-error.d.ts +11 -0
- package/dist/preview-render-error.js +36 -0
- package/dist/preview-render-recovery.d.ts +9 -0
- package/dist/preview-render-recovery.js +42 -0
- package/dist/preview-session-recovery.d.ts +1 -0
- package/dist/preview-session-recovery.js +1 -0
- package/dist/preview-standalone-host.d.ts +3 -0
- package/dist/preview-standalone-host.js +55 -0
- package/dist/preview-text.d.ts +2 -0
- package/dist/preview-text.js +1 -0
- package/dist/preview-view-bootstrap.d.ts +5 -0
- package/dist/preview-view-bootstrap.js +13 -0
- package/dist/public-delivery.d.ts +6 -0
- package/dist/public-delivery.js +7 -0
- package/dist/query-fetch-options.d.ts +7 -0
- package/dist/query-fetch-options.js +193 -0
- package/dist/query.d.ts +2 -0
- package/dist/query.js +1 -0
- package/dist/rendering.d.ts +33 -0
- package/dist/rendering.js +42 -0
- package/dist/revalidate.d.ts +12 -0
- package/dist/revalidate.js +33 -0
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +1 -0
- package/dist/server-runtime.d.ts +208 -0
- package/dist/server-runtime.js +1584 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +6 -0
- package/dist/slice-zone.d.ts +2 -0
- package/dist/slice-zone.js +1 -0
- package/dist/storystick-component-types.d.ts +26 -0
- package/dist/storystick-component-types.js +1 -0
- package/dist/storystick-component.d.ts +10 -0
- package/dist/storystick-component.js +45 -0
- package/dist/storystick-error-recovery.d.ts +12 -0
- package/dist/storystick-error-recovery.js +11 -0
- package/dist/storystick-preview-runtime.d.ts +30 -0
- package/dist/storystick-preview-runtime.js +561 -0
- package/dist/storystick.d.ts +24 -0
- package/dist/storystick.js +23 -0
- package/dist/testing.d.ts +9 -0
- package/dist/testing.js +40 -0
- package/dist/video.d.ts +1 -0
- package/dist/video.js +1 -0
- package/package.json +128 -3
- package/recipes/README.md +91 -0
- 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
|
+
}
|