statewire 0.4.0 → 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.
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,