@foir/sdk 0.9.0 → 0.11.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/CHANGELOG.md +719 -27
- package/README.md +121 -2
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +18 -0
- package/dist/auth.js +209 -47
- package/dist/client.js +101 -13
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +81 -13
- package/dist/data/client.d.ts +92 -9
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +14 -1
- package/dist/data/index.d.ts +22 -10
- package/dist/data/manifest.d.ts +40 -4
- package/dist/data/schema.d.ts +18 -11
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +363 -24
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +591 -64
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +58 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +260 -34
- package/dist/push.js +163 -36
- package/dist/react.js +157 -33
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +4 -2
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +20 -1
- package/dist/server.js +602 -10
- package/dist/transport/foir-http.d.ts +168 -15
- package/package.json +10 -2
package/dist/server.js
CHANGED
|
@@ -238,13 +238,93 @@ async function requireScopedToken(request, options) {
|
|
|
238
238
|
403
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
|
+
const rawContext = request.headers.get(CONTEXT_HEADER);
|
|
241
242
|
return {
|
|
242
243
|
claims,
|
|
243
244
|
subject,
|
|
244
|
-
context: parseContextHeader(
|
|
245
|
+
context: parseContextHeader(rawContext),
|
|
246
|
+
// The token handed over is the one verified above, not a fresh read of the
|
|
247
|
+
// header, so the client presents exactly what this guard accepted.
|
|
248
|
+
dispatch: { token: () => token, context: rawContext }
|
|
245
249
|
};
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
// src/server/read-dispatch.ts
|
|
253
|
+
var OperationPayloadError = class extends Error {
|
|
254
|
+
status;
|
|
255
|
+
constructor(message) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = "OperationPayloadError";
|
|
258
|
+
this.status = 400;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
262
|
+
function refuse(message) {
|
|
263
|
+
throw new OperationPayloadError(`[foir] not an operation dispatch body: ${message}`);
|
|
264
|
+
}
|
|
265
|
+
function requireString(from, key, where, allowEmpty = false) {
|
|
266
|
+
const value = from[key];
|
|
267
|
+
if (typeof value !== "string" || !allowEmpty && value === "") {
|
|
268
|
+
refuse(`${where}.${key} is ${describe(value)}, expected a${allowEmpty ? "" : " non-empty"} string`);
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
function describe(value) {
|
|
273
|
+
if (value === void 0) return "missing";
|
|
274
|
+
if (value === null) return "null";
|
|
275
|
+
if (Array.isArray(value)) return "an array";
|
|
276
|
+
return `a ${typeof value}`;
|
|
277
|
+
}
|
|
278
|
+
async function readDispatch(request) {
|
|
279
|
+
let text;
|
|
280
|
+
try {
|
|
281
|
+
text = await request.text();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new OperationPayloadError(
|
|
284
|
+
`[foir] could not read the dispatch body (has something already consumed it?): ${err instanceof Error ? err.message : String(err)}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
let body;
|
|
288
|
+
try {
|
|
289
|
+
body = JSON.parse(text);
|
|
290
|
+
} catch {
|
|
291
|
+
refuse("the body is not JSON");
|
|
292
|
+
}
|
|
293
|
+
if (!isObject(body)) refuse(`the body is ${describe(body)}, expected a JSON object`);
|
|
294
|
+
if (typeof body.executionId !== "string" || typeof body.operationKey !== "string") {
|
|
295
|
+
refuse(
|
|
296
|
+
"it has no executionId and operationKey. The platform sends the run, not the input: the caller's input is under `input`"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
requireString(body, "executionId", "body");
|
|
300
|
+
requireString(body, "operationKey", "body");
|
|
301
|
+
const trigger = body.trigger;
|
|
302
|
+
if (!isObject(trigger)) refuse(`body.trigger is ${describe(trigger)}, expected an object`);
|
|
303
|
+
requireString(trigger, "type", "body.trigger");
|
|
304
|
+
const context = body.context;
|
|
305
|
+
if (!isObject(context)) refuse(`body.context is ${describe(context)}, expected an object`);
|
|
306
|
+
requireString(context, "tenantId", "body.context");
|
|
307
|
+
requireString(context, "projectId", "body.context");
|
|
308
|
+
requireString(context, "userId", "body.context", true);
|
|
309
|
+
requireString(context, "timestamp", "body.context");
|
|
310
|
+
if (context.customerId !== void 0) requireString(context, "customerId", "body.context");
|
|
311
|
+
const input = body.input;
|
|
312
|
+
if (input !== null && !isObject(input)) {
|
|
313
|
+
refuse(`body.input is ${describe(input)}, expected an object`);
|
|
314
|
+
}
|
|
315
|
+
const payload = { ...body, input: input ?? {} };
|
|
316
|
+
for (const block of ["content", "record", "triggerContext", "callback"]) {
|
|
317
|
+
const value = payload[block];
|
|
318
|
+
if (value === void 0) continue;
|
|
319
|
+
if (value === null) {
|
|
320
|
+
delete payload[block];
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!isObject(value)) refuse(`body.${block} is ${describe(value)}, expected an object`);
|
|
324
|
+
}
|
|
325
|
+
return payload;
|
|
326
|
+
}
|
|
327
|
+
|
|
248
328
|
// src/server/callback-client.ts
|
|
249
329
|
function createCallbackClient(callback, opts) {
|
|
250
330
|
const { executionId, signingSecret } = opts;
|
|
@@ -351,6 +431,20 @@ var FoirRequestError = class extends Error {
|
|
|
351
431
|
status;
|
|
352
432
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
353
433
|
graphQLErrors;
|
|
434
|
+
/**
|
|
435
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
436
|
+
* response. Present only on that path: without it, opting into strictness
|
|
437
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
438
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
439
|
+
*/
|
|
440
|
+
partialData;
|
|
441
|
+
/**
|
|
442
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
443
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
444
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
445
|
+
* developer-facing message, has it without re-parsing anything.
|
|
446
|
+
*/
|
|
447
|
+
prunedSelections;
|
|
354
448
|
cause;
|
|
355
449
|
constructor(message, options) {
|
|
356
450
|
super(message);
|
|
@@ -358,6 +452,8 @@ var FoirRequestError = class extends Error {
|
|
|
358
452
|
this.cause = options?.cause;
|
|
359
453
|
this.status = options?.status;
|
|
360
454
|
this.graphQLErrors = options?.graphQLErrors;
|
|
455
|
+
this.partialData = options?.partialData;
|
|
456
|
+
this.prunedSelections = options?.prunedSelections;
|
|
361
457
|
}
|
|
362
458
|
/**
|
|
363
459
|
* The code of the first error, if the response carried one.
|
|
@@ -412,16 +508,46 @@ function readFoirRetryAfterMs(headers) {
|
|
|
412
508
|
const seconds = Number(headers.get("Retry-After"));
|
|
413
509
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
414
510
|
}
|
|
511
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
512
|
+
"x-api-key",
|
|
513
|
+
"authorization",
|
|
514
|
+
"x-foir-token",
|
|
515
|
+
"x-foir-context"
|
|
516
|
+
]);
|
|
517
|
+
function refuseCredentialHeaders(extra) {
|
|
518
|
+
if (!extra) return;
|
|
519
|
+
for (const name of Object.keys(extra)) {
|
|
520
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
521
|
+
throw new FoirRequestError(
|
|
522
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
415
527
|
function buildFoirHeaders(input) {
|
|
416
528
|
const headers = {
|
|
417
|
-
"Content-Type": "application/json"
|
|
418
|
-
"x-api-key": input.apiKey
|
|
529
|
+
"Content-Type": "application/json"
|
|
419
530
|
};
|
|
420
|
-
if (input.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
531
|
+
if (input.dispatchToken !== void 0) {
|
|
532
|
+
refuseCredentialHeaders(input.extra);
|
|
533
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
534
|
+
if (input.dispatchContext) {
|
|
535
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
headers["x-api-key"] = input.apiKey;
|
|
539
|
+
if (input.draftChannel) {
|
|
540
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
541
|
+
}
|
|
542
|
+
if (input.bearerToken) {
|
|
543
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
544
|
+
}
|
|
545
|
+
if (input.orgId) {
|
|
546
|
+
headers["X-Org-Id"] = input.orgId;
|
|
547
|
+
}
|
|
548
|
+
if (input.orgUrl) {
|
|
549
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
550
|
+
}
|
|
425
551
|
}
|
|
426
552
|
if (input.extra) {
|
|
427
553
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -438,6 +564,403 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
438
564
|
return base + path;
|
|
439
565
|
}
|
|
440
566
|
|
|
567
|
+
// src/data/transport.ts
|
|
568
|
+
async function resolveBearerToken(config) {
|
|
569
|
+
const source = config.bearerToken;
|
|
570
|
+
if (typeof source === "function") return await source() ?? void 0;
|
|
571
|
+
return source ?? void 0;
|
|
572
|
+
}
|
|
573
|
+
async function resolveDispatchToken(dispatch) {
|
|
574
|
+
const token = (await dispatch.token())?.trim();
|
|
575
|
+
if (!token) {
|
|
576
|
+
throw new FoirRequestError(
|
|
577
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return token;
|
|
581
|
+
}
|
|
582
|
+
async function resolveFoirAuth(config) {
|
|
583
|
+
if (config.dispatch !== void 0) {
|
|
584
|
+
return {
|
|
585
|
+
headers: buildFoirHeaders({
|
|
586
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
587
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
588
|
+
extra: config.headers
|
|
589
|
+
})
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const bearerToken = await resolveBearerToken(config);
|
|
593
|
+
return {
|
|
594
|
+
headers: buildFoirHeaders({
|
|
595
|
+
apiKey: config.apiKey,
|
|
596
|
+
draftChannel: config.channel === "draft",
|
|
597
|
+
bearerToken,
|
|
598
|
+
orgId: config.orgId,
|
|
599
|
+
orgUrl: config.orgUrl,
|
|
600
|
+
extra: config.headers
|
|
601
|
+
}),
|
|
602
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/data/ws-transport.ts
|
|
607
|
+
var GQL = {
|
|
608
|
+
connectionInit: "connection_init",
|
|
609
|
+
connectionAck: "connection_ack",
|
|
610
|
+
ping: "ping",
|
|
611
|
+
pong: "pong",
|
|
612
|
+
subscribe: "subscribe",
|
|
613
|
+
next: "next",
|
|
614
|
+
error: "error",
|
|
615
|
+
complete: "complete"
|
|
616
|
+
};
|
|
617
|
+
var SUBPROTOCOL = "graphql-transport-ws";
|
|
618
|
+
var OPEN_READY_STATE = 1;
|
|
619
|
+
var DEFAULT_RECONNECT = {
|
|
620
|
+
maxAttempts: Infinity,
|
|
621
|
+
initialDelayMs: 500,
|
|
622
|
+
maxDelayMs: 15e3,
|
|
623
|
+
jitter: 0.25
|
|
624
|
+
};
|
|
625
|
+
function foirSocketEndpoint(apiUrl) {
|
|
626
|
+
const trimmed = apiUrl.replace(/\/+$/, "").replace(/\/graphql$/, "");
|
|
627
|
+
return `${trimmed.replace(/^http/, "ws")}/graphql/ws`;
|
|
628
|
+
}
|
|
629
|
+
var FoirSocketTransport = class {
|
|
630
|
+
config;
|
|
631
|
+
policy;
|
|
632
|
+
registrations = /* @__PURE__ */ new Map();
|
|
633
|
+
socket = null;
|
|
634
|
+
acknowledged = false;
|
|
635
|
+
connecting = false;
|
|
636
|
+
attempt = 0;
|
|
637
|
+
nextId = 0;
|
|
638
|
+
disposed = false;
|
|
639
|
+
keepAlive = null;
|
|
640
|
+
retryTimer = null;
|
|
641
|
+
constructor(config) {
|
|
642
|
+
if (config.dispatch !== void 0) {
|
|
643
|
+
throw new FoirRequestError(
|
|
644
|
+
"[foir] a client built from a dispatch token cannot subscribe. The subscription socket authenticates an api key, an admin token or a customer bearer, and has no path for a dispatch token. Queries and mutations made as the operation go to /graphql and work as they are."
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
this.config = config;
|
|
648
|
+
this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Start a subscription.
|
|
652
|
+
*
|
|
653
|
+
* Returns synchronously so a caller can hold the handle immediately, while
|
|
654
|
+
* the connect and the handshake proceed in the background. A subscription
|
|
655
|
+
* registered before the socket is acknowledged is sent the moment it is —
|
|
656
|
+
* which is the same path a reconnect takes, so the ordinary case and the
|
|
657
|
+
* recovery case exercise one piece of code.
|
|
658
|
+
*/
|
|
659
|
+
subscribe(request, handlers) {
|
|
660
|
+
if (this.disposed) {
|
|
661
|
+
throw new FoirRequestError("[foir] this client has been disposed; create a new one");
|
|
662
|
+
}
|
|
663
|
+
this.nextId += 1;
|
|
664
|
+
const id = String(this.nextId);
|
|
665
|
+
this.registrations.set(id, {
|
|
666
|
+
id,
|
|
667
|
+
request,
|
|
668
|
+
handlers,
|
|
669
|
+
sent: false,
|
|
670
|
+
established: false
|
|
671
|
+
});
|
|
672
|
+
this.ensureConnected();
|
|
673
|
+
this.flush();
|
|
674
|
+
return {
|
|
675
|
+
unsubscribe: () => this.cancel(id)
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** Close the socket and drop every subscription. */
|
|
679
|
+
dispose() {
|
|
680
|
+
this.disposed = true;
|
|
681
|
+
this.registrations.clear();
|
|
682
|
+
this.teardownSocket(true);
|
|
683
|
+
}
|
|
684
|
+
/** Live subscription count. Exposed for tests and diagnostics. */
|
|
685
|
+
get size() {
|
|
686
|
+
return this.registrations.size;
|
|
687
|
+
}
|
|
688
|
+
// -------------------------------------------------------------------------
|
|
689
|
+
// Connection
|
|
690
|
+
// -------------------------------------------------------------------------
|
|
691
|
+
ensureConnected() {
|
|
692
|
+
if (this.disposed || this.socket || this.connecting) return;
|
|
693
|
+
this.connecting = true;
|
|
694
|
+
const factory = this.config.webSocketImpl ?? defaultWebSocketFactory();
|
|
695
|
+
const url = this.config.wsUrl ?? foirSocketEndpoint(this.config.apiUrl);
|
|
696
|
+
let socket;
|
|
697
|
+
try {
|
|
698
|
+
socket = factory(url, SUBPROTOCOL);
|
|
699
|
+
} catch (cause) {
|
|
700
|
+
this.connecting = false;
|
|
701
|
+
this.scheduleReconnect(
|
|
702
|
+
new FoirRequestError(`[foir] could not open a WebSocket to ${url}: ${String(cause)}`)
|
|
703
|
+
);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
this.socket = socket;
|
|
707
|
+
socket.onopen = () => {
|
|
708
|
+
void this.sendConnectionInit(socket);
|
|
709
|
+
};
|
|
710
|
+
socket.onmessage = (event) => this.receive(event.data);
|
|
711
|
+
socket.onerror = () => {
|
|
712
|
+
};
|
|
713
|
+
socket.onclose = () => this.handleClose();
|
|
714
|
+
}
|
|
715
|
+
async sendConnectionInit(socket) {
|
|
716
|
+
this.connecting = false;
|
|
717
|
+
try {
|
|
718
|
+
const { headers } = await resolveFoirAuth(this.config);
|
|
719
|
+
const { "Content-Type": _contentType, ...params } = headers;
|
|
720
|
+
const extra = await this.config.connectionParams?.() ?? {};
|
|
721
|
+
this.send(socket, {
|
|
722
|
+
type: GQL.connectionInit,
|
|
723
|
+
payload: { ...params, ...extra }
|
|
724
|
+
});
|
|
725
|
+
} catch (cause) {
|
|
726
|
+
this.failAll(
|
|
727
|
+
new FoirRequestError(`[foir] could not resolve the subscription credential: ${String(cause)}`)
|
|
728
|
+
);
|
|
729
|
+
this.teardownSocket(true);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
handleClose() {
|
|
733
|
+
const wasAcknowledged = this.acknowledged;
|
|
734
|
+
this.teardownSocket(false);
|
|
735
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
736
|
+
for (const registration of this.registrations.values()) {
|
|
737
|
+
registration.sent = false;
|
|
738
|
+
}
|
|
739
|
+
this.scheduleReconnect(
|
|
740
|
+
new FoirRequestError(
|
|
741
|
+
wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
scheduleReconnect(error) {
|
|
746
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
747
|
+
if (this.attempt >= this.policy.maxAttempts) {
|
|
748
|
+
this.failAll(error);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const exponent = Math.min(this.attempt, 20);
|
|
752
|
+
const base = Math.min(
|
|
753
|
+
this.policy.initialDelayMs * 2 ** exponent,
|
|
754
|
+
this.policy.maxDelayMs
|
|
755
|
+
);
|
|
756
|
+
const delay = base + base * this.policy.jitter * Math.random();
|
|
757
|
+
this.attempt += 1;
|
|
758
|
+
this.retryTimer = setTimeout(() => {
|
|
759
|
+
this.retryTimer = null;
|
|
760
|
+
this.ensureConnected();
|
|
761
|
+
}, delay);
|
|
762
|
+
const timer = this.retryTimer;
|
|
763
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
764
|
+
}
|
|
765
|
+
teardownSocket(closeIt) {
|
|
766
|
+
if (this.keepAlive !== null) {
|
|
767
|
+
clearInterval(this.keepAlive);
|
|
768
|
+
this.keepAlive = null;
|
|
769
|
+
}
|
|
770
|
+
if (this.retryTimer !== null) {
|
|
771
|
+
clearTimeout(this.retryTimer);
|
|
772
|
+
this.retryTimer = null;
|
|
773
|
+
}
|
|
774
|
+
const socket = this.socket;
|
|
775
|
+
this.socket = null;
|
|
776
|
+
this.acknowledged = false;
|
|
777
|
+
this.connecting = false;
|
|
778
|
+
if (!socket) return;
|
|
779
|
+
socket.onopen = null;
|
|
780
|
+
socket.onmessage = null;
|
|
781
|
+
socket.onerror = null;
|
|
782
|
+
socket.onclose = null;
|
|
783
|
+
if (closeIt) {
|
|
784
|
+
try {
|
|
785
|
+
socket.close(1e3, "client disposed");
|
|
786
|
+
} catch {
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
// Frames
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
receive(data) {
|
|
794
|
+
if (typeof data !== "string") return;
|
|
795
|
+
let message;
|
|
796
|
+
try {
|
|
797
|
+
message = JSON.parse(data);
|
|
798
|
+
} catch {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
switch (message.type) {
|
|
802
|
+
case GQL.connectionAck:
|
|
803
|
+
this.acknowledged = true;
|
|
804
|
+
this.attempt = 0;
|
|
805
|
+
this.startKeepAlive();
|
|
806
|
+
this.flush();
|
|
807
|
+
return;
|
|
808
|
+
case GQL.ping:
|
|
809
|
+
if (this.socket) this.send(this.socket, { type: GQL.pong });
|
|
810
|
+
return;
|
|
811
|
+
case GQL.pong:
|
|
812
|
+
return;
|
|
813
|
+
case GQL.next: {
|
|
814
|
+
const registration = message.id ? this.registrations.get(message.id) : void 0;
|
|
815
|
+
if (!registration) return;
|
|
816
|
+
const payload = message.payload;
|
|
817
|
+
const root = payload?.data;
|
|
818
|
+
if (!root) return;
|
|
819
|
+
const keys = Object.keys(root);
|
|
820
|
+
if (keys.length !== 1) return;
|
|
821
|
+
registration.handlers.onData(root[keys[0]]);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
case GQL.error: {
|
|
825
|
+
const error = new FoirRequestError(`[foir] subscription refused: ${describe2(message.payload)}`, {
|
|
826
|
+
graphQLErrors: graphQLErrorItems(message.payload)
|
|
827
|
+
});
|
|
828
|
+
if (!message.id) {
|
|
829
|
+
this.failAll(error);
|
|
830
|
+
this.teardownSocket(true);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const registration = this.registrations.get(message.id);
|
|
834
|
+
this.registrations.delete(message.id);
|
|
835
|
+
registration?.handlers.onError?.(error);
|
|
836
|
+
this.closeIfIdle();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
case GQL.complete: {
|
|
840
|
+
if (!message.id) return;
|
|
841
|
+
const registration = this.registrations.get(message.id);
|
|
842
|
+
this.registrations.delete(message.id);
|
|
843
|
+
registration?.handlers.onComplete?.();
|
|
844
|
+
this.closeIfIdle();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
default:
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Send every registration the current socket has not yet been told about.
|
|
853
|
+
*
|
|
854
|
+
* This IS the resubscribe path. A reconnect clears `sent` on every
|
|
855
|
+
* registration and lands here, so a subscription re-established after a drop
|
|
856
|
+
* takes the same code as one opened for the first time — there is no second
|
|
857
|
+
* implementation that can be correct in one case and wrong in the other.
|
|
858
|
+
*/
|
|
859
|
+
flush() {
|
|
860
|
+
const socket = this.socket;
|
|
861
|
+
if (!socket || !this.acknowledged || socket.readyState !== OPEN_READY_STATE) return;
|
|
862
|
+
for (const registration of this.registrations.values()) {
|
|
863
|
+
if (registration.sent) continue;
|
|
864
|
+
this.send(socket, {
|
|
865
|
+
id: registration.id,
|
|
866
|
+
type: GQL.subscribe,
|
|
867
|
+
payload: registration.request
|
|
868
|
+
});
|
|
869
|
+
registration.sent = true;
|
|
870
|
+
if (registration.established) {
|
|
871
|
+
registration.handlers.onReconnect?.();
|
|
872
|
+
} else {
|
|
873
|
+
registration.established = true;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
cancel(id) {
|
|
878
|
+
const registration = this.registrations.get(id);
|
|
879
|
+
if (!registration) return;
|
|
880
|
+
this.registrations.delete(id);
|
|
881
|
+
if (this.socket && registration.sent && this.socket.readyState === OPEN_READY_STATE) {
|
|
882
|
+
this.send(this.socket, { id, type: GQL.complete });
|
|
883
|
+
}
|
|
884
|
+
this.closeIfIdle();
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Close the socket once nothing is using it.
|
|
888
|
+
*
|
|
889
|
+
* A socket held open with no subscriptions is a connection the server has to
|
|
890
|
+
* keep, a keepalive timer that keeps a serverless invocation alive, and
|
|
891
|
+
* nothing in return.
|
|
892
|
+
*/
|
|
893
|
+
closeIfIdle() {
|
|
894
|
+
if (this.registrations.size === 0) this.teardownSocket(true);
|
|
895
|
+
}
|
|
896
|
+
failAll(error) {
|
|
897
|
+
const registrations = [...this.registrations.values()];
|
|
898
|
+
this.registrations.clear();
|
|
899
|
+
for (const registration of registrations) {
|
|
900
|
+
registration.handlers.onError?.(error);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Client-side keepalive.
|
|
905
|
+
*
|
|
906
|
+
* The server pings on its own timer and this client answers, which is enough
|
|
907
|
+
* for the server to know the client is alive. This is the other direction:
|
|
908
|
+
* an idle subscription can sit for hours with no events, and a proxy that
|
|
909
|
+
* times out idle sockets will drop it. A periodic `ping` keeps it warm, and
|
|
910
|
+
* the pong that comes back proves the path is still there.
|
|
911
|
+
*/
|
|
912
|
+
startKeepAlive() {
|
|
913
|
+
if (this.keepAlive !== null) clearInterval(this.keepAlive);
|
|
914
|
+
this.keepAlive = setInterval(() => {
|
|
915
|
+
const socket = this.socket;
|
|
916
|
+
if (!socket || socket.readyState !== OPEN_READY_STATE) return;
|
|
917
|
+
this.send(socket, { type: GQL.ping });
|
|
918
|
+
}, 2e4);
|
|
919
|
+
const timer = this.keepAlive;
|
|
920
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
921
|
+
}
|
|
922
|
+
send(socket, message) {
|
|
923
|
+
try {
|
|
924
|
+
socket.send(JSON.stringify(message));
|
|
925
|
+
} catch {
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
function defaultWebSocketFactory() {
|
|
930
|
+
const Impl = globalThis.WebSocket;
|
|
931
|
+
if (typeof Impl !== "function") {
|
|
932
|
+
throw new FoirRequestError(
|
|
933
|
+
"[foir] no WebSocket implementation is available in this runtime. Pass one as `webSocketImpl` \u2014 e.g. `webSocketImpl: (url, protocols) => new WebSocket(url, protocols)` from the `ws` package on Node < 22."
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return (url, protocols) => new Impl(url, protocols);
|
|
937
|
+
}
|
|
938
|
+
function graphQLErrorItems(payload) {
|
|
939
|
+
const list = Array.isArray(payload) ? payload : payload == null ? [] : [payload];
|
|
940
|
+
const items = [];
|
|
941
|
+
for (const entry of list) {
|
|
942
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
943
|
+
const { message, path, extensions } = entry;
|
|
944
|
+
if (extensions === void 0 && path === void 0) continue;
|
|
945
|
+
items.push({
|
|
946
|
+
message: typeof message === "string" ? message : describe2(entry),
|
|
947
|
+
...Array.isArray(path) ? { path } : {},
|
|
948
|
+
...typeof extensions === "object" && extensions !== null ? { extensions } : {}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
return items.length > 0 ? items : void 0;
|
|
952
|
+
}
|
|
953
|
+
function describe2(payload) {
|
|
954
|
+
if (payload == null) return "no reason given";
|
|
955
|
+
if (typeof payload === "string") return payload;
|
|
956
|
+
if (Array.isArray(payload)) return payload.map(describe2).join("; ");
|
|
957
|
+
if (typeof payload === "object") {
|
|
958
|
+
const message = payload.message;
|
|
959
|
+
if (typeof message === "string") return message;
|
|
960
|
+
}
|
|
961
|
+
return JSON.stringify(payload);
|
|
962
|
+
}
|
|
963
|
+
|
|
441
964
|
// src/server/events-client.ts
|
|
442
965
|
var FOIR_EVENTS_PATH = "/v1/events";
|
|
443
966
|
var FOIR_EVENTS_SCOPE = "events:list.project";
|
|
@@ -486,7 +1009,7 @@ function assertOperatorKey(apiKey) {
|
|
|
486
1009
|
}
|
|
487
1010
|
if (!key.startsWith("sk_")) {
|
|
488
1011
|
throw new FoirRequestError(
|
|
489
|
-
`[foir] createEventsClient: apiKey must be an sk_ secret key
|
|
1012
|
+
`[foir] createEventsClient: apiKey must be an sk_ secret key, got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
|
|
490
1013
|
);
|
|
491
1014
|
}
|
|
492
1015
|
return key;
|
|
@@ -629,7 +1152,74 @@ function createEventsClient(config) {
|
|
|
629
1152
|
after = page.nextCursor;
|
|
630
1153
|
}
|
|
631
1154
|
}
|
|
632
|
-
|
|
1155
|
+
let socket = null;
|
|
1156
|
+
let live = 0;
|
|
1157
|
+
function subscribe(params) {
|
|
1158
|
+
const { after, events, onData, onError, onComplete, onReconnect } = params;
|
|
1159
|
+
socket ??= new FoirSocketTransport({
|
|
1160
|
+
apiUrl: config.apiUrl,
|
|
1161
|
+
apiKey,
|
|
1162
|
+
...config.headers ? { headers: config.headers } : {},
|
|
1163
|
+
...config.wsUrl ? { wsUrl: config.wsUrl } : {},
|
|
1164
|
+
...config.webSocketImpl ? { webSocketImpl: config.webSocketImpl } : {},
|
|
1165
|
+
...config.reconnect ? { reconnect: config.reconnect } : {}
|
|
1166
|
+
});
|
|
1167
|
+
const variables = {
|
|
1168
|
+
...after !== void 0 ? { after: readSeq(after, "after") } : {},
|
|
1169
|
+
...events && events.length > 0 ? { events } : {}
|
|
1170
|
+
};
|
|
1171
|
+
live += 1;
|
|
1172
|
+
let released = false;
|
|
1173
|
+
const release = () => {
|
|
1174
|
+
if (released) return;
|
|
1175
|
+
released = true;
|
|
1176
|
+
live -= 1;
|
|
1177
|
+
if (live === 0 && socket) {
|
|
1178
|
+
socket.dispose();
|
|
1179
|
+
socket = null;
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
const handle = socket.subscribe(
|
|
1183
|
+
{ query: EVENTS_SUBSCRIPTION, variables },
|
|
1184
|
+
{
|
|
1185
|
+
onData: (event) => {
|
|
1186
|
+
if (typeof event?.seq === "number" && event.seq > (variables.after ?? 0)) {
|
|
1187
|
+
variables.after = event.seq;
|
|
1188
|
+
}
|
|
1189
|
+
onData(event);
|
|
1190
|
+
},
|
|
1191
|
+
onError: (error) => {
|
|
1192
|
+
release();
|
|
1193
|
+
onError?.(asEventsSocketError(error, variables.after));
|
|
1194
|
+
},
|
|
1195
|
+
onComplete: () => {
|
|
1196
|
+
release();
|
|
1197
|
+
onComplete?.();
|
|
1198
|
+
},
|
|
1199
|
+
...onReconnect ? { onReconnect } : {}
|
|
1200
|
+
}
|
|
1201
|
+
);
|
|
1202
|
+
return {
|
|
1203
|
+
unsubscribe: () => {
|
|
1204
|
+
handle.unsubscribe();
|
|
1205
|
+
release();
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
return { list, stream, subscribe };
|
|
1210
|
+
}
|
|
1211
|
+
var EVENTS_SUBSCRIPTION = "subscription FoirEvents($after: EventSeq, $events: [String!]) {\n events(after: $after, events: $events) {\n id\n seq\n event\n payload\n actor { kind ref }\n causationChain\n createdAt\n }\n}";
|
|
1212
|
+
function asEventsSocketError(error, requestedCursor) {
|
|
1213
|
+
for (const item of error.graphQLErrors ?? []) {
|
|
1214
|
+
if (item.extensions?.code !== "cursor_expired") continue;
|
|
1215
|
+
const resumeFrom = item.extensions.resumeFrom;
|
|
1216
|
+
return new FoirEventCursorExpiredError(`[foir] events: cursor expired: ${item.message}`, {
|
|
1217
|
+
status: 410,
|
|
1218
|
+
...typeof resumeFrom === "number" ? { resumeFrom } : {},
|
|
1219
|
+
...requestedCursor !== void 0 ? { requestedCursor } : {}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
return error;
|
|
633
1223
|
}
|
|
634
1224
|
export {
|
|
635
1225
|
FOIR_EVENTS_PATH,
|
|
@@ -637,6 +1227,7 @@ export {
|
|
|
637
1227
|
FOIR_EVENTS_SCOPE,
|
|
638
1228
|
FoirEventCursorExpiredError,
|
|
639
1229
|
FoirRequestError,
|
|
1230
|
+
OperationPayloadError,
|
|
640
1231
|
ScopedTokenError,
|
|
641
1232
|
ServerClientError,
|
|
642
1233
|
createCallbackClient,
|
|
@@ -649,6 +1240,7 @@ export {
|
|
|
649
1240
|
isFoirUnauthenticated,
|
|
650
1241
|
parseContextHeader,
|
|
651
1242
|
parseSubject,
|
|
1243
|
+
readDispatch,
|
|
652
1244
|
recordsReadModels,
|
|
653
1245
|
recordsWriteModels,
|
|
654
1246
|
requireScopedToken,
|