@storystick/client 0.0.0-stage → 0.2.0-rc.1
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 +22 -0
- package/README.md +180 -2
- package/dist/.source-hash +1 -0
- package/dist/_fetchers.d.ts +51 -0
- package/dist/_fetchers.js +144 -0
- package/dist/_stateMachine.d.ts +102 -0
- package/dist/_stateMachine.js +168 -0
- package/dist/_wireAuth.d.ts +48 -0
- package/dist/_wireAuth.js +52 -0
- package/dist/adapter.d.ts +14 -0
- package/dist/adapter.js +10 -0
- package/dist/content-values.d.ts +11 -0
- package/dist/content-values.js +401 -0
- package/dist/errors.d.ts +65 -0
- package/dist/errors.js +45 -0
- package/dist/hydrate-slice.d.ts +2 -0
- package/dist/hydrate-slice.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +9 -0
- package/dist/infra-resolver.d.ts +94 -0
- package/dist/infra-resolver.js +130 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +20 -0
- package/dist/locale-routing.d.ts +5 -0
- package/dist/locale-routing.js +33 -0
- package/dist/normalize-pathname.d.ts +79 -0
- package/dist/normalize-pathname.js +103 -0
- package/dist/preview-frame-dependencies.d.ts +39 -0
- package/dist/preview-frame-dependencies.js +84 -0
- package/dist/preview-query-receipt.d.ts +3 -0
- package/dist/preview-query-receipt.js +16 -0
- package/dist/preview-read-lifecycle.d.ts +10 -0
- package/dist/preview-read-lifecycle.js +40 -0
- package/dist/preview-read-observation.d.ts +18 -0
- package/dist/preview-read-observation.js +81 -0
- package/dist/preview-scope.d.ts +3 -0
- package/dist/preview-scope.js +22 -0
- package/dist/preview-target.d.ts +73 -0
- package/dist/preview-target.js +86 -0
- package/dist/project-locales.d.ts +4 -0
- package/dist/project-locales.js +12 -0
- package/dist/public-delivery.d.ts +36 -0
- package/dist/public-delivery.js +55 -0
- package/dist/published-routes.d.ts +3 -0
- package/dist/published-routes.js +100 -0
- package/dist/query-builder.d.ts +225 -0
- package/dist/query-builder.js +294 -0
- package/dist/query-client.d.ts +54 -0
- package/dist/query-client.js +306 -0
- package/dist/query-compiler.d.ts +5 -0
- package/dist/query-compiler.js +12 -0
- package/dist/query-schema.d.ts +32 -0
- package/dist/query-schema.js +1 -0
- package/dist/redirect-search.d.ts +4 -0
- package/dist/redirect-search.js +14 -0
- package/dist/reference-resolver.d.ts +37 -0
- package/dist/reference-resolver.js +126 -0
- package/dist/resolve-route.d.ts +27 -0
- package/dist/resolve-route.js +145 -0
- package/dist/resolve.d.ts +43 -0
- package/dist/resolve.js +160 -0
- package/dist/schemas.d.ts +71 -0
- package/dist/schemas.js +75 -0
- package/dist/seo.d.ts +52 -0
- package/dist/seo.js +93 -0
- package/dist/signed-webhook.d.ts +13 -0
- package/dist/signed-webhook.js +54 -0
- package/dist/transport.d.ts +29 -0
- package/dist/transport.js +584 -0
- package/dist/typed-route.d.ts +22 -0
- package/dist/typed-route.js +1 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/package.json +70 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Shared HTTP boundary for framework delivery adapters. No cache or queue policy. */
|
|
2
|
+
export async function signWebhookBody(body, secret) {
|
|
3
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
4
|
+
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
|
|
5
|
+
return `sha256=${Array.from(new Uint8Array(signature), byte => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
6
|
+
}
|
|
7
|
+
export async function readSignedWebhook(request, options) {
|
|
8
|
+
const limit = 1024 * 1024;
|
|
9
|
+
const failure = (status, error) => ({ ok: false, status, error });
|
|
10
|
+
if (Number(request.headers.get('content-length')) > limit)
|
|
11
|
+
return failure(413, 'payload_too_large');
|
|
12
|
+
const signature = request.headers.get(options.signatureHeader ?? 'x-storystick-signature')?.trim().replace(/^sha256=/, '');
|
|
13
|
+
if (!signature || !/^[a-f0-9]{64}$/i.test(signature))
|
|
14
|
+
return failure(401, 'invalid_signature');
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
const reader = request.body?.getReader();
|
|
18
|
+
try {
|
|
19
|
+
if (reader)
|
|
20
|
+
while (true) {
|
|
21
|
+
const { done, value } = await reader.read();
|
|
22
|
+
if (done)
|
|
23
|
+
break;
|
|
24
|
+
size += value.byteLength;
|
|
25
|
+
if (size > limit) {
|
|
26
|
+
await reader.cancel();
|
|
27
|
+
return failure(413, 'payload_too_large');
|
|
28
|
+
}
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return failure(400, 'invalid_body');
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
reader?.releaseLock();
|
|
37
|
+
}
|
|
38
|
+
const bytes = new Uint8Array(size);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
bytes.set(chunk, offset);
|
|
42
|
+
offset += chunk.length;
|
|
43
|
+
}
|
|
44
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(options.secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
45
|
+
const candidate = Uint8Array.from(signature.match(/../g), byte => parseInt(byte, 16));
|
|
46
|
+
if (!await crypto.subtle.verify('HMAC', key, candidate, bytes))
|
|
47
|
+
return failure(401, 'invalid_signature');
|
|
48
|
+
try {
|
|
49
|
+
return { ok: true, payload: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return failure(400, 'invalid_json');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PublicTransport, TransportResponse } from './types.js';
|
|
2
|
+
export interface HttpTransportOptions {
|
|
3
|
+
/** Project short ID expected by this client; this never selects a server tenant. */
|
|
4
|
+
projectId: string;
|
|
5
|
+
/** Explicit CDN origin for public Query V2 only. */
|
|
6
|
+
deliveryEndpoint?: string;
|
|
7
|
+
/** Resolved Storystick HTTP gateway URL. */
|
|
8
|
+
endpoint: string;
|
|
9
|
+
/** Caller's long-lived API key (`ss_sk_live_*` or `ss_pk_live_*`). */
|
|
10
|
+
apiKey: string;
|
|
11
|
+
/** Exact customer application build, transmitted with every published read. */
|
|
12
|
+
applicationBuildId?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional override of `globalThis.fetch`. Tests pass a stub here
|
|
15
|
+
* to observe wire shape without hitting the network.
|
|
16
|
+
*/
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the default Pattern D HTTP transport. Shape matches the
|
|
21
|
+
* `Transport` interface; the configured client wires this up by
|
|
22
|
+
* default and the user can substitute a mock via
|
|
23
|
+
* `createClient({ schema, transport: customTransport })`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createHttpTransportV2(opts: HttpTransportOptions): PublicTransport;
|
|
26
|
+
/** Shared consumption boundary, including injected transports and null results. */
|
|
27
|
+
export declare function assertResponseProject(response: TransportResponse, expected: string): void;
|
|
28
|
+
/** Check nested SDK transport bindings before the inner transport can observe data. */
|
|
29
|
+
export declare function assertProjectMatch(expected: string, actual: string): void;
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { isExactPublicQueryResultV2, queryResultHttpStatus, QUERY_HTTP_PROTOCOL_HEADER, QUERY_HTTP_PROTOCOL, QUERY_PROJECT_ID_HEADER } from '@storystick/types/delivery-contract';
|
|
2
|
+
import { isPublicReadIdentity, PUBLIC_DELIVERY_IDENTITY_HEADER } from '@storystick/types/public-delivery';
|
|
3
|
+
/** Shared HTTP reads: direct API keys in production, delegated JWTs in Preview. */
|
|
4
|
+
import { contentShapeCapabilityHeaders } from '@storystick/types';
|
|
5
|
+
import { StorystickError } from './errors.js';
|
|
6
|
+
import { httpFailureRetryable, isTemporaryHttpStatus, parseRetryAfter } from '@storystick/types/http-failure';
|
|
7
|
+
import { hasUsablePreviewToken } from './preview-scope.js';
|
|
8
|
+
import { PROJECT_ID_MISMATCH } from '@storystick/types/errors/codes';
|
|
9
|
+
import { attachPreviewReadObservation } from './preview-read-observation.js';
|
|
10
|
+
import { encodePreviewGatewayContext, PREVIEW_GATEWAY_CONTEXT_HEADER, PREVIEW_QUERY_RECEIPT_HEADER, } from '@storystick/types/preview-query-targeting';
|
|
11
|
+
// ─────────────────────────────────────────────
|
|
12
|
+
// UDF path routing
|
|
13
|
+
// ─────────────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Map a Convex-style udf path to a Pattern D HTTP endpoint descriptor.
|
|
16
|
+
* Mirrors the routes Central's `gateway_queries.ts` httpActions register
|
|
17
|
+
* at `/api/q/*` and the consolidated wire layer in
|
|
18
|
+
* `packages/next/src/server.ts` consumed today.
|
|
19
|
+
*
|
|
20
|
+
* `getRouteByPathname` (ADR-094 §1.bis) routes cross-storytype URL lookups
|
|
21
|
+
* to the same family of indexes — one indexed call regardless of how many
|
|
22
|
+
* URL-routable storytypes the project declares (ADR-086 invariant).
|
|
23
|
+
*/
|
|
24
|
+
function endpointForUdf(udfPath) {
|
|
25
|
+
switch (udfPath) {
|
|
26
|
+
case 'queries:getRouteByPathname':
|
|
27
|
+
return { method: 'GET', path: '/api/q/getRouteByPathname' };
|
|
28
|
+
case 'queries:listPublishedLocaleRoutes':
|
|
29
|
+
return { method: 'GET', path: '/api/q/listPublishedLocaleRoutes' };
|
|
30
|
+
case 'locales:get':
|
|
31
|
+
return { method: 'GET', path: '/api/q/locales/settings' };
|
|
32
|
+
case 'queries:queryV2':
|
|
33
|
+
return { method: 'POST', path: '/api/q/queryV2' };
|
|
34
|
+
case 'queries:schemas.current':
|
|
35
|
+
// ADR-131 Amendment 002 §3.1 — schema delivery via the existing
|
|
36
|
+
// `/api/q/schemas/current` HTTP action (gateway_queries.ts::schemasCurrent).
|
|
37
|
+
// Consumed by `fetchCurrentSchema` (packages/client/src/schemas.ts).
|
|
38
|
+
return { method: 'GET', path: '/api/q/schemas/current' };
|
|
39
|
+
default:
|
|
40
|
+
throw new StorystickError(`Unknown query path "${udfPath}". Expected one of: ` +
|
|
41
|
+
`queries:queryV2, queries:getRouteByPathname, queries:listPublishedLocaleRoutes, queries:schemas.current, locales:get.`, { code: 'storystick/INVALID_UDF_PATH', category: 'validation' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build the default Pattern D HTTP transport. Shape matches the
|
|
46
|
+
* `Transport` interface; the configured client wires this up by
|
|
47
|
+
* default and the user can substitute a mock via
|
|
48
|
+
* `createClient({ schema, transport: customTransport })`.
|
|
49
|
+
*/
|
|
50
|
+
export function createHttpTransportV2(opts) {
|
|
51
|
+
if (!opts.projectId?.trim())
|
|
52
|
+
throw new TypeError('storystick/QUERY_INVALID: projectId is required.');
|
|
53
|
+
const { endpoint, apiKey, applicationBuildId, fetchImpl } = opts;
|
|
54
|
+
const fetchFn = fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
55
|
+
async function request(input) {
|
|
56
|
+
assertProjectMatch(input.expectedProjectId ?? opts.projectId, opts.projectId);
|
|
57
|
+
const { udfPath, args, options, requestContext, signal: signalArg } = input;
|
|
58
|
+
const { method, path } = endpointForUdf(udfPath);
|
|
59
|
+
// Use the shared `hasUsablePreviewToken` predicate so the builder's
|
|
60
|
+
// `assertPreviewIntent` guard and this preview-path switch agree on
|
|
61
|
+
// what counts as "bound": a non-empty string. `previewToken: ''`
|
|
62
|
+
// and runtime `null` previously slipped through `!!previewToken` and
|
|
63
|
+
// produced production reads on a preview-intent client.
|
|
64
|
+
const isPreview = hasUsablePreviewToken(requestContext);
|
|
65
|
+
const previewToken = isPreview ? requestContext.previewToken : undefined;
|
|
66
|
+
const token = isPreview ? previewToken : apiKey;
|
|
67
|
+
// Wire shape build.
|
|
68
|
+
const readEndpoint = !isPreview && udfPath === 'queries:queryV2' && opts.deliveryEndpoint
|
|
69
|
+
? opts.deliveryEndpoint : endpoint;
|
|
70
|
+
const url = buildUrl({ endpoint: readEndpoint, path, method, args, options, isPreview });
|
|
71
|
+
const body = method === 'POST'
|
|
72
|
+
? buildPostBody(args, options, isPreview, udfPath !== 'queries:queryV2')
|
|
73
|
+
: undefined;
|
|
74
|
+
const signal = signalArg ?? options?.signal;
|
|
75
|
+
const fetchInit = buildFetchInit({
|
|
76
|
+
method,
|
|
77
|
+
token,
|
|
78
|
+
applicationBuildId,
|
|
79
|
+
isPreview,
|
|
80
|
+
udfPath,
|
|
81
|
+
requestContext,
|
|
82
|
+
options,
|
|
83
|
+
body,
|
|
84
|
+
signal,
|
|
85
|
+
});
|
|
86
|
+
const response = await fetchTransportResponse(fetchFn, url, fetchInit);
|
|
87
|
+
// Only a delegated Preview token can be refreshed. A rejected API key
|
|
88
|
+
// must surface immediately; no mint, fallback or hidden auth retry.
|
|
89
|
+
if (isPreview && await shouldRefreshAndRetry(response)) {
|
|
90
|
+
const refresh = requestContext?.refreshPreviewToken;
|
|
91
|
+
const retryToken = refresh ? await refresh(previewToken) : undefined;
|
|
92
|
+
if (typeof retryToken !== 'string' || retryToken.length === 0) {
|
|
93
|
+
throw new StorystickError('Preview token expired and could not be refreshed. Re-enter Studio preview to obtain a fresh token.', { code: 'storystick/PREVIEW_TOKEN_EXPIRED', category: 'auth', recoverable: true, retryable: false });
|
|
94
|
+
}
|
|
95
|
+
const retryInit = buildFetchInit({
|
|
96
|
+
method,
|
|
97
|
+
token: retryToken,
|
|
98
|
+
isPreview,
|
|
99
|
+
udfPath,
|
|
100
|
+
requestContext,
|
|
101
|
+
options,
|
|
102
|
+
body,
|
|
103
|
+
signal,
|
|
104
|
+
});
|
|
105
|
+
const retry = await fetchTransportResponse(fetchFn, url, retryInit);
|
|
106
|
+
return decode(retry, input, isPreview);
|
|
107
|
+
}
|
|
108
|
+
return decode(response, input, isPreview);
|
|
109
|
+
}
|
|
110
|
+
async function decode(response, input, isPreview) {
|
|
111
|
+
let data;
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
// Only the closed Query V2 result protocol can survive a non-2xx status.
|
|
114
|
+
// Other errors (including HTML 503s and auth errors) retain their normal path.
|
|
115
|
+
const cardinality = input.args.query?.cardinality;
|
|
116
|
+
if (input.udfPath === 'queries:queryV2' &&
|
|
117
|
+
(response.status === 503 || response.status === 422) &&
|
|
118
|
+
(cardinality === 'one' || cardinality === 'many')) {
|
|
119
|
+
const candidate = await response.clone().json().catch(() => null);
|
|
120
|
+
if (isExactPublicQueryResultV2(candidate, cardinality) &&
|
|
121
|
+
candidate.exactness !== 'exact' && queryResultHttpStatus(candidate) === response.status) {
|
|
122
|
+
data = candidate;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (data === undefined)
|
|
126
|
+
throw await buildStorystickError(response);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
try {
|
|
130
|
+
data = await response.json();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if ((input.signal ?? input.options?.signal)?.aborted)
|
|
134
|
+
throw error;
|
|
135
|
+
const protocol = error instanceof SyntaxError;
|
|
136
|
+
throw new StorystickError(protocol ? 'The Storystick gateway returned invalid JSON.' : 'The Storystick response stream was interrupted.', {
|
|
137
|
+
code: protocol ? 'storystick/QUERY_PROTOCOL' : 'storystick/QUERY_NETWORK',
|
|
138
|
+
category: 'system', retryable: !protocol, status: response.status,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let publicReadIdentity;
|
|
143
|
+
if (!isPreview && ['queries:queryV2', 'queries:getRouteByPathname', 'queries:listPublishedLocaleRoutes'].includes(input.udfPath) && response.headers.has(PUBLIC_DELIVERY_IDENTITY_HEADER)) {
|
|
144
|
+
let value = null;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(response.headers.get(PUBLIC_DELIVERY_IDENTITY_HEADER));
|
|
147
|
+
}
|
|
148
|
+
catch { /* Rejected by the identity validator below. */ }
|
|
149
|
+
if (!isPublicReadIdentity(value))
|
|
150
|
+
throw new StorystickError('Invalid public read identity.', { code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false });
|
|
151
|
+
publicReadIdentity = value;
|
|
152
|
+
}
|
|
153
|
+
const result = {
|
|
154
|
+
data, ...queryResponseMetadata(input.udfPath, response),
|
|
155
|
+
projectId: response.headers.get(QUERY_PROJECT_ID_HEADER) ?? publicReadIdentity?.projectShortId,
|
|
156
|
+
...(publicReadIdentity ? { publicReadIdentity } : {}),
|
|
157
|
+
};
|
|
158
|
+
assertResponseProject(result, opts.projectId);
|
|
159
|
+
fireOnAccessedContent({
|
|
160
|
+
udfPath: input.udfPath, args: input.args, data, response,
|
|
161
|
+
requestContext: input.requestContext, isPreview,
|
|
162
|
+
});
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
return { request };
|
|
166
|
+
}
|
|
167
|
+
/** Shared consumption boundary, including injected transports and null results. */
|
|
168
|
+
export function assertResponseProject(response, expected) {
|
|
169
|
+
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
|
170
|
+
throw new StorystickError('The Storystick response has no project identity.', {
|
|
171
|
+
code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const identity = response.publicReadIdentity;
|
|
175
|
+
if (identity !== undefined && !isPublicReadIdentity(identity)) {
|
|
176
|
+
throw new StorystickError('Invalid public read identity.', { code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false });
|
|
177
|
+
}
|
|
178
|
+
const actual = response.projectId ?? identity?.projectShortId;
|
|
179
|
+
if (typeof actual !== 'string' || !actual.trim() || actual.length > 1024 ||
|
|
180
|
+
(identity && identity.projectShortId !== actual)) {
|
|
181
|
+
throw new StorystickError('The Storystick response has no consistent project identity.', {
|
|
182
|
+
code: 'storystick/QUERY_PROTOCOL', category: 'system', retryable: false,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
assertProjectMatch(expected, actual);
|
|
186
|
+
}
|
|
187
|
+
/** Check nested SDK transport bindings before the inner transport can observe data. */
|
|
188
|
+
export function assertProjectMatch(expected, actual) {
|
|
189
|
+
if (actual !== expected) {
|
|
190
|
+
throw new StorystickError('The Storystick response belongs to a different project.', {
|
|
191
|
+
code: PROJECT_ID_MISMATCH.code, category: PROJECT_ID_MISMATCH.category,
|
|
192
|
+
recoverable: PROJECT_ID_MISMATCH.recoverable, retryable: PROJECT_ID_MISMATCH.retryable,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function fetchTransportResponse(fetchFn, url, init) {
|
|
197
|
+
try {
|
|
198
|
+
return await fetchFn(url, init);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (init.signal?.aborted ||
|
|
202
|
+
(error instanceof Error && error.name === 'AbortError')) {
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
throw new StorystickError('The Storystick gateway is unreachable.', {
|
|
206
|
+
code: 'storystick/QUERY_NETWORK',
|
|
207
|
+
category: 'system',
|
|
208
|
+
recoverable: true,
|
|
209
|
+
retryable: true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function queryResponseMetadata(udfPath, response) {
|
|
214
|
+
if (udfPath !== 'queries:queryV2')
|
|
215
|
+
return {};
|
|
216
|
+
const queryHash = response.headers.get('X-Storystick-Query-Hash');
|
|
217
|
+
const readScopeIdentity = response.headers.get('X-Storystick-Read-Scope-Identity');
|
|
218
|
+
const receiptId = response.headers.get(PREVIEW_QUERY_RECEIPT_HEADER);
|
|
219
|
+
return {
|
|
220
|
+
...(queryHash && readScopeIdentity
|
|
221
|
+
? { executionIdentity: { queryHash, readScopeIdentity } }
|
|
222
|
+
: {}),
|
|
223
|
+
...(receiptId ? { previewQueryReceiptId: receiptId } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// ─────────────────────────────────────────────
|
|
227
|
+
// Internal helpers (URL build, fetch init, retry decision, error build)
|
|
228
|
+
// ─────────────────────────────────────────────
|
|
229
|
+
function buildUrl(args) {
|
|
230
|
+
const { endpoint, path, method, args: queryArgs, options, isPreview } = args;
|
|
231
|
+
let url = `${endpoint}${path}`;
|
|
232
|
+
if (method !== 'GET')
|
|
233
|
+
return url;
|
|
234
|
+
const params = new URLSearchParams();
|
|
235
|
+
for (const [key, value] of Object.entries(queryArgs)) {
|
|
236
|
+
if (key === 'preview')
|
|
237
|
+
continue; // gateway derives preview from JWT env claim
|
|
238
|
+
if (value === undefined || value === null)
|
|
239
|
+
continue;
|
|
240
|
+
if (value === true) {
|
|
241
|
+
params.set(key, '1');
|
|
242
|
+
}
|
|
243
|
+
else if (value === false) {
|
|
244
|
+
// skip — gateway treats absence as false (matches legacy behavior)
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
else if (Array.isArray(value)) {
|
|
248
|
+
params.set(key, value.join(','));
|
|
249
|
+
}
|
|
250
|
+
else if (typeof value === 'object') {
|
|
251
|
+
params.set(key, JSON.stringify(value));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
params.set(key, String(value));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const locale = normalizeRequestLocale(options?.locale);
|
|
258
|
+
if (locale && !params.has('locale'))
|
|
259
|
+
params.set('locale', locale);
|
|
260
|
+
if (isPreview)
|
|
261
|
+
params.set('preview', '1');
|
|
262
|
+
if ([...params.keys()].length > 0) {
|
|
263
|
+
url += `?${params.toString()}`;
|
|
264
|
+
}
|
|
265
|
+
return url;
|
|
266
|
+
}
|
|
267
|
+
function buildPostBody(args, options, isPreview, includePreview = true) {
|
|
268
|
+
const { preview: _stripped, ...rest } = args;
|
|
269
|
+
void _stripped;
|
|
270
|
+
const locale = normalizeRequestLocale(options?.locale);
|
|
271
|
+
return JSON.stringify({
|
|
272
|
+
...rest,
|
|
273
|
+
...(locale && rest.locale === undefined ? { locale } : {}),
|
|
274
|
+
...(includePreview ? { preview: isPreview } : {}),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function normalizeRequestLocale(value) {
|
|
278
|
+
if (typeof value !== 'string')
|
|
279
|
+
return undefined;
|
|
280
|
+
const trimmed = value.trim();
|
|
281
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
282
|
+
}
|
|
283
|
+
function buildFetchInit(args) {
|
|
284
|
+
const { method, token, isPreview, udfPath, requestContext, options, body, signal, } = args;
|
|
285
|
+
const headers = {
|
|
286
|
+
Authorization: `Bearer ${token}`,
|
|
287
|
+
'X-Storystick-Client': 'sdk',
|
|
288
|
+
...(!isPreview ? contentShapeCapabilityHeaders('api', undefined, args.applicationBuildId) : {}),
|
|
289
|
+
};
|
|
290
|
+
if (udfPath === 'queries:queryV2')
|
|
291
|
+
headers[QUERY_HTTP_PROTOCOL_HEADER] = QUERY_HTTP_PROTOCOL;
|
|
292
|
+
if (isPreview)
|
|
293
|
+
headers['X-Storystick-Preview'] = '1';
|
|
294
|
+
if (isPreview &&
|
|
295
|
+
(udfPath === 'queries:queryV2' || udfPath === 'queries:getRouteByPathname') &&
|
|
296
|
+
requestContext?.previewReadContext &&
|
|
297
|
+
requestContext.previewQueryConsumer) {
|
|
298
|
+
headers[PREVIEW_GATEWAY_CONTEXT_HEADER] = encodePreviewGatewayContext({
|
|
299
|
+
version: 2,
|
|
300
|
+
contextId: requestContext.previewReadContext.contextId,
|
|
301
|
+
revision: requestContext.previewReadContext.revision,
|
|
302
|
+
...(requestContext.previewSessionId ? { sessionId: requestContext.previewSessionId } : {}),
|
|
303
|
+
...(requestContext.variantId
|
|
304
|
+
? { variantId: requestContext.variantId }
|
|
305
|
+
: {}),
|
|
306
|
+
...(requestContext.releaseId
|
|
307
|
+
? { releaseId: requestContext.releaseId }
|
|
308
|
+
: {}),
|
|
309
|
+
queryConsumer: requestContext.previewQueryConsumer,
|
|
310
|
+
...(requestContext.previewReadContext.perspective.kind === 'published' ? { published: true } : {}),
|
|
311
|
+
...(requestContext.previewReadContext.perspective.kind !== 'published' && requestContext.previewLiveQueryOverlayEnabled === true
|
|
312
|
+
? { liveQueryOverlay: true }
|
|
313
|
+
: {}),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (method === 'POST')
|
|
317
|
+
headers['Content-Type'] = 'application/json';
|
|
318
|
+
const init = {
|
|
319
|
+
method,
|
|
320
|
+
headers,
|
|
321
|
+
body,
|
|
322
|
+
};
|
|
323
|
+
if (signal)
|
|
324
|
+
init.signal = signal;
|
|
325
|
+
if (isPreview) {
|
|
326
|
+
// Preview reads must never enter Next's persistent fetch cache.
|
|
327
|
+
// `Cache-Control: no-store` + `cache: 'no-store'` win over any
|
|
328
|
+
// caller-supplied directives by being applied LAST (ADR-091
|
|
329
|
+
// Consequences > preview semantics).
|
|
330
|
+
init.cache = 'no-store';
|
|
331
|
+
headers['Cache-Control'] = 'no-store';
|
|
332
|
+
return init;
|
|
333
|
+
}
|
|
334
|
+
// Production path — honor user-supplied cache and next.* directives.
|
|
335
|
+
if (options?.cache)
|
|
336
|
+
init.cache = options.cache;
|
|
337
|
+
if (options?.deliveryCache === 'bypass') {
|
|
338
|
+
headers['x-storystick-delivery-fresh'] = '1';
|
|
339
|
+
init.cache = 'no-store';
|
|
340
|
+
return init;
|
|
341
|
+
}
|
|
342
|
+
if (options?.next) {
|
|
343
|
+
const next = {};
|
|
344
|
+
if (options.next.revalidate !== undefined)
|
|
345
|
+
next.revalidate = options.next.revalidate;
|
|
346
|
+
if (options.next.tags && options.next.tags.length > 0)
|
|
347
|
+
next.tags = options.next.tags;
|
|
348
|
+
if (Object.keys(next).length > 0)
|
|
349
|
+
init.next = next;
|
|
350
|
+
}
|
|
351
|
+
return init;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Refresh-vs-retry decision per ADR-091 / Tension #5 lock B.
|
|
355
|
+
*
|
|
356
|
+
* Decision tree (codex Lane A finding 2 — canonical metadata wins):
|
|
357
|
+
* - Body parses + carries a canonical `category` field → trust it.
|
|
358
|
+
* Refresh+retry iff `category === 'auth' && retryable === true`.
|
|
359
|
+
* `category === 'auth' && retryable === false` (e.g. ACCESS_DENIED)
|
|
360
|
+
* does NOT trigger refresh — the original error is surfaced.
|
|
361
|
+
* - Body absent / unparseable / no `category` field → legacy
|
|
362
|
+
* status-based fallback for pre-batch-4 deployments. HTTP 401/403
|
|
363
|
+
* means refreshable.
|
|
364
|
+
*
|
|
365
|
+
* Re-clones the response so the body can be re-read by the caller's
|
|
366
|
+
* StorystickError construction path.
|
|
367
|
+
*/
|
|
368
|
+
async function shouldRefreshAndRetry(response) {
|
|
369
|
+
if (response.ok)
|
|
370
|
+
return false;
|
|
371
|
+
let canonicalSeen = false;
|
|
372
|
+
try {
|
|
373
|
+
const cloned = response.clone();
|
|
374
|
+
const bodyJson = (await cloned.json().catch(() => null));
|
|
375
|
+
const wrapped = bodyJson?.error;
|
|
376
|
+
const candidate = wrapped ?? bodyJson ?? undefined;
|
|
377
|
+
if (candidate && candidate.category !== undefined) {
|
|
378
|
+
canonicalSeen = true;
|
|
379
|
+
return candidate.category === 'auth' && candidate.retryable === true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* canonical-envelope absent — fall through */
|
|
384
|
+
}
|
|
385
|
+
if (canonicalSeen)
|
|
386
|
+
return false;
|
|
387
|
+
// Pre-batch-4 deployments: status-based legacy fallback.
|
|
388
|
+
return response.status === 401 || response.status === 403;
|
|
389
|
+
}
|
|
390
|
+
function errorRecord(value) {
|
|
391
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
392
|
+
? value : {};
|
|
393
|
+
}
|
|
394
|
+
/** Read-only transport: explicit metadata wins; HTTP remains usable without it. */
|
|
395
|
+
async function buildStorystickError(response) {
|
|
396
|
+
const body = errorRecord(await response.json().catch(() => null));
|
|
397
|
+
const wrapped = errorRecord(body.error);
|
|
398
|
+
const code = typeof wrapped.code === 'string' ? wrapped.code
|
|
399
|
+
: typeof body.code === 'string' ? body.code : `HTTP_${response.status}`;
|
|
400
|
+
const message = typeof wrapped.message === 'string' ? wrapped.message
|
|
401
|
+
: typeof body.message === 'string' ? body.message : response.statusText || 'Request failed';
|
|
402
|
+
const temporary = isTemporaryHttpStatus(response.status);
|
|
403
|
+
const category = typeof wrapped.category === 'string' &&
|
|
404
|
+
['validation', 'conflict', 'auth', 'system', 'cancel'].includes(wrapped.category)
|
|
405
|
+
? wrapped.category : undefined;
|
|
406
|
+
return new StorystickError(`${code} — ${message}`, {
|
|
407
|
+
ref: typeof wrapped.ref === 'string' ? wrapped.ref : response.headers.get('X-Storystick-Error-Ref') || undefined,
|
|
408
|
+
code,
|
|
409
|
+
category: category ?? (temporary ? 'system' : undefined),
|
|
410
|
+
recoverable: typeof wrapped.recoverable === 'boolean' ? wrapped.recoverable : undefined,
|
|
411
|
+
retryable: httpFailureRetryable({ operation: 'read', status: response.status,
|
|
412
|
+
retryable: typeof wrapped.retryable === 'boolean' ? wrapped.retryable : undefined }),
|
|
413
|
+
payload: wrapped.payload,
|
|
414
|
+
status: response.status,
|
|
415
|
+
retryAfterMs: parseRetryAfter(response.headers.get('Retry-After'), Date.now()),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// ─────────────────────────────────────────────
|
|
419
|
+
// onAccessedDocuments tap
|
|
420
|
+
// ─────────────────────────────────────────────
|
|
421
|
+
/**
|
|
422
|
+
* Fire the `onAccessedDocuments` tap with extracted documentIds when
|
|
423
|
+
* the preview path is bound. Fire-and-forget — never blocks, never
|
|
424
|
+
* throws. Production calls do NOT fire the tap; observation is a
|
|
425
|
+
* preview-only telemetry channel (ADR-131 §D3.1b R2b item 16).
|
|
426
|
+
*
|
|
427
|
+
* Gated invariant: callback signature is sync-only by design. If a
|
|
428
|
+
* future adapter wants an async tap, the adapter wraps `Promise.allSettled`
|
|
429
|
+
* inside the sync callback — this layer must never block the request
|
|
430
|
+
* response on telemetry I/O.
|
|
431
|
+
*/
|
|
432
|
+
function fireOnAccessedContent(input) {
|
|
433
|
+
if (!input.isPreview)
|
|
434
|
+
return;
|
|
435
|
+
const documentIds = extractAccessedDocumentIds(input.udfPath, input.data);
|
|
436
|
+
const legacyTap = input.requestContext?.onAccessedDocuments;
|
|
437
|
+
if (legacyTap && documentIds.length > 0) {
|
|
438
|
+
try {
|
|
439
|
+
legacyTap(documentIds);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Same fire-and-forget isolation as the enriched tap below.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const codeVersion = input.requestContext?.previewCodeVersion;
|
|
446
|
+
const observationId = input.requestContext?.previewObservationId;
|
|
447
|
+
const watermarkHeader = input.response.headers.get('X-Storystick-Preview-Read-Watermark');
|
|
448
|
+
const renderWatermark = watermarkHeader === null ? Number.NaN : Number(watermarkHeader);
|
|
449
|
+
// Amendment-003-style fence: a mixed old/new SDK pair emits no enriched
|
|
450
|
+
// observation. It must never attach an unversioned watermark to a frame.
|
|
451
|
+
if (typeof codeVersion !== 'string' ||
|
|
452
|
+
codeVersion.length === 0 ||
|
|
453
|
+
typeof observationId !== 'string' ||
|
|
454
|
+
observationId.length === 0 ||
|
|
455
|
+
!Number.isFinite(renderWatermark))
|
|
456
|
+
return;
|
|
457
|
+
const receiptId = input.response.headers.get(PREVIEW_QUERY_RECEIPT_HEADER);
|
|
458
|
+
const observation = {
|
|
459
|
+
...(input.requestContext?.previewReadContext
|
|
460
|
+
? { readContext: input.requestContext.previewReadContext }
|
|
461
|
+
: {}),
|
|
462
|
+
...(receiptId && /^pqr_[a-f0-9]{64}$/.test(receiptId) ? { receiptId } : {}),
|
|
463
|
+
documentIds,
|
|
464
|
+
storyTypes: extractAccessedStoryTypes(input.udfPath, input.args, input.data),
|
|
465
|
+
codeVersion,
|
|
466
|
+
observationId,
|
|
467
|
+
renderWatermark,
|
|
468
|
+
};
|
|
469
|
+
attachPreviewReadObservation(input.data, observation);
|
|
470
|
+
const tap = input.requestContext?.onAccessedContent;
|
|
471
|
+
if (!tap)
|
|
472
|
+
return;
|
|
473
|
+
// A zero-document list is still a read dependency on its story type. The
|
|
474
|
+
// first document created for that type must wake the displayed frame.
|
|
475
|
+
if (observation.documentIds.length === 0 &&
|
|
476
|
+
observation.storyTypes.length === 0)
|
|
477
|
+
return;
|
|
478
|
+
try {
|
|
479
|
+
tap(observation);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// Tap implementation threw — swallow. The tap is fire-and-forget
|
|
483
|
+
// by contract; an exception there must never propagate into the
|
|
484
|
+
// RSC render path.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function extractAccessedStoryTypes(udfPath, args, data) {
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
const add = (value) => {
|
|
490
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
491
|
+
seen.add(value.trim());
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
const addFromDocument = (value) => {
|
|
495
|
+
if (value && typeof value === 'object') {
|
|
496
|
+
add(value.storyType);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
switch (udfPath) {
|
|
500
|
+
case 'queries:getRouteByPathname':
|
|
501
|
+
if (Array.isArray(args.storyTypes)) {
|
|
502
|
+
for (const storyType of args.storyTypes)
|
|
503
|
+
add(storyType);
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
case 'queries:queryV2':
|
|
507
|
+
if (args.query && typeof args.query === 'object') {
|
|
508
|
+
const target = args.query.target;
|
|
509
|
+
if (target && typeof target === 'object') {
|
|
510
|
+
add(target.storyType);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
default:
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (Array.isArray(data)) {
|
|
518
|
+
for (const row of data)
|
|
519
|
+
addFromDocument(row);
|
|
520
|
+
}
|
|
521
|
+
else if (data && typeof data === 'object') {
|
|
522
|
+
addFromDocument(data);
|
|
523
|
+
const items = data.data;
|
|
524
|
+
if (Array.isArray(items)) {
|
|
525
|
+
for (const row of items)
|
|
526
|
+
addFromDocument(row);
|
|
527
|
+
}
|
|
528
|
+
const document = data.document;
|
|
529
|
+
addFromDocument(document);
|
|
530
|
+
const documents = data.documents;
|
|
531
|
+
if (Array.isArray(documents)) {
|
|
532
|
+
for (const row of documents)
|
|
533
|
+
addFromDocument(row);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return [...seen];
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Per-udfPath documentId extraction (ADR-131 §D3.1b). Returns a
|
|
540
|
+
* de-duplicated, declared-order list (Set semantics with stable
|
|
541
|
+
* insertion order — JS Map/Set preserve insertion order).
|
|
542
|
+
*
|
|
543
|
+
* Shape rules (mirrors the gateway's response envelopes):
|
|
544
|
+
* - `queries:getRouteByPathname`→ `{ document: { documentId, ... } }`
|
|
545
|
+
* on hit; miss envelopes do not contain a document
|
|
546
|
+
* - `queries:queryV2` → document(s) in the exact result `.data`
|
|
547
|
+
*
|
|
548
|
+
* Unknown udfPath OR a response missing the expected `documentId` shape
|
|
549
|
+
* returns the empty list — the tap is silently inert rather than
|
|
550
|
+
* surfacing a contract violation. The orchestrator's second-preview
|
|
551
|
+
* bundle composition will reconverge via the schema marker path
|
|
552
|
+
* (§D3.1a 3-source union — chrome:true ∪ page-closure ∪ observed).
|
|
553
|
+
*/
|
|
554
|
+
function extractAccessedDocumentIds(udfPath, data) {
|
|
555
|
+
const seen = new Set();
|
|
556
|
+
const add = (value) => {
|
|
557
|
+
if (typeof value === 'string' && value.length > 0)
|
|
558
|
+
seen.add(value);
|
|
559
|
+
};
|
|
560
|
+
switch (udfPath) {
|
|
561
|
+
case 'queries:getRouteByPathname': {
|
|
562
|
+
if (data && typeof data === 'object') {
|
|
563
|
+
const doc = data.document;
|
|
564
|
+
if (doc && typeof doc === 'object') {
|
|
565
|
+
add(doc.documentId);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
case 'queries:queryV2': {
|
|
571
|
+
if (data && typeof data === 'object') {
|
|
572
|
+
const result = data.data;
|
|
573
|
+
for (const row of Array.isArray(result) ? result : [result]) {
|
|
574
|
+
if (row && typeof row === 'object')
|
|
575
|
+
add(row.documentId);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
default:
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
return Array.from(seen);
|
|
584
|
+
}
|