@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 +21 -0
- package/README.md +161 -2
- package/dist/cache-control.d.ts +20 -0
- package/dist/cache-control.js +69 -0
- package/dist/dedupe.d.ts +22 -0
- package/dist/dedupe.js +46 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +154 -0
- package/dist/freshness.d.ts +31 -0
- package/dist/freshness.js +41 -0
- package/dist/http-resource.d.ts +13 -0
- package/dist/http-resource.js +52 -0
- package/dist/http.d.ts +71 -0
- package/dist/http.js +229 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +13 -0
- package/dist/internal/bytes.d.ts +11 -0
- package/dist/internal/bytes.js +37 -0
- package/dist/internal/timers.d.ts +21 -0
- package/dist/internal/timers.js +76 -0
- package/dist/kv-store.d.ts +19 -0
- package/dist/kv-store.js +73 -0
- package/dist/site.d.ts +94 -0
- package/dist/site.js +489 -0
- package/dist/snapshot.d.ts +33 -0
- package/dist/snapshot.js +113 -0
- package/dist/status-handler.d.ts +8 -0
- package/dist/status-handler.js +32 -0
- package/dist/status.d.ts +54 -0
- package/dist/status.js +40 -0
- package/dist/store.d.ts +40 -0
- package/dist/store.js +59 -0
- package/dist/webhook-signature.d.ts +30 -0
- package/dist/webhook-signature.js +128 -0
- package/dist/webhook.d.ts +48 -0
- package/dist/webhook.js +199 -0
- package/package.json +38 -4
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
|
-
#
|
|
1
|
+
# @wmind/site-core
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/dedupe.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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>;
|