@foir/sdk 0.8.0 → 0.10.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 +550 -28
- package/README.md +111 -0
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +93 -4
- package/dist/auth/index.d.ts +4 -4
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +10 -0
- package/dist/auth.js +138 -19
- package/dist/client.js +63 -6
- 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/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 +131 -12
- package/dist/data/client.d.ts +82 -1
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +1 -1
- package/dist/data/index.d.ts +11 -9
- package/dist/data/manifest.d.ts +33 -6
- package/dist/data/schema.d.ts +2 -2
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +120 -3
- package/dist/data.js +377 -16
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +59 -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 +179 -6
- package/dist/push.js +81 -8
- package/dist/react.js +76 -5
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +3 -2
- package/dist/server/require-scoped-token.d.ts +1 -1
- package/dist/server.js +454 -2
- package/dist/transport/foir-http.d.ts +113 -10
- package/package.json +10 -2
package/dist/server.js
CHANGED
|
@@ -351,6 +351,20 @@ var FoirRequestError = class extends Error {
|
|
|
351
351
|
status;
|
|
352
352
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
353
353
|
graphQLErrors;
|
|
354
|
+
/**
|
|
355
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
356
|
+
* response. Present only on that path: without it, opting into strictness
|
|
357
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
358
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
359
|
+
*/
|
|
360
|
+
partialData;
|
|
361
|
+
/**
|
|
362
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
363
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
364
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
365
|
+
* developer-facing message, has it without re-parsing anything.
|
|
366
|
+
*/
|
|
367
|
+
prunedSelections;
|
|
354
368
|
cause;
|
|
355
369
|
constructor(message, options) {
|
|
356
370
|
super(message);
|
|
@@ -358,6 +372,8 @@ var FoirRequestError = class extends Error {
|
|
|
358
372
|
this.cause = options?.cause;
|
|
359
373
|
this.status = options?.status;
|
|
360
374
|
this.graphQLErrors = options?.graphQLErrors;
|
|
375
|
+
this.partialData = options?.partialData;
|
|
376
|
+
this.prunedSelections = options?.prunedSelections;
|
|
361
377
|
}
|
|
362
378
|
/**
|
|
363
379
|
* The code of the first error, if the response carried one.
|
|
@@ -423,6 +439,9 @@ function buildFoirHeaders(input) {
|
|
|
423
439
|
if (input.bearerToken) {
|
|
424
440
|
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
425
441
|
}
|
|
442
|
+
if (input.orgId) {
|
|
443
|
+
headers["X-Org-Id"] = input.orgId;
|
|
444
|
+
}
|
|
426
445
|
if (input.extra) {
|
|
427
446
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
428
447
|
headers[key] = value;
|
|
@@ -438,6 +457,372 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
438
457
|
return base + path;
|
|
439
458
|
}
|
|
440
459
|
|
|
460
|
+
// src/data/transport.ts
|
|
461
|
+
async function resolveBearerToken(config) {
|
|
462
|
+
const source = config.bearerToken;
|
|
463
|
+
if (typeof source === "function") return await source() ?? void 0;
|
|
464
|
+
return source ?? void 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/data/ws-transport.ts
|
|
468
|
+
var GQL = {
|
|
469
|
+
connectionInit: "connection_init",
|
|
470
|
+
connectionAck: "connection_ack",
|
|
471
|
+
ping: "ping",
|
|
472
|
+
pong: "pong",
|
|
473
|
+
subscribe: "subscribe",
|
|
474
|
+
next: "next",
|
|
475
|
+
error: "error",
|
|
476
|
+
complete: "complete"
|
|
477
|
+
};
|
|
478
|
+
var SUBPROTOCOL = "graphql-transport-ws";
|
|
479
|
+
var OPEN_READY_STATE = 1;
|
|
480
|
+
var DEFAULT_RECONNECT = {
|
|
481
|
+
maxAttempts: Infinity,
|
|
482
|
+
initialDelayMs: 500,
|
|
483
|
+
maxDelayMs: 15e3,
|
|
484
|
+
jitter: 0.25
|
|
485
|
+
};
|
|
486
|
+
function foirSocketEndpoint(apiUrl) {
|
|
487
|
+
const trimmed = apiUrl.replace(/\/+$/, "").replace(/\/graphql$/, "");
|
|
488
|
+
return `${trimmed.replace(/^http/, "ws")}/graphql/ws`;
|
|
489
|
+
}
|
|
490
|
+
var FoirSocketTransport = class {
|
|
491
|
+
config;
|
|
492
|
+
policy;
|
|
493
|
+
registrations = /* @__PURE__ */ new Map();
|
|
494
|
+
socket = null;
|
|
495
|
+
acknowledged = false;
|
|
496
|
+
connecting = false;
|
|
497
|
+
attempt = 0;
|
|
498
|
+
nextId = 0;
|
|
499
|
+
disposed = false;
|
|
500
|
+
keepAlive = null;
|
|
501
|
+
retryTimer = null;
|
|
502
|
+
constructor(config) {
|
|
503
|
+
this.config = config;
|
|
504
|
+
this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Start a subscription.
|
|
508
|
+
*
|
|
509
|
+
* Returns synchronously so a caller can hold the handle immediately, while
|
|
510
|
+
* the connect and the handshake proceed in the background. A subscription
|
|
511
|
+
* registered before the socket is acknowledged is sent the moment it is —
|
|
512
|
+
* which is the same path a reconnect takes, so the ordinary case and the
|
|
513
|
+
* recovery case exercise one piece of code.
|
|
514
|
+
*/
|
|
515
|
+
subscribe(request, handlers) {
|
|
516
|
+
if (this.disposed) {
|
|
517
|
+
throw new FoirRequestError("[foir] this client has been disposed; create a new one");
|
|
518
|
+
}
|
|
519
|
+
this.nextId += 1;
|
|
520
|
+
const id = String(this.nextId);
|
|
521
|
+
this.registrations.set(id, {
|
|
522
|
+
id,
|
|
523
|
+
request,
|
|
524
|
+
handlers,
|
|
525
|
+
sent: false,
|
|
526
|
+
established: false
|
|
527
|
+
});
|
|
528
|
+
this.ensureConnected();
|
|
529
|
+
this.flush();
|
|
530
|
+
return {
|
|
531
|
+
unsubscribe: () => this.cancel(id)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/** Close the socket and drop every subscription. */
|
|
535
|
+
dispose() {
|
|
536
|
+
this.disposed = true;
|
|
537
|
+
this.registrations.clear();
|
|
538
|
+
this.teardownSocket(true);
|
|
539
|
+
}
|
|
540
|
+
/** Live subscription count. Exposed for tests and diagnostics. */
|
|
541
|
+
get size() {
|
|
542
|
+
return this.registrations.size;
|
|
543
|
+
}
|
|
544
|
+
// -------------------------------------------------------------------------
|
|
545
|
+
// Connection
|
|
546
|
+
// -------------------------------------------------------------------------
|
|
547
|
+
ensureConnected() {
|
|
548
|
+
if (this.disposed || this.socket || this.connecting) return;
|
|
549
|
+
this.connecting = true;
|
|
550
|
+
const factory = this.config.webSocketImpl ?? defaultWebSocketFactory();
|
|
551
|
+
const url = this.config.wsUrl ?? foirSocketEndpoint(this.config.apiUrl);
|
|
552
|
+
let socket;
|
|
553
|
+
try {
|
|
554
|
+
socket = factory(url, SUBPROTOCOL);
|
|
555
|
+
} catch (cause) {
|
|
556
|
+
this.connecting = false;
|
|
557
|
+
this.scheduleReconnect(
|
|
558
|
+
new FoirRequestError(`[foir] could not open a WebSocket to ${url}: ${String(cause)}`)
|
|
559
|
+
);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
this.socket = socket;
|
|
563
|
+
socket.onopen = () => {
|
|
564
|
+
void this.sendConnectionInit(socket);
|
|
565
|
+
};
|
|
566
|
+
socket.onmessage = (event) => this.receive(event.data);
|
|
567
|
+
socket.onerror = () => {
|
|
568
|
+
};
|
|
569
|
+
socket.onclose = () => this.handleClose();
|
|
570
|
+
}
|
|
571
|
+
async sendConnectionInit(socket) {
|
|
572
|
+
this.connecting = false;
|
|
573
|
+
try {
|
|
574
|
+
const headers = buildFoirHeaders({
|
|
575
|
+
apiKey: this.config.apiKey,
|
|
576
|
+
draftChannel: this.config.channel === "draft",
|
|
577
|
+
bearerToken: await resolveBearerToken(this.config),
|
|
578
|
+
orgId: this.config.orgId,
|
|
579
|
+
extra: this.config.headers
|
|
580
|
+
});
|
|
581
|
+
const { "Content-Type": _contentType, ...params } = headers;
|
|
582
|
+
const extra = await this.config.connectionParams?.() ?? {};
|
|
583
|
+
this.send(socket, {
|
|
584
|
+
type: GQL.connectionInit,
|
|
585
|
+
payload: { ...params, ...extra }
|
|
586
|
+
});
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
this.failAll(
|
|
589
|
+
new FoirRequestError(`[foir] could not resolve the subscription credential: ${String(cause)}`)
|
|
590
|
+
);
|
|
591
|
+
this.teardownSocket(true);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
handleClose() {
|
|
595
|
+
const wasAcknowledged = this.acknowledged;
|
|
596
|
+
this.teardownSocket(false);
|
|
597
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
598
|
+
for (const registration of this.registrations.values()) {
|
|
599
|
+
registration.sent = false;
|
|
600
|
+
}
|
|
601
|
+
this.scheduleReconnect(
|
|
602
|
+
new FoirRequestError(
|
|
603
|
+
wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
|
|
604
|
+
)
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
scheduleReconnect(error) {
|
|
608
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
609
|
+
if (this.attempt >= this.policy.maxAttempts) {
|
|
610
|
+
this.failAll(error);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const exponent = Math.min(this.attempt, 20);
|
|
614
|
+
const base = Math.min(
|
|
615
|
+
this.policy.initialDelayMs * 2 ** exponent,
|
|
616
|
+
this.policy.maxDelayMs
|
|
617
|
+
);
|
|
618
|
+
const delay = base + base * this.policy.jitter * Math.random();
|
|
619
|
+
this.attempt += 1;
|
|
620
|
+
this.retryTimer = setTimeout(() => {
|
|
621
|
+
this.retryTimer = null;
|
|
622
|
+
this.ensureConnected();
|
|
623
|
+
}, delay);
|
|
624
|
+
const timer = this.retryTimer;
|
|
625
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
626
|
+
}
|
|
627
|
+
teardownSocket(closeIt) {
|
|
628
|
+
if (this.keepAlive !== null) {
|
|
629
|
+
clearInterval(this.keepAlive);
|
|
630
|
+
this.keepAlive = null;
|
|
631
|
+
}
|
|
632
|
+
if (this.retryTimer !== null) {
|
|
633
|
+
clearTimeout(this.retryTimer);
|
|
634
|
+
this.retryTimer = null;
|
|
635
|
+
}
|
|
636
|
+
const socket = this.socket;
|
|
637
|
+
this.socket = null;
|
|
638
|
+
this.acknowledged = false;
|
|
639
|
+
this.connecting = false;
|
|
640
|
+
if (!socket) return;
|
|
641
|
+
socket.onopen = null;
|
|
642
|
+
socket.onmessage = null;
|
|
643
|
+
socket.onerror = null;
|
|
644
|
+
socket.onclose = null;
|
|
645
|
+
if (closeIt) {
|
|
646
|
+
try {
|
|
647
|
+
socket.close(1e3, "client disposed");
|
|
648
|
+
} catch {
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// -------------------------------------------------------------------------
|
|
653
|
+
// Frames
|
|
654
|
+
// -------------------------------------------------------------------------
|
|
655
|
+
receive(data) {
|
|
656
|
+
if (typeof data !== "string") return;
|
|
657
|
+
let message;
|
|
658
|
+
try {
|
|
659
|
+
message = JSON.parse(data);
|
|
660
|
+
} catch {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
switch (message.type) {
|
|
664
|
+
case GQL.connectionAck:
|
|
665
|
+
this.acknowledged = true;
|
|
666
|
+
this.attempt = 0;
|
|
667
|
+
this.startKeepAlive();
|
|
668
|
+
this.flush();
|
|
669
|
+
return;
|
|
670
|
+
case GQL.ping:
|
|
671
|
+
if (this.socket) this.send(this.socket, { type: GQL.pong });
|
|
672
|
+
return;
|
|
673
|
+
case GQL.pong:
|
|
674
|
+
return;
|
|
675
|
+
case GQL.next: {
|
|
676
|
+
const registration = message.id ? this.registrations.get(message.id) : void 0;
|
|
677
|
+
if (!registration) return;
|
|
678
|
+
const payload = message.payload;
|
|
679
|
+
const root = payload?.data;
|
|
680
|
+
if (!root) return;
|
|
681
|
+
const keys = Object.keys(root);
|
|
682
|
+
if (keys.length !== 1) return;
|
|
683
|
+
registration.handlers.onData(root[keys[0]]);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
case GQL.error: {
|
|
687
|
+
const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
|
|
688
|
+
graphQLErrors: graphQLErrorItems(message.payload)
|
|
689
|
+
});
|
|
690
|
+
if (!message.id) {
|
|
691
|
+
this.failAll(error);
|
|
692
|
+
this.teardownSocket(true);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const registration = this.registrations.get(message.id);
|
|
696
|
+
this.registrations.delete(message.id);
|
|
697
|
+
registration?.handlers.onError?.(error);
|
|
698
|
+
this.closeIfIdle();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
case GQL.complete: {
|
|
702
|
+
if (!message.id) return;
|
|
703
|
+
const registration = this.registrations.get(message.id);
|
|
704
|
+
this.registrations.delete(message.id);
|
|
705
|
+
registration?.handlers.onComplete?.();
|
|
706
|
+
this.closeIfIdle();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
default:
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Send every registration the current socket has not yet been told about.
|
|
715
|
+
*
|
|
716
|
+
* This IS the resubscribe path. A reconnect clears `sent` on every
|
|
717
|
+
* registration and lands here, so a subscription re-established after a drop
|
|
718
|
+
* takes the same code as one opened for the first time — there is no second
|
|
719
|
+
* implementation that can be correct in one case and wrong in the other.
|
|
720
|
+
*/
|
|
721
|
+
flush() {
|
|
722
|
+
const socket = this.socket;
|
|
723
|
+
if (!socket || !this.acknowledged || socket.readyState !== OPEN_READY_STATE) return;
|
|
724
|
+
for (const registration of this.registrations.values()) {
|
|
725
|
+
if (registration.sent) continue;
|
|
726
|
+
this.send(socket, {
|
|
727
|
+
id: registration.id,
|
|
728
|
+
type: GQL.subscribe,
|
|
729
|
+
payload: registration.request
|
|
730
|
+
});
|
|
731
|
+
registration.sent = true;
|
|
732
|
+
if (registration.established) {
|
|
733
|
+
registration.handlers.onReconnect?.();
|
|
734
|
+
} else {
|
|
735
|
+
registration.established = true;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
cancel(id) {
|
|
740
|
+
const registration = this.registrations.get(id);
|
|
741
|
+
if (!registration) return;
|
|
742
|
+
this.registrations.delete(id);
|
|
743
|
+
if (this.socket && registration.sent && this.socket.readyState === OPEN_READY_STATE) {
|
|
744
|
+
this.send(this.socket, { id, type: GQL.complete });
|
|
745
|
+
}
|
|
746
|
+
this.closeIfIdle();
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Close the socket once nothing is using it.
|
|
750
|
+
*
|
|
751
|
+
* A socket held open with no subscriptions is a connection the server has to
|
|
752
|
+
* keep, a keepalive timer that keeps a serverless invocation alive, and
|
|
753
|
+
* nothing in return.
|
|
754
|
+
*/
|
|
755
|
+
closeIfIdle() {
|
|
756
|
+
if (this.registrations.size === 0) this.teardownSocket(true);
|
|
757
|
+
}
|
|
758
|
+
failAll(error) {
|
|
759
|
+
const registrations = [...this.registrations.values()];
|
|
760
|
+
this.registrations.clear();
|
|
761
|
+
for (const registration of registrations) {
|
|
762
|
+
registration.handlers.onError?.(error);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Client-side keepalive.
|
|
767
|
+
*
|
|
768
|
+
* The server pings on its own timer and this client answers, which is enough
|
|
769
|
+
* for the server to know the client is alive. This is the other direction:
|
|
770
|
+
* an idle subscription can sit for hours with no events, and a proxy that
|
|
771
|
+
* times out idle sockets will drop it. A periodic `ping` keeps it warm, and
|
|
772
|
+
* the pong that comes back proves the path is still there.
|
|
773
|
+
*/
|
|
774
|
+
startKeepAlive() {
|
|
775
|
+
if (this.keepAlive !== null) clearInterval(this.keepAlive);
|
|
776
|
+
this.keepAlive = setInterval(() => {
|
|
777
|
+
const socket = this.socket;
|
|
778
|
+
if (!socket || socket.readyState !== OPEN_READY_STATE) return;
|
|
779
|
+
this.send(socket, { type: GQL.ping });
|
|
780
|
+
}, 2e4);
|
|
781
|
+
const timer = this.keepAlive;
|
|
782
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
783
|
+
}
|
|
784
|
+
send(socket, message) {
|
|
785
|
+
try {
|
|
786
|
+
socket.send(JSON.stringify(message));
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
function defaultWebSocketFactory() {
|
|
792
|
+
const Impl = globalThis.WebSocket;
|
|
793
|
+
if (typeof Impl !== "function") {
|
|
794
|
+
throw new FoirRequestError(
|
|
795
|
+
"[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."
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return (url, protocols) => new Impl(url, protocols);
|
|
799
|
+
}
|
|
800
|
+
function graphQLErrorItems(payload) {
|
|
801
|
+
const list = Array.isArray(payload) ? payload : payload == null ? [] : [payload];
|
|
802
|
+
const items = [];
|
|
803
|
+
for (const entry of list) {
|
|
804
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
805
|
+
const { message, path, extensions } = entry;
|
|
806
|
+
if (extensions === void 0 && path === void 0) continue;
|
|
807
|
+
items.push({
|
|
808
|
+
message: typeof message === "string" ? message : describe(entry),
|
|
809
|
+
...Array.isArray(path) ? { path } : {},
|
|
810
|
+
...typeof extensions === "object" && extensions !== null ? { extensions } : {}
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
return items.length > 0 ? items : void 0;
|
|
814
|
+
}
|
|
815
|
+
function describe(payload) {
|
|
816
|
+
if (payload == null) return "no reason given";
|
|
817
|
+
if (typeof payload === "string") return payload;
|
|
818
|
+
if (Array.isArray(payload)) return payload.map(describe).join("; ");
|
|
819
|
+
if (typeof payload === "object") {
|
|
820
|
+
const message = payload.message;
|
|
821
|
+
if (typeof message === "string") return message;
|
|
822
|
+
}
|
|
823
|
+
return JSON.stringify(payload);
|
|
824
|
+
}
|
|
825
|
+
|
|
441
826
|
// src/server/events-client.ts
|
|
442
827
|
var FOIR_EVENTS_PATH = "/v1/events";
|
|
443
828
|
var FOIR_EVENTS_SCOPE = "events:list.project";
|
|
@@ -486,7 +871,7 @@ function assertOperatorKey(apiKey) {
|
|
|
486
871
|
}
|
|
487
872
|
if (!key.startsWith("sk_")) {
|
|
488
873
|
throw new FoirRequestError(
|
|
489
|
-
`[foir] createEventsClient: apiKey must be an sk_ secret key
|
|
874
|
+
`[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
875
|
);
|
|
491
876
|
}
|
|
492
877
|
return key;
|
|
@@ -629,7 +1014,74 @@ function createEventsClient(config) {
|
|
|
629
1014
|
after = page.nextCursor;
|
|
630
1015
|
}
|
|
631
1016
|
}
|
|
632
|
-
|
|
1017
|
+
let socket = null;
|
|
1018
|
+
let live = 0;
|
|
1019
|
+
function subscribe(params) {
|
|
1020
|
+
const { after, events, onData, onError, onComplete, onReconnect } = params;
|
|
1021
|
+
socket ??= new FoirSocketTransport({
|
|
1022
|
+
apiUrl: config.apiUrl,
|
|
1023
|
+
apiKey,
|
|
1024
|
+
...config.headers ? { headers: config.headers } : {},
|
|
1025
|
+
...config.wsUrl ? { wsUrl: config.wsUrl } : {},
|
|
1026
|
+
...config.webSocketImpl ? { webSocketImpl: config.webSocketImpl } : {},
|
|
1027
|
+
...config.reconnect ? { reconnect: config.reconnect } : {}
|
|
1028
|
+
});
|
|
1029
|
+
const variables = {
|
|
1030
|
+
...after !== void 0 ? { after: readSeq(after, "after") } : {},
|
|
1031
|
+
...events && events.length > 0 ? { events } : {}
|
|
1032
|
+
};
|
|
1033
|
+
live += 1;
|
|
1034
|
+
let released = false;
|
|
1035
|
+
const release = () => {
|
|
1036
|
+
if (released) return;
|
|
1037
|
+
released = true;
|
|
1038
|
+
live -= 1;
|
|
1039
|
+
if (live === 0 && socket) {
|
|
1040
|
+
socket.dispose();
|
|
1041
|
+
socket = null;
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
const handle = socket.subscribe(
|
|
1045
|
+
{ query: EVENTS_SUBSCRIPTION, variables },
|
|
1046
|
+
{
|
|
1047
|
+
onData: (event) => {
|
|
1048
|
+
if (typeof event?.seq === "number" && event.seq > (variables.after ?? 0)) {
|
|
1049
|
+
variables.after = event.seq;
|
|
1050
|
+
}
|
|
1051
|
+
onData(event);
|
|
1052
|
+
},
|
|
1053
|
+
onError: (error) => {
|
|
1054
|
+
release();
|
|
1055
|
+
onError?.(asEventsSocketError(error, variables.after));
|
|
1056
|
+
},
|
|
1057
|
+
onComplete: () => {
|
|
1058
|
+
release();
|
|
1059
|
+
onComplete?.();
|
|
1060
|
+
},
|
|
1061
|
+
...onReconnect ? { onReconnect } : {}
|
|
1062
|
+
}
|
|
1063
|
+
);
|
|
1064
|
+
return {
|
|
1065
|
+
unsubscribe: () => {
|
|
1066
|
+
handle.unsubscribe();
|
|
1067
|
+
release();
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
return { list, stream, subscribe };
|
|
1072
|
+
}
|
|
1073
|
+
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}";
|
|
1074
|
+
function asEventsSocketError(error, requestedCursor) {
|
|
1075
|
+
for (const item of error.graphQLErrors ?? []) {
|
|
1076
|
+
if (item.extensions?.code !== "cursor_expired") continue;
|
|
1077
|
+
const resumeFrom = item.extensions.resumeFrom;
|
|
1078
|
+
return new FoirEventCursorExpiredError(`[foir] events: cursor expired: ${item.message}`, {
|
|
1079
|
+
status: 410,
|
|
1080
|
+
...typeof resumeFrom === "number" ? { resumeFrom } : {},
|
|
1081
|
+
...requestedCursor !== void 0 ? { requestedCursor } : {}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
return error;
|
|
633
1085
|
}
|
|
634
1086
|
export {
|
|
635
1087
|
FOIR_EVENTS_PATH,
|
|
@@ -69,6 +69,42 @@ export interface FoirGraphQLErrorItem {
|
|
|
69
69
|
path?: Array<string | number>;
|
|
70
70
|
extensions?: FoirErrorExtensions;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* One selection the server stripped from the document before executing it.
|
|
74
|
+
*
|
|
75
|
+
* The data plane PRUNES rather than refuses: a selection that does not match
|
|
76
|
+
* the project's current schema is removed and the rest of the query runs, so an
|
|
77
|
+
* editor-driven model change cannot fail a deployed client's query mid-flight.
|
|
78
|
+
* The report arrives in `extensions.pruned` and deliberately NOT in `errors`,
|
|
79
|
+
* because clients treat any `errors` entry as a failed response.
|
|
80
|
+
*
|
|
81
|
+
* Which is why this type exists here. Pruning is the ONLY signal for the
|
|
82
|
+
* commonest way to get a quietly incomplete object — asking for a field the
|
|
83
|
+
* type does not have — and it produces no error for `strict` to catch.
|
|
84
|
+
*/
|
|
85
|
+
export interface FoirPrunedSelection {
|
|
86
|
+
/** Dotted path to the stripped selection, e.g. `pageBySlug.logo.id`. */
|
|
87
|
+
path: string;
|
|
88
|
+
/** Why it was stripped. See `MALFORMED_PRUNE_REASONS`. */
|
|
89
|
+
reason: string;
|
|
90
|
+
/** The parent type the selection was made on, when the server named one. */
|
|
91
|
+
type?: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The prune reasons that mean THE DOCUMENT IS WRONG, as opposed to the schema
|
|
95
|
+
* having moved underneath a document that was right when it shipped.
|
|
96
|
+
*
|
|
97
|
+
* A field the type does not have, a subselection on a scalar, or a composite
|
|
98
|
+
* asked for without one: each is a query that could never have been served as
|
|
99
|
+
* written. They are the shape that silently returns a plausible object with a
|
|
100
|
+
* key missing, which is how an empty file id became a `setNull` and would have
|
|
101
|
+
* deleted a logo on the next save.
|
|
102
|
+
*
|
|
103
|
+
* The rest — dead type conditions, dead fragment spreads, unreferenced
|
|
104
|
+
* fragments, unused variables — are what the pruner exists to absorb, and they
|
|
105
|
+
* stay a warning.
|
|
106
|
+
*/
|
|
107
|
+
export declare const MALFORMED_PRUNE_REASONS: ReadonlySet<string>;
|
|
72
108
|
/** What the `X-RateLimit-*` headers said on the response that carried them. */
|
|
73
109
|
export interface FoirRateLimitSnapshot {
|
|
74
110
|
/** Requests allowed per window for this credential. */
|
|
@@ -83,11 +119,27 @@ export declare class FoirRequestError extends Error {
|
|
|
83
119
|
readonly status?: number;
|
|
84
120
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
85
121
|
readonly graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
|
|
122
|
+
/**
|
|
123
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
124
|
+
* response. Present only on that path: without it, opting into strictness
|
|
125
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
126
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
127
|
+
*/
|
|
128
|
+
readonly partialData?: unknown;
|
|
129
|
+
/**
|
|
130
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
131
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
132
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
133
|
+
* developer-facing message, has it without re-parsing anything.
|
|
134
|
+
*/
|
|
135
|
+
readonly prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
|
|
86
136
|
readonly cause?: unknown;
|
|
87
137
|
constructor(message: string, options?: {
|
|
88
138
|
cause?: unknown;
|
|
89
139
|
status?: number;
|
|
90
140
|
graphQLErrors?: ReadonlyArray<FoirGraphQLErrorItem>;
|
|
141
|
+
partialData?: unknown;
|
|
142
|
+
prunedSelections?: ReadonlyArray<FoirPrunedSelection>;
|
|
91
143
|
});
|
|
92
144
|
/**
|
|
93
145
|
* The code of the first error, if the response carried one.
|
|
@@ -158,6 +210,26 @@ export interface FoirRequestOptions {
|
|
|
158
210
|
* (e.g. `myProfile`). Used by `@foir/sdk/auth`'s post-login profile fetch.
|
|
159
211
|
*/
|
|
160
212
|
bearerToken?: string;
|
|
213
|
+
/**
|
|
214
|
+
* The organization this request acts in, sent as `X-Org-Id`. For a
|
|
215
|
+
* project-scoped key: the server resolves it to an active organization of
|
|
216
|
+
* the key's own project (else `NOT_FOUND`) and binds the request to it — a
|
|
217
|
+
* key already confined to an organization may only name that one (else
|
|
218
|
+
* `PERMISSION_DENIED`). See `FoirClientConfig.orgId` for the full contract.
|
|
219
|
+
*
|
|
220
|
+
* PER CALL, which is the narrower and riskier of the two spellings. The
|
|
221
|
+
* client-level option is the one to reach for: set it once where the
|
|
222
|
+
* organization is resolved and every request from that client carries it,
|
|
223
|
+
* so there is no call left to forget. This one is for the odd request that
|
|
224
|
+
* has to act somewhere else — and a request that omits it is not refused,
|
|
225
|
+
* it reads the project-shared tier, which is a smaller answer rather than an
|
|
226
|
+
* error.
|
|
227
|
+
*
|
|
228
|
+
* ⚠️ It names a SUBTREE. Organizations nest and reach runs downward, so a
|
|
229
|
+
* reseller's id covers every client beneath it. Send the narrowest id that
|
|
230
|
+
* covers what the request is about.
|
|
231
|
+
*/
|
|
232
|
+
orgId?: string;
|
|
161
233
|
/**
|
|
162
234
|
* Called with the `X-RateLimit-*` headers of every response, successful or
|
|
163
235
|
* not, and with `undefined` when a response carried none.
|
|
@@ -192,6 +264,12 @@ export interface FoirHeaderInput {
|
|
|
192
264
|
*/
|
|
193
265
|
draftChannel?: boolean;
|
|
194
266
|
bearerToken?: string;
|
|
267
|
+
/**
|
|
268
|
+
* The organization the request acts in (`X-Org-Id`). Set from
|
|
269
|
+
* `FoirClientConfig.orgId` / `FoirRequestOptions.orgId`; a caller who
|
|
270
|
+
* spells the header in `extra` still wins, because `extra` is applied last.
|
|
271
|
+
*/
|
|
272
|
+
orgId?: string;
|
|
195
273
|
/** Caller-supplied extras, applied last so a caller can override. */
|
|
196
274
|
extra?: Readonly<Record<string, string>>;
|
|
197
275
|
}
|
|
@@ -218,17 +296,42 @@ export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string,
|
|
|
218
296
|
* accepted. `data/transport.ts` re-exports it under its published name.
|
|
219
297
|
*/
|
|
220
298
|
export declare function foirApiEndpoint(apiUrl: string, path: `/${string}`): string;
|
|
299
|
+
/**
|
|
300
|
+
* Foir DISCARDED a credential you sent, and served the request without it.
|
|
301
|
+
*
|
|
302
|
+
* A customer JWT is optional: if it fails to verify, the read is served as an
|
|
303
|
+
* ordinary anonymous one rather than refused, because plenty of reads
|
|
304
|
+
* legitimately have nobody signed in. That is the right behaviour and it has a
|
|
305
|
+
* sharp edge — a lapsed token and a genuinely anonymous request produce the
|
|
306
|
+
* same 200, so the failure surfaces as WRONG CONTENT rather than as an error.
|
|
307
|
+
* This block is how you find out.
|
|
308
|
+
*
|
|
309
|
+
* Present only when a token was actually sent and rejected. Its presence is
|
|
310
|
+
* always a signal.
|
|
311
|
+
*/
|
|
312
|
+
export interface FoirAuthDowngrade {
|
|
313
|
+
/** Always `"rejected"`. */
|
|
314
|
+
customerToken: string;
|
|
315
|
+
/**
|
|
316
|
+
* `expired` — re-exchange and retry. `invalid` — not a customer token for
|
|
317
|
+
* this environment. `wrong_project` — the token and the api-key name
|
|
318
|
+
* different projects.
|
|
319
|
+
*/
|
|
320
|
+
reason: 'expired' | 'invalid' | 'wrong_project';
|
|
321
|
+
/** The authority the request actually ran under. */
|
|
322
|
+
servedAs: string;
|
|
323
|
+
}
|
|
221
324
|
export interface FoirResponseEnvelope<T> {
|
|
222
325
|
data?: T;
|
|
223
326
|
errors?: Array<FoirGraphQLErrorItem>;
|
|
327
|
+
extensions?: {
|
|
328
|
+
auth?: FoirAuthDowngrade;
|
|
329
|
+
pruned?: {
|
|
330
|
+
selections?: Array<FoirPrunedSelection>;
|
|
331
|
+
};
|
|
332
|
+
[key: string]: unknown;
|
|
333
|
+
};
|
|
224
334
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
* GraphQL can return partial data alongside errors; when data is present the
|
|
229
|
-
* errors are logged and the data returned (callers handle nulls), and we only
|
|
230
|
-
* throw when there is no data at all. Both Foir data-plane routes — `/graphql`
|
|
231
|
-
* and `/cdn/graphql` — hand their document to the same executor, so the two
|
|
232
|
-
* return this envelope and get the same treatment.
|
|
233
|
-
*/
|
|
234
|
-
export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>): T;
|
|
335
|
+
export declare function readFoirEnvelope<T>(json: FoirResponseEnvelope<T>, options?: {
|
|
336
|
+
strict?: boolean;
|
|
337
|
+
}): T;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foir/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Official Foir SDK — editor iframe client + server-side platform helpers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
"types": "./dist/data/index.d.ts",
|
|
26
26
|
"import": "./dist/data.js"
|
|
27
27
|
},
|
|
28
|
+
"./live": {
|
|
29
|
+
"types": "./dist/live/index.d.ts",
|
|
30
|
+
"import": "./dist/live.js"
|
|
31
|
+
},
|
|
32
|
+
"./live/react": {
|
|
33
|
+
"types": "./dist/live/react/index.d.ts",
|
|
34
|
+
"import": "./dist/live-react.js"
|
|
35
|
+
},
|
|
28
36
|
"./media": {
|
|
29
37
|
"types": "./dist/media/index.d.ts",
|
|
30
38
|
"import": "./dist/media.js"
|
|
@@ -157,7 +165,7 @@
|
|
|
157
165
|
"node": ">=18.0.0"
|
|
158
166
|
},
|
|
159
167
|
"scripts": {
|
|
160
|
-
"build": "tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
|
|
168
|
+
"build": "node scripts/clean-dist.mjs && tsup && tsc -p tsconfig.build.json && node scripts/assert-export-types.mjs && node scripts/assert-public-exports.mjs",
|
|
161
169
|
"snapshot:exports": "node scripts/assert-public-exports.mjs --update",
|
|
162
170
|
"dev": "node scripts/dev-watch.mjs",
|
|
163
171
|
"check-types": "tsc --noEmit",
|