@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +71 -4
  5. package/dist/auth/index.d.ts +3 -3
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. 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(request.headers.get(CONTEXT_HEADER))
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.draftChannel) {
421
- headers["X-Foir-Schema-Channel"] = "draft";
422
- }
423
- if (input.bearerToken) {
424
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
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 (sk_ or sk_t_), got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
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
- return { list, stream };
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,