@foir/sdk 0.7.0 → 0.8.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 (60) hide show
  1. package/CHANGELOG.md +805 -4
  2. package/README.md +46 -15
  3. package/dist/auth/auth.d.ts +104 -1
  4. package/dist/auth/index.d.ts +12 -6
  5. package/dist/auth/operations.d.ts +85 -64
  6. package/dist/auth/ops.d.ts +99 -0
  7. package/dist/auth.js +775 -106
  8. package/dist/chunk-IGNLWT3H.js +35 -0
  9. package/dist/chunk-OHYVKTGB.js +45 -0
  10. package/dist/chunk-TAL5H6VA.js +39 -0
  11. package/dist/client.js +25 -31
  12. package/dist/codegen/block-registry.d.ts +13 -19
  13. package/dist/codegen/block-registry.js +3 -31
  14. package/dist/codegen/block-scaffold.d.ts +12 -21
  15. package/dist/codegen/block-scaffold.js +3 -41
  16. package/dist/codegen/index.d.ts +9 -4
  17. package/dist/codegen/index.js +7 -11
  18. package/dist/codegen/item-registry.d.ts +28 -0
  19. package/dist/codegen/item-registry.js +7 -0
  20. package/dist/codegen/item-scaffold.d.ts +29 -0
  21. package/dist/codegen/item-scaffold.js +7 -0
  22. package/dist/codegen/preset.d.ts +14 -7
  23. package/dist/codegen/shared.d.ts +20 -9
  24. package/dist/content/content-renderer.d.ts +90 -24
  25. package/dist/content/index.d.ts +13 -7
  26. package/dist/content/richtext.d.ts +66 -18
  27. package/dist/content.js +227 -80
  28. package/dist/data/builtins.d.ts +302 -92
  29. package/dist/data/client.d.ts +97 -25
  30. package/dist/data/document.d.ts +251 -0
  31. package/dist/data/index.d.ts +38 -19
  32. package/dist/data/manifest.d.ts +266 -3
  33. package/dist/data/schema.d.ts +78 -8
  34. package/dist/data/selection.d.ts +231 -37
  35. package/dist/data/subjects.d.ts +221 -0
  36. package/dist/data/subscription-document.d.ts +75 -0
  37. package/dist/data/subscriptions.d.ts +181 -0
  38. package/dist/data/transport.d.ts +255 -37
  39. package/dist/data/ws-transport.d.ts +197 -0
  40. package/dist/data.js +1419 -45
  41. package/dist/graphql/cdn-route.d.ts +12 -28
  42. package/dist/media/index.d.ts +6 -2
  43. package/dist/media/media.d.ts +2 -2
  44. package/dist/media/screen-capture.d.ts +81 -0
  45. package/dist/media.js +97 -0
  46. package/dist/next.js +663 -182
  47. package/dist/push.js +700 -90
  48. package/dist/react/FoirFeedback.d.ts +62 -0
  49. package/dist/react/feedback-notes.d.ts +101 -0
  50. package/dist/react/index.d.ts +7 -1
  51. package/dist/react.js +1272 -194
  52. package/dist/realtime.js +1 -1
  53. package/dist/server/events-client.d.ts +279 -0
  54. package/dist/server/index.d.ts +4 -0
  55. package/dist/server.js +298 -0
  56. package/dist/transport/foir-http.d.ts +23 -6
  57. package/dist/transport/persisted-query.d.ts +68 -0
  58. package/dist/transport/retry.d.ts +1 -1
  59. package/package.json +13 -1
  60. package/dist/chunk-ADO5FJNM.js +0 -30
@@ -1,11 +1,53 @@
1
1
  /**
2
- * `POST /v1/query` transport.
2
+ * The data client's transport: two GraphQL lanes, and no other.
3
3
  *
4
- * Everything Foir-specific about the request — which key, the preview schema
5
- * channel, the customer bearer, the error shape — comes from
6
- * `../transport/foir-http`, the same module `@foir/sdk/client`'s `foirRequest`
7
- * uses. This file adds only what is specific to the JSON op endpoint: the URL,
8
- * the body, and reading the single root field back out.
4
+ * ---------------------------------------------------------------------------
5
+ * Where a call goes
6
+ * ---------------------------------------------------------------------------
7
+ *
8
+ * read + `pk_` key + no customer bearer + published channel
9
+ * → `GET /cdn/graphql?key=pk_…&extensions={"persistedQuery":{"sha256Hash":…}}`
10
+ * A shared cache in front of the caller stores the response, because the
11
+ * request is a GET and the credential is in the URL.
12
+ *
13
+ * anything else — `sk_`, a customer bearer, a mutation, the draft channel
14
+ * → `POST /graphql` with the `x-api-key` header and the APQ body.
15
+ *
16
+ * All five conditions the server's shared-lane gate refuses on are evaluated
17
+ * HERE, locally: this code is constructing the URL, it can read the key prefix,
18
+ * it knows whether a bearer resolved, the manifest told it whether the op is a
19
+ * mutation, and the channel is its own configuration. No schema, no extra round
20
+ * trip. `foirRoute` is that decision, isolated so each condition can be tested on
21
+ * its own rather than inferred from an end-to-end call that happened to take one
22
+ * path.
23
+ *
24
+ * The server remains the authority. It REFUSES an ineligible request rather than
25
+ * serving it uncached (`NOT_SHAREABLE`, with the condition named), and a refusal
26
+ * is retried on `/graphql` — but eligibility is never discovered by trial.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * Persisted queries
30
+ * ---------------------------------------------------------------------------
31
+ *
32
+ * Complete-by-default produces maximal documents: `page.findMany` prints ~34kB.
33
+ * So the hash goes first and the document follows only on a miss, on both lanes.
34
+ * A miss answers `PERSISTED_QUERY_NOT_FOUND` with **HTTP 200**, and the retry
35
+ * that carries the document is always a POST — never a longer GET, which is the
36
+ * limit the hash exists to stay under.
37
+ *
38
+ * ---------------------------------------------------------------------------
39
+ * One wire format, and no second one
40
+ * ---------------------------------------------------------------------------
41
+ *
42
+ * Every op prints. The manifest publishes a `payload` for each op a credential
43
+ * can reach — every namespace op included, and the model ops whose result is not
44
+ * the model — so no call has to be described some other way. An op this client
45
+ * cannot print is an error that names the op.
46
+ *
47
+ * That is a constraint the module holds to, not an accident of what exists: a
48
+ * second format kept alive beside this one could only ever be reached as a
49
+ * silent fallback, and an op that quietly stopped being printed would land there
50
+ * unnoticed.
9
51
  *
10
52
  * Edge-safe: `fetch` only, no Node built-ins, and no module-level state of any
11
53
  * kind. A shared cache on a stateless multi-tenant edge worker is a
@@ -14,29 +56,51 @@
14
56
  * across instances is cached across tenants.
15
57
  */
16
58
  import { type FoirTokenProvider } from '../transport/foir-http';
17
- /** The wire body `POST /v1/query` accepts. Exactly one of model/namespace. */
18
- export type FoirRawOp = {
19
- op: string;
20
- args?: Record<string, unknown>;
21
- include?: Record<string, unknown>;
22
- /** Remove named paths. Mutually exclusive with `pick` — the server 400s on both. */
23
- omit?: readonly string[];
24
- /** Keep only named paths. Mutually exclusive with `omit`. `_id` is always returned. */
25
- pick?: readonly string[];
26
- } & ({
27
- model: string;
28
- namespace?: never;
29
- } | {
30
- namespace: string;
31
- model?: never;
32
- });
33
- export interface FoirClientConfig {
59
+ import type { FoirDocumentPlan, FoirNamespaceSelections, FoirSelections, FoirSelectionUnions } from './document';
60
+ import type { FoirSchemaDefinition } from './schema';
61
+ import type { FoirSubjectRegistry } from './subjects';
62
+ import type { FoirReconnectPolicy, FoirWebSocketFactory } from './ws-transport';
63
+ /**
64
+ * `foir.generated.ts`, whole — the shape a namespace import of it has.
65
+ *
66
+ * ```ts
67
+ * import * as generated from './foir.generated';
68
+ * const foir = createFoirClient({ apiUrl, apiKey, generated });
69
+ * ```
70
+ *
71
+ * Every member is REQUIRED, and that is the point rather than an oversight.
72
+ * `foir types` emits all of them together and there is no case where a consumer
73
+ * wants a subset, so a module missing one is stale or hand-assembled — and the
74
+ * cost of accepting it quietly is the failure this type exists to stop:
75
+ * `foirSelections` once shipped in a shape the printer could not read, every
76
+ * call fell through to `GET /v1/manifest`, and nothing said so. A partial bundle
77
+ * is a compile error here and a refusal at construction, never a fallback.
78
+ *
79
+ * `foirSchemaType` is how `S` is inferred. A namespace import carries VALUES,
80
+ * and `FoirSchema` is an interface — it does not survive to runtime — so without
81
+ * a value whose TYPE is `FoirSchema` there would be nothing to infer from and
82
+ * every call site would go back to restating `createFoirClient<FoirSchema>`.
83
+ * Its runtime value is `undefined` by design and nothing reads it.
84
+ */
85
+ export interface FoirGeneratedModule<S extends FoirSchemaDefinition = FoirSchemaDefinition> {
86
+ /** `foirSchemaVersion` — the manifest version these types came from. Drives the drift check. */
87
+ readonly foirSchemaVersion: number;
88
+ /** `foirSelections` — one entry per model, in the printer's own input shape. */
89
+ readonly foirSelections: FoirSelections;
90
+ /** `foirNamespaceSelections` — the same, per namespace op. */
91
+ readonly foirNamespaceSelections: FoirNamespaceSelections;
92
+ /** `foirSelectionUnions` — each union expanded once; a tree references one by name. */
93
+ readonly foirSelectionUnions: FoirSelectionUnions;
94
+ /** Type carrier for `FoirSchema`. Never read: its runtime value is `undefined`. */
95
+ readonly foirSchemaType: S;
96
+ }
97
+ export interface FoirClientConfig<S extends FoirSchemaDefinition = FoirSchemaDefinition> {
34
98
  /**
35
99
  * Foir public API origin, e.g. `https://api.foir.dev`.
36
100
  *
37
101
  * A full GraphQL endpoint is accepted too — `PLATFORM_API_URL` is
38
102
  * conventionally `https://api.foir.dev/graphql` and people will paste it — so
39
- * a trailing `/graphql` is stripped before `/v1/query` is appended.
103
+ * a trailing `/graphql` is stripped before a path is appended.
40
104
  */
41
105
  apiUrl: string;
42
106
  /** `pk_…` or `sk_…`. The manifest, and therefore the generated types, are shaped by it. */
@@ -53,6 +117,76 @@ export interface FoirClientConfig {
53
117
  * per-request; a bare string is used as-is.
54
118
  */
55
119
  bearerToken?: string | FoirTokenProvider;
120
+ /**
121
+ * The generated module, whole — **the documented way to wire a client**.
122
+ *
123
+ * ```ts
124
+ * import * as generated from './foir.generated';
125
+ * const foir = createFoirClient({ apiUrl, apiKey, generated });
126
+ * ```
127
+ *
128
+ * It supplies `schemaVersion`, `selections`, `namespaceSelections` and
129
+ * `selectionUnions` in one member, and carries `FoirSchema` with it so the
130
+ * type parameter is inferred rather than restated. An artifact added to
131
+ * `foir types` later then costs a call site nothing.
132
+ *
133
+ * The four fields below still work and still win where both are given, so a
134
+ * client written against the older shape is untouched. What they cannot do is
135
+ * be individually forgotten: `foir types` emits all of them together, four out
136
+ * of five was always a mistake, and it used to degrade into a silent manifest
137
+ * fetch. Passing the module makes that a refusal instead.
138
+ *
139
+ * Omitting `generated` AND `selections` asks for the manifest fallback, which
140
+ * is supported, announced (see `announceManifestFallback`) and never reached
141
+ * by accident.
142
+ */
143
+ generated?: FoirGeneratedModule<S>;
144
+ /**
145
+ * The runtime half of `foir.generated.ts` — `foirSelections`, the resolved
146
+ * complete-by-default selection per model.
147
+ *
148
+ * Pass it, or pass `generated`, which carries it. Without either the client
149
+ * fetches `GET /v1/manifest` once at first use to get the same trees, which
150
+ * works and is the reason a zero-codegen start still functions, but it puts a
151
+ * round trip in front of the first query on every cold edge invocation to
152
+ * learn something the generated file already knows.
153
+ */
154
+ selections?: FoirSelections;
155
+ /**
156
+ * `foirNamespaceSelections` from the same generated module — the namespace
157
+ * half of the surface, one `payload` per namespace op.
158
+ *
159
+ * Read only when `selections` is passed; the manifest fallback carries both.
160
+ * Omitting it under `selections` makes every `foir.ns.*` call an error naming
161
+ * the namespace, which is the honest failure for a generated module that
162
+ * predates namespace payloads.
163
+ */
164
+ namespaceSelections?: FoirNamespaceSelections;
165
+ /** `foirSelectionUnions` from the same generated module. Required whenever a tree names a union. */
166
+ selectionUnions?: FoirSelectionUnions;
167
+ /**
168
+ * A registry that records which records this client fetched, so an annotation
169
+ * surface can anchor to the RECORD rather than to a URL or a CSS selector.
170
+ *
171
+ * ```ts
172
+ * const subjects = createFoirSubjectRegistry();
173
+ * const foir = createFoirClient({ apiUrl, apiKey, generated, subjects });
174
+ * ```
175
+ *
176
+ * There is no boolean to set. A registry is a list of what a page fetched,
177
+ * which is a disclosure surface — so it exists only when the host builds one,
178
+ * the host holds the only reference, and its lifetime is the host's. Omitting
179
+ * this field is the off state, and no other input reaches it.
180
+ *
181
+ * Build one per client, never at module scope: a shared registry on a
182
+ * multi-tenant edge worker is shared across tenants, for the same reason
183
+ * nothing else in this client keeps module-level state.
184
+ *
185
+ * `foir.raw()` does not record — a hand-written document has no published
186
+ * payload, so there are no record positions to read. Seed those with
187
+ * `subjects.add(...)`.
188
+ */
189
+ subjects?: FoirSubjectRegistry;
56
190
  /** Extra headers, applied last so they can override the ones above. */
57
191
  headers?: Readonly<Record<string, string>>;
58
192
  /**
@@ -69,13 +203,62 @@ export interface FoirClientConfig {
69
203
  /**
70
204
  * `foirSchemaVersion` from the generated module. When set, the client
71
205
  * compares it against the server's manifest once and warns on mismatch.
72
- * Omitted — e.g. types generated as a bare `.d.ts` — disables the check.
206
+ * Carried by `generated`; set it here only to override that.
73
207
  */
74
208
  schemaVersion?: number;
75
209
  /** Set `false` to suppress the drift check even when `schemaVersion` is set. */
76
210
  checkSchemaDrift?: boolean;
77
211
  /** Called instead of `console.warn` when drift is detected. */
78
212
  onSchemaDrift?: (drift: FoirSchemaDrift) => void;
213
+ /**
214
+ * Set `false` to silence the one-line notice a client prints when it is built
215
+ * with neither `generated` nor `selections` and will therefore read its
216
+ * selection trees from `GET /v1/manifest`.
217
+ *
218
+ * The notice exists because the fallback is legitimate and expensive at the
219
+ * same time: it is what makes a zero-codegen start work, and it is also what a
220
+ * mis-wired generated module used to silently become. Announcing it is the
221
+ * difference between choosing it and landing in it.
222
+ */
223
+ announceManifestFallback?: boolean;
224
+ /**
225
+ * WebSocket endpoint. Defaults to `apiUrl` with the scheme swapped to `ws`
226
+ * and `/graphql/ws` appended, which is where the public API serves
227
+ * subscriptions. Override only for a proxy or a test server.
228
+ */
229
+ wsUrl?: string;
230
+ /**
231
+ * WebSocket constructor, called as `(url, protocols)`.
232
+ *
233
+ * Defaults to `globalThis.WebSocket`, which browsers, Deno, Bun, Cloudflare
234
+ * Workers and Node 22+ all provide. On Node 18/20 there is none, so pass one:
235
+ * `webSocketImpl: (url, p) => new WebSocket(url, p)` from the `ws` package.
236
+ */
237
+ webSocketImpl?: FoirWebSocketFactory;
238
+ /** How the subscription socket behaves when it drops. */
239
+ reconnect?: FoirReconnectPolicy;
240
+ /**
241
+ * Extra `connection_init` parameters, resolved on EVERY connect.
242
+ *
243
+ * A WebSocket handshake carries no headers a browser will let you set, so
244
+ * graphql-ws puts credentials in the `connection_init` payload — and this
245
+ * client builds that payload from the same `buildFoirHeaders` the HTTP
246
+ * transport uses, so `apiKey`, `bearerToken` and `headers` all reach the
247
+ * socket already. What none of them can do is carry a credential that is
248
+ * neither of the two the header builder knows about, resolved fresh.
249
+ *
250
+ * Both halves matter. `headers` is a static object captured when the client
251
+ * was constructed; a socket outlives a short-lived token, and a reconnect
252
+ * that replays a captured credential re-presents an expired one and fails to
253
+ * re-authenticate — on exactly the drop the reconnect logic exists to
254
+ * recover from. This is a function, called per connect, so the credential
255
+ * the socket presents is the one that is valid now.
256
+ *
257
+ * Merged LAST, so it wins over everything above it. Returning `{}` (or
258
+ * omitting the field) leaves the payload exactly as the header builder made
259
+ * it.
260
+ */
261
+ connectionParams?: () => Record<string, string> | Promise<Record<string, string>>;
79
262
  }
80
263
  export interface FoirSchemaDrift {
81
264
  /** Manifest version the local types were generated from. */
@@ -87,21 +270,56 @@ export interface FoirSchemaDrift {
87
270
  /**
88
271
  * Build a `/v1/...` URL from whatever the caller put in `apiUrl`.
89
272
  *
90
- * Tolerates the three things people actually pass: an origin, an origin with a
91
- * trailing slash, and the conventional `PLATFORM_API_URL` value
92
- * (`https://api.foir.dev/graphql`).
273
+ * The published name for `foirApiEndpoint`, which is where the implementation
274
+ * lives now that `@foir/sdk/server`'s events client needs the same tolerance for
275
+ * a `PLATFORM_API_URL` ending in `/graphql`. Kept as a named function rather
276
+ * than a re-export so `@foir/sdk/data`'s public surface is unchanged.
93
277
  */
94
278
  export declare function foirDataEndpoint(apiUrl: string, path: `/${string}`): string;
95
279
  /** Resolve `config.bearerToken`, whether it is a string, a function, or absent. */
96
280
  export declare function resolveBearerToken(config: Pick<FoirClientConfig, 'bearerToken'>): Promise<string | undefined>;
281
+ /** Which GraphQL endpoint a call is sent to. */
282
+ export type FoirLane = 'shared' | 'origin';
283
+ /**
284
+ * Why a call did not take the shared lane. One name per condition the server's
285
+ * gate refuses on, so a decision can be asserted rather than inferred.
286
+ */
287
+ export type FoirLaneReason = 'key' | 'bearer' | 'operation' | 'channel';
288
+ export interface FoirRouteDecision {
289
+ lane: FoirLane;
290
+ /** Set only on the origin lane: the first condition that ruled the shared one out. */
291
+ reason?: FoirLaneReason;
292
+ }
293
+ export interface FoirRouteInput {
294
+ apiKey: string;
295
+ /** The RESOLVED bearer, not the provider — a provider that yields null is not a bearer. */
296
+ bearerToken?: string;
297
+ mutation: boolean;
298
+ channel?: 'published' | 'draft';
299
+ }
97
300
  /**
98
- * Execute one op and return the single root field's value.
99
- *
100
- * The response is a normal GraphQL envelope keyed by the compiled document's
101
- * root field (`{"data":{"tillyBlocks":{…}}}`), and the client has no manifest at
102
- * runtime so it does not know that name. It does not need to: one op compiles
103
- * to exactly one root field, so "the only key of `data`" is unambiguous. A
104
- * response with a different number of keys means the server changed shape, and
105
- * guessing would be worse than saying so.
301
+ * Where this call goes.
302
+ *
303
+ * The first condition — that the credential is in the URL — needs no test here
304
+ * because this client always puts it there (`buildCdnUrl`); it is asserted on
305
+ * the built URL instead. The other four are decided below, in the order the
306
+ * server states them.
307
+ */
308
+ export declare function foirRoute(input: FoirRouteInput): FoirRouteDecision;
309
+ /**
310
+ * Execute a printed document and return the single root field's value.
311
+ *
312
+ * One op prints exactly one root field, and the plan says which — so unlike the
313
+ * JSON lane, which had to guess from "the only key of `data`", this reads the
314
+ * name it asked for. A response missing it means the server answered something
315
+ * else, and saying so beats returning `undefined`.
316
+ */
317
+ export declare function executeFoirDocument<T>(config: FoirClientConfig, plan: FoirDocumentPlan): Promise<T>;
318
+ /**
319
+ * Execute a hand-written document and return the whole `data` object.
320
+ *
321
+ * The escape hatch keeps the routing, the APQ handshake and the error shape;
322
+ * what it gives up is the single-root-field unwrapping, because a document
323
+ * written by hand may legitimately have several.
106
324
  */
107
- export declare function postFoirOp<T>(config: FoirClientConfig, body: FoirRawOp): Promise<T>;
325
+ export declare function executeFoirRawDocument<T>(config: FoirClientConfig, document: string, mutation: boolean, variables?: Record<string, unknown>): Promise<T>;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The graphql-ws transport — `@foir/sdk/data`'s second wire, beside the HTTP
3
+ * one.
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * Why the protocol is implemented here rather than imported
7
+ * ---------------------------------------------------------------------------
8
+ *
9
+ * `graphql-ws` (the npm package) would do this, and `packages/shell` uses it.
10
+ * It is not used here for three reasons that are specific to this subpath:
11
+ *
12
+ * 1. `@foir/sdk/data` has exactly one runtime dependency and a build-time
13
+ * assertion that nothing in its import graph reaches `graphql` or a Node
14
+ * built-in. A second runtime dependency on this subpath is a real cost to
15
+ * every storefront that installs it.
16
+ * 2. The protocol surface actually used is small and closed: `connection_init`
17
+ * / `connection_ack`, `subscribe`, `next` / `error` / `complete`, `ping` /
18
+ * `pong`. The server (`api-public/internal/ws`) implements exactly that
19
+ * set and nothing else, so there is no negotiation to get wrong.
20
+ * 3. Reconnect-with-resubscribe has to be OURS regardless. A generic client
21
+ * reconnects the socket; what a caller needs is their subscriptions still
22
+ * running afterwards, which means replaying the subscribe frames — and
23
+ * that bookkeeping is the part worth testing, not the framing.
24
+ *
25
+ * The wire is the standard `graphql-transport-ws` subprotocol either way, so a
26
+ * consumer who prefers the package can still talk to the same endpoint.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * Credentials
30
+ * ---------------------------------------------------------------------------
31
+ *
32
+ * A WebSocket handshake carries no request headers a browser will let you set,
33
+ * so graphql-ws puts credentials in the `connection_init` payload. This client
34
+ * builds that payload from `buildFoirHeaders` — the same function the HTTP
35
+ * transport builds its headers from — and drops only `Content-Type`, which
36
+ * means nothing on a socket. The server reads `x-api-key` and `Authorization`
37
+ * from those params under exactly those names, so the two surfaces authenticate
38
+ * with one builder rather than two spellings that can drift.
39
+ *
40
+ * A credential the header builder does not know about goes in
41
+ * `config.connectionParams`, which is a FUNCTION and is called on every
42
+ * connect — so a short-lived credential is re-resolved on reconnect instead of
43
+ * being replayed after it expired.
44
+ */
45
+ import { FoirRequestError } from '../transport/foir-http';
46
+ import { type FoirClientConfig } from './transport';
47
+ /**
48
+ * The slice of `WebSocket` this transport uses.
49
+ *
50
+ * Deliberately the event-handler PROPERTIES rather than `addEventListener`,
51
+ * because that is the intersection of the browser's `WebSocket`, Node's global
52
+ * one, and the `ws` package — so a consumer can pass any of the three without
53
+ * an adapter.
54
+ */
55
+ export interface FoirWebSocketLike {
56
+ readyState: number;
57
+ send(data: string): void;
58
+ close(code?: number, reason?: string): void;
59
+ onopen: ((event: unknown) => void) | null;
60
+ onmessage: ((event: {
61
+ data: unknown;
62
+ }) => void) | null;
63
+ onerror: ((event: unknown) => void) | null;
64
+ onclose: ((event: unknown) => void) | null;
65
+ }
66
+ /** Constructs a socket. Matches `new WebSocket(url, protocols)`. */
67
+ export type FoirWebSocketFactory = (url: string, protocols: string | string[]) => FoirWebSocketLike;
68
+ /** How the transport behaves when the socket drops. */
69
+ export interface FoirReconnectPolicy {
70
+ /**
71
+ * Attempts before giving up and reporting the failure to every live
72
+ * subscription. `Infinity` retries forever, which is the right default for a
73
+ * long-lived stream in a browser tab.
74
+ */
75
+ maxAttempts?: number;
76
+ /** Delay before the first retry. Doubles, capped at `maxDelayMs`. */
77
+ initialDelayMs?: number;
78
+ maxDelayMs?: number;
79
+ /**
80
+ * Random fraction of the delay added on each attempt, so a server restart
81
+ * does not bring every client back simultaneously. 0 disables it, which is
82
+ * what tests want and production does not.
83
+ */
84
+ jitter?: number;
85
+ }
86
+ /** A live subscription. Idempotent teardown. */
87
+ export interface FoirSubscription {
88
+ unsubscribe(): void;
89
+ }
90
+ export interface FoirSubscriptionHandlers<T> {
91
+ /** One event. Called for every `next` frame the server pushes. */
92
+ onData: (value: T) => void;
93
+ /**
94
+ * A terminal error for THIS subscription: the server refused it, or the
95
+ * transport gave up reconnecting. Not called for a drop the transport
96
+ * recovers from — that is what `onReconnect` reports.
97
+ */
98
+ onError?: (error: FoirRequestError) => void;
99
+ /** The server ended the stream (an execution reached a terminal state). */
100
+ onComplete?: () => void;
101
+ /**
102
+ * The socket dropped and this subscription was re-established.
103
+ *
104
+ * Worth surfacing rather than hiding, because a subscription is not a
105
+ * durable log: events published while the socket was down were not buffered
106
+ * anywhere and are gone. A consumer that needs them refetches here. (The
107
+ * `events` subscription is the exception — it is cursor-resumable — and it is
108
+ * not one of the fields this client addresses.)
109
+ */
110
+ onReconnect?: () => void;
111
+ }
112
+ /** What to send in one `subscribe` frame. */
113
+ export interface FoirSubscribeRequest {
114
+ query: string;
115
+ variables?: Record<string, unknown>;
116
+ }
117
+ /**
118
+ * Derive the WebSocket endpoint from the HTTP one.
119
+ *
120
+ * `apiUrl` is conventionally `https://api.foir.dev` or
121
+ * `https://api.foir.dev/graphql` — people paste whichever their env holds, and
122
+ * the HTTP transport already strips a trailing `/graphql` for the same reason.
123
+ * The socket lives at `/graphql/ws`, so this normalises to the origin and
124
+ * appends it.
125
+ */
126
+ export declare function foirSocketEndpoint(apiUrl: string): string;
127
+ /**
128
+ * The socket, its subscriptions, and the reconnect loop.
129
+ *
130
+ * One instance per client. It is lazy — nothing connects until the first
131
+ * `subscribe` — and it closes itself when the last subscription is dropped, so
132
+ * a client that is constructed and only used for queries never opens a socket.
133
+ */
134
+ export declare class FoirSocketTransport {
135
+ private readonly config;
136
+ private readonly policy;
137
+ private readonly registrations;
138
+ private socket;
139
+ private acknowledged;
140
+ private connecting;
141
+ private attempt;
142
+ private nextId;
143
+ private disposed;
144
+ private keepAlive;
145
+ private retryTimer;
146
+ constructor(config: FoirClientConfig);
147
+ /**
148
+ * Start a subscription.
149
+ *
150
+ * Returns synchronously so a caller can hold the handle immediately, while
151
+ * the connect and the handshake proceed in the background. A subscription
152
+ * registered before the socket is acknowledged is sent the moment it is —
153
+ * which is the same path a reconnect takes, so the ordinary case and the
154
+ * recovery case exercise one piece of code.
155
+ */
156
+ subscribe<T>(request: FoirSubscribeRequest, handlers: FoirSubscriptionHandlers<T>): FoirSubscription;
157
+ /** Close the socket and drop every subscription. */
158
+ dispose(): void;
159
+ /** Live subscription count. Exposed for tests and diagnostics. */
160
+ get size(): number;
161
+ private ensureConnected;
162
+ private sendConnectionInit;
163
+ private handleClose;
164
+ private scheduleReconnect;
165
+ private teardownSocket;
166
+ private receive;
167
+ /**
168
+ * Send every registration the current socket has not yet been told about.
169
+ *
170
+ * This IS the resubscribe path. A reconnect clears `sent` on every
171
+ * registration and lands here, so a subscription re-established after a drop
172
+ * takes the same code as one opened for the first time — there is no second
173
+ * implementation that can be correct in one case and wrong in the other.
174
+ */
175
+ private flush;
176
+ private cancel;
177
+ /**
178
+ * Close the socket once nothing is using it.
179
+ *
180
+ * A socket held open with no subscriptions is a connection the server has to
181
+ * keep, a keepalive timer that keeps a serverless invocation alive, and
182
+ * nothing in return.
183
+ */
184
+ private closeIfIdle;
185
+ private failAll;
186
+ /**
187
+ * Client-side keepalive.
188
+ *
189
+ * The server pings on its own timer and this client answers, which is enough
190
+ * for the server to know the client is alive. This is the other direction:
191
+ * an idle subscription can sit for hours with no events, and a proxy that
192
+ * times out idle sockets will drop it. A periodic `ping` keeps it warm, and
193
+ * the pong that comes back proves the path is still there.
194
+ */
195
+ private startKeepAlive;
196
+ private send;
197
+ }