statewire 0.0.5 → 0.5.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/client/connection.d.ts +1 -1
  3. package/dist/client/connection.js.map +1 -1
  4. package/dist/client/core.d.ts.map +1 -1
  5. package/dist/client/core.js +1 -0
  6. package/dist/client/core.js.map +1 -1
  7. package/dist/client/session.d.ts +1 -5
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +18 -29
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +2 -14
  12. package/dist/host/host.d.ts.map +1 -1
  13. package/dist/host/host.js +41 -92
  14. package/dist/host/host.js.map +1 -1
  15. package/dist/host.d.ts +11 -17
  16. package/dist/host.d.ts.map +1 -1
  17. package/dist/host.js +2 -2
  18. package/dist/statewire.d.ts +8 -14
  19. package/dist/statewire.d.ts.map +1 -1
  20. package/dist/statewire.js.map +1 -1
  21. package/dist/transports/remote.js +1 -4
  22. package/dist/transports/remote.js.map +1 -1
  23. package/dist/transports/sse.d.ts +4 -1
  24. package/dist/transports/sse.d.ts.map +1 -1
  25. package/dist/transports/sse.js +41 -25
  26. package/dist/transports/sse.js.map +1 -1
  27. package/dist/transports/ws.d.ts +2 -1
  28. package/dist/transports/ws.d.ts.map +1 -1
  29. package/dist/transports/ws.js +25 -6
  30. package/dist/transports/ws.js.map +1 -1
  31. package/dist/util/statePath.js +8 -6
  32. package/dist/util/statePath.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/client/connection.ts +1 -1
  35. package/src/client/core.ts +1 -0
  36. package/src/client/session.ts +9 -26
  37. package/src/host/host.ts +55 -113
  38. package/src/host.ts +9 -19
  39. package/src/statewire.ts +8 -14
  40. package/src/transports/remote.ts +1 -7
  41. package/src/transports/sse.ts +71 -27
  42. package/src/transports/ws.ts +35 -4
package/src/host/host.ts CHANGED
@@ -102,82 +102,10 @@ export const useStatewireState = <TState>(
102
102
 
103
103
  // ─── commands ───────────────────────────────────────────
104
104
 
105
- type ExecutionSettled =
105
+ type Settled =
106
106
  | { type: "resolved"; value: unknown }
107
107
  | { type: "rejected"; error: unknown };
108
108
 
109
- type ExecutionBind = {
110
- onAck: () => void;
111
- onSettle: (settled: ExecutionSettled) => void;
112
- };
113
-
114
- type ExecutionState = {
115
- acked: boolean;
116
- settled: ExecutionSettled | null;
117
- bind: ExecutionBind | null;
118
- };
119
-
120
- const executionStates = new WeakMap<object, ExecutionState>();
121
-
122
- /**
123
- * `CommandExecution(executor)` runs the (possibly async) `executor({ ack })`
124
- * immediately: its return value resolves the execution, a throw/rejection
125
- * rejects it; `ack()` releases the client's ack watermark before the executor
126
- * finishes. `CommandExecution.withResolvers()` returns `[execution,
127
- * { ack, resolve, reject }]` instead — settle them from your own spawned work.
128
- */
129
- export const CommandExecution = Object.assign(
130
- <T = void>(
131
- executor: (resolvers: {
132
- ack: () => void;
133
- }) => T | void | PromiseLike<T | void>,
134
- ): StatewireHost.Execution<T> => {
135
- const state: ExecutionState = { acked: false, settled: null, bind: null };
136
- const execution = Object.freeze(
137
- {},
138
- ) as unknown as StatewireHost.Execution<T>;
139
- executionStates.set(execution, state);
140
- const settle = (settled: ExecutionSettled) => {
141
- if (state.settled) return;
142
- state.settled = settled;
143
- state.acked = true;
144
- state.bind?.onSettle(settled);
145
- };
146
- const ack = () => {
147
- if (state.acked) return;
148
- state.acked = true;
149
- state.bind?.onAck();
150
- };
151
- void (async () => {
152
- try {
153
- settle({ type: "resolved", value: await executor({ ack }) });
154
- } catch (error) {
155
- settle({ type: "rejected", error });
156
- }
157
- })();
158
- return execution;
159
- },
160
- {
161
- withResolvers: <T = void>(): [
162
- StatewireHost.Execution<T>,
163
- StatewireHost.ExecutionResolvers<T>,
164
- ] => {
165
- let resolve!: (value: T) => void;
166
- let reject!: (error: unknown) => void;
167
- const promise = new Promise<T>((res, rej) => {
168
- resolve = res;
169
- reject = rej;
170
- });
171
- let ack!: () => void;
172
- const execution = CommandExecution<T>(({ ack: a }) => {
173
- ack = a;
174
- return promise;
175
- });
176
- return [execution, { ack: () => ack(), resolve, reject }];
177
- },
178
- },
179
- );
180
-
181
109
  export type CommandsInvocation = {
182
110
  readonly method: string;
183
111
  readonly params: readonly unknown[];
@@ -279,7 +207,7 @@ const settleInvocation = (
279
207
  cell: CommandsCell,
280
208
  inv: ActiveInvocation,
281
209
  call: QueuedCall,
282
- settled: ExecutionSettled,
210
+ settled: Settled,
283
211
  ) => {
284
212
  ackInvocation(cell, inv);
285
213
  if (settled.type === "resolved") {
@@ -299,14 +227,13 @@ const settleInvocation = (
299
227
  }
300
228
  };
301
229
 
302
- const pump = (cell: CommandsCell) => {
303
- if (cell.running || !cell.handlers) {
304
- watchStall(cell);
305
- return;
306
- }
307
- const call = cell.queue.shift();
308
- if (!call) return;
309
- const handler = cell.handlers[call.method];
230
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
231
+ typeof value === "object" &&
232
+ value !== null &&
233
+ typeof (value as { then?: unknown }).then === "function";
234
+
235
+ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
236
+ const handler = cell.handlers![call.method];
310
237
  if (handler === undefined) {
311
238
  const error = new StatewireSendError({
312
239
  fate: "unknown-command",
@@ -314,7 +241,6 @@ const pump = (cell: CommandsCell) => {
314
241
  });
315
242
  call.origin?.onSettle?.({ type: "rejected", error });
316
243
  call.reject(error);
317
- pump(cell);
318
244
  return;
319
245
  }
320
246
  const inv: ActiveInvocation = {
@@ -328,39 +254,55 @@ const pump = (cell: CommandsCell) => {
328
254
  clientId: call.origin.clientId,
329
255
  }),
330
256
  };
331
- cell.running = true;
332
257
  cell.listener?.onStart(inv);
333
258
  cell.listener?.onPosted?.(inv);
334
259
  cell.listener?.onCovered?.(inv);
335
- void (async () => {
336
- try {
337
- const value = await (
338
- handler as (...args: readonly unknown[]) => unknown
339
- ).call(call.origin?.caller ?? LOCAL_CALLER, ...call.params);
340
- const execution =
341
- typeof value === "object" && value !== null
342
- ? executionStates.get(value)
343
- : undefined;
344
- if (execution) {
345
- if (execution.acked) ackInvocation(cell, inv);
346
- if (execution.settled) {
347
- settleInvocation(cell, inv, call, execution.settled);
348
- } else {
349
- execution.bind = {
350
- onAck: () => ackInvocation(cell, inv),
351
- onSettle: (settled) => settleInvocation(cell, inv, call, settled),
352
- };
353
- }
354
- } else {
355
- settleInvocation(cell, inv, call, { type: "resolved", value });
356
- }
357
- } catch (error) {
358
- settleInvocation(cell, inv, call, { type: "rejected", error });
359
- } finally {
360
- cell.running = false;
361
- pump(cell);
260
+ const ctx: StatewireHost.Ctx = {
261
+ caller: call.origin?.caller ?? LOCAL_CALLER,
262
+ ack: () => ackInvocation(cell, inv),
263
+ };
264
+ try {
265
+ const value = (
266
+ handler as (
267
+ this: StatewireHost.Ctx,
268
+ ...args: readonly unknown[]
269
+ ) => unknown
270
+ ).call(ctx, ...call.params);
271
+ if (isThenable(value)) {
272
+ value.then(
273
+ (resolved) =>
274
+ settleInvocation(cell, inv, call, {
275
+ type: "resolved",
276
+ value: resolved,
277
+ }),
278
+ (error: unknown) =>
279
+ settleInvocation(cell, inv, call, { type: "rejected", error }),
280
+ );
281
+ } else {
282
+ settleInvocation(cell, inv, call, { type: "resolved", value });
362
283
  }
363
- })();
284
+ } catch (error) {
285
+ settleInvocation(cell, inv, call, { type: "rejected", error });
286
+ }
287
+ };
288
+
289
+ // Prefix-only advancement: each handler runs synchronously to its first
290
+ // await, then the pump moves on; settlement rides the returned promise.
291
+ const pump = (cell: CommandsCell) => {
292
+ if (cell.running || !cell.handlers) {
293
+ watchStall(cell);
294
+ return;
295
+ }
296
+ cell.running = true;
297
+ try {
298
+ while (cell.handlers) {
299
+ const call = cell.queue.shift();
300
+ if (!call) break;
301
+ dispatch(cell, call);
302
+ }
303
+ } finally {
304
+ cell.running = false;
305
+ }
364
306
  };
365
307
 
366
308
  const watchStall = (cell: CommandsCell) => {
@@ -378,7 +320,7 @@ const watchStall = (cell: CommandsCell) => {
378
320
 
379
321
  export const useStatewireCommands = <THandlers extends Statewire.Commands>(
380
322
  commands:
381
- | (THandlers & ThisType<StatewireHost.Caller>)
323
+ | (THandlers & ThisType<StatewireHost.Ctx>)
382
324
  | undefined
383
325
  | null
384
326
  | false,
package/src/host.ts CHANGED
@@ -4,17 +4,12 @@ import type { ServerOp } from "./host/draft";
4
4
  export { StatewireReject } from "./host/engine";
5
5
  export { append } from "./host/draft";
6
6
  export {
7
- CommandExecution,
8
7
  getStateHost,
9
8
  useStatewireState,
10
9
  useStatewireCommands,
11
10
  useStatewireEmit,
12
11
  } from "./host/host";
13
12
 
14
- declare const executionResult: unique symbol;
15
-
16
- type UnwrapExecution<V> = V extends StatewireHost.Execution<infer T> ? T : V;
17
-
18
13
  export namespace StatewireHost {
19
14
  export type StateCtl<TState = unknown> = {
20
15
  /** Apply server-vocabulary ops (root replace = whole-state swap). */
@@ -31,23 +26,18 @@ export namespace StatewireHost {
31
26
  export type StateSource = Statewire.StateSource;
32
27
 
33
28
  /**
34
- * Handle a command handler returns to keep the command open past its own
35
- * return: `ack()` releases the client's optimistic overlay / ack watermark,
36
- * `resolve`/`reject` settle the command (and imply ack). `ack` is idempotent
37
- * and a no-op after settle.
29
+ * Per-invocation handle bound as `this` of a command handler: `caller` is
30
+ * the submitting client, `ack()` releases the client's optimistic overlay /
31
+ * ack watermark before the handler settles (idempotent, a no-op after
32
+ * settle). The command settles when the handler's returned promise does.
38
33
  */
39
- export type Execution<T = void> = {
40
- readonly [executionResult]: (value: T) => void;
41
- };
42
-
43
- export type ExecutionResolvers<T = void> = {
44
- ack: () => void;
45
- resolve: (value: T) => void;
46
- reject: (error: unknown) => void;
34
+ export type Ctx = {
35
+ readonly caller: Caller;
36
+ readonly ack: () => void;
47
37
  };
48
38
 
49
39
  /**
50
- * The submitting client, bound as `this` of a command handler. Holdable
40
+ * The submitting client (`this.caller` in a command handler). Holdable
51
41
  * across a long run: `clientId` is the client's `Statewire-Client-Id`
52
42
  * (in-process dispatch has none — reading it throws), `context` reads the
53
43
  * client's current ambient context live (null until the client sends one),
@@ -62,7 +52,7 @@ export namespace StatewireHost {
62
52
  export type HandledCommands<THandlers extends Statewire.Commands> = {
63
53
  [K in keyof THandlers]: (
64
54
  ...args: Parameters<THandlers[K]>
65
- ) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
55
+ ) => Awaited<ReturnType<THandlers[K]>>;
66
56
  };
67
57
 
68
58
  export type Fin = {
package/src/statewire.ts CHANGED
@@ -119,6 +119,7 @@ export namespace StatewireClient {
119
119
  update: Statewire.CommandUpdate<TCommands>,
120
120
  ctx: { state: NonNullable<TState> },
121
121
  ) => void;
122
+ onConnectionChange?: (connection: Connection) => void;
122
123
  };
123
124
 
124
125
  export type Helpers<
@@ -140,7 +141,7 @@ export namespace StatewireClient {
140
141
  | { status: "live" }
141
142
  | {
142
143
  status: "reconnecting";
143
- cause: "dropped" | "evicted" | "error";
144
+ cause: "dropped" | "evicted" | "error" | "unauthorized";
144
145
  attempt: number;
145
146
  nextRetryAt: number;
146
147
  lastError?: string;
@@ -202,33 +203,26 @@ export namespace StatewireClient {
202
203
  cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
203
204
  };
204
205
 
205
- /**
206
- * A pending command offered for replay while the session is being evicted.
207
- * Return "replay" to re-enqueue it under the rotated client id instead of
208
- * settling it as a session-lost failure.
209
- */
210
- export type SessionLostCommand = {
211
- method: string;
212
- params: unknown[];
213
- seq: number;
214
- };
206
+ /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
207
+ export type HeadersOption =
208
+ | HeadersInit
209
+ | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
215
210
 
216
211
  export type SSEOptions = StatewireSessionStoreOptions & {
217
212
  url: string;
218
- headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
213
+ headers?: HeadersOption;
219
214
  fetch?: typeof globalThis.fetch;
220
215
  onChange?: (connection: Connection) => void;
221
- onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
222
216
  };
223
217
 
224
218
  export type WSOptions = StatewireSessionStoreOptions & {
225
219
  url: string;
220
+ headers?: HeadersOption;
226
221
  protocols?: string | string[];
227
222
  query?:
228
223
  | Record<string, string>
229
224
  | (() => Record<string, string> | Promise<Record<string, string>>);
230
225
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
231
226
  onChange?: (connection: Connection) => void;
232
- onCommandSessionLost?: (command: SessionLostCommand) => "replay" | void;
233
227
  };
234
228
  }
@@ -20,13 +20,7 @@ export const useStatewireRemote = <
20
20
  useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
21
21
  ): Statewire<any, any, any> => {
22
22
  const emit = useStatewireEmit<any>();
23
- const s = useStatewireSession(
24
- {
25
- sessionStore: options.sessionStore,
26
- onCommandSessionLost: options.onCommandSessionLost,
27
- },
28
- emit,
29
- );
23
+ const s = useStatewireSession({ sessionStore: options.sessionStore }, emit);
30
24
  const conn = useLink(options, s.ctx);
31
25
  useEffect(() => setTransport(s, conn), [s, conn]);
32
26
 
@@ -72,13 +72,16 @@ export type RemoteLink = {
72
72
  const baseUrl = (options: StatewireClient.SSEOptions) =>
73
73
  options.url.replace(/\/$/, "");
74
74
 
75
+ export type AuthState = { stale: boolean };
76
+
75
77
  const requestHeaders = async (
76
78
  options: StatewireClient.SSEOptions,
77
79
  extra: Record<string, string>,
80
+ ctx: { staleAuth: boolean },
78
81
  ) => {
79
82
  const { headers } = options;
80
83
  const merged = new Headers(
81
- typeof headers === "function" ? await headers() : headers,
84
+ typeof headers === "function" ? await headers(ctx) : headers,
82
85
  );
83
86
  for (const [name, value] of Object.entries(extra)) merged.set(name, value);
84
87
  return merged;
@@ -131,12 +134,19 @@ const consume = async (
131
134
  const attemptStream = async (
132
135
  c: Conn,
133
136
  options: StatewireClient.SSEOptions,
137
+ auth: AuthState,
134
138
  abort: AbortController,
135
139
  ): Promise<"stop" | "reattach" | "ended"> => {
136
- const headers = await requestHeaders(options, {
137
- "Statewire-Client-Id": c.ctx.clientId,
138
- accept: "text/event-stream",
139
- });
140
+ const stale = auth.stale;
141
+ auth.stale = false;
142
+ const headers = await requestHeaders(
143
+ options,
144
+ {
145
+ "Statewire-Client-Id": c.ctx.clientId,
146
+ accept: "text/event-stream",
147
+ },
148
+ { staleAuth: stale },
149
+ );
140
150
  const heartbeat = new AbortController();
141
151
  const headerTimer = setTimeout(
142
152
  () => heartbeat.abort(),
@@ -158,6 +168,11 @@ const attemptStream = async (
158
168
  clearTimeout(headerTimer);
159
169
  }
160
170
  if (res.status === 429 || res.status === 503) throw overloaded(res);
171
+ if (res.status === 401) {
172
+ if (stale) c.episode = { cause: "unauthorized" };
173
+ auth.stale = true;
174
+ throw new Error("unauthorized");
175
+ }
161
176
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
162
177
  const reader = res.body.getReader();
163
178
  try {
@@ -170,23 +185,28 @@ const attemptStream = async (
170
185
  const overloaded = (res: Response) =>
171
186
  overloadedError(parseRetryAfter(res.headers.get("Retry-After")));
172
187
 
173
- const sendCommand = async (
188
+ const postCommand = async (
174
189
  c: Conn,
175
190
  options: StatewireClient.SSEOptions,
176
191
  { seq, command }: { seq: number; command: StatewireWireCommand },
177
- ): Promise<StatewireSubmitResult> => {
178
- const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
179
- const res = await timedFetch(
192
+ timeout: AbortSignal,
193
+ stale: boolean,
194
+ ): Promise<Response> =>
195
+ timedFetch(
180
196
  options,
181
197
  `${baseUrl(options)}/commands`,
182
198
  {
183
199
  method: "POST",
184
- headers: await requestHeaders(options, {
185
- "content-type": "application/json",
186
- "Statewire-Client-Id": c.ctx.clientId,
187
- "Statewire-Command-Seq": String(seq),
188
- ...(c.lease !== null && { "Statewire-Lease": c.lease }),
189
- }),
200
+ headers: await requestHeaders(
201
+ options,
202
+ {
203
+ "content-type": "application/json",
204
+ "Statewire-Client-Id": c.ctx.clientId,
205
+ "Statewire-Command-Seq": String(seq),
206
+ ...(c.lease !== null && { "Statewire-Lease": c.lease }),
207
+ },
208
+ { staleAuth: stale },
209
+ ),
190
210
  body: JSON.stringify(encodeWireCommand(command)),
191
211
  signal: AbortSignal.any([
192
212
  c.lifetime?.signal ?? AbortSignal.abort(),
@@ -195,6 +215,18 @@ const sendCommand = async (
195
215
  },
196
216
  timeout,
197
217
  );
218
+
219
+ const sendCommand = async (
220
+ c: Conn,
221
+ options: StatewireClient.SSEOptions,
222
+ auth: AuthState,
223
+ args: { seq: number; command: StatewireWireCommand },
224
+ ): Promise<StatewireSubmitResult> => {
225
+ const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
226
+ let res = await postCommand(c, options, args, timeout, false);
227
+ if (res.status === 401 && typeof options.headers === "function") {
228
+ res = await postCommand(c, options, args, timeout, true);
229
+ }
198
230
  if (res.ok) {
199
231
  let body: { res?: StatewireResponse } | null;
200
232
  try {
@@ -209,24 +241,35 @@ const sendCommand = async (
209
241
  ? { type: "receipt", response: reviveResponse(body.res) }
210
242
  : { type: "receipt" };
211
243
  }
244
+ if (res.status === 429 || res.status === 503) throw overloaded(res);
245
+ if (res.status === 401) {
246
+ // Fresh credentials did not clear the 401: signal auth-failed on the
247
+ // connection and let the send retry as a transport fault.
248
+ auth.stale = true;
249
+ c.episode = { cause: "unauthorized" };
250
+ c.connection.reconnect();
251
+ throw new Error("unauthorized");
252
+ }
253
+ // Bodies settle, statuses pace: only a statewire-shaped body carries a
254
+ // verdict; any bare status is middleware noise — a transport fault, retried.
255
+ const body = (await res.json().catch(() => null)) as {
256
+ error?: string;
257
+ rejections?: { message?: string }[];
258
+ } | null;
259
+ if (body?.error === "rejected") {
260
+ return {
261
+ type: "rejected",
262
+ message: body.rejections?.[0]?.message ?? `HTTP ${res.status}`,
263
+ };
264
+ }
212
265
  if (res.status === 409 || res.status === 412 || res.status === 423) {
213
- // Only a body naming the same verdict as the status is statewire's own;
214
- // a bare status is middleware noise — a transport fault, retried.
215
266
  const verdict =
216
267
  res.status === 409
217
268
  ? ("seq-gap" as const)
218
269
  : res.status === 412
219
270
  ? ("unknown-client" as const)
220
271
  : ("stale-lease" as const);
221
- const body = (await res.json().catch(() => null)) as {
222
- error?: string;
223
- } | null;
224
272
  if (body?.error === verdict) return { type: verdict };
225
- throw new Error(`HTTP ${res.status}`);
226
- }
227
- if (res.status === 429 || res.status === 503) throw overloaded(res);
228
- if (res.status >= 400 && res.status < 500) {
229
- return { type: "rejected", message: `HTTP ${res.status}` };
230
273
  }
231
274
  throw new Error(`HTTP ${res.status}`);
232
275
  };
@@ -237,18 +280,19 @@ const useSseLink = (
237
280
  ): RemoteLink => {
238
281
  const latest = useRef(options);
239
282
  latest.current = options;
283
+ const [auth] = useState((): AuthState => ({ stale: false }));
240
284
  const c = useStatewireConn({
241
285
  url: options.url,
242
286
  onChange: options.onChange,
243
287
  ctx,
244
- attemptOnce: (c, abort) => attemptStream(c, options, abort),
288
+ attemptOnce: (c, abort) => attemptStream(c, options, auth, abort),
245
289
  });
246
290
  const [link] = useState(
247
291
  (): RemoteLink => ({
248
292
  get connection() {
249
293
  return c.connection;
250
294
  },
251
- send: (args) => sendCommand(c, latest.current, args),
295
+ send: (args) => sendCommand(c, latest.current, auth, args),
252
296
  }),
253
297
  );
254
298
  return link;
@@ -29,11 +29,13 @@ type PendingCommand = {
29
29
  type WsChannel = {
30
30
  socket: WebSocket | null;
31
31
  pending: PendingCommand[];
32
+ authStale: boolean;
32
33
  };
33
34
 
34
35
  const OPEN = 1;
35
36
 
36
37
  export const WS_SUBPROTOCOL = "statewire.v1";
38
+ export const UNAUTHORIZED_CLOSE = 4401;
37
39
 
38
40
  const socketUrl = async (
39
41
  options: StatewireClient.WSOptions,
@@ -48,6 +50,23 @@ const socketUrl = async (
48
50
  return `${base}/ws?${params.toString()}`;
49
51
  };
50
52
 
53
+ const headersFrame = async (
54
+ options: StatewireClient.WSOptions,
55
+ stale: boolean,
56
+ ): Promise<string | null> => {
57
+ const { headers } = options;
58
+ if (headers === undefined) return null;
59
+ const resolved = new Headers(
60
+ typeof headers === "function"
61
+ ? await headers({ staleAuth: stale })
62
+ : headers,
63
+ );
64
+ return JSON.stringify({
65
+ type: "headers",
66
+ headers: Object.fromEntries(resolved),
67
+ });
68
+ };
69
+
51
70
  const settleOverloaded = (
52
71
  w: WsChannel,
53
72
  { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },
@@ -81,6 +100,8 @@ const runSocket = (
81
100
  c: Conn,
82
101
  w: WsChannel,
83
102
  ws: WebSocket,
103
+ frame: string | null,
104
+ staleAuth: boolean,
84
105
  abort: AbortController,
85
106
  ): Promise<"stop" | "reattach"> =>
86
107
  new Promise((resolve, reject) => {
@@ -98,6 +119,7 @@ const runSocket = (
98
119
  arm();
99
120
  abort.signal.addEventListener("abort", () => ws.close());
100
121
  ws.onerror = () => {};
122
+ if (frame !== null) ws.onopen = () => ws.send(frame);
101
123
  const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
102
124
  const fail = (error: unknown) => {
103
125
  clearTimeout(timer);
@@ -141,12 +163,16 @@ const runSocket = (
141
163
  fail(error);
142
164
  }
143
165
  };
144
- ws.onclose = () => {
166
+ ws.onclose = (event?: { code?: number }) => {
145
167
  closed = true;
146
168
  clearTimeout(timer);
147
169
  if (w.socket === ws) failPending(w, new Error("network"));
148
170
  if (outcome) resolve(outcome);
149
- else reject(new Error(timedOut ? "timeout" : "network"));
171
+ else if (event?.code === UNAUTHORIZED_CLOSE) {
172
+ if (staleAuth) c.episode = { cause: "unauthorized" };
173
+ w.authStale = true;
174
+ reject(new Error("unauthorized"));
175
+ } else reject(new Error(timedOut ? "timeout" : "network"));
150
176
  };
151
177
  });
152
178
 
@@ -159,6 +185,9 @@ const attemptSocket = async (
159
185
  let ws: WebSocket | null = null;
160
186
  try {
161
187
  const url = await socketUrl(options, c.ctx.clientId);
188
+ const stale = w.authStale;
189
+ w.authStale = false;
190
+ const frame = await headersFrame(options, stale);
162
191
  abort.signal.throwIfAborted();
163
192
  const { protocols, webSocket } = options;
164
193
  const WS = webSocket ?? globalThis.WebSocket;
@@ -171,7 +200,7 @@ const attemptSocket = async (
171
200
  : protocols;
172
201
  ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
173
202
  w.socket = ws;
174
- return await runSocket(c, w, ws, abort);
203
+ return await runSocket(c, w, ws, frame, stale, abort);
175
204
  } finally {
176
205
  if (w.socket === ws) w.socket = null;
177
206
  }
@@ -219,7 +248,9 @@ const useWsLink = (
219
248
  options: StatewireClient.WSOptions,
220
249
  ctx: StatewireRemoteProps,
221
250
  ): RemoteLink => {
222
- const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));
251
+ const [w] = useState(
252
+ (): WsChannel => ({ socket: null, pending: [], authStale: false }),
253
+ );
223
254
  const c = useStatewireConn({
224
255
  url: options.url,
225
256
  onChange: options.onChange,