okengine 0.21.0 → 0.22.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/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -25
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.ts +20 -4
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +200 -20
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/build-lib.ts +9 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { OKID_ALPHABET } from "../okid-shared.ts";
|
|
6
7
|
import { createClient } from "./create.ts";
|
|
8
|
+
import { IDEMPOTENCY_KEY_ALPHABET } from "./transport.ts";
|
|
7
9
|
import type { AppOf } from "./types.ts";
|
|
8
10
|
|
|
9
11
|
type PingApp = AppOf<{
|
|
@@ -15,11 +17,16 @@ type PingApp = AppOf<{
|
|
|
15
17
|
};
|
|
16
18
|
}>;
|
|
17
19
|
|
|
20
|
+
const getPing = {
|
|
21
|
+
"sys.ping": { method: "GET", path: "/ping" },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
18
24
|
describe("transport — retry", () => {
|
|
19
25
|
test("retries 5xx then succeeds", async () => {
|
|
20
26
|
let n = 0;
|
|
21
27
|
const api = createClient<PingApp>("http://app.test", {
|
|
22
28
|
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
29
|
+
routes: getPing,
|
|
23
30
|
fetch: async () => {
|
|
24
31
|
n += 1;
|
|
25
32
|
if (n < 3) return new Response("nope", { status: 503 });
|
|
@@ -37,6 +44,7 @@ describe("transport — retry", () => {
|
|
|
37
44
|
let n = 0;
|
|
38
45
|
const api = createClient<PingApp>("http://app.test", {
|
|
39
46
|
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
47
|
+
routes: getPing,
|
|
40
48
|
fetch: async () => {
|
|
41
49
|
n += 1;
|
|
42
50
|
return new Response("nope", { status: 502 });
|
|
@@ -73,6 +81,223 @@ describe("transport — retry", () => {
|
|
|
73
81
|
expect(error?.code).toBe("InternalError");
|
|
74
82
|
expect(error?.message).toMatch(/password policy/i);
|
|
75
83
|
});
|
|
84
|
+
|
|
85
|
+
test("POST retries when it carries an idempotency key", async () => {
|
|
86
|
+
let handled = 0;
|
|
87
|
+
const keys: string[] = [];
|
|
88
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
89
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
90
|
+
fetch: async (_input, init) => {
|
|
91
|
+
handled += 1;
|
|
92
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
93
|
+
throw new TypeError("Failed to fetch");
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const lost = await api.sys.ping();
|
|
98
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
99
|
+
expect(handled).toBe(3);
|
|
100
|
+
expect(keys[0]).toBe(keys[1]);
|
|
101
|
+
expect(keys[1]).toBe(keys[2]);
|
|
102
|
+
expect(keys[0]!.length).toBeGreaterThanOrEqual(16);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("idempotencyKey false runs once unless retry is opted in", async () => {
|
|
106
|
+
let handled = 0;
|
|
107
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
108
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
109
|
+
fetch: async (_input, init) => {
|
|
110
|
+
handled += 1;
|
|
111
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
112
|
+
throw new TypeError("Failed to fetch");
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const lost = await api.sys.ping({ idempotencyKey: false });
|
|
117
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
118
|
+
expect(handled).toBe(1);
|
|
119
|
+
|
|
120
|
+
handled = 0;
|
|
121
|
+
const opted = await api.sys.ping({ idempotencyKey: false, retry: true });
|
|
122
|
+
expect(opted.error?.code).toBe("TransportError");
|
|
123
|
+
expect(handled).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("two calls mint two keys; a string key is reused", async () => {
|
|
127
|
+
const keys: string[] = [];
|
|
128
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
129
|
+
fetch: async (_input, init) => {
|
|
130
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
131
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await api.sys.ping();
|
|
135
|
+
await api.sys.ping();
|
|
136
|
+
expect(keys[0]).not.toBe(keys[1]);
|
|
137
|
+
keys.length = 0;
|
|
138
|
+
await api.sys.ping({ idempotencyKey: "form-key-0123456789" });
|
|
139
|
+
expect(keys).toEqual(["form-key-0123456789"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("inlined idempotency alphabet matches OKID_ALPHABET", () => {
|
|
143
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toBe(OKID_ALPHABET);
|
|
144
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toHaveLength(64);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("missing crypto sends no idempotency key", async () => {
|
|
148
|
+
const original = globalThis.crypto;
|
|
149
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
150
|
+
value: undefined,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
156
|
+
fetch: async (_input, init) => {
|
|
157
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
158
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const { error } = await api.sys.ping();
|
|
162
|
+
expect(error).toBeNull();
|
|
163
|
+
} finally {
|
|
164
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
165
|
+
value: original,
|
|
166
|
+
configurable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("GET sends no idempotency key", async () => {
|
|
173
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
174
|
+
routes: getPing,
|
|
175
|
+
fetch: async (_input, init) => {
|
|
176
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
177
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const { error } = await api.sys.ping();
|
|
181
|
+
expect(error).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("401 resend reuses the idempotency key", async () => {
|
|
185
|
+
const keys: string[] = [];
|
|
186
|
+
let token = "old";
|
|
187
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
188
|
+
auth: {
|
|
189
|
+
getToken: () => token,
|
|
190
|
+
refresh: async () => {
|
|
191
|
+
token = "new";
|
|
192
|
+
return token;
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
fetch: async (_input, init) => {
|
|
196
|
+
const headers = new Headers(init?.headers);
|
|
197
|
+
keys.push(headers.get("idempotency-key") ?? "");
|
|
198
|
+
if (headers.get("authorization") === "Bearer old") {
|
|
199
|
+
return new Response("unauthorized", { status: 401 });
|
|
200
|
+
}
|
|
201
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const { error } = await api.sys.ping();
|
|
205
|
+
expect(error).toBeNull();
|
|
206
|
+
expect(keys).toHaveLength(2);
|
|
207
|
+
expect(keys[0]).toBe(keys[1]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("IdempotencyInProgress waits for Retry-After then replays", async () => {
|
|
211
|
+
let n = 0;
|
|
212
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
213
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
214
|
+
timeout: 30,
|
|
215
|
+
fetch: async (_input, init) => {
|
|
216
|
+
n += 1;
|
|
217
|
+
const signal = init?.signal;
|
|
218
|
+
if (n === 1) {
|
|
219
|
+
await new Promise<void>((_resolve, reject) => {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
signal?.addEventListener("abort", () => {
|
|
225
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (n === 2) {
|
|
230
|
+
return Response.json(
|
|
231
|
+
{ data: null, error: { code: "IdempotencyInProgress", data: {} } },
|
|
232
|
+
{ status: 409, headers: { "retry-after": "0.01" } },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return Response.json(
|
|
236
|
+
{ data: { ok: true }, error: null },
|
|
237
|
+
{ headers: { "idempotent-replayed": "true" } },
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await api.sys.ping();
|
|
243
|
+
expect(result.error).toBeNull();
|
|
244
|
+
expect(result.data).toEqual({ ok: true });
|
|
245
|
+
expect(result.meta?.idempotentReplayed).toBe(true);
|
|
246
|
+
expect(n).toBe(3);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("other 409 envelopes are not retried", async () => {
|
|
250
|
+
let n = 0;
|
|
251
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
252
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
253
|
+
fetch: async () => {
|
|
254
|
+
n += 1;
|
|
255
|
+
return Response.json(
|
|
256
|
+
{ data: null, error: { code: "Conflict", data: {} } },
|
|
257
|
+
{ status: 409 },
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const { error } = await api.sys.ping();
|
|
262
|
+
expect(error?.code).toBe("Conflict");
|
|
263
|
+
expect(n).toBe(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("GET still retries a network error", async () => {
|
|
267
|
+
let n = 0;
|
|
268
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
269
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
270
|
+
routes: getPing,
|
|
271
|
+
fetch: async () => {
|
|
272
|
+
n += 1;
|
|
273
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
274
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const { data, error } = await api.sys.ping();
|
|
279
|
+
expect(error).toBeNull();
|
|
280
|
+
expect(data).toEqual({ ok: true });
|
|
281
|
+
expect(n).toBe(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("QUERY still retries a network error", async () => {
|
|
285
|
+
let n = 0;
|
|
286
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
287
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
288
|
+
routes: { "sys.ping": { method: "QUERY", path: "/ping" } },
|
|
289
|
+
fetch: async () => {
|
|
290
|
+
n += 1;
|
|
291
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
292
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const { data, error } = await api.sys.ping();
|
|
297
|
+
expect(error).toBeNull();
|
|
298
|
+
expect(data).toEqual({ ok: true });
|
|
299
|
+
expect(n).toBe(2);
|
|
300
|
+
});
|
|
76
301
|
});
|
|
77
302
|
|
|
78
303
|
describe("transport — timeout", () => {
|
package/src/client/transport.ts
CHANGED
|
@@ -12,12 +12,26 @@ import type {
|
|
|
12
12
|
ClientHeaders,
|
|
13
13
|
ClientOptions,
|
|
14
14
|
} from "./types.ts";
|
|
15
|
+
import {
|
|
16
|
+
applyAuthHeader,
|
|
17
|
+
applyHeaderBag,
|
|
18
|
+
interpolatePath,
|
|
19
|
+
resolveHeaders,
|
|
20
|
+
toQuery,
|
|
21
|
+
} from "./wire.ts";
|
|
15
22
|
|
|
16
|
-
/** Per-call transport options (binary decode, abort). */
|
|
23
|
+
/** Per-call transport options (binary decode, abort, retry opt-in). */
|
|
17
24
|
export interface TransportCallOptions {
|
|
18
25
|
readonly headers?: ClientHeaders;
|
|
19
26
|
readonly response?: "json" | "blob" | "arrayBuffer";
|
|
20
27
|
readonly signal?: AbortSignal;
|
|
28
|
+
/**
|
|
29
|
+
* Repeat this call under the client `retry` policy even when it sends
|
|
30
|
+
* no idempotency key.
|
|
31
|
+
*/
|
|
32
|
+
readonly retry?: boolean;
|
|
33
|
+
/** Stable key, or `false` to send none. */
|
|
34
|
+
readonly idempotencyKey?: string | false;
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
/** Internal transport handle. */
|
|
@@ -56,10 +70,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
56
70
|
let delay = delay0;
|
|
57
71
|
const callClientOpts: ClientOptions =
|
|
58
72
|
callOpts.signal !== undefined ? { ...opts, signal: callOpts.signal } : opts;
|
|
73
|
+
const method = (opts.routes?.[key.replace("/", ".")]?.method ?? "POST").toUpperCase();
|
|
74
|
+
const idempotencyKey = resolveIdempotencyKey(method, callOpts.idempotencyKey);
|
|
75
|
+
const allowRetry =
|
|
76
|
+
callOpts.retry === true ||
|
|
77
|
+
method === "GET" ||
|
|
78
|
+
method === "QUERY" ||
|
|
79
|
+
idempotencyKey !== undefined;
|
|
59
80
|
|
|
60
81
|
for (;;) {
|
|
61
82
|
try {
|
|
62
|
-
const res = await once(
|
|
83
|
+
const res = await once(
|
|
84
|
+
base,
|
|
85
|
+
key,
|
|
86
|
+
input,
|
|
87
|
+
callClientOpts,
|
|
88
|
+
fetchFn,
|
|
89
|
+
callOpts.headers,
|
|
90
|
+
idempotencyKey,
|
|
91
|
+
);
|
|
63
92
|
if (
|
|
64
93
|
res.status === 401 &&
|
|
65
94
|
opts.auth &&
|
|
@@ -71,9 +100,22 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
71
100
|
await opts.auth.refresh();
|
|
72
101
|
continue;
|
|
73
102
|
}
|
|
103
|
+
if (res.status === 409 && allowRetry && attempt < retries) {
|
|
104
|
+
const seconds = Number(res.headers.get("retry-after"));
|
|
105
|
+
const wait = seconds > 0 ? seconds * 1000 : delay;
|
|
106
|
+
const structured = await decodeIfEnvelope(res);
|
|
107
|
+
if (structured?.error?.code === "IdempotencyInProgress") {
|
|
108
|
+
await sleep(wait);
|
|
109
|
+
delay *= backoff;
|
|
110
|
+
attempt += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (structured) return stampReplay(structured, res);
|
|
114
|
+
return transportEnvelope("HTTP 409", 409);
|
|
115
|
+
}
|
|
74
116
|
if (res.status >= 500) {
|
|
75
117
|
const structured = await decodeIfEnvelope(res);
|
|
76
|
-
if (structured) return structured;
|
|
118
|
+
if (structured) return stampReplay(structured, res);
|
|
77
119
|
throw new Error(`HTTP ${res.status}`);
|
|
78
120
|
}
|
|
79
121
|
if (callOpts.response === "blob" || callOpts.response === "arrayBuffer") {
|
|
@@ -82,7 +124,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
82
124
|
return decode(res);
|
|
83
125
|
} catch (err) {
|
|
84
126
|
const transient = isTransient(err);
|
|
85
|
-
if (!transient || attempt >= retries) {
|
|
127
|
+
if (!transient || !allowRetry || attempt >= retries) {
|
|
86
128
|
return transportEnvelope(err instanceof Error ? err.message : String(err));
|
|
87
129
|
}
|
|
88
130
|
await sleep(delay);
|
|
@@ -102,7 +144,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
102
144
|
* @param message - Human-readable failure text
|
|
103
145
|
* @param status - Optional HTTP status
|
|
104
146
|
*/
|
|
105
|
-
function transportEnvelope(message: string, status?: number): ClientEnvelope {
|
|
147
|
+
export function transportEnvelope(message: string, status?: number): ClientEnvelope {
|
|
106
148
|
return {
|
|
107
149
|
data: null,
|
|
108
150
|
error: {
|
|
@@ -120,10 +162,20 @@ function normalizeCallOpts(
|
|
|
120
162
|
if (Array.isArray(headersOrOpts)) return { headers: headersOrOpts };
|
|
121
163
|
if (
|
|
122
164
|
typeof headersOrOpts === "object" &&
|
|
123
|
-
("response" in headersOrOpts ||
|
|
165
|
+
("response" in headersOrOpts ||
|
|
166
|
+
"signal" in headersOrOpts ||
|
|
167
|
+
"headers" in headersOrOpts ||
|
|
168
|
+
"retry" in headersOrOpts ||
|
|
169
|
+
"idempotencyKey" in headersOrOpts)
|
|
124
170
|
) {
|
|
125
171
|
const o = headersOrOpts as TransportCallOptions;
|
|
126
|
-
if (
|
|
172
|
+
if (
|
|
173
|
+
o.response !== undefined ||
|
|
174
|
+
o.signal !== undefined ||
|
|
175
|
+
o.headers !== undefined ||
|
|
176
|
+
o.retry !== undefined ||
|
|
177
|
+
o.idempotencyKey !== undefined
|
|
178
|
+
) {
|
|
127
179
|
return o;
|
|
128
180
|
}
|
|
129
181
|
}
|
|
@@ -131,7 +183,9 @@ function normalizeCallOpts(
|
|
|
131
183
|
}
|
|
132
184
|
|
|
133
185
|
/**
|
|
134
|
-
* Single HTTP attempt. Throws on network / abort / 5xx
|
|
186
|
+
* Single HTTP attempt. Throws on network / abort / non-envelope 5xx.
|
|
187
|
+
* A client timeout is rethrown as a plain `Error` so the retry loop can
|
|
188
|
+
* repeat it. A caller `AbortSignal` stays an `AbortError` and is not retried.
|
|
135
189
|
*
|
|
136
190
|
* @param base - Origin
|
|
137
191
|
* @param key - `unit/flow`
|
|
@@ -147,6 +201,7 @@ async function once(
|
|
|
147
201
|
opts: ClientOptions,
|
|
148
202
|
fetchFn: ClientFetch,
|
|
149
203
|
callHeaders?: ClientHeaders,
|
|
204
|
+
idempotencyKey?: string,
|
|
150
205
|
): Promise<Response> {
|
|
151
206
|
const route = opts.routes?.[key.replace("/", ".")];
|
|
152
207
|
const { url, method, body } = route
|
|
@@ -154,45 +209,32 @@ async function once(
|
|
|
154
209
|
: rpcRequest(base, key, input);
|
|
155
210
|
|
|
156
211
|
const headers = new Headers();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
} else if (extra) {
|
|
161
|
-
for (const [k, v] of Object.entries(extra)) headers.set(k, v);
|
|
162
|
-
}
|
|
163
|
-
if (Array.isArray(callHeaders)) {
|
|
164
|
-
for (const [k, v] of callHeaders) headers.set(k, v);
|
|
165
|
-
} else if (callHeaders) {
|
|
166
|
-
for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
|
|
167
|
-
}
|
|
212
|
+
applyHeaderBag(headers, await resolveHeaders(opts));
|
|
213
|
+
applyHeaderBag(headers, callHeaders);
|
|
214
|
+
if (idempotencyKey !== undefined) headers.set("Idempotency-Key", idempotencyKey);
|
|
168
215
|
if (body !== undefined && !headers.has("content-type") && typeof body === "string") {
|
|
169
216
|
headers.set("content-type", "application/json");
|
|
170
217
|
}
|
|
218
|
+
await applyAuthHeader(headers, opts);
|
|
171
219
|
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: undefined;
|
|
176
|
-
if (token && !headers.has("authorization")) {
|
|
177
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
178
|
-
}
|
|
220
|
+
const timeout = opts.timeout !== undefined ? AbortSignal.timeout(opts.timeout) : undefined;
|
|
221
|
+
const signal =
|
|
222
|
+
opts.signal && timeout ? AbortSignal.any([opts.signal, timeout]) : (opts.signal ?? timeout);
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
224
|
+
try {
|
|
225
|
+
return await fetchFn(url, {
|
|
226
|
+
method,
|
|
227
|
+
headers,
|
|
228
|
+
body: body as RequestInit["body"],
|
|
229
|
+
signal,
|
|
230
|
+
...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (isAbortError(err) && timeout?.aborted === true && opts.signal?.aborted !== true) {
|
|
234
|
+
throw new Error(err instanceof Error ? err.message : "timeout");
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
185
237
|
}
|
|
186
|
-
const signal =
|
|
187
|
-
signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
188
|
-
|
|
189
|
-
return await fetchFn(url, {
|
|
190
|
-
method,
|
|
191
|
-
headers,
|
|
192
|
-
body: body as RequestInit["body"],
|
|
193
|
-
signal,
|
|
194
|
-
...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
|
|
195
|
-
});
|
|
196
238
|
}
|
|
197
239
|
|
|
198
240
|
/**
|
|
@@ -236,58 +278,43 @@ function restRequest(
|
|
|
236
278
|
if (isRawBody(input)) {
|
|
237
279
|
return { url: `${base}${path}`, method: method.toUpperCase(), body: input };
|
|
238
280
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const query: string[] = [];
|
|
243
|
-
const rest: Record<string, unknown> = {};
|
|
244
|
-
|
|
245
|
-
for (const [k, v] of Object.entries(params)) {
|
|
246
|
-
const token = `:${k}`;
|
|
247
|
-
if (pathOut.includes(token)) {
|
|
248
|
-
pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
|
|
249
|
-
} else {
|
|
250
|
-
rest[k] = v;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
281
|
+
const interpolated = interpolatePath(path, input);
|
|
282
|
+
const pathOut = interpolated.path;
|
|
283
|
+
const rest = interpolated.rest;
|
|
253
284
|
|
|
254
285
|
const upper = method.toUpperCase();
|
|
286
|
+
const hasRest = Object.keys(rest).length > 0;
|
|
255
287
|
let body: ClientBodyInit | undefined;
|
|
288
|
+
let qs = "";
|
|
256
289
|
if (upper === "GET" || upper === "HEAD") {
|
|
257
|
-
|
|
258
|
-
if (v !== undefined) {
|
|
259
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
290
|
+
qs = toQuery(rest);
|
|
262
291
|
} else if (upper === "QUERY") {
|
|
263
292
|
// RFC 10008 QUERY always carries JSON content (empty object when only path params).
|
|
264
|
-
body = JSON.stringify(
|
|
265
|
-
} else if (
|
|
266
|
-
body = JSON.stringify(
|
|
293
|
+
body = JSON.stringify(hasRest ? rest : {});
|
|
294
|
+
} else if (hasRest || path === pathOut) {
|
|
295
|
+
body = JSON.stringify(hasRest ? rest : (input ?? {}));
|
|
267
296
|
}
|
|
268
297
|
|
|
269
|
-
const qs = query.length ? `?${query.join("&")}` : "";
|
|
270
298
|
return { url: `${base}${pathOut}${qs}`, method: upper, body };
|
|
271
299
|
}
|
|
272
300
|
|
|
273
301
|
function isRawBody(input: unknown): input is ClientBodyInit {
|
|
274
|
-
if (input
|
|
275
|
-
if (
|
|
276
|
-
if (
|
|
277
|
-
if (
|
|
278
|
-
if (
|
|
279
|
-
if (typeof ReadableStream !== "undefined" && input instanceof ReadableStream) return true;
|
|
302
|
+
if (input instanceof Blob) return true;
|
|
303
|
+
if (input instanceof FormData) return true;
|
|
304
|
+
if (input instanceof ArrayBuffer) return true;
|
|
305
|
+
if (ArrayBuffer.isView(input)) return true;
|
|
306
|
+
if (input instanceof ReadableStream) return true;
|
|
280
307
|
return false;
|
|
281
308
|
}
|
|
282
309
|
|
|
283
310
|
async function decode(res: Response): Promise<ClientEnvelope> {
|
|
284
311
|
if (res.status === 204) {
|
|
285
|
-
return { data: undefined, error: null };
|
|
312
|
+
return stampReplay({ data: undefined, error: null }, res);
|
|
286
313
|
}
|
|
287
314
|
|
|
288
315
|
const text = await res.text();
|
|
289
316
|
if (!text) {
|
|
290
|
-
if (res.ok) return { data: undefined, error: null };
|
|
317
|
+
if (res.ok) return stampReplay({ data: undefined, error: null }, res);
|
|
291
318
|
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
292
319
|
}
|
|
293
320
|
|
|
@@ -299,11 +326,11 @@ async function decode(res: Response): Promise<ClientEnvelope> {
|
|
|
299
326
|
}
|
|
300
327
|
|
|
301
328
|
if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
|
|
302
|
-
return json as ClientEnvelope;
|
|
329
|
+
return stampReplay(json as ClientEnvelope, res);
|
|
303
330
|
}
|
|
304
331
|
|
|
305
332
|
if (res.ok) {
|
|
306
|
-
return { data: json, error: null };
|
|
333
|
+
return stampReplay({ data: json, error: null }, res);
|
|
307
334
|
}
|
|
308
335
|
|
|
309
336
|
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
@@ -311,28 +338,69 @@ async function decode(res: Response): Promise<ClientEnvelope> {
|
|
|
311
338
|
|
|
312
339
|
async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promise<ClientEnvelope> {
|
|
313
340
|
if (!res.ok) {
|
|
314
|
-
const structured = await
|
|
341
|
+
const structured = await decodeIfEnvelope(res);
|
|
315
342
|
if (structured) return structured;
|
|
316
343
|
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
317
344
|
}
|
|
318
345
|
const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
|
|
319
|
-
return { data, error: null };
|
|
346
|
+
return stampReplay({ data, error: null }, res);
|
|
320
347
|
}
|
|
321
348
|
|
|
322
|
-
/**
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
349
|
+
/**
|
|
350
|
+
* Default `okid()` alphabet, inlined.
|
|
351
|
+
*
|
|
352
|
+
* Same 64-character Base64URL order as {@link OKID_ALPHABET}. Importing
|
|
353
|
+
* `okid` would pull `lazyRequire` and every alphabet constant into the client.
|
|
354
|
+
*/
|
|
355
|
+
export const IDEMPOTENCY_KEY_ALPHABET =
|
|
356
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 21-char key from `crypto.getRandomValues`.
|
|
360
|
+
*
|
|
361
|
+
* `getRandomValues` works on plain HTTP. When `crypto` is missing entirely,
|
|
362
|
+
* returns `undefined` so the call sends no key and runs once.
|
|
363
|
+
*/
|
|
364
|
+
export function mintIdempotencyKey(): string | undefined {
|
|
365
|
+
const webCrypto = globalThis.crypto;
|
|
366
|
+
if (webCrypto?.getRandomValues === undefined) return undefined;
|
|
367
|
+
const bytes = new Uint8Array(21);
|
|
368
|
+
webCrypto.getRandomValues(bytes);
|
|
369
|
+
let out = "";
|
|
370
|
+
for (let i = 0; i < 21; i++) out += IDEMPOTENCY_KEY_ALPHABET[bytes[i]! & 63]!;
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Key for this logical call, or `undefined` when none is sent.
|
|
376
|
+
*
|
|
377
|
+
* @param method - HTTP method, already uppercased
|
|
378
|
+
* @param opt - Per-call override
|
|
379
|
+
*/
|
|
380
|
+
function resolveIdempotencyKey(
|
|
381
|
+
method: string,
|
|
382
|
+
opt: string | false | undefined,
|
|
383
|
+
): string | undefined {
|
|
384
|
+
if (opt === false) return undefined;
|
|
385
|
+
if (typeof opt === "string") return opt;
|
|
386
|
+
if (method === "GET") return undefined;
|
|
387
|
+
return mintIdempotencyKey();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Copy `Idempotent-Replayed: true` onto result meta.
|
|
392
|
+
*
|
|
393
|
+
* @param envelope - Decoded envelope
|
|
394
|
+
* @param res - HTTP response
|
|
395
|
+
*/
|
|
396
|
+
function stampReplay(envelope: ClientEnvelope, res: Response): ClientEnvelope {
|
|
397
|
+
if (res.headers.get("idempotent-replayed") !== "true") return envelope;
|
|
398
|
+
const prev = envelope.meta;
|
|
399
|
+
return { ...envelope, meta: { ...prev, idempotentReplayed: true } };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function isAbortError(err: unknown): boolean {
|
|
403
|
+
return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
336
404
|
}
|
|
337
405
|
|
|
338
406
|
function isTransient(err: unknown): boolean {
|