@cosmicdrift/kumiko-dispatcher-live 0.105.2 → 0.109.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dispatcher-live",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.0",
|
|
4
4
|
"description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
27
|
+
"@cosmicdrift/kumiko-headless": "0.109.0"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"registry": "https://registry.npmjs.org",
|
|
@@ -46,7 +46,10 @@ describe("createLiveDispatcher", () => {
|
|
|
46
46
|
expect(headers["Content-Type"]).toBe("application/json");
|
|
47
47
|
expect(headers["X-CSRF-Token"]).toBe("csrf-abc");
|
|
48
48
|
const body = JSON.parse(call?.init.body as string);
|
|
49
|
-
expect(body).
|
|
49
|
+
expect(body).toMatchObject({ type: "app:write:task:create", payload: { title: "hello" } });
|
|
50
|
+
// Auto-generated idempotency key (#761) — always present.
|
|
51
|
+
expect(typeof body.requestId).toBe("string");
|
|
52
|
+
expect(body.requestId.length).toBeGreaterThan(8);
|
|
50
53
|
});
|
|
51
54
|
|
|
52
55
|
test("write: propagates requestId into body when provided", async () => {
|
|
@@ -59,6 +62,43 @@ describe("createLiveDispatcher", () => {
|
|
|
59
62
|
expect(body.requestId).toBe("idem-99");
|
|
60
63
|
});
|
|
61
64
|
|
|
65
|
+
test("write: generates a fresh requestId per invocation when none provided (#761)", async () => {
|
|
66
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
|
|
67
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
68
|
+
|
|
69
|
+
await disp.write("app:write:x:create", { a: 1 });
|
|
70
|
+
await disp.write("app:write:x:create", { a: 1 });
|
|
71
|
+
|
|
72
|
+
const first = JSON.parse(calls[0]?.init.body as string).requestId;
|
|
73
|
+
const second = JSON.parse(calls[1]?.init.body as string).requestId;
|
|
74
|
+
expect(typeof first).toBe("string");
|
|
75
|
+
expect(typeof second).toBe("string");
|
|
76
|
+
expect(first).not.toBe(second);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("batch: generates one requestId for the whole batch when none provided (#761)", async () => {
|
|
80
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
|
|
81
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
82
|
+
|
|
83
|
+
await disp.batch([
|
|
84
|
+
{ type: "a", payload: {} },
|
|
85
|
+
{ type: "b", payload: {} },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
89
|
+
expect(typeof body.requestId).toBe("string");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("batch: propagates an explicit requestId (#761)", async () => {
|
|
93
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
|
|
94
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
95
|
+
|
|
96
|
+
await disp.batch([{ type: "a", payload: {} }], { requestId: "batch-7" });
|
|
97
|
+
|
|
98
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
99
|
+
expect(body.requestId).toBe("batch-7");
|
|
100
|
+
});
|
|
101
|
+
|
|
62
102
|
test("write: no CSRF token → header omitted, request still fires (server will 401/csrf-mismatch)", async () => {
|
|
63
103
|
const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
|
|
64
104
|
const disp = createLiveDispatcher({ fetch, readCsrf: () => undefined });
|
package/src/dispatcher-live.ts
CHANGED
|
@@ -154,7 +154,11 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
154
154
|
opts?: WriteOpts,
|
|
155
155
|
): Promise<WriteResult<TData>> {
|
|
156
156
|
const body: Record<string, unknown> = { type, payload };
|
|
157
|
-
|
|
157
|
+
// Idempotency by default (#761): without a requestId the server-side
|
|
158
|
+
// dedup never engages and a transport-level double-send duplicates
|
|
159
|
+
// events. Callers with a logical-submit id (savable queue, form
|
|
160
|
+
// controllers) pass their own and keep it across their retries.
|
|
161
|
+
body["requestId"] = opts?.requestId ?? generateRequestId();
|
|
158
162
|
const call = await callJson(PATH_WRITE, body, opts?.signal);
|
|
159
163
|
return normalizeWriteResult<TData>(call);
|
|
160
164
|
},
|
|
@@ -171,7 +175,10 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
171
175
|
|
|
172
176
|
async batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
|
|
173
177
|
const body: Record<string, unknown> = { commands };
|
|
174
|
-
|
|
178
|
+
// One id for the whole batch — the server caches the BatchResult
|
|
179
|
+
// under it, so a retried batch returns the cached outcome instead of
|
|
180
|
+
// re-executing the commands (#761).
|
|
181
|
+
body["requestId"] = opts?.requestId ?? generateRequestId();
|
|
175
182
|
const call = await callJson(PATH_BATCH, body, opts?.signal);
|
|
176
183
|
return normalizeBatchResponse(call);
|
|
177
184
|
},
|
|
@@ -189,6 +196,16 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
189
196
|
const EMPTY_PENDING_WRITES: readonly PendingWrite[] = Object.freeze([]);
|
|
190
197
|
const EMPTY_PENDING_FILES: readonly PendingFile[] = Object.freeze([]);
|
|
191
198
|
|
|
199
|
+
// crypto.randomUUID where available (browser, Bun, Node); Math.random
|
|
200
|
+
// fallback for React-Native runtimes without the WebCrypto polyfill.
|
|
201
|
+
// Uniqueness only needs to hold per user within the server's dedup window —
|
|
202
|
+
// this is an idempotency key, not a security token.
|
|
203
|
+
function generateRequestId(): string {
|
|
204
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
205
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
206
|
+
return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
192
209
|
type CallOutcome =
|
|
193
210
|
| { readonly ok: true; readonly body: unknown; readonly status: number }
|
|
194
211
|
| { readonly ok: false; readonly networkFailure: DispatcherError };
|