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.
- package/README.md +1 -1
- package/dist/client/connection.d.ts +1 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +1 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +1 -5
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +18 -29
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +2 -14
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +41 -92
- package/dist/host/host.js.map +1 -1
- package/dist/host.d.ts +11 -17
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -2
- package/dist/statewire.d.ts +8 -14
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.js +1 -4
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +4 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +41 -25
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +25 -6
- package/dist/transports/ws.js.map +1 -1
- package/dist/util/statePath.js +8 -6
- package/dist/util/statePath.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +1 -1
- package/src/client/core.ts +1 -0
- package/src/client/session.ts +9 -26
- package/src/host/host.ts +55 -113
- package/src/host.ts +9 -19
- package/src/statewire.ts +8 -14
- package/src/transports/remote.ts +1 -7
- package/src/transports/sse.ts +71 -27
- 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
|
|
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:
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
readonly
|
|
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
|
|
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
|
-
) =>
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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?:
|
|
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
|
}
|
package/src/transports/remote.ts
CHANGED
|
@@ -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
|
|
package/src/transports/sse.ts
CHANGED
|
@@ -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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
188
|
+
const postCommand = async (
|
|
174
189
|
c: Conn,
|
|
175
190
|
options: StatewireClient.SSEOptions,
|
|
176
191
|
{ seq, command }: { seq: number; command: StatewireWireCommand },
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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;
|
package/src/transports/ws.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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,
|