statewire 0.4.0 → 0.6.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 +4 -16
- package/dist/client/session.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- 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/testing.d.ts +1 -2
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +1 -3
- package/dist/transports/testing.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/recordMutations.d.ts +3 -1
- package/dist/util/recordMutations.d.ts.map +1 -1
- package/dist/util/recordMutations.js +3 -1
- package/dist/util/recordMutations.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/index.ts +1 -0
- package/src/statewire.ts +8 -14
- package/src/transports/remote.ts +1 -7
- package/src/transports/sse.ts +71 -27
- package/src/transports/testing.ts +1 -6
- package/src/transports/ws.ts +35 -4
- package/src/util/recordMutations.ts +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recordMutations.js","names":["sharedDraftTraps","PathKey","Path","DRAFT","Symbol","Node","base","Record","PropertyKey","copy","modified","isArray","parent","path","proxy","assigned","Set","isDraftable","value","Array","proto","Object","getPrototypeOf","prototype","nodeOf","undefined","latest","node","prepareCopy","slice","markModified","n","resolveAssigned","container","key","keys","child","finalize","result","has","
|
|
1
|
+
{"version":3,"file":"recordMutations.js","names":["sharedDraftTraps","PathKey","Path","DRAFT","Symbol","Node","base","Record","PropertyKey","copy","modified","isArray","parent","path","proxy","assigned","Set","isDraftable","value","Array","proto","Object","getPrototypeOf","prototype","nodeOf","undefined","latest","node","prepareCopy","slice","markModified","n","resolveAssigned","container","key","keys","child","finalize","result","has","applyOptimistic","state","TState","mutate","draft","recordMutations","T","changedPaths","Error","seen","record","join","add","push","createNode","touched","prop","Proxy","get","_target","current","Number","set","next","source","hasOwn","deleteProperty","Reflect","root"],"sources":["../../src/util/recordMutations.ts"],"sourcesContent":["import { sharedDraftTraps } from \"./draftTraps\";\n\ntype PathKey = string | number;\ntype Path = readonly PathKey[];\n\nconst DRAFT = Symbol(\"statewire.draft\");\n\ntype Node = {\n base: Record<PropertyKey, unknown>;\n copy: Record<PropertyKey, unknown> | null;\n modified: boolean;\n isArray: boolean;\n parent: Node | null;\n path: Path;\n proxy: unknown;\n assigned: Set<PropertyKey> | null;\n};\n\nconst isDraftable = (value: unknown): value is object => {\n if (typeof value !== \"object\" || value === null) return false;\n if (Array.isArray(value)) return true;\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n};\n\nconst nodeOf = (value: unknown): Node | undefined =>\n typeof value === \"object\" && value !== null\n ? (value as { [DRAFT]?: Node })[DRAFT]\n : undefined;\n\nconst latest = (node: Node) => node.copy ?? node.base;\n\nconst prepareCopy = (node: Node) => {\n node.copy ??= node.isArray\n ? ((node.base as unknown as unknown[]).slice() as unknown as Record<\n PropertyKey,\n unknown\n >)\n : { ...node.base };\n};\n\nconst markModified = (node: Node) => {\n for (let n: Node | null = node; n && !n.modified; n = n.parent) {\n n.modified = true;\n }\n};\n\nconst resolveAssigned = (container: object) => {\n for (const key of Object.keys(container)) {\n const value = (container as Record<string, unknown>)[key];\n const child = nodeOf(value);\n if (child) {\n (container as Record<string, unknown>)[key] = finalize(child);\n } else if (isDraftable(value)) {\n resolveAssigned(value);\n }\n }\n};\n\nconst finalize = (node: Node): unknown => {\n if (!node.modified) return node.base;\n const result = node.copy!;\n for (const key of Object.keys(result)) {\n const value = result[key];\n const child = nodeOf(value);\n if (child) {\n result[key] = finalize(child);\n } else if (node.assigned?.has(key) && isDraftable(value)) {\n resolveAssigned(value);\n }\n }\n return result;\n};\n\n/** Run a mutator against a recording draft, as the client does for `optimistic`. */\nexport const applyOptimistic = <TState>(\n state: TState,\n mutate: (draft: TState) => void,\n): TState => recordMutations(state, mutate).state;\n\nexport const recordMutations = <T>(\n base: T,\n mutate: (draft: T) => void,\n): { state: T; changedPaths: Path[] } => {\n if (!isDraftable(base)) {\n throw new Error(\"statewire: cannot record mutations on primitive state\");\n }\n\n const changedPaths: Path[] = [];\n const seen = new Set<string>();\n const record = (path: Path) => {\n const key = path.join(\"\u0000\");\n if (seen.has(key)) return;\n seen.add(key);\n changedPaths.push(path);\n };\n\n const createNode = (value: object, parent: Node | null, path: Path): Node => {\n const node: Node = {\n base: value as Record<PropertyKey, unknown>,\n copy: null,\n modified: false,\n isArray: Array.isArray(value),\n parent,\n path,\n proxy: null,\n assigned: null,\n };\n const touched = (prop: PropertyKey) => {\n if (typeof prop !== \"string\") return;\n record(node.isArray ? node.path : [...node.path, prop]);\n };\n node.proxy = new Proxy(value, {\n get: (_target, prop) => {\n if (prop === DRAFT) return node;\n const current = latest(node)[prop];\n if (typeof prop === \"symbol\") return current;\n if (nodeOf(current)) return current;\n if (isDraftable(current) && current === node.base[prop]) {\n prepareCopy(node);\n const child = createNode(current, node, [\n ...path,\n node.isArray ? Number(prop) : prop,\n ]);\n node.copy![prop] = child.proxy;\n return child.proxy;\n }\n return current;\n },\n set: (_target, prop, next) => {\n const source = latest(node);\n if (Object.hasOwn(source, prop) && source[prop] === next) return true;\n prepareCopy(node);\n markModified(node);\n touched(prop);\n node.copy![prop] = next;\n (node.assigned ??= new Set()).add(prop);\n return true;\n },\n deleteProperty: (_target, prop) => {\n if (!Object.hasOwn(latest(node), prop)) return true;\n prepareCopy(node);\n markModified(node);\n touched(prop);\n delete node.copy![prop];\n return true;\n },\n has: (_target, prop) => prop === DRAFT || Reflect.has(latest(node), prop),\n ...sharedDraftTraps(() => latest(node), \"inside a mutator\"),\n });\n return node;\n };\n\n const root = createNode(base as unknown as object, null, []);\n mutate(root.proxy as T);\n return { state: finalize(root) as T, changedPaths };\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/client/connection.ts
CHANGED
package/src/client/core.ts
CHANGED
|
@@ -159,6 +159,7 @@ const createCore = (
|
|
|
159
159
|
const connection = getConnection();
|
|
160
160
|
const connectionChanged = connection !== notifiedConnection;
|
|
161
161
|
notifiedConnection = connection;
|
|
162
|
+
if (connectionChanged) options.onConnectionChange?.(connection);
|
|
162
163
|
if (stateChanged || connectionChanged) {
|
|
163
164
|
for (const listener of [...listeners]) listener();
|
|
164
165
|
}
|
package/src/client/session.ts
CHANGED
|
@@ -147,11 +147,7 @@ const rejectedSend = (
|
|
|
147
147
|
|
|
148
148
|
type AttachState = "detached" | "attached" | "finished";
|
|
149
149
|
|
|
150
|
-
export type StatewireSessionOptions = StatewireSessionStoreOptions
|
|
151
|
-
onCommandSessionLost?:
|
|
152
|
-
| ((command: StatewireClient.SessionLostCommand) => "replay" | void)
|
|
153
|
-
| undefined;
|
|
154
|
-
};
|
|
150
|
+
export type StatewireSessionOptions = StatewireSessionStoreOptions;
|
|
155
151
|
|
|
156
152
|
type Session = {
|
|
157
153
|
record: StatewireSessionRecord;
|
|
@@ -172,7 +168,6 @@ type Session = {
|
|
|
172
168
|
transport: RemoteLink | null;
|
|
173
169
|
waiters: (() => void)[];
|
|
174
170
|
lifecycle: CommandsListener | null;
|
|
175
|
-
onCommandSessionLost: StatewireSessionOptions["onCommandSessionLost"];
|
|
176
171
|
notify: () => void;
|
|
177
172
|
emitEvent: (event: unknown) => void;
|
|
178
173
|
send: (command: StatewireWireCommand) => Promise<unknown>;
|
|
@@ -219,7 +214,6 @@ const createSession = (options: StatewireSessionOptions): Session => {
|
|
|
219
214
|
transport: null,
|
|
220
215
|
waiters: [],
|
|
221
216
|
lifecycle: null,
|
|
222
|
-
onCommandSessionLost: options.onCommandSessionLost,
|
|
223
217
|
notify: () => {},
|
|
224
218
|
emitEvent: () => {},
|
|
225
219
|
send: (command) => sendCommand(s, command),
|
|
@@ -298,25 +292,15 @@ const settleAll = (
|
|
|
298
292
|
}
|
|
299
293
|
};
|
|
300
294
|
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// attach retry evicts again and re-offers.
|
|
295
|
+
// Claims every unsettled real command for replay under the rotated client id,
|
|
296
|
+
// in seq order; context submissions settle and the context re-sends whole.
|
|
304
297
|
const claimReplays = (s: Session): Pending[] => {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
method: entry.inv.method,
|
|
312
|
-
params: [...entry.inv.params],
|
|
313
|
-
seq: entry.seq,
|
|
314
|
-
});
|
|
315
|
-
if (decision === "replay") claimed.add(entry);
|
|
316
|
-
}
|
|
317
|
-
if (claimed.size === 0) return [];
|
|
318
|
-
s.pending = s.pending.filter((p) => !claimed.has(p));
|
|
319
|
-
return [...claimed];
|
|
298
|
+
const claimed = s.pending.filter(
|
|
299
|
+
(entry) => entry.inv !== null && !isResolved(entry.state),
|
|
300
|
+
);
|
|
301
|
+
if (claimed.length === 0) return [];
|
|
302
|
+
s.pending = s.pending.filter((p) => !claimed.includes(p));
|
|
303
|
+
return claimed;
|
|
320
304
|
};
|
|
321
305
|
|
|
322
306
|
const evict = (
|
|
@@ -745,7 +729,6 @@ export const useStatewireSession = (
|
|
|
745
729
|
emit: (event: unknown) => void,
|
|
746
730
|
): Session => {
|
|
747
731
|
const [s] = useState(() => createSession(options));
|
|
748
|
-
s.onCommandSessionLost = options.onCommandSessionLost;
|
|
749
732
|
useEffect(() => {
|
|
750
733
|
s.emitEvent = emit;
|
|
751
734
|
return () => {
|
package/src/index.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
|
|
|
3
3
|
export { StatewireSSE } from "./transports/sse";
|
|
4
4
|
export { StatewireWS } from "./transports/ws";
|
|
5
5
|
export { StatewireSendError } from "./types";
|
|
6
|
+
export { applyOptimistic } from "./util/recordMutations";
|
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;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
2
2
|
import type { StatewireEnvelope } from "../client/connection";
|
|
3
|
-
import { recordMutations } from "../util/recordMutations";
|
|
4
3
|
import { StatewireSSE } from "./sse";
|
|
5
4
|
import type { Statewire } from "../types";
|
|
6
5
|
import { openSSEStream, sseFrame } from "./fake-sse";
|
|
@@ -11,11 +10,7 @@ export * from "./fixtures";
|
|
|
11
10
|
/** Let queued microtasks and op batches drain through the statewire client. */
|
|
12
11
|
export const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
export const applyOptimistic = <TState>(
|
|
16
|
-
state: TState,
|
|
17
|
-
mutate: (draft: TState) => void,
|
|
18
|
-
): TState => recordMutations(state, mutate).state;
|
|
13
|
+
export { applyOptimistic } from "../util/recordMutations";
|
|
19
14
|
|
|
20
15
|
export type MockTransport<TState> = {
|
|
21
16
|
/** Pass as the `transport:` option of useStatewire / Harness. */
|
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,
|
|
Binary file
|