@wmind/site-core 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WMIND S.r.l.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # @wmind/site-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The product-independent core of the site SDK: an HTTP transport, request-driven refresh over one
4
+ cache model, an atomic-swap snapshot, webhooks and status. Product modules (`@wmind/site-entitycms`,
5
+ `@wmind/site-legal`) build on it; a site normally installs a module, not this package alone.
6
+
7
+ - **Status:** MIT, final name. Version `0.0.0`, `private` and unpublished until the publishing plan
8
+ (changesets, the `wmind` npm organisation, a manual publish workflow).
9
+ - **Runs on:** Node 22, browsers and edge runtimes (Cloudflare Workers, Vercel, Netlify). Web
10
+ platform APIs only (`fetch`, `Request`, `Response`, `crypto.subtle`, `AbortController`); zero
11
+ runtime dependencies.
12
+ - **Is not:** a product client (no EntityCMS or Kit paths, headers or events) or a framework
13
+ adapter.
14
+ - **Design:** `docs/superpowers/specs/2026-11-02-site-sdk-design.md` in the entitycms repository.
15
+
16
+ ## Rules a module author keeps
17
+
18
+ 1. **Requests drive refreshes.** No timer ever starts a refresh or an HTTP call. A copy past its
19
+ freshness is refreshed when a request asks for it; the flight is shared by every concurrent
20
+ request for the key (one per key and process) and is never cancelled. A flight that has not
21
+ settled after `refreshMaxMs` (60 s) is abandoned by the next request, which starts a new one;
22
+ the abandoned one writes nothing when it settles.
23
+ 2. **Budgets, not waits.** A request with a copy waits for the refresh at most `refreshBudgetMs`
24
+ (default 0: it answers with the copy at once); a request without one waits at most
25
+ `coldBudgetMs` (10 s), then gets `Unavailable` `refresh_budget_exceeded`. The refresh goes on and
26
+ the next request finds it. Pass `waitUntil` where the platform has one.
27
+ 3. **A copy never expires by time.** It is served until a refresh succeeds; `status()` shows its
28
+ age. `maxStaleMs` opts out. A full refresh answering the product's own 404 (the JSON error
29
+ envelope) deletes the copy; any other 404 (a load balancer's HTML page) and every other error
30
+ keep it and start the error pause: `errorPauseMs` (15 s), or the answer's `Retry-After` when
31
+ longer but at most `maxErrorPauseMs` (10 min), during which nothing is retried.
32
+ 4. **Keys carry the tenant, and the product when a module sets it.** A store key is
33
+ `encodeURIComponent(tenant):key`, or `encodeURIComponent(namespace)|encodeURIComponent(tenant):key`
34
+ with `createSiteCache({ namespace: 'legal' })`; a module should always set its namespace, so
35
+ products and tenants can share one memory store or one KV namespace.
36
+ 5. **Resources are declared, not made per request.** `site.resource()` keeps one entry per key
37
+ for the life of the process. A module keyed on request input (a document code from the URL)
38
+ checks the key against what exists before declaring a resource for it.
39
+ 6. **A `Snapshot` stays in a `MemoryCacheStore`.** It refuses JSON; a KV store reports
40
+ `store_unserialisable`.
41
+ 7. **Webhooks accelerate, the periodic full refresh guarantees.** `invalidate()` marks a copy due;
42
+ it never deletes it. Within one process the mark is never lost to a refresh landing at the same
43
+ time, and a copy a 404 deleted is never written back. Instances sharing a KV store are not
44
+ serialised (the store has no compare-and-set): a refresh on one instance can overwrite a mark
45
+ written by another, until the next full refresh.
46
+ 8. **Answers marked `no-store` or `private` are never stored.** EntityCMS marks every non-GET
47
+ answer so; read such endpoints in your own loader.
48
+
49
+ ## Known limits
50
+
51
+ - **A loader slower than `refreshMaxMs` never lands.** Each request past `refreshMaxMs` (60 s)
52
+ abandons the running flight and starts another, and an abandoned flight writes nothing, so a
53
+ loader that always takes longer keeps restarting and its copy never moves. `httpResource` is
54
+ bounded by the transport's 30 s deadline and is not affected; a loader making several calls in a
55
+ row is. Set `refreshMaxMs` above the loader's worst case. A later version may let an abandoned
56
+ flight still write when the stored copy is older than its start.
57
+ - **A store call that never settles blocks its key.** Writes to one key are queued in process, so a
58
+ `CacheStore` (or `KvBackend`) call that never settles leaves that key's later refresh writes and
59
+ `invalidate()` waiting for good; other keys are unaffected. Give the backend its own timeout. A
60
+ later version may bound store calls with a budget.
61
+
62
+ ## Quick start
63
+
64
+ ```ts
65
+ import { createHttp, createSiteCache, httpResource } from '@wmind/site-core';
66
+
67
+ const http = createHttp({
68
+ baseUrl: 'https://api.example.com',
69
+ auth: { bearer: process.env.API_KEY! }, // omit for a public API; never logged or shown
70
+ headers: { 'x-tenant-id': TENANT_ID },
71
+ });
72
+
73
+ const site = createSiteCache({
74
+ tenant: TENANT_ID,
75
+ namespace: 'legal', // the product module's prefix for every store key
76
+ // store: new KvCacheStore(backend) to share copies between instances
77
+ // policy: { fullEveryMs: 300_000 } overrides the answer's Cache-Control
78
+ });
79
+
80
+ const privacy = httpResource<{ title: string; html: string }>(site, http, {
81
+ key: 'doc:PRIVACY_POLICY:it',
82
+ request: { path: '/v1/documents/PRIVACY_POLICY', query: { lang: 'it' } },
83
+ preview: (token) => ({ headers: { 'x-preview-token': token } }),
84
+ });
85
+
86
+ // In a request handler (Astro, Next.js, a Worker…):
87
+ const doc = await privacy.get({ waitUntil: ctx.waitUntil });
88
+ const draft = await privacy.get(site.preview(token)); // this request only, never stored
89
+ ```
90
+
91
+ Freshness defaults to the answer's `s-maxage`, else `max-age`, else 60 s; a 304 to the stored
92
+ `ETag` keeps the copy. A module with its own loaders uses `site.resource({ key, full, partial? })`:
93
+ `full` and `partial` answer `{ kind: 'value', value, etag?, cacheControl? }` or
94
+ `{ kind: 'not_modified' }`, and `Snapshot.of(spec, rows)` / `snapshot.with({ upsert, remove })`
95
+ build the next store apart from the one being served.
96
+
97
+ ## Webhooks
98
+
99
+ ```ts
100
+ import { createWebhookHandler } from '@wmind/site-core';
101
+
102
+ export const POST = createWebhookHandler({
103
+ secrets: [process.env.WEBHOOK_SECRET_NEW!, process.env.WEBHOOK_SECRET_OLD!], // during a rotation
104
+ signatureHeader: 'x-entitycms-signature', // the product module's header
105
+ onEvent: async (event) => {
106
+ if (event.event === 'entity.published') await privacy.invalidate();
107
+ },
108
+ status: site,
109
+ });
110
+ ```
111
+
112
+ - Header `t=<ts>,v1=<hex>[,v1=<hex>…]`, HMAC-SHA256 over `<ts>.<raw body bytes>`; any secret
113
+ matching any `v1` verifies; tolerance 300 s. One HMAC per secret whatever the header holds; a
114
+ header over 1,024 characters or with more than 8 `v1` is `malformed_signature_header`.
115
+ - The body is capped at 1 MiB before any signature work (413).
116
+ - A verified, well-formed event always answers 2xx, so a slow handler never makes the sender
117
+ disable the subscription. `budgetMs` (4 s, under EntityCMS's 5 s delivery timeout) counts from
118
+ the request's arrival: 204 when `onEvent` finished within it, 202 when it is still running
119
+ (handed to `waitUntil`) or failed (the failure goes to `status()` and the event can be resent).
120
+ A repeated event (the envelope `id`, else `ts` + body hash) is 200 `{ ok: true, deduped: true }`.
121
+ - Refusals: 405, 415, 413, 401 `{ error: <reason> }` (`missing_signature`,
122
+ `malformed_signature_header`, `timestamp_outside_tolerance`, `signature_mismatch`), 400
123
+ `invalid_json` / `malformed_event`.
124
+
125
+ ## Status
126
+
127
+ `site.status()` gives, per resource, the copy's age, the last full and partial refresh, whether a
128
+ refresh is flying, the error pause and the last error; then the last webhook received, verified
129
+ and rejected, and the most recent error. To serve it:
130
+
131
+ ```ts
132
+ import { createStatusHandler } from '@wmind/site-core';
133
+
134
+ export const GET = createStatusHandler({ token: process.env.SDK_STATUS_TOKEN!, site }); // >= 32 chars
135
+ ```
136
+
137
+ It answers only `Authorization: Bearer <token>` (compared in constant time) and never shows the
138
+ API bearer.
139
+
140
+ ## Errors
141
+
142
+ Every error is a `SiteSdkError` with `code`, `status` (0 when there was no HTTP answer),
143
+ `details`, `requestId` and, on 429, `retryAfterSeconds`. Subclasses: `NotFound` (a 404 carrying the
144
+ product's JSON error envelope), `RateLimited` (429), `Unavailable` (502, 503, 504, any other 404,
145
+ network, timeout, budgets). The transport
146
+ retries 429 (after `Retry-After`) and 503 (400 ms · 2ⁿ, at most 6 times) within a 30 s deadline;
147
+ it never retries a network error or a timeout.
148
+
149
+ Codes this package mints:
150
+
151
+ | Code | Class | When |
152
+ |---|---|---|
153
+ | `network_error`, `timeout` | `Unavailable` | no HTTP answer (status 0) |
154
+ | `unexpected_response` | by status (`Unavailable` for a 404) | an answer that is not the JSON error envelope, a 2xx that is not JSON, a 304 to a request without `If-None-Match` |
155
+ | `refresh_budget_exceeded` | `Unavailable` | no servable copy within the budget; `details.reason` `cold`, `max_stale` or `preview` |
156
+ | `store_unavailable` | `Unavailable` | the cache store failed |
157
+ | `refresh_abandoned` | `Unavailable` (status only) | a flight older than `refreshMaxMs` was replaced |
158
+ | `store_unserialisable` | `SiteSdkError` | a value a KV store cannot hold as JSON |
159
+ | `unexpected_not_modified` | `SiteSdkError` (304) | a loader answered `not_modified` with no copy |
160
+ | `preview_unsupported` | `SiteSdkError` | a preview of an `httpResource` with no `preview` |
161
+
162
+ Any other code is the API's own (`not_found`, `rate_limited`, `unauthorized`…), passed through.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `Cache-Control` of an answer, read for the default freshness (spec §4.2.4): EntityCMS sends
3
+ * `s-maxage=60, stale-while-revalidate=3600`, the Kit's public API `max-age=300,
4
+ * stale-while-revalidate=86400`. Only `s-maxage`, else `max-age`, sets how long a copy is fresh;
5
+ * `stale-while-revalidate` never expires a copy (R9). A `no-store` or `private` answer is served
6
+ * to the requests waiting for it and never stored.
7
+ */
8
+ export interface CacheControl {
9
+ maxAgeMs: number | null;
10
+ sMaxAgeMs: number | null;
11
+ staleWhileRevalidateMs: number | null;
12
+ noStore: boolean;
13
+ noCache: boolean;
14
+ private: boolean;
15
+ }
16
+ export declare function parseCacheControl(header: string | null): CacheControl;
17
+ /** How long a copy is fresh: `no-cache` → 0; else `s-maxage`, else `max-age`, else null (R9). */
18
+ export declare function freshForMs(cc: CacheControl): number | null;
19
+ /** Whether a copy may be kept: neither `no-store` nor `private`. */
20
+ export declare function isStorable(cc: CacheControl): boolean;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `Cache-Control` of an answer, read for the default freshness (spec §4.2.4): EntityCMS sends
3
+ * `s-maxage=60, stale-while-revalidate=3600`, the Kit's public API `max-age=300,
4
+ * stale-while-revalidate=86400`. Only `s-maxage`, else `max-age`, sets how long a copy is fresh;
5
+ * `stale-while-revalidate` never expires a copy (R9). A `no-store` or `private` answer is served
6
+ * to the requests waiting for it and never stored.
7
+ */
8
+ /** Delta-seconds as milliseconds, or null for anything but a non-negative integer. */
9
+ function seconds(value) {
10
+ if (value === undefined)
11
+ return null;
12
+ const text = value
13
+ .trim()
14
+ .replace(/^"(.*)"$/, '$1')
15
+ .trim();
16
+ if (!/^\d+$/.test(text))
17
+ return null;
18
+ return Number(text) * 1000;
19
+ }
20
+ export function parseCacheControl(header) {
21
+ const cc = {
22
+ maxAgeMs: null,
23
+ sMaxAgeMs: null,
24
+ staleWhileRevalidateMs: null,
25
+ noStore: false,
26
+ noCache: false,
27
+ private: false,
28
+ };
29
+ if (!header)
30
+ return cc;
31
+ for (const part of header.split(',')) {
32
+ const eq = part.indexOf('=');
33
+ const name = (eq === -1 ? part : part.slice(0, eq)).trim().toLowerCase();
34
+ const value = eq === -1 ? undefined : part.slice(eq + 1);
35
+ switch (name) {
36
+ case 'max-age':
37
+ cc.maxAgeMs = seconds(value);
38
+ break;
39
+ case 's-maxage':
40
+ cc.sMaxAgeMs = seconds(value);
41
+ break;
42
+ case 'stale-while-revalidate':
43
+ cc.staleWhileRevalidateMs = seconds(value);
44
+ break;
45
+ case 'no-store':
46
+ cc.noStore = true;
47
+ break;
48
+ case 'no-cache':
49
+ cc.noCache = true;
50
+ break;
51
+ case 'private':
52
+ cc.private = true;
53
+ break;
54
+ default:
55
+ break;
56
+ }
57
+ }
58
+ return cc;
59
+ }
60
+ /** How long a copy is fresh: `no-cache` → 0; else `s-maxage`, else `max-age`, else null (R9). */
61
+ export function freshForMs(cc) {
62
+ if (cc.noCache)
63
+ return 0;
64
+ return cc.sMaxAgeMs ?? cc.maxAgeMs;
65
+ }
66
+ /** Whether a copy may be kept: neither `no-store` nor `private`. */
67
+ export function isStorable(cc) {
68
+ return !cc.noStore && !cc.private;
69
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Webhook dedupe (spec §4.2 Webhook): a key is claimed once within its TTL. The in-memory store is
3
+ * per process, swept on each call and capped; it never sets a timer.
4
+ */
5
+ export interface DedupeStore {
6
+ /** True the first time within `ttlSeconds`. */
7
+ claim(key: string, ttlSeconds: number): Promise<boolean>;
8
+ release(key: string): Promise<void>;
9
+ }
10
+ export interface MemoryDedupeStoreOptions {
11
+ /** Default `Date.now`. */
12
+ now?: () => number;
13
+ /** 10_000. */
14
+ maxEntries?: number;
15
+ }
16
+ export declare const DEFAULT_DEDUPE_MAX_ENTRIES = 10000;
17
+ export declare class MemoryDedupeStore implements DedupeStore {
18
+ #private;
19
+ constructor(options?: MemoryDedupeStoreOptions);
20
+ claim(key: string, ttlSeconds: number): Promise<boolean>;
21
+ release(key: string): Promise<void>;
22
+ }
package/dist/dedupe.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Webhook dedupe (spec §4.2 Webhook): a key is claimed once within its TTL. The in-memory store is
3
+ * per process, swept on each call and capped; it never sets a timer.
4
+ */
5
+ export const DEFAULT_DEDUPE_MAX_ENTRIES = 10_000;
6
+ export class MemoryDedupeStore {
7
+ /** key → expiry (ms), in claim order. */
8
+ #entries = new Map();
9
+ #now;
10
+ #maxEntries;
11
+ constructor(options = {}) {
12
+ const max = options.maxEntries ?? DEFAULT_DEDUPE_MAX_ENTRIES;
13
+ if (!Number.isInteger(max) || max < 1) {
14
+ throw new TypeError('MemoryDedupeStore: maxEntries must be an integer >= 1');
15
+ }
16
+ this.#now = options.now ?? Date.now;
17
+ this.#maxEntries = max;
18
+ }
19
+ async claim(key, ttlSeconds) {
20
+ const t = this.#now();
21
+ this.#sweep(t);
22
+ const expiresAt = this.#entries.get(key);
23
+ if (expiresAt !== undefined && t < expiresAt)
24
+ return false;
25
+ this.#entries.delete(key);
26
+ this.#entries.set(key, t + ttlSeconds * 1000);
27
+ while (this.#entries.size > this.#maxEntries) {
28
+ const oldest = this.#entries.keys().next();
29
+ if (oldest.done)
30
+ break;
31
+ this.#entries.delete(oldest.value);
32
+ }
33
+ return true;
34
+ }
35
+ async release(key) {
36
+ this.#entries.delete(key);
37
+ }
38
+ /** Drops expired keys from the front (claim order); a later expired key is caught on lookup. */
39
+ #sweep(t) {
40
+ for (const [key, expiresAt] of this.#entries) {
41
+ if (t < expiresAt)
42
+ break;
43
+ this.#entries.delete(key);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The one error class of the site SDK (spec §4.2 Errori): `code`, `status`, `details`, `requestId`,
3
+ * `retryAfterSeconds?`, with subclasses only where a caller must tell them apart.
4
+ *
5
+ * An error never carries a `cause`: a platform error can hold the request, and the request holds
6
+ * the bearer, which must never appear in an error, a log or `status()`.
7
+ */
8
+ export interface SiteSdkErrorInit {
9
+ code: string;
10
+ message: string;
11
+ /** The HTTP status; 0 when there was no HTTP answer. */
12
+ status: number;
13
+ details?: Readonly<Record<string, unknown>> | null;
14
+ requestId?: string | null;
15
+ retryAfterSeconds?: number;
16
+ }
17
+ export interface SiteSdkErrorJson {
18
+ name: string;
19
+ code: string;
20
+ message: string;
21
+ status: number;
22
+ details: Readonly<Record<string, unknown>> | null;
23
+ requestId: string | null;
24
+ retryAfterSeconds?: number;
25
+ }
26
+ export declare class SiteSdkError extends Error {
27
+ name: string;
28
+ readonly code: string;
29
+ readonly status: number;
30
+ readonly details: Readonly<Record<string, unknown>> | null;
31
+ readonly requestId: string | null;
32
+ readonly retryAfterSeconds?: number;
33
+ constructor(init: SiteSdkErrorInit);
34
+ toJSON(): SiteSdkErrorJson;
35
+ }
36
+ /** The resource does not exist (HTTP 404). */
37
+ export declare class NotFound extends SiteSdkError {
38
+ name: string;
39
+ }
40
+ /** The API refused for rate (HTTP 429); `retryAfterSeconds` when the answer said. */
41
+ export declare class RateLimited extends SiteSdkError {
42
+ name: string;
43
+ }
44
+ /** No usable answer: 502, 503, 504, a network error, a timeout, or a budget that elapsed. */
45
+ export declare class Unavailable extends SiteSdkError {
46
+ name: string;
47
+ }
48
+ /** 404 → NotFound; 429 → RateLimited; 502, 503, 504 and status 0 with code network_error | timeout → Unavailable; else SiteSdkError. */
49
+ export declare function siteSdkError(init: SiteSdkErrorInit): SiteSdkError;
50
+ /** Seconds from `Retry-After` (delta-seconds or HTTP-date against `now`), undefined when absent or unreadable. */
51
+ export declare function parseRetryAfter(value: string | null, now: number): number | undefined;
52
+ /**
53
+ * Never throws. Reads `{ error: { code, message, details, requestId? } }` (both products),
54
+ * requestId from `X-Request-Id` first; on 429 retryAfterSeconds from the header, else
55
+ * `details.retryAfterSeconds`; a body that is not that envelope gives code `unexpected_response`
56
+ * and the message `${label} answered ${status}: <first 200 chars of the text, whitespace collapsed>`.
57
+ *
58
+ * Only a 404 that carries the envelope with a JSON content type is `NotFound` (review I2): the
59
+ * product says the resource is gone, and the engine deletes the copy (R5). Any other 404 (a load
60
+ * balancer's or a CDN's HTML page, a wrong route, plain text) is `Unavailable` `unexpected_response`:
61
+ * the copy is kept and the error pause starts.
62
+ */
63
+ export declare function errorFromResponse(res: Response, label: string, now: number): Promise<SiteSdkError>;
package/dist/errors.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The one error class of the site SDK (spec §4.2 Errori): `code`, `status`, `details`, `requestId`,
3
+ * `retryAfterSeconds?`, with subclasses only where a caller must tell them apart.
4
+ *
5
+ * An error never carries a `cause`: a platform error can hold the request, and the request holds
6
+ * the bearer, which must never appear in an error, a log or `status()`.
7
+ */
8
+ export class SiteSdkError extends Error {
9
+ name = 'SiteSdkError';
10
+ code;
11
+ status;
12
+ details;
13
+ requestId;
14
+ retryAfterSeconds;
15
+ constructor(init) {
16
+ super(init.message);
17
+ this.code = init.code;
18
+ this.status = init.status;
19
+ this.details = init.details ?? null;
20
+ this.requestId = init.requestId ?? null;
21
+ if (init.retryAfterSeconds !== undefined)
22
+ this.retryAfterSeconds = init.retryAfterSeconds;
23
+ }
24
+ toJSON() {
25
+ const json = {
26
+ name: this.name,
27
+ code: this.code,
28
+ message: this.message,
29
+ status: this.status,
30
+ details: this.details,
31
+ requestId: this.requestId,
32
+ };
33
+ if (this.retryAfterSeconds !== undefined)
34
+ json.retryAfterSeconds = this.retryAfterSeconds;
35
+ return json;
36
+ }
37
+ }
38
+ /** The resource does not exist (HTTP 404). */
39
+ export class NotFound extends SiteSdkError {
40
+ name = 'NotFound';
41
+ }
42
+ /** The API refused for rate (HTTP 429); `retryAfterSeconds` when the answer said. */
43
+ export class RateLimited extends SiteSdkError {
44
+ name = 'RateLimited';
45
+ }
46
+ /** No usable answer: 502, 503, 504, a network error, a timeout, or a budget that elapsed. */
47
+ export class Unavailable extends SiteSdkError {
48
+ name = 'Unavailable';
49
+ }
50
+ /** 404 → NotFound; 429 → RateLimited; 502, 503, 504 and status 0 with code network_error | timeout → Unavailable; else SiteSdkError. */
51
+ export function siteSdkError(init) {
52
+ if (init.status === 404)
53
+ return new NotFound(init);
54
+ if (init.status === 429)
55
+ return new RateLimited(init);
56
+ if (init.status === 502 || init.status === 503 || init.status === 504) {
57
+ return new Unavailable(init);
58
+ }
59
+ if (init.status === 0 && (init.code === 'network_error' || init.code === 'timeout')) {
60
+ return new Unavailable(init);
61
+ }
62
+ return new SiteSdkError(init);
63
+ }
64
+ /** Seconds from `Retry-After` (delta-seconds or HTTP-date against `now`), undefined when absent or unreadable. */
65
+ export function parseRetryAfter(value, now) {
66
+ if (value === null)
67
+ return undefined;
68
+ const text = value.trim();
69
+ if (text === '')
70
+ return undefined;
71
+ if (/^\d+$/.test(text))
72
+ return Number(text);
73
+ // An HTTP-date always names a weekday or a month; a bare signed number is not one.
74
+ if (!/[a-z]/i.test(text))
75
+ return undefined;
76
+ const at = Date.parse(text);
77
+ if (Number.isNaN(at))
78
+ return undefined;
79
+ return Math.max(0, Math.ceil((at - now) / 1000));
80
+ }
81
+ const FALLBACK_TEXT_MAX = 200;
82
+ function isRecord(value) {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
84
+ }
85
+ /** `application/json`, with parameters, or a `+json` type (`application/problem+json`). */
86
+ function isJsonContentType(value) {
87
+ return /^application\/(?:[\w.-]+\+)?json\s*(?:;|$)/i.test((value ?? '').trim());
88
+ }
89
+ /**
90
+ * Never throws. Reads `{ error: { code, message, details, requestId? } }` (both products),
91
+ * requestId from `X-Request-Id` first; on 429 retryAfterSeconds from the header, else
92
+ * `details.retryAfterSeconds`; a body that is not that envelope gives code `unexpected_response`
93
+ * and the message `${label} answered ${status}: <first 200 chars of the text, whitespace collapsed>`.
94
+ *
95
+ * Only a 404 that carries the envelope with a JSON content type is `NotFound` (review I2): the
96
+ * product says the resource is gone, and the engine deletes the copy (R5). Any other 404 (a load
97
+ * balancer's or a CDN's HTML page, a wrong route, plain text) is `Unavailable` `unexpected_response`:
98
+ * the copy is kept and the error pause starts.
99
+ */
100
+ export async function errorFromResponse(res, label, now) {
101
+ let text = '';
102
+ try {
103
+ text = await res.text();
104
+ }
105
+ catch {
106
+ text = '';
107
+ }
108
+ const headerRequestId = res.headers.get('x-request-id');
109
+ let envelope = null;
110
+ try {
111
+ const parsed = JSON.parse(text);
112
+ if (isRecord(parsed) && isRecord(parsed.error))
113
+ envelope = parsed.error;
114
+ }
115
+ catch {
116
+ envelope = null;
117
+ }
118
+ const code = envelope?.code;
119
+ const message = envelope?.message;
120
+ const productAnswer = res.status !== 404 || isJsonContentType(res.headers.get('content-type'));
121
+ if (envelope && typeof code === 'string' && typeof message === 'string' && productAnswer) {
122
+ const details = isRecord(envelope.details) ? envelope.details : null;
123
+ const bodyRequestId = typeof envelope.requestId === 'string' ? envelope.requestId : null;
124
+ let retryAfterSeconds;
125
+ if (res.status === 429) {
126
+ retryAfterSeconds = parseRetryAfter(res.headers.get('retry-after'), now);
127
+ const fromDetails = details?.retryAfterSeconds;
128
+ if (retryAfterSeconds === undefined &&
129
+ typeof fromDetails === 'number' &&
130
+ Number.isFinite(fromDetails) &&
131
+ fromDetails >= 0) {
132
+ retryAfterSeconds = fromDetails;
133
+ }
134
+ }
135
+ return siteSdkError({
136
+ code,
137
+ message,
138
+ status: res.status,
139
+ details,
140
+ requestId: headerRequestId ?? bodyRequestId,
141
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
142
+ });
143
+ }
144
+ const snippet = text.replace(/\s+/g, ' ').trim().slice(0, FALLBACK_TEXT_MAX);
145
+ const retryAfterSeconds = res.status === 429 ? parseRetryAfter(res.headers.get('retry-after'), now) : undefined;
146
+ const init = {
147
+ code: 'unexpected_response',
148
+ message: `${label} answered ${res.status}: ${snippet}`,
149
+ status: res.status,
150
+ requestId: headerRequestId,
151
+ ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
152
+ };
153
+ return res.status === 404 ? new Unavailable(init) : siteSdkError(init);
154
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The pure freshness policy (spec §4.2.3): `decideRefresh(state, now, policy)` →
3
+ * `'full' | 'partial' | 'none'`, generalised from collieuganei's `src/lib/freshness.ts`. No clock,
4
+ * no I/O: the engine passes the time, and the product module decides what a full and a partial
5
+ * refresh are (a product without a partial sets `partialAvailable: false`).
6
+ */
7
+ export interface FreshnessPolicy {
8
+ fullEveryMs: number;
9
+ /** Without it a partial runs only on invalidation. */
10
+ partialEveryMs?: number;
11
+ errorPauseMs: number;
12
+ }
13
+ export declare const DEFAULT_FULL_EVERY_MS = 60000;
14
+ export declare const DEFAULT_ERROR_PAUSE_MS = 15000;
15
+ export interface RefreshState {
16
+ /** Start of the last successful full refresh; null = no copy. */
17
+ lastFullAt: number | null;
18
+ /** Start of the last successful partial refresh. */
19
+ lastPartialAt: number | null;
20
+ invalidatedAt: number | null;
21
+ lastErrorAt: number | null;
22
+ partialAvailable: boolean;
23
+ }
24
+ export type RefreshDecision = 'full' | 'partial' | 'none';
25
+ /**
26
+ * Order (load-bearing): pause → cold → full due → invalidated → partial due → none.
27
+ * - The error pause holds even a cold key (R4): one refresh per pause, not one per request.
28
+ * - Invalidated gives `partial` when one is available, else `full`.
29
+ * - The partial cadence counts from the later of the last full and the last partial refresh.
30
+ */
31
+ export declare function decideRefresh(state: RefreshState, now: number, policy: FreshnessPolicy): RefreshDecision;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The pure freshness policy (spec §4.2.3): `decideRefresh(state, now, policy)` →
3
+ * `'full' | 'partial' | 'none'`, generalised from collieuganei's `src/lib/freshness.ts`. No clock,
4
+ * no I/O: the engine passes the time, and the product module decides what a full and a partial
5
+ * refresh are (a product without a partial sets `partialAvailable: false`).
6
+ */
7
+ export const DEFAULT_FULL_EVERY_MS = 60_000;
8
+ export const DEFAULT_ERROR_PAUSE_MS = 15_000;
9
+ /**
10
+ * Whether `every` ms have passed since `at`. A negative elapsed time (the clock stepped back: NTP,
11
+ * a VM resume) counts as due, so a step back triggers one refresh instead of freezing them (R11).
12
+ */
13
+ function due(at, now, every) {
14
+ const elapsed = now - at;
15
+ return elapsed < 0 || elapsed >= every;
16
+ }
17
+ /**
18
+ * Order (load-bearing): pause → cold → full due → invalidated → partial due → none.
19
+ * - The error pause holds even a cold key (R4): one refresh per pause, not one per request.
20
+ * - Invalidated gives `partial` when one is available, else `full`.
21
+ * - The partial cadence counts from the later of the last full and the last partial refresh.
22
+ */
23
+ export function decideRefresh(state, now, policy) {
24
+ if (state.lastErrorAt !== null) {
25
+ const sinceError = now - state.lastErrorAt;
26
+ if (sinceError >= 0 && sinceError < policy.errorPauseMs)
27
+ return 'none';
28
+ }
29
+ if (state.lastFullAt === null)
30
+ return 'full';
31
+ if (due(state.lastFullAt, now, policy.fullEveryMs))
32
+ return 'full';
33
+ if (state.invalidatedAt !== null)
34
+ return state.partialAvailable ? 'partial' : 'full';
35
+ if (state.partialAvailable && policy.partialEveryMs !== undefined) {
36
+ const lastRefresh = Math.max(state.lastFullAt, state.lastPartialAt ?? state.lastFullAt);
37
+ if (due(lastRefresh, now, policy.partialEveryMs))
38
+ return 'partial';
39
+ }
40
+ return 'none';
41
+ }
@@ -0,0 +1,13 @@
1
+ import type { FreshnessPolicy } from './freshness.js';
2
+ import type { Http, HttpRequest } from './http.js';
3
+ import type { Resource, SiteCache } from './site.js';
4
+ export interface HttpResourceDefinition<T> {
5
+ key: string;
6
+ request: Omit<HttpRequest, 'etag'>;
7
+ /** Default: the body as is. */
8
+ map?: (body: unknown) => T;
9
+ policy?: Partial<FreshnessPolicy>;
10
+ /** What a preview request adds; without it a preview is `preview_unsupported`. */
11
+ preview?: (token: string) => Pick<HttpRequest, 'headers' | 'query'>;
12
+ }
13
+ export declare function httpResource<T>(site: SiteCache, http: Http, definition: HttpResourceDefinition<T>): Resource<T>;