@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31
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/README.md +304 -12
- package/dist/app-config/index.d.ts +39 -1
- package/dist/app-config/index.js.map +1 -1
- package/dist/authz/index.js +47 -391
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +157 -16
- package/dist/codegen/index.js +772 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +4 -4
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +39 -16
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +230 -35
- package/dist/db/index.js +316 -160
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-CIZlYJlL.d.ts +157 -0
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +47 -391
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +202 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +94 -18
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +18 -5
- package/dist/event/sse/index.js +452 -114
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +52 -397
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +75 -14
- package/dist/nextjs/index.js +200 -84
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +46 -8
- package/dist/nextjs/server.js +15 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -42
- package/dist/ops/index.js +648 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +5 -2
- package/dist/route/index.js +107 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
- package/dist/router-C57y-vz1.d.ts +184 -0
- package/dist/router-CX5uVjmU.d.ts +406 -0
- package/dist/server/index.d.ts +130 -7
- package/dist/server/index.js +1174 -164
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +206 -0
- package/dist/state/client.js +678 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +443 -0
- package/dist/state/index.js +644 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +49 -0
- package/dist/state/react.js +50 -0
- package/dist/state/react.js.map +1 -0
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1013 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/types-CKH0D3Dy.d.ts +441 -0
- package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
- package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +41 -11
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +721 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +442 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +845 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +445 -0
- package/src/event/README.md +994 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +756 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/src/state/README.md +806 -0
- package/dist/token-manager-BT5EnUAR.d.ts +0 -278
- package/dist/types-9oszaJqp.d.ts +0 -250
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
2
|
+
|
|
3
|
+
// src/state/client.ts
|
|
4
|
+
|
|
5
|
+
// src/state/client-store.ts
|
|
6
|
+
function stableKey(key) {
|
|
7
|
+
if (!key) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
return JSON.stringify(Object.keys(key).sort().map((field) => [field, key[field]]));
|
|
11
|
+
}
|
|
12
|
+
function sameJson(a, b) {
|
|
13
|
+
if (a === b) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const aKeys = Object.keys(a);
|
|
20
|
+
const bRecord = b;
|
|
21
|
+
return aKeys.length === Object.keys(b).length && aKeys.every((field) => field in bRecord && sameJson(a[field], bRecord[field]));
|
|
22
|
+
}
|
|
23
|
+
function isOlder(stamp, arrival) {
|
|
24
|
+
return stamp !== void 0 && arrival.at < stamp;
|
|
25
|
+
}
|
|
26
|
+
function changes(current, arrival) {
|
|
27
|
+
return current?.at === void 0 || !sameJson(current.value, arrival.value) || !sameJson(current.data, arrival.data);
|
|
28
|
+
}
|
|
29
|
+
function entryOf(frame, status) {
|
|
30
|
+
const { at, value, data } = frame;
|
|
31
|
+
return Object.freeze(data === void 0 ? { at, value, status } : { at, value, data, status });
|
|
32
|
+
}
|
|
33
|
+
var StateStore = class {
|
|
34
|
+
entries = /* @__PURE__ */ new Map();
|
|
35
|
+
/**
|
|
36
|
+
* The newest `at` seen per slot, kept apart from the entry: an arrival
|
|
37
|
+
* that changes nothing still orders the ones after it, while the entry's
|
|
38
|
+
* `at` stays the `at` of the read that last changed the value.
|
|
39
|
+
*/
|
|
40
|
+
stamps = /* @__PURE__ */ new Map();
|
|
41
|
+
listeners = /* @__PURE__ */ new Map();
|
|
42
|
+
/**
|
|
43
|
+
* The slots the server stopped serving, with the generation of the
|
|
44
|
+
* connection that said so. Kept apart from the entries: a revoke can
|
|
45
|
+
* arrive before the slot has a value, and an arrival afterwards supplies
|
|
46
|
+
* the value without clearing the mark.
|
|
47
|
+
*/
|
|
48
|
+
revoked = /* @__PURE__ */ new Map();
|
|
49
|
+
/** The store slot of `(id, key)`. */
|
|
50
|
+
static slot(id, keyText) {
|
|
51
|
+
return `${id}\0${keyText}`;
|
|
52
|
+
}
|
|
53
|
+
current(slot) {
|
|
54
|
+
return this.entries.get(slot);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Apply an arrival: the value under the ordering rule, the status from the
|
|
58
|
+
* revoked mark. Listeners hear only a changed status, value or data.
|
|
59
|
+
*/
|
|
60
|
+
apply(slot, frame) {
|
|
61
|
+
const current = this.entries.get(slot);
|
|
62
|
+
const status = this.revoked.has(slot) ? "revoked" : "ready";
|
|
63
|
+
const ordered = !isOlder(this.stamps.get(slot), frame);
|
|
64
|
+
if (ordered) {
|
|
65
|
+
this.stamps.set(slot, frame.at);
|
|
66
|
+
}
|
|
67
|
+
if (ordered && changes(current, frame)) {
|
|
68
|
+
this.replace(slot, entryOf(frame, status));
|
|
69
|
+
} else if (current.status !== status) {
|
|
70
|
+
this.replace(slot, entryOf(current, status));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Mark `slot` revoked by the connection of `generation`; its value, if any, is kept. */
|
|
74
|
+
revoke(slot, generation) {
|
|
75
|
+
this.revoked.set(slot, generation);
|
|
76
|
+
const entry = this.entries.get(slot);
|
|
77
|
+
if (entry?.status !== "revoked") {
|
|
78
|
+
this.replace(slot, Object.freeze(entry ? { ...entry, status: "revoked" } : { status: "revoked" }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The generation of the connection that revoked `slot`, or `undefined` while it is served. */
|
|
82
|
+
revokedBy(slot) {
|
|
83
|
+
return this.revoked.get(slot);
|
|
84
|
+
}
|
|
85
|
+
/** Clear the revoked mark; the next arrival for `slot` is `'ready'`. */
|
|
86
|
+
allow(slot) {
|
|
87
|
+
this.revoked.delete(slot);
|
|
88
|
+
}
|
|
89
|
+
listen(slot, listener) {
|
|
90
|
+
const set = this.listeners.get(slot) ?? /* @__PURE__ */ new Set();
|
|
91
|
+
set.add(listener);
|
|
92
|
+
this.listeners.set(slot, set);
|
|
93
|
+
}
|
|
94
|
+
unlisten(slot, listener) {
|
|
95
|
+
const set = this.listeners.get(slot);
|
|
96
|
+
if (listener) {
|
|
97
|
+
set?.delete(listener);
|
|
98
|
+
}
|
|
99
|
+
if (!listener || set?.size === 0) {
|
|
100
|
+
this.listeners.delete(slot);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
clearListeners() {
|
|
104
|
+
this.listeners.clear();
|
|
105
|
+
}
|
|
106
|
+
/** Replace the entry and notify; a throwing listener is logged and does not stop the others. */
|
|
107
|
+
replace(slot, entry) {
|
|
108
|
+
this.entries.set(slot, entry);
|
|
109
|
+
for (const listener of [...this.listeners.get(slot) ?? []]) {
|
|
110
|
+
notify(slot, listener);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
function notify(slot, listener) {
|
|
115
|
+
try {
|
|
116
|
+
listener();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn(`[state] A listener of state "${slot.split("\0")[0]}" threw:`, error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/state/client.ts
|
|
123
|
+
var CONNECTED = Object.freeze({ status: "connected" });
|
|
124
|
+
var FAILED = Object.freeze({ status: "failed" });
|
|
125
|
+
var STARTING = Object.freeze({ status: "reconnecting", attempt: 0 });
|
|
126
|
+
function reconnecting(attempt) {
|
|
127
|
+
return attempt === 0 ? STARTING : Object.freeze({ status: "reconnecting", attempt });
|
|
128
|
+
}
|
|
129
|
+
function sameStatus(a, b) {
|
|
130
|
+
return a.status === b.status && a.attempt === b.attempt;
|
|
131
|
+
}
|
|
132
|
+
function routeName(method, id) {
|
|
133
|
+
return `${method}${id[0].toUpperCase()}${id.slice(1)}State`;
|
|
134
|
+
}
|
|
135
|
+
function stateEventName(id) {
|
|
136
|
+
return `state.${id}.changed`;
|
|
137
|
+
}
|
|
138
|
+
function carries(query, key) {
|
|
139
|
+
return Object.entries(key).every(([field, value]) => query[field] === value);
|
|
140
|
+
}
|
|
141
|
+
function sortedFields(fields) {
|
|
142
|
+
return [...fields].sort().join(", ");
|
|
143
|
+
}
|
|
144
|
+
function createStateClient(config) {
|
|
145
|
+
return new BrowserStateClient(config);
|
|
146
|
+
}
|
|
147
|
+
var BrowserStateClient = class {
|
|
148
|
+
constructor(config) {
|
|
149
|
+
this.config = config;
|
|
150
|
+
this.api = createApi(config.api);
|
|
151
|
+
this.reportError = config.onError ?? ((error) => console.error("[state] A refresh failed:", error));
|
|
152
|
+
if (config.initial) {
|
|
153
|
+
this.hydrate(config.initial);
|
|
154
|
+
}
|
|
155
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
156
|
+
globalThis.addEventListener("online", this.onOnline);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
store = new StateStore();
|
|
160
|
+
api;
|
|
161
|
+
watched = /* @__PURE__ */ new Map();
|
|
162
|
+
reportError;
|
|
163
|
+
manifest;
|
|
164
|
+
manifestRead;
|
|
165
|
+
stream;
|
|
166
|
+
syncQueued = false;
|
|
167
|
+
status = STARTING;
|
|
168
|
+
connectionListeners = /* @__PURE__ */ new Set();
|
|
169
|
+
onOnline = () => this.retry();
|
|
170
|
+
/** The retries of the first snapshot since its last start, against `maxRetries`. */
|
|
171
|
+
retries = 0;
|
|
172
|
+
/** The lifetime whose subscriptions wait on the first snapshot — its read in flight, or its retry timer set. */
|
|
173
|
+
waitingLifetime;
|
|
174
|
+
retryTimer;
|
|
175
|
+
/** Bumped by `close()`: a snapshot started before it is discarded. */
|
|
176
|
+
lifetime = 0;
|
|
177
|
+
/** Bumped on every connection open; tags the snapshot read for it. */
|
|
178
|
+
generation = 0;
|
|
179
|
+
/** Bumped on every automatic read and every grant, to tell which came first. */
|
|
180
|
+
clock = 0;
|
|
181
|
+
/** The clock at the latest grant of each slot. */
|
|
182
|
+
grants = /* @__PURE__ */ new Map();
|
|
183
|
+
async get(id, key) {
|
|
184
|
+
await this.requireManifest();
|
|
185
|
+
const frame = await this.api[routeName("get", id)].call(key ? { query: key } : {});
|
|
186
|
+
this.store.apply(this.slot(id, key), frame);
|
|
187
|
+
return frame;
|
|
188
|
+
}
|
|
189
|
+
async write(id, input, key) {
|
|
190
|
+
if (!(await this.requireManifest())[id]?.write) {
|
|
191
|
+
throw new Error(`State client: state "${id}" declares no .write(), so it has no write route.`);
|
|
192
|
+
}
|
|
193
|
+
const frame = await this.api[routeName("put", id)].call({ body: input, ...key ? { query: key } : {} });
|
|
194
|
+
this.store.apply(this.slot(id, key), frame);
|
|
195
|
+
return frame;
|
|
196
|
+
}
|
|
197
|
+
snapshot(query = this.streamQuery()) {
|
|
198
|
+
return this.readSnapshot(query, this.tag());
|
|
199
|
+
}
|
|
200
|
+
subscribe(id, ...args) {
|
|
201
|
+
const listener = args[args.length - 1];
|
|
202
|
+
const key = args.length === 2 ? args[0] : void 0;
|
|
203
|
+
const refusal = this.manifest && this.refusal(id, key);
|
|
204
|
+
if (refusal) {
|
|
205
|
+
this.reportError(refusal);
|
|
206
|
+
return () => void 0;
|
|
207
|
+
}
|
|
208
|
+
const notify2 = () => listener();
|
|
209
|
+
const slot = this.slot(id, key);
|
|
210
|
+
this.store.listen(slot, notify2);
|
|
211
|
+
this.watch(slot, id, key, 1);
|
|
212
|
+
let live = true;
|
|
213
|
+
return () => {
|
|
214
|
+
if (live) {
|
|
215
|
+
live = false;
|
|
216
|
+
this.store.unlisten(slot, notify2);
|
|
217
|
+
this.watch(slot, id, key, -1);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
current(id, key) {
|
|
222
|
+
return this.store.current(this.slot(id, key));
|
|
223
|
+
}
|
|
224
|
+
connection() {
|
|
225
|
+
return this.status;
|
|
226
|
+
}
|
|
227
|
+
subscribeConnection(listener) {
|
|
228
|
+
const notify2 = (status) => listener(status);
|
|
229
|
+
this.connectionListeners.add(notify2);
|
|
230
|
+
return () => {
|
|
231
|
+
this.connectionListeners.delete(notify2);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Restart what gave up or is still retrying, now: the first snapshot
|
|
236
|
+
* while the manifest is unknown, else the stream — a new `sse.subscribe`,
|
|
237
|
+
* whose attempt count starts at 0. Nothing to do when connected or with
|
|
238
|
+
* nothing watched — so after `close()`, until a new `subscribe`.
|
|
239
|
+
*/
|
|
240
|
+
retry() {
|
|
241
|
+
if (this.status === CONNECTED || this.watched.size === 0) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (this.manifest) {
|
|
245
|
+
this.openStream(this.streamQuery());
|
|
246
|
+
} else {
|
|
247
|
+
this.rereadManifest();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
close() {
|
|
251
|
+
this.lifetime++;
|
|
252
|
+
clearTimeout(this.retryTimer);
|
|
253
|
+
this.retryTimer = void 0;
|
|
254
|
+
this.retries = 0;
|
|
255
|
+
this.closeStream();
|
|
256
|
+
this.watched.clear();
|
|
257
|
+
this.store.clearListeners();
|
|
258
|
+
this.connectionListeners.clear();
|
|
259
|
+
this.status = STARTING;
|
|
260
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
261
|
+
globalThis.removeEventListener("online", this.onOnline);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** The status is `next` from now; each connection listener hears it, a throwing one logged. */
|
|
265
|
+
setConnection(next) {
|
|
266
|
+
if (sameStatus(next, this.status)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.status = next;
|
|
270
|
+
for (const listener of [...this.connectionListeners]) {
|
|
271
|
+
try {
|
|
272
|
+
listener(next);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.warn("[state] A connection listener threw:", error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Start from the snapshot the server rendered with: its manifest, then
|
|
280
|
+
* each state present, as a read snapshot applies them, under generation 0
|
|
281
|
+
* — no connection yet. Nothing is marked `'revoked'`: nothing is watched,
|
|
282
|
+
* and an omission in the render's snapshot is not a denial by the stream.
|
|
283
|
+
*/
|
|
284
|
+
hydrate(initial) {
|
|
285
|
+
const snapshot = initial.snapshot;
|
|
286
|
+
this.installManifest(snapshot.manifest);
|
|
287
|
+
this.applyPresent(snapshot, initial.query ?? {}, this.tag());
|
|
288
|
+
}
|
|
289
|
+
slot(id, key) {
|
|
290
|
+
return StateStore.slot(id, stableKey(key));
|
|
291
|
+
}
|
|
292
|
+
tag() {
|
|
293
|
+
return { generation: this.generation, tick: ++this.clock };
|
|
294
|
+
}
|
|
295
|
+
/** The key of state `id` the query names; `undefined` for a keyless state. */
|
|
296
|
+
keyIn(id, query) {
|
|
297
|
+
const fields = this.manifest[id].key;
|
|
298
|
+
return fields.length ? Object.fromEntries(fields.map((field) => [field, query[field]])) : void 0;
|
|
299
|
+
}
|
|
300
|
+
/** Why the manifest refuses `subscribe(id, key)` — an unknown id, or key fields not the state's. */
|
|
301
|
+
refusal(id, key) {
|
|
302
|
+
const state = this.manifest[id];
|
|
303
|
+
if (!state) {
|
|
304
|
+
return new Error(`State client: the server's manifest has no state "${id}".`);
|
|
305
|
+
}
|
|
306
|
+
const expected = sortedFields(state.key);
|
|
307
|
+
const given = sortedFields(Object.keys(key ?? {}));
|
|
308
|
+
return expected === given ? void 0 : new Error(`State client: state "${id}" takes the key fields [${expected}], given [${given}].`);
|
|
309
|
+
}
|
|
310
|
+
/** The first snapshot, read once however many callers wait for it. */
|
|
311
|
+
ensureManifest() {
|
|
312
|
+
if (this.manifest) {
|
|
313
|
+
return Promise.resolve();
|
|
314
|
+
}
|
|
315
|
+
this.manifestRead ??= this.snapshot().then(() => void 0).finally(() => {
|
|
316
|
+
this.manifestRead = void 0;
|
|
317
|
+
});
|
|
318
|
+
return this.manifestRead;
|
|
319
|
+
}
|
|
320
|
+
/** The manifest for a call the app made; one whose wait outlived `close()` is refused. */
|
|
321
|
+
async requireManifest() {
|
|
322
|
+
await this.ensureManifest();
|
|
323
|
+
if (!this.manifest) {
|
|
324
|
+
throw new Error("State client: the client was closed while the first snapshot was in flight.");
|
|
325
|
+
}
|
|
326
|
+
return this.manifest;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Read the snapshot and apply it: the manifest first — `keyIn` and the
|
|
330
|
+
* key checks need it — then each state present, then each watched state
|
|
331
|
+
* it omits. A snapshot that resolves after `close()` is discarded.
|
|
332
|
+
*/
|
|
333
|
+
async readSnapshot(query, tag) {
|
|
334
|
+
const lifetime = this.lifetime;
|
|
335
|
+
const snapshot = await this.api.getStates.call({ query });
|
|
336
|
+
if (lifetime === this.lifetime) {
|
|
337
|
+
this.installManifest(snapshot.manifest);
|
|
338
|
+
this.applyPresent(snapshot, query, tag);
|
|
339
|
+
this.applyOmitted(snapshot, query, tag);
|
|
340
|
+
}
|
|
341
|
+
return snapshot;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The manifest is static; the first one stays. Subscriptions made before
|
|
345
|
+
* it are checked now, and the stream opens for those it accepts —
|
|
346
|
+
* whichever call (`subscribe`, `get`, `write`, `snapshot`) brought it.
|
|
347
|
+
*/
|
|
348
|
+
installManifest(manifest) {
|
|
349
|
+
if (this.manifest) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.manifest = manifest;
|
|
353
|
+
for (const [slot, watch] of this.watched) {
|
|
354
|
+
const refusal = this.refusal(watch.id, watch.key);
|
|
355
|
+
if (refusal) {
|
|
356
|
+
this.watched.delete(slot);
|
|
357
|
+
this.store.unlisten(slot);
|
|
358
|
+
this.reportError(refusal);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (this.watched.size > 0) {
|
|
362
|
+
this.queueSync();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* An arrival from a read started for the connection of `tag`: a
|
|
367
|
+
* connection newer than the one that revoked the slot lifts the mark, an
|
|
368
|
+
* older or the same connection leaves `'revoked'`. The lift is by
|
|
369
|
+
* generation alone — a frame or a write answer of the new connection may
|
|
370
|
+
* land first with a newer `at` — and the ordering rule decides only the value.
|
|
371
|
+
*/
|
|
372
|
+
serve(slot, frame, generation) {
|
|
373
|
+
if ((this.store.revokedBy(slot) ?? Infinity) < generation) {
|
|
374
|
+
this.store.allow(slot);
|
|
375
|
+
}
|
|
376
|
+
this.store.apply(slot, frame);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* A state present is served, under the generation rule of `serve`. A state
|
|
380
|
+
* the manifest does not describe, or a keyed one whose key fields the query
|
|
381
|
+
* lacks, is skipped: the server never serves either, but a hand-built
|
|
382
|
+
* `initial` might.
|
|
383
|
+
*/
|
|
384
|
+
applyPresent(snapshot, query, tag) {
|
|
385
|
+
for (const [id, result] of Object.entries(snapshot.states)) {
|
|
386
|
+
if (this.manifest[id]?.key.every((field) => field in query)) {
|
|
387
|
+
this.serve(this.slot(id, this.keyIn(id, query)), { at: snapshot.at, ...result }, tag.generation);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A watched state whose key the query carried (or a keyless one) and that
|
|
393
|
+
* the snapshot omits is not served to this subject: `'revoked'`. Not when
|
|
394
|
+
* a newer connection exists, nor when a grant came after the read started.
|
|
395
|
+
*/
|
|
396
|
+
applyOmitted(snapshot, query, tag) {
|
|
397
|
+
if (tag.generation !== this.generation) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
for (const [slot, watch] of this.watched) {
|
|
401
|
+
const asked = !watch.key || carries(query, watch.key);
|
|
402
|
+
const grantedSince = (this.grants.get(slot) ?? 0) > tag.tick;
|
|
403
|
+
if (asked && !(watch.id in snapshot.states) && !grantedSince) {
|
|
404
|
+
this.store.revoke(slot, tag.generation);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
watch(slot, id, key, delta) {
|
|
409
|
+
const count = (this.watched.get(slot)?.count ?? 0) + delta;
|
|
410
|
+
if (count > 0) {
|
|
411
|
+
this.watched.set(slot, { id, key, count });
|
|
412
|
+
} else {
|
|
413
|
+
this.watched.delete(slot);
|
|
414
|
+
}
|
|
415
|
+
if (delta === 1 || this.watched.size === 0 || this.status !== FAILED) {
|
|
416
|
+
this.queueSync();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* One stream for the client, its query the union of the subscribed keys.
|
|
421
|
+
* Batched to the end of the task, so components mounting together (or a
|
|
422
|
+
* StrictMode unmount-remount) open the stream once.
|
|
423
|
+
*/
|
|
424
|
+
queueSync() {
|
|
425
|
+
if (!this.syncQueued) {
|
|
426
|
+
this.syncQueued = true;
|
|
427
|
+
queueMicrotask(() => {
|
|
428
|
+
this.syncQueued = false;
|
|
429
|
+
this.sync();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Until the manifest is known, subscriptions wait for the first snapshot;
|
|
435
|
+
* the stream opens after it, never before — its `events=` comes from it.
|
|
436
|
+
* A client built with `initial` knows it from the start. With nothing
|
|
437
|
+
* watched the stream closes, the retry count starts over for the next
|
|
438
|
+
* wait, and the status is the start value: no stream, nothing answered.
|
|
439
|
+
*/
|
|
440
|
+
sync() {
|
|
441
|
+
if (this.watched.size === 0) {
|
|
442
|
+
this.retries = 0;
|
|
443
|
+
this.closeStream();
|
|
444
|
+
this.setConnection(STARTING);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (!this.manifest) {
|
|
448
|
+
this.awaitManifest();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const query = this.streamQuery();
|
|
452
|
+
if (this.stream?.failed || this.stream?.text !== stableKey(query)) {
|
|
453
|
+
this.openStream(query);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* One waiter per lifetime on the first snapshot. A failure goes to
|
|
458
|
+
* `onError`, and the read is retried after `retryDelay` — the stream that
|
|
459
|
+
* would reconnect on its own does not exist yet. A retry stops on
|
|
460
|
+
* `close()`, when nothing is watched (`sync` closes instead), or after
|
|
461
|
+
* `maxRetries`; a wait started after that last one restarts the count.
|
|
462
|
+
*/
|
|
463
|
+
awaitManifest() {
|
|
464
|
+
const lifetime = this.lifetime;
|
|
465
|
+
if (this.waitingLifetime === lifetime) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (this.status === FAILED) {
|
|
469
|
+
this.retries = 0;
|
|
470
|
+
this.setConnection(STARTING);
|
|
471
|
+
}
|
|
472
|
+
this.waitingLifetime = lifetime;
|
|
473
|
+
this.ensureManifest().then(
|
|
474
|
+
() => lifetime === this.lifetime && this.resync(),
|
|
475
|
+
(error) => {
|
|
476
|
+
this.reportError(error);
|
|
477
|
+
if (lifetime === this.lifetime) {
|
|
478
|
+
this.retryLater();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
/** The wait is over; `sync` opens the stream, or waits again when a closed lifetime's read was discarded. */
|
|
484
|
+
resync() {
|
|
485
|
+
this.waitingLifetime = void 0;
|
|
486
|
+
this.sync();
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Read the first snapshot again after `retryDelay`, as the SSE client
|
|
490
|
+
* reconnects a dropped stream — or, with `maxRetries` spent, give up:
|
|
491
|
+
* `'failed'`, no timer, no wait.
|
|
492
|
+
*/
|
|
493
|
+
retryLater() {
|
|
494
|
+
const maxRetries = this.config.maxRetries ?? 0;
|
|
495
|
+
if (maxRetries > 0 && this.retries >= maxRetries) {
|
|
496
|
+
this.waitingLifetime = void 0;
|
|
497
|
+
this.setConnection(FAILED);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.retries++;
|
|
501
|
+
this.retryTimer = setTimeout(() => {
|
|
502
|
+
this.retryTimer = void 0;
|
|
503
|
+
this.resync();
|
|
504
|
+
}, this.config.retryDelay ?? 3e3);
|
|
505
|
+
this.retryTimer.unref?.();
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* `retry()` before the manifest: the count starts over and the first
|
|
509
|
+
* snapshot is read now, its pending timer cleared. A read in flight is
|
|
510
|
+
* already the retry; it is not doubled.
|
|
511
|
+
*/
|
|
512
|
+
rereadManifest() {
|
|
513
|
+
this.retries = 0;
|
|
514
|
+
if (this.waitingLifetime === this.lifetime && this.retryTimer === void 0) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
clearTimeout(this.retryTimer);
|
|
518
|
+
this.retryTimer = void 0;
|
|
519
|
+
this.setConnection(STARTING);
|
|
520
|
+
this.resync();
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Every subscribed key's fields; one connection carries one value per
|
|
524
|
+
* field, so on a conflict the latest subscribed key wins. The keys it
|
|
525
|
+
* shadows are read once when the stream opens and get no frames.
|
|
526
|
+
*/
|
|
527
|
+
streamQuery() {
|
|
528
|
+
return Object.assign({}, ...[...this.watched.values()].map((watch) => watch.key ?? {}));
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Open (or reopen) the stream. `events` is every state of the manifest, in
|
|
532
|
+
* its order, never only the subscribed ones: a state subscribed later would
|
|
533
|
+
* otherwise be unrequested, and the server's `grant` cannot activate it.
|
|
534
|
+
* Unless connected, the status is `'reconnecting'` 0 until it opens: a
|
|
535
|
+
* new subscription counts its attempts from 0. A reopen on a key change
|
|
536
|
+
* while connected stays `'connected'`.
|
|
537
|
+
*/
|
|
538
|
+
openStream(query) {
|
|
539
|
+
this.closeStream();
|
|
540
|
+
if (this.status !== CONNECTED) {
|
|
541
|
+
this.setConnection(STARTING);
|
|
542
|
+
}
|
|
543
|
+
const ids = Object.keys(this.manifest);
|
|
544
|
+
const stream = { query, text: stableKey(query), generation: 0, closedByClient: false, failed: false, close: () => void 0 };
|
|
545
|
+
stream.close = this.config.sse.subscribe({
|
|
546
|
+
events: ids.map(stateEventName),
|
|
547
|
+
handlers: Object.fromEntries(ids.map((id) => [stateEventName(id), (frame) => this.onFrame(id, frame, stream)])),
|
|
548
|
+
query,
|
|
549
|
+
onOpen: () => {
|
|
550
|
+
stream.generation = ++this.generation;
|
|
551
|
+
this.streamStatus(stream, CONNECTED);
|
|
552
|
+
},
|
|
553
|
+
onReconnect: (attempt) => this.streamStatus(stream, reconnecting(attempt)),
|
|
554
|
+
onClose: () => this.onStreamClose(stream),
|
|
555
|
+
onConnected: (connected) => this.onConnected(connected, stream),
|
|
556
|
+
onSubscriptionChange: (change) => this.onSubscriptionChange(change, stream)
|
|
557
|
+
});
|
|
558
|
+
this.stream = stream;
|
|
559
|
+
}
|
|
560
|
+
/** Flagged first: the SSE client fires `onClose` on this teardown too, and it is no failure. */
|
|
561
|
+
closeStream() {
|
|
562
|
+
if (this.stream) {
|
|
563
|
+
this.stream.closedByClient = true;
|
|
564
|
+
this.stream.close();
|
|
565
|
+
this.stream = void 0;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** The status of a callback of `stream`, when no other stream replaced it. */
|
|
569
|
+
streamStatus(stream, status) {
|
|
570
|
+
if (stream === this.stream) {
|
|
571
|
+
this.setConnection(status);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The SSE client fires `onClose` on every teardown; one the client did
|
|
576
|
+
* not start is the reconnect attempts exhausted: `'failed'`, and the
|
|
577
|
+
* stream counts as absent for the next `sync`.
|
|
578
|
+
*/
|
|
579
|
+
onStreamClose(stream) {
|
|
580
|
+
if (!stream.closedByClient && stream === this.stream) {
|
|
581
|
+
stream.failed = true;
|
|
582
|
+
this.setConnection(FAILED);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Every connection — the first, a reconnect, a reopen on a key change —
|
|
587
|
+
* must cover the frames sent while no stream was open. The server says in
|
|
588
|
+
* `connected` which states it served their current frame right after it;
|
|
589
|
+
* the snapshot is read only when one it did not serve is neither denied
|
|
590
|
+
* nor served — or when the server predates `initial`. A denied state the
|
|
591
|
+
* client watches is `'revoked'`, as an omission from that snapshot would
|
|
592
|
+
* be. A connection already replaced is ignored.
|
|
593
|
+
*/
|
|
594
|
+
onConnected(connected, stream) {
|
|
595
|
+
if (stream !== this.stream || stream.generation !== this.generation) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const denied = new Set(connected.deniedEvents);
|
|
599
|
+
const served = new Set(connected.initial ?? []);
|
|
600
|
+
const unserved = Object.keys(this.manifest).map(stateEventName).filter((name) => !denied.has(name) && !served.has(name));
|
|
601
|
+
this.revokeDenied(denied, stream);
|
|
602
|
+
if (connected.initial && unserved.length === 0) {
|
|
603
|
+
this.readShadowedKeys(stream.query);
|
|
604
|
+
} else {
|
|
605
|
+
this.refresh(stream.query);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** Each watched state the connection denies, on the key it carries, marked `'revoked'` by it. */
|
|
609
|
+
revokeDenied(denied, stream) {
|
|
610
|
+
for (const id of Object.keys(this.manifest)) {
|
|
611
|
+
const slot = this.slot(id, this.keyIn(id, stream.query));
|
|
612
|
+
if (denied.has(stateEventName(id)) && this.watched.has(slot)) {
|
|
613
|
+
this.store.revoke(slot, stream.generation);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** The snapshot for the stream's query, and one read for each subscribed key the query shadows. */
|
|
618
|
+
refresh(query) {
|
|
619
|
+
this.readSnapshot(query, this.tag()).catch(this.reportError);
|
|
620
|
+
this.readShadowedKeys(query);
|
|
621
|
+
}
|
|
622
|
+
/** One read for each subscribed key the query shadows: the stream serves one key per field. */
|
|
623
|
+
readShadowedKeys(query) {
|
|
624
|
+
for (const watch of this.watched.values()) {
|
|
625
|
+
if (watch.key && !carries(query, watch.key)) {
|
|
626
|
+
this.readShadowed(watch.id, watch.key, this.tag()).catch(this.reportError);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* A shadowed key gets no frames, no grant and no snapshot entry; the
|
|
632
|
+
* server answering its own read is the evidence of access instead.
|
|
633
|
+
*/
|
|
634
|
+
async readShadowed(id, key, tag) {
|
|
635
|
+
const frame = await this.api[routeName("get", id)].call({ query: key });
|
|
636
|
+
this.serve(this.slot(id, key), frame, tag.generation);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* A frame for a key nobody here subscribed is dropped — the server's filter
|
|
640
|
+
* is the first guard, this the second. A frame is served under its
|
|
641
|
+
* connection's generation: the server sends one only on a connection that
|
|
642
|
+
* allows the state, so a newer connection's frame lifts an older revoke.
|
|
643
|
+
*/
|
|
644
|
+
onFrame(id, frame, stream) {
|
|
645
|
+
const slot = this.slot(id, frame.key);
|
|
646
|
+
if (this.watched.has(slot)) {
|
|
647
|
+
this.serve(slot, frame, stream.generation);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* A revoke or a grant concerns the key the connection carries — the
|
|
652
|
+
* server scopes both by the connect query — and so does the read after a
|
|
653
|
+
* grant. Revoked: the entry keeps its value, marked `'revoked'`, until a
|
|
654
|
+
* grant. Granted: the mark is cleared and the key read once; the server
|
|
655
|
+
* sends a frame too, in either order, and both arrive `'ready'`.
|
|
656
|
+
*/
|
|
657
|
+
onSubscriptionChange(change, stream) {
|
|
658
|
+
const id = /^state\.(.+)\.changed$/.exec(change.event)?.[1];
|
|
659
|
+
if (id === void 0 || !this.manifest[id]) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const key = this.keyIn(id, stream.query);
|
|
663
|
+
const slot = this.slot(id, key);
|
|
664
|
+
if (change.status === "revoked") {
|
|
665
|
+
this.store.revoke(slot, stream.generation);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
this.grants.set(slot, ++this.clock);
|
|
669
|
+
this.store.allow(slot);
|
|
670
|
+
if (this.watched.has(slot)) {
|
|
671
|
+
this.get(id, key).catch(this.reportError);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
export { createStateClient };
|
|
677
|
+
//# sourceMappingURL=client.js.map
|
|
678
|
+
//# sourceMappingURL=client.js.map
|