@storystick/client 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 +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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveStorystickPage` — core URL → document resolver (ADR-094 §1).
|
|
3
|
+
*
|
|
4
|
+
* Takes a configured client and a pathname (and optional storytype filter),
|
|
5
|
+
* dispatches a SINGLE call to the backend `queries:getRouteByPathname`
|
|
6
|
+
* cross-storytype endpoint (Lane A0), and returns a discriminated
|
|
7
|
+
* {@link ResolvedRoute}.
|
|
8
|
+
*
|
|
9
|
+
* Single-call semantics: the backend resolves cross-storytype via the
|
|
10
|
+
* `by_projectId_resolvedPathname` index (ADR-086 URL-uniqueness invariant);
|
|
11
|
+
* no SDK-side fan-out across storytypes. 1 indexed call per invocation,
|
|
12
|
+
* regardless of how many storytypes the project declares.
|
|
13
|
+
*
|
|
14
|
+
* Production vs preview parity is JWT-driven — the configured client's
|
|
15
|
+
* `requestContext.previewToken` (when set) routes the underlying transport
|
|
16
|
+
* to the preview JWT; otherwise the API-key mint cache supplies the
|
|
17
|
+
* production JWT (audit P1-1).
|
|
18
|
+
*/
|
|
19
|
+
import { attachDocumentLocaleRouting, publicHrefForLocale, resolveLocalePathname } from './locale-routing.js';
|
|
20
|
+
import { getInternalClientRuntime } from './query-client.js';
|
|
21
|
+
import { normalizePathname } from './normalize-pathname.js';
|
|
22
|
+
import { attachPublicRouteIdentity } from './public-delivery.js';
|
|
23
|
+
import { hasUsablePreviewToken, withPreviewVariantArgs } from './preview-scope.js';
|
|
24
|
+
import { attachPreviewFrameDependencies, previewAssetDependenciesFromValue, } from './preview-frame-dependencies.js';
|
|
25
|
+
import { createPreviewProvenanceReadCacheKey, runPreviewProvenanceReadLifecycle, } from './preview-read-lifecycle.js';
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a developer-supplied pathname to a `ResolvedRoute` discriminated
|
|
28
|
+
* union. Throws on infrastructure failures (auth, network, gateway 5xx) —
|
|
29
|
+
* the return shape only models *resolution outcomes*, never failures
|
|
30
|
+
* (per ADR-094 §1 error strategy).
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveStorystickPage(client, input) {
|
|
33
|
+
// ADR-094 §1 storyTypes semantics — empty array is rejected as
|
|
34
|
+
// INVALID_ARGS. The almost-always-a-bug case (developer forgot to
|
|
35
|
+
// populate the list) gets a clear error rather than silently
|
|
36
|
+
// returning notFound.
|
|
37
|
+
if (input.storyTypes !== undefined && input.storyTypes.length === 0) {
|
|
38
|
+
throw new Error('StorystickError: resolveStorystickPage was called with `storyTypes: []` ' +
|
|
39
|
+
'(empty array). Empty storyTypes is rejected as INVALID_ARGS — pass a non-empty array ' +
|
|
40
|
+
'to filter to specific types, or omit the argument entirely to resolve any URL-routable storytype. ' +
|
|
41
|
+
'See ADR-094 §1 storyTypes semantics.');
|
|
42
|
+
}
|
|
43
|
+
const canonical = normalizePathname(input.pathname);
|
|
44
|
+
const runtime = getInternalClientRuntime(client);
|
|
45
|
+
// Dispatch a single cross-storytype call via the configured client's
|
|
46
|
+
// transport. We don't go through a generated story query because this helper
|
|
47
|
+
// isn't bound to a single storytype — the backend resolves any URL-routable
|
|
48
|
+
// type that matches the canonical pathname (ADR-094 §1.bis).
|
|
49
|
+
const args = { pathname: canonical, ...(input.locale ? { locale: input.locale } : {}) };
|
|
50
|
+
if (input.storyTypes && input.storyTypes.length > 0) {
|
|
51
|
+
args.storyTypes = [...input.storyTypes];
|
|
52
|
+
}
|
|
53
|
+
return runPreviewProvenanceReadLifecycle(runtime.requestContext, {
|
|
54
|
+
kind: 'resolved-route',
|
|
55
|
+
cacheKey: createPreviewProvenanceReadCacheKey('resolved-route', {
|
|
56
|
+
pathname: canonical,
|
|
57
|
+
locale: input.locale,
|
|
58
|
+
publicOrigins: runtime.localeRouting?.publicOrigins,
|
|
59
|
+
storyTypes: input.storyTypes ? [...input.storyTypes].sort() : undefined,
|
|
60
|
+
}),
|
|
61
|
+
executeRaw: async () => {
|
|
62
|
+
const response = await runtime.transport.request({
|
|
63
|
+
udfPath: 'queries:getRouteByPathname',
|
|
64
|
+
args: withPreviewVariantArgs('queries:getRouteByPathname', args, runtime.requestContext),
|
|
65
|
+
options: undefined,
|
|
66
|
+
requestContext: runtime.requestContext,
|
|
67
|
+
signal: undefined,
|
|
68
|
+
});
|
|
69
|
+
const data = response.data;
|
|
70
|
+
if (!data)
|
|
71
|
+
return { type: 'notFound' };
|
|
72
|
+
if ('notFound' in data) {
|
|
73
|
+
return {
|
|
74
|
+
type: 'notFound',
|
|
75
|
+
...(data.localeContext ? { localeContext: siteLocaleContext(data.localeContext, runtime.localeRouting?.publicOrigins) } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if ('redirectTo' in data) {
|
|
79
|
+
let redirectTo = data.redirectTo;
|
|
80
|
+
if (runtime.localeRouting?.publicOrigins && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
|
|
81
|
+
const resolved = resolveLocalePathname(redirectTo, runtime.localeRouting);
|
|
82
|
+
if (!resolved.ok)
|
|
83
|
+
throw new TypeError('Invalid published redirect locale.');
|
|
84
|
+
const suffix = redirectTo.match(/[?#].*$/)?.[0] ?? '';
|
|
85
|
+
redirectTo = publicHrefForLocale(resolved.pathname, resolved.locale, runtime.localeRouting) + suffix;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
type: 'redirect',
|
|
89
|
+
redirectTo,
|
|
90
|
+
permanent: data.permanent === true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (hasUsablePreviewToken(runtime.requestContext)) {
|
|
94
|
+
attachPreviewFrameDependencies(data.document, [
|
|
95
|
+
...routeDependencies({
|
|
96
|
+
pathname: canonical,
|
|
97
|
+
storyTypes: input.storyTypes,
|
|
98
|
+
document: data.document,
|
|
99
|
+
storyType: data.storyType,
|
|
100
|
+
}),
|
|
101
|
+
...previewAssetDependenciesFromValue(data.document),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
const localeContext = siteLocaleContext(data.localeContext, runtime.localeRouting?.publicOrigins);
|
|
105
|
+
attachDocumentLocaleRouting(data.document, localeContext);
|
|
106
|
+
if (!hasUsablePreviewToken(runtime.requestContext))
|
|
107
|
+
attachPublicRouteIdentity(data.document, response.publicReadIdentity);
|
|
108
|
+
return {
|
|
109
|
+
type: 'document',
|
|
110
|
+
document: data.document,
|
|
111
|
+
storyType: data.storyType,
|
|
112
|
+
canonicalPathname: data.localeContext.pathname,
|
|
113
|
+
publicPathname: localeContext.publicPathname,
|
|
114
|
+
localeContext,
|
|
115
|
+
primaryLocale: data.primaryLocale,
|
|
116
|
+
alternates: data.alternates,
|
|
117
|
+
seo: data.seo ?? { indexable: true },
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function routeDependencies(input) {
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
kind: 'route-lookup',
|
|
126
|
+
pathname: input.pathname,
|
|
127
|
+
...(input.storyTypes && input.storyTypes.length > 0
|
|
128
|
+
? { storyTypes: [...input.storyTypes].sort() }
|
|
129
|
+
: {}),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
kind: 'document',
|
|
133
|
+
documentId: input.document.documentId,
|
|
134
|
+
storyType: input.storyType,
|
|
135
|
+
reason: 'route-document',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
function siteLocaleContext(context, publicOrigins) {
|
|
140
|
+
return {
|
|
141
|
+
...context,
|
|
142
|
+
...(publicOrigins ? { publicOrigins } : {}),
|
|
143
|
+
...(publicOrigins?.[context.locale] ? { publicPathname: '/' + context.pathname.replace(/^\/+/, '') } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project shortId resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves a project shortId (8-char Crockford, e.g. `a3k9m2x7`) to its
|
|
5
|
+
* infrastructure config (endpoint, metadata, plan, features) via the
|
|
6
|
+
* Storystick gateway API.
|
|
7
|
+
*
|
|
8
|
+
* Post ADR-063 (Wave 2): the public project identifier is the shortId.
|
|
9
|
+
* The same identifier appears in URLs (`/p/<label>-<shortId>`), SDK
|
|
10
|
+
* config (`STORYSTICK_PROJECT_ID=<shortId>`), API key prefixes, logs,
|
|
11
|
+
* and support lookups. Previous slug-based config has been removed.
|
|
12
|
+
*
|
|
13
|
+
* Post multi-tenant refactor (ADR-058): the gateway returns `endpoint`
|
|
14
|
+
* (the Storystick gateway URL — same for all tenants) + tenant-scoped
|
|
15
|
+
* metadata. Tenant isolation is carried by a JWT claim that the gateway
|
|
16
|
+
* mints from the caller's API key server-side — the SDK does not need
|
|
17
|
+
* to know the internal Convex id.
|
|
18
|
+
*
|
|
19
|
+
* Used by:
|
|
20
|
+
* - generated clients that need endpoint discovery
|
|
21
|
+
* - @storystick/next server helpers (server-side, cached in memory)
|
|
22
|
+
*/
|
|
23
|
+
export interface ProjectConfig {
|
|
24
|
+
/** Storystick gateway endpoint.
|
|
25
|
+
* Same URL for every project on this Storystick deployment. */
|
|
26
|
+
readonly endpoint: string;
|
|
27
|
+
/** Public project identifier — the 8-char Crockford shortId that
|
|
28
|
+
* appears in URLs, SDK env config, API key prefixes, logs. */
|
|
29
|
+
readonly projectId: string;
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly plan: string;
|
|
32
|
+
readonly features: Readonly<Record<string, boolean>>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a project shortId to its config.
|
|
36
|
+
*
|
|
37
|
+
* Client-side: caches in sessionStorage (survives page navigations).
|
|
38
|
+
* Server-side: caches in module-level Map (expires after five minutes).
|
|
39
|
+
*
|
|
40
|
+
* const config = await resolveProject('a3k9m2x7')
|
|
41
|
+
* // → { endpoint: 'https://...', projectId: 'a3k9m2x7', plan: 'free', ... }
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveProject(projectId: string, gatewayUrl?: string): Promise<ProjectConfig>;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { STORYSTICK_GATEWAY_ORIGIN } from '@storystick/types/public-endpoints';
|
|
2
|
+
// ─────────────────────────────────────────────
|
|
3
|
+
// Constants
|
|
4
|
+
// ─────────────────────────────────────────────
|
|
5
|
+
const DEFAULT_GATEWAY_URL = STORYSTICK_GATEWAY_ORIGIN;
|
|
6
|
+
const PROJECT_CACHE_PREFIX = 'storystick:project:v1:';
|
|
7
|
+
const PROJECT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
// Module-level cache for server-side (bounded and refreshed after five minutes)
|
|
9
|
+
const PROJECT_CACHE_MAX_ENTRIES = 128;
|
|
10
|
+
const _cache = new Map();
|
|
11
|
+
const inFlight = new Map();
|
|
12
|
+
// ─────────────────────────────────────────────
|
|
13
|
+
// Resolution
|
|
14
|
+
// ─────────────────────────────────────────────
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a project shortId to its config.
|
|
17
|
+
*
|
|
18
|
+
* Client-side: caches in sessionStorage (survives page navigations).
|
|
19
|
+
* Server-side: caches in module-level Map (expires after five minutes).
|
|
20
|
+
*
|
|
21
|
+
* const config = await resolveProject('a3k9m2x7')
|
|
22
|
+
* // → { endpoint: 'https://...', projectId: 'a3k9m2x7', plan: 'free', ... }
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveProject(projectId, gatewayUrl) {
|
|
25
|
+
const gateway = gatewayUrl ?? DEFAULT_GATEWAY_URL;
|
|
26
|
+
// ADR-131 C9 codex R3 P2 fix : the cache key must include the gateway
|
|
27
|
+
// URL so that a self-host customer passing a non-default `gatewayUrl`
|
|
28
|
+
// does not get the default-gateway-resolved config back from the cache.
|
|
29
|
+
// The same shortId resolved through different gateways yields different
|
|
30
|
+
// endpoint/plan/features rows.
|
|
31
|
+
const cacheKey = `${projectId}::${gateway}`;
|
|
32
|
+
const cached = getFromCache(cacheKey, projectId);
|
|
33
|
+
if (cached)
|
|
34
|
+
return cached;
|
|
35
|
+
const pending = inFlight.get(cacheKey);
|
|
36
|
+
if (pending)
|
|
37
|
+
return pending;
|
|
38
|
+
const request = fetchProject(projectId, gateway, cacheKey);
|
|
39
|
+
// Bound bookkeeping even when a discovery endpoint is unavailable.
|
|
40
|
+
if (inFlight.size < PROJECT_CACHE_MAX_ENTRIES)
|
|
41
|
+
inFlight.set(cacheKey, request);
|
|
42
|
+
try {
|
|
43
|
+
return await request;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (inFlight.get(cacheKey) === request)
|
|
47
|
+
inFlight.delete(cacheKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function fetchProject(projectId, gateway, cacheKey) {
|
|
51
|
+
const url = `${gateway}/api/projects/${encodeURIComponent(projectId)}/config`;
|
|
52
|
+
const response = await fetch(url);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
if (response.status === 404) {
|
|
55
|
+
const body = await response.json().catch(() => ({}));
|
|
56
|
+
throw new Error(body.message ??
|
|
57
|
+
`StorystickError: Project "${projectId}" not found.\n` +
|
|
58
|
+
`Check your STORYSTICK_PROJECT_ID environment variable.\n`);
|
|
59
|
+
}
|
|
60
|
+
if (response.status === 503) {
|
|
61
|
+
const body = await response.json().catch(() => ({}));
|
|
62
|
+
throw new Error(body.message ??
|
|
63
|
+
`StorystickError: Project "${projectId}" has no backend provisioned.\n`);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`StorystickError: Failed to resolve project "${projectId}" (HTTP ${response.status})`);
|
|
66
|
+
}
|
|
67
|
+
// Gateway returns { endpoint, projectId: <convex _id>, name, shortId,
|
|
68
|
+
// urlLabel, plan, features }. Post-Wave-2 the SDK treats the shortId
|
|
69
|
+
// as the public projectId; the Convex `_id` stays hidden inside the
|
|
70
|
+
// gateway (used only for JWT minting + internal FK integrity).
|
|
71
|
+
const raw = await response.json();
|
|
72
|
+
if (!isRecord(raw))
|
|
73
|
+
throw new Error('StorystickError: Invalid project discovery response.');
|
|
74
|
+
const config = {
|
|
75
|
+
endpoint: raw.endpoint,
|
|
76
|
+
projectId: raw.shortId,
|
|
77
|
+
name: raw.name,
|
|
78
|
+
plan: raw.plan,
|
|
79
|
+
features: raw.features,
|
|
80
|
+
};
|
|
81
|
+
if (!isProjectConfig(config, projectId)) {
|
|
82
|
+
throw new Error('StorystickError: Invalid project discovery response.');
|
|
83
|
+
}
|
|
84
|
+
Object.freeze(config.features);
|
|
85
|
+
Object.freeze(config);
|
|
86
|
+
// Store in cache (keyed by projectId + gateway URL — see cacheKey above)
|
|
87
|
+
setInCache(cacheKey, config);
|
|
88
|
+
return config;
|
|
89
|
+
}
|
|
90
|
+
// ─────────────────────────────────────────────
|
|
91
|
+
// Caching
|
|
92
|
+
// ─────────────────────────────────────────────
|
|
93
|
+
function getFromCache(cacheKey, projectId) {
|
|
94
|
+
// Server-side: module-level Map
|
|
95
|
+
if (typeof window === 'undefined') {
|
|
96
|
+
const entry = _cache.get(cacheKey);
|
|
97
|
+
if (!entry)
|
|
98
|
+
return null;
|
|
99
|
+
const age = Date.now() - entry.timestamp;
|
|
100
|
+
if (age < 0 || age >= PROJECT_CACHE_TTL_MS) {
|
|
101
|
+
_cache.delete(cacheKey);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
// Least recently used entries are evicted first.
|
|
105
|
+
_cache.delete(cacheKey);
|
|
106
|
+
_cache.set(cacheKey, entry);
|
|
107
|
+
return entry.config;
|
|
108
|
+
}
|
|
109
|
+
// Client-side: sessionStorage
|
|
110
|
+
try {
|
|
111
|
+
const raw = sessionStorage.getItem(`${PROJECT_CACHE_PREFIX}${cacheKey}`);
|
|
112
|
+
if (!raw)
|
|
113
|
+
return null;
|
|
114
|
+
const parsed = JSON.parse(raw);
|
|
115
|
+
if (!isRecord(parsed))
|
|
116
|
+
return null;
|
|
117
|
+
const { config, timestamp } = parsed;
|
|
118
|
+
if (typeof timestamp !== 'number' ||
|
|
119
|
+
!Number.isFinite(timestamp) ||
|
|
120
|
+
timestamp > Date.now() ||
|
|
121
|
+
Date.now() - timestamp >= PROJECT_CACHE_TTL_MS ||
|
|
122
|
+
!isProjectConfig(config, projectId))
|
|
123
|
+
return null;
|
|
124
|
+
Object.freeze(config.features);
|
|
125
|
+
return Object.freeze(config);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function setInCache(cacheKey, config) {
|
|
132
|
+
// Server-side
|
|
133
|
+
if (typeof window === 'undefined') {
|
|
134
|
+
_cache.delete(cacheKey);
|
|
135
|
+
_cache.set(cacheKey, { config, timestamp: Date.now() });
|
|
136
|
+
while (_cache.size > PROJECT_CACHE_MAX_ENTRIES)
|
|
137
|
+
_cache.delete(_cache.keys().next().value);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Client-side
|
|
141
|
+
try {
|
|
142
|
+
sessionStorage.setItem(`${PROJECT_CACHE_PREFIX}${cacheKey}`, JSON.stringify({ config, timestamp: Date.now() }));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// sessionStorage might be unavailable (private browsing, etc.)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function isRecord(value) {
|
|
149
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
function isProjectConfig(value, projectId) {
|
|
152
|
+
if (!isRecord(value) || !isRecord(value.features))
|
|
153
|
+
return false;
|
|
154
|
+
return typeof value.endpoint === 'string' &&
|
|
155
|
+
value.endpoint.length > 0 &&
|
|
156
|
+
value.projectId === projectId &&
|
|
157
|
+
typeof value.name === 'string' &&
|
|
158
|
+
typeof value.plan === 'string' &&
|
|
159
|
+
Object.values(value.features).every((feature) => typeof feature === 'boolean');
|
|
160
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema delivery — ADR-131 Amendment 002.
|
|
3
|
+
*
|
|
4
|
+
* Standalone wrapper around the existing `/api/q/schemas/current` HTTP
|
|
5
|
+
* route (`apps/studio/convex/gateway_queries.ts::schemasCurrent`). Auth
|
|
6
|
+
* comes from `client.requestContext.previewToken` in preview mode (the
|
|
7
|
+
* iframe runtime path) ; production callers use the same configured
|
|
8
|
+
* client and inherit the API-key mint chain.
|
|
9
|
+
*
|
|
10
|
+
* NOT exposed as `client.schemas.current()`. The client factory's Proxy
|
|
11
|
+
* treats unknown string keys as story types (see `index.ts:198`) ;
|
|
12
|
+
* adding `schemas` as a first-class namespace would require modifying
|
|
13
|
+
* the surface/type/metadata assembly. The standalone-function pattern
|
|
14
|
+
* sidesteps that and matches the existing convention for non-storytype
|
|
15
|
+
* SDK calls.
|
|
16
|
+
*/
|
|
17
|
+
import type { Client } from './query-client.js';
|
|
18
|
+
import type { RequestOptions } from './types.js';
|
|
19
|
+
import { STORY_SCHEMA_FORMAT_VERSION } from '@storystick/types';
|
|
20
|
+
import { StorystickError } from './errors.js';
|
|
21
|
+
export declare const SCHEMA_FORMAT_UNSUPPORTED_CODE: "storystick/SCHEMA_FORMAT_UNSUPPORTED";
|
|
22
|
+
export interface SchemaFormatUnsupportedPayload {
|
|
23
|
+
expectedFormatVersion: typeof STORY_SCHEMA_FORMAT_VERSION;
|
|
24
|
+
receivedFormatVersion: number | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function isSchemaFormatUnsupportedError(error: unknown): error is StorystickError & {
|
|
27
|
+
readonly code: typeof SCHEMA_FORMAT_UNSUPPORTED_CODE;
|
|
28
|
+
readonly payload: SchemaFormatUnsupportedPayload;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Shape returned by `GET /api/q/schemas/current`. For an installed
|
|
32
|
+
* generation this is its row in the `schemas` Convex table. A verified,
|
|
33
|
+
* not-yet-installed B Preview uses the same envelope with `pushedAt: 0`.
|
|
34
|
+
*
|
|
35
|
+
* The route returns the full row — the iframe schema-store keeps it
|
|
36
|
+
* verbatim to preserve fidelity for future tooling ; downstream consumers
|
|
37
|
+
* (resolver §D6.1, render-for-correlation) extract the fields they need.
|
|
38
|
+
*
|
|
39
|
+
* `null` when no schema has been pushed yet for the tenant.
|
|
40
|
+
*
|
|
41
|
+
* Additional Convex metadata (`_id`, `_creationTime`) may be present on
|
|
42
|
+
* the row at runtime ; the interface omits them for clarity. Callers
|
|
43
|
+
* that need them can read via `(row as { _id?: string })._id`.
|
|
44
|
+
*/
|
|
45
|
+
export interface RawSchemaRow {
|
|
46
|
+
projectId: string;
|
|
47
|
+
formatVersion: typeof STORY_SCHEMA_FORMAT_VERSION;
|
|
48
|
+
version: string;
|
|
49
|
+
stories: unknown;
|
|
50
|
+
storyCount: number;
|
|
51
|
+
zoneMappings?: unknown;
|
|
52
|
+
fieldBindings?: unknown;
|
|
53
|
+
diagnostics?: unknown;
|
|
54
|
+
pushedAt: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Fetch the schema paired to this client's signed build. Multi-tenant
|
|
58
|
+
* scoping is enforced by `gatewayTenantQuery`; the JWT's `projectId` is
|
|
59
|
+
* the structural cross-tenant boundary.
|
|
60
|
+
*
|
|
61
|
+
* Returns `null` if no schema has been pushed for the tenant.
|
|
62
|
+
*
|
|
63
|
+
* Wire path (ADR-131 Amendment 002 §3.1) :
|
|
64
|
+
* client.transport.request({ udfPath: 'queries:schemas.current', ... })
|
|
65
|
+
* → endpointForUdf('queries:schemas.current')
|
|
66
|
+
* → GET /api/q/schemas/current
|
|
67
|
+
* → authAndLimit (preview JWT OR API-key mint)
|
|
68
|
+
* → gateway_queries::schemasCurrent
|
|
69
|
+
* → signed generation resolution → exact schema row (or prepared target)
|
|
70
|
+
*/
|
|
71
|
+
export declare function fetchCurrentSchema(client: Client, options?: RequestOptions): Promise<RawSchemaRow | null>;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema delivery — ADR-131 Amendment 002.
|
|
3
|
+
*
|
|
4
|
+
* Standalone wrapper around the existing `/api/q/schemas/current` HTTP
|
|
5
|
+
* route (`apps/studio/convex/gateway_queries.ts::schemasCurrent`). Auth
|
|
6
|
+
* comes from `client.requestContext.previewToken` in preview mode (the
|
|
7
|
+
* iframe runtime path) ; production callers use the same configured
|
|
8
|
+
* client and inherit the API-key mint chain.
|
|
9
|
+
*
|
|
10
|
+
* NOT exposed as `client.schemas.current()`. The client factory's Proxy
|
|
11
|
+
* treats unknown string keys as story types (see `index.ts:198`) ;
|
|
12
|
+
* adding `schemas` as a first-class namespace would require modifying
|
|
13
|
+
* the surface/type/metadata assembly. The standalone-function pattern
|
|
14
|
+
* sidesteps that and matches the existing convention for non-storytype
|
|
15
|
+
* SDK calls.
|
|
16
|
+
*/
|
|
17
|
+
import { STORY_SCHEMA_FORMAT_VERSION } from '@storystick/types';
|
|
18
|
+
import { StorystickError } from './errors.js';
|
|
19
|
+
import { getInternalClientRuntime } from './query-client.js';
|
|
20
|
+
export const SCHEMA_FORMAT_UNSUPPORTED_CODE = 'storystick/SCHEMA_FORMAT_UNSUPPORTED';
|
|
21
|
+
export function isSchemaFormatUnsupportedError(error) {
|
|
22
|
+
return error instanceof StorystickError &&
|
|
23
|
+
error.code === SCHEMA_FORMAT_UNSUPPORTED_CODE;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fetch the schema paired to this client's signed build. Multi-tenant
|
|
27
|
+
* scoping is enforced by `gatewayTenantQuery`; the JWT's `projectId` is
|
|
28
|
+
* the structural cross-tenant boundary.
|
|
29
|
+
*
|
|
30
|
+
* Returns `null` if no schema has been pushed for the tenant.
|
|
31
|
+
*
|
|
32
|
+
* Wire path (ADR-131 Amendment 002 §3.1) :
|
|
33
|
+
* client.transport.request({ udfPath: 'queries:schemas.current', ... })
|
|
34
|
+
* → endpointForUdf('queries:schemas.current')
|
|
35
|
+
* → GET /api/q/schemas/current
|
|
36
|
+
* → authAndLimit (preview JWT OR API-key mint)
|
|
37
|
+
* → gateway_queries::schemasCurrent
|
|
38
|
+
* → signed generation resolution → exact schema row (or prepared target)
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchCurrentSchema(client, options) {
|
|
41
|
+
const runtime = getInternalClientRuntime(client);
|
|
42
|
+
const response = await runtime.transport.request({
|
|
43
|
+
udfPath: 'queries:schemas.current',
|
|
44
|
+
args: {},
|
|
45
|
+
options,
|
|
46
|
+
requestContext: runtime.requestContext,
|
|
47
|
+
});
|
|
48
|
+
if (response.data === null)
|
|
49
|
+
return null;
|
|
50
|
+
if (!response.data ||
|
|
51
|
+
typeof response.data !== 'object' ||
|
|
52
|
+
Array.isArray(response.data) ||
|
|
53
|
+
response.data.formatVersion !==
|
|
54
|
+
STORY_SCHEMA_FORMAT_VERSION) {
|
|
55
|
+
const received = response.data !== null &&
|
|
56
|
+
typeof response.data === 'object' &&
|
|
57
|
+
!Array.isArray(response.data) &&
|
|
58
|
+
typeof response.data.formatVersion ===
|
|
59
|
+
'number'
|
|
60
|
+
? response.data.formatVersion
|
|
61
|
+
: null;
|
|
62
|
+
throw new StorystickError(`Unsupported Storystick schema format; expected V${STORY_SCHEMA_FORMAT_VERSION}, ` +
|
|
63
|
+
`received ${received === null ? 'a payload without formatVersion' : `V${received}`}.`, {
|
|
64
|
+
code: SCHEMA_FORMAT_UNSUPPORTED_CODE,
|
|
65
|
+
category: 'validation',
|
|
66
|
+
recoverable: true,
|
|
67
|
+
retryable: false,
|
|
68
|
+
payload: {
|
|
69
|
+
expectedFormatVersion: STORY_SCHEMA_FORMAT_VERSION,
|
|
70
|
+
receivedFormatVersion: received,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return response.data;
|
|
75
|
+
}
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ResolvedRoute } from './index.js';
|
|
2
|
+
import { type PublicLocaleOrigins } from './locale-routing.js';
|
|
3
|
+
type ClientResolvedDocumentRoute = Extract<ResolvedRoute, {
|
|
4
|
+
type: 'document';
|
|
5
|
+
}>;
|
|
6
|
+
export interface ResolvedDocumentRoute {
|
|
7
|
+
readonly localeContext: ClientResolvedDocumentRoute['localeContext'];
|
|
8
|
+
readonly publicPathname: ClientResolvedDocumentRoute['publicPathname'];
|
|
9
|
+
readonly alternates: ClientResolvedDocumentRoute['alternates'];
|
|
10
|
+
readonly primaryLocale: ClientResolvedDocumentRoute['primaryLocale'];
|
|
11
|
+
readonly seo: ClientResolvedDocumentRoute['seo'];
|
|
12
|
+
}
|
|
13
|
+
export interface StorystickAlternatesOptions {
|
|
14
|
+
readonly siteUrl: string | URL;
|
|
15
|
+
readonly publicOrigins?: PublicLocaleOrigins;
|
|
16
|
+
}
|
|
17
|
+
export interface StorystickPublishedLocaleRoute {
|
|
18
|
+
readonly documentId: string;
|
|
19
|
+
readonly storyType: string;
|
|
20
|
+
readonly locale: string;
|
|
21
|
+
readonly pathname: string;
|
|
22
|
+
readonly publicPathname: string;
|
|
23
|
+
readonly defaultLocale?: string;
|
|
24
|
+
readonly primaryLocale?: string;
|
|
25
|
+
readonly seo: ClientResolvedDocumentRoute['seo'];
|
|
26
|
+
}
|
|
27
|
+
export interface StorystickSitemapOptions {
|
|
28
|
+
readonly locale?: string;
|
|
29
|
+
readonly siteUrl: string | URL;
|
|
30
|
+
readonly publicOrigins?: PublicLocaleOrigins;
|
|
31
|
+
}
|
|
32
|
+
export interface StorystickAlternates {
|
|
33
|
+
canonical?: string;
|
|
34
|
+
languages?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
export interface StorystickSitemapEntry {
|
|
37
|
+
url: string;
|
|
38
|
+
alternates?: {
|
|
39
|
+
languages: Record<string, string>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export declare const STORYSTICK_SITEMAP_MAX_URLS = 50000;
|
|
43
|
+
/**
|
|
44
|
+
* Convert a publication-backed route map to canonical and language URLs. Missing
|
|
45
|
+
* translations are absent by construction. The publication SEO posture is
|
|
46
|
+
* frozen at publish time so metadata and sitemap share one decision.
|
|
47
|
+
*/
|
|
48
|
+
export declare function storystickAlternates(route: ResolvedDocumentRoute, options: StorystickAlternatesOptions): StorystickAlternates | undefined;
|
|
49
|
+
/** Pure mapping used by `ss.sitemap` and pinned independently in unit tests. */
|
|
50
|
+
export declare function buildStorystickSitemap(routes: readonly StorystickPublishedLocaleRoute[], options: StorystickSitemapOptions): StorystickSitemapEntry[];
|
|
51
|
+
export declare function buildStorystickSitemapShards(routes: readonly StorystickPublishedLocaleRoute[], options: StorystickSitemapOptions, shardSize?: number): StorystickSitemapEntry[][];
|
|
52
|
+
export {};
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { publicHrefForPublication, publishedLocaleHrefs, publicHrefForLocale, normalizePublicLocaleOrigins } from './locale-routing.js';
|
|
2
|
+
export const STORYSTICK_SITEMAP_MAX_URLS = 50_000;
|
|
3
|
+
function absoluteUrl(siteUrl, pathname) {
|
|
4
|
+
const base = new URL(siteUrl);
|
|
5
|
+
if (/^https?:\/\//.test(pathname))
|
|
6
|
+
return new URL(pathname).toString();
|
|
7
|
+
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
8
|
+
return new URL(normalizedPathname, base).toString();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Convert a publication-backed route map to canonical and language URLs. Missing
|
|
12
|
+
* translations are absent by construction. The publication SEO posture is
|
|
13
|
+
* frozen at publish time so metadata and sitemap share one decision.
|
|
14
|
+
*/
|
|
15
|
+
export function storystickAlternates(route, options) {
|
|
16
|
+
const settings = { ...route.localeContext, ...(options.publicOrigins ? { publicOrigins: normalizePublicLocaleOrigins(options.publicOrigins) } : {}) };
|
|
17
|
+
const currentUrl = () => absoluteUrl(options.siteUrl, publicHrefForLocale(route.localeContext.pathname, route.localeContext.locale, settings));
|
|
18
|
+
if (!route.seo.indexable) {
|
|
19
|
+
return {
|
|
20
|
+
canonical: route.seo.canonicalUrl ??
|
|
21
|
+
currentUrl(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (route.seo.canonicalUrl !== undefined) {
|
|
25
|
+
return { canonical: route.seo.canonicalUrl };
|
|
26
|
+
}
|
|
27
|
+
const publishedLanguages = Object.entries(publishedLocaleHrefs(route.alternates, settings));
|
|
28
|
+
if (publishedLanguages.length === 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
const languages = Object.fromEntries(publishedLanguages.map(([locale, pathname]) => [
|
|
31
|
+
locale,
|
|
32
|
+
absoluteUrl(options.siteUrl, pathname),
|
|
33
|
+
]));
|
|
34
|
+
const available = new Set(publishedLanguages.map(([locale]) => locale));
|
|
35
|
+
const xDefaultLocale = available.has(route.localeContext.defaultLocale)
|
|
36
|
+
? route.localeContext.defaultLocale
|
|
37
|
+
: available.has(route.primaryLocale)
|
|
38
|
+
? route.primaryLocale
|
|
39
|
+
: route.localeContext.locale;
|
|
40
|
+
const xDefaultPathname = languages[xDefaultLocale];
|
|
41
|
+
if (xDefaultPathname) {
|
|
42
|
+
languages['x-default'] = absoluteUrl(options.siteUrl, xDefaultPathname);
|
|
43
|
+
}
|
|
44
|
+
const canonicalPathname = languages[route.localeContext.locale];
|
|
45
|
+
return {
|
|
46
|
+
...(canonicalPathname
|
|
47
|
+
? { canonical: absoluteUrl(options.siteUrl, canonicalPathname) }
|
|
48
|
+
: {}),
|
|
49
|
+
languages,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Pure mapping used by `ss.sitemap` and pinned independently in unit tests. */
|
|
53
|
+
export function buildStorystickSitemap(routes, options) {
|
|
54
|
+
const origins = options.publicOrigins ? normalizePublicLocaleOrigins(options.publicOrigins) : undefined;
|
|
55
|
+
const urlFor = (route) => absoluteUrl(options.siteUrl, publicHrefForPublication(route, origins));
|
|
56
|
+
const localRoutes = routes.filter((route) => route.seo.indexable && route.seo.canonicalUrl === undefined);
|
|
57
|
+
const byDocument = new Map();
|
|
58
|
+
for (const route of localRoutes) {
|
|
59
|
+
const cluster = byDocument.get(route.documentId) ?? [];
|
|
60
|
+
cluster.push(route);
|
|
61
|
+
byDocument.set(route.documentId, cluster);
|
|
62
|
+
}
|
|
63
|
+
return localRoutes.filter(route => !options.locale || route.locale === options.locale).map((route) => {
|
|
64
|
+
const cluster = byDocument.get(route.documentId) ?? [route];
|
|
65
|
+
if (cluster.length < 2) {
|
|
66
|
+
return { url: urlFor(route) };
|
|
67
|
+
}
|
|
68
|
+
const languages = Object.fromEntries(cluster.map((alternate) => [
|
|
69
|
+
alternate.locale,
|
|
70
|
+
urlFor(alternate),
|
|
71
|
+
]));
|
|
72
|
+
const locales = new Set(cluster.map((alternate) => alternate.locale));
|
|
73
|
+
const defaultLocale = cluster.find((alternate) => alternate.defaultLocale !== undefined &&
|
|
74
|
+
locales.has(alternate.defaultLocale))?.defaultLocale;
|
|
75
|
+
const primaryLocale = cluster.find((alternate) => alternate.primaryLocale !== undefined &&
|
|
76
|
+
locales.has(alternate.primaryLocale))?.primaryLocale;
|
|
77
|
+
const xDefaultLocale = defaultLocale ?? primaryLocale ?? cluster[0].locale;
|
|
78
|
+
languages['x-default'] = languages[xDefaultLocale];
|
|
79
|
+
return {
|
|
80
|
+
url: urlFor(route),
|
|
81
|
+
alternates: { languages },
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export function buildStorystickSitemapShards(routes, options, shardSize = STORYSTICK_SITEMAP_MAX_URLS) {
|
|
86
|
+
if (!Number.isSafeInteger(shardSize) || shardSize < 1) {
|
|
87
|
+
throw new RangeError('sitemap shardSize must be a positive integer');
|
|
88
|
+
}
|
|
89
|
+
const sitemap = buildStorystickSitemap(routes, options);
|
|
90
|
+
if (sitemap.length === 0)
|
|
91
|
+
return [[]];
|
|
92
|
+
return Array.from({ length: Math.ceil(sitemap.length / shardSize) }, (_, index) => sitemap.slice(index * shardSize, (index + 1) * shardSize));
|
|
93
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Shared HTTP boundary for framework delivery adapters. No cache or queue policy. */
|
|
2
|
+
export declare function signWebhookBody(body: string, secret: string): Promise<string>;
|
|
3
|
+
export declare function readSignedWebhook(request: Request, options: {
|
|
4
|
+
secret: string;
|
|
5
|
+
signatureHeader?: string;
|
|
6
|
+
}): Promise<{
|
|
7
|
+
ok: true;
|
|
8
|
+
payload: unknown;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
status: number;
|
|
12
|
+
error: string;
|
|
13
|
+
}>;
|