okengine 0.20.0 → 0.21.1
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 +3 -7
- package/site/content/docs/client/calling.mdx +117 -29
- package/site/content/docs/client/index.mdx +3 -3
- package/site/content/docs/elements/flow/http.mdx +18 -15
- package/site/content/docs/elements/flow/index.mdx +26 -18
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/files.mdx +11 -5
- package/site/content/docs/elements/store/index.mdx +2 -3
- package/site/content/docs/elements/store/kv.mdx +8 -2
- package/site/content/docs/elements/store/sql.mdx +9 -6
- package/site/content/docs/elements/vault/index.mdx +11 -8
- package/site/content/docs/elements/vault/secrets.mdx +6 -3
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/recipes/rustfs.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +1 -1
- package/site/content/docs/reference/errors.mdx +199 -25
- package/site/content/docs/reference/fx.mdx +6 -1
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/site/content/docs/understand/try-it.mdx +758 -25
- package/src/cli/dev-app-runner.ts +2 -1
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +14 -27
- package/src/client/explain.test.ts +252 -0
- package/src/client/explain.ts +272 -0
- package/src/client/live.test.ts +44 -0
- package/src/client/live.ts +44 -101
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +26 -68
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +51 -112
- package/src/client/types.ts +17 -6
- package/src/client/wire.ts +119 -0
- package/src/client-react/live-resource.ts +6 -2
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/interpret.ts +45 -0
- package/src/compiler/response.ts +17 -27
- package/src/console/server/invoke-user-flow.test.ts +8 -2
- package/src/console/server/invoke-user-flow.ts +12 -18
- package/src/console/server/security.gate.test.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-Bgt9bq2r.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-B43CXZZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-92uM5MO7.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-DtGVPtcs.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DN7l6zmC.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-BmIR9FV8.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-BMHs-IzK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-BlAEVgNW.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BDf7OAHv.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-BKpaes3n.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-WnVLI-0j.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-B3GLNVfF.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-DMbnAe3f.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-KvFDingJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-C4NdNuxP.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-CBYl0LW_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/docker/docker.test.ts +3 -3
- package/src/docker/images-config.test.ts +4 -4
- package/src/docker/stack-id.test.ts +1 -1
- package/src/elements/store/files-errors.test.ts +149 -0
- package/src/elements/store/files-errors.ts +189 -0
- package/src/elements/store/kv-errors.test.ts +98 -0
- package/src/elements/store/kv-errors.ts +139 -0
- package/src/elements/store/resource.ts +11 -7
- package/src/elements/store/runtime.ts +18 -13
- package/src/elements/store/sql-errors.test.ts +197 -0
- package/src/elements/store/sql-errors.ts +294 -0
- package/src/elements/store/sql-session.test.ts +52 -0
- package/src/elements/store/sql-session.ts +26 -4
- package/src/elements/store/store-errors.ts +47 -0
- package/src/http.ts +9 -1
- package/src/i18n/catalogs/ar.ts +18 -0
- package/src/i18n/catalogs/en.ts +18 -0
- package/src/index.ts +9 -1
- package/src/kernel/app.ts +23 -4
- package/src/kernel/builtin-errors.test.ts +117 -0
- package/src/kernel/builtin-errors.ts +129 -0
- package/src/kernel/call.test.ts +182 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +23 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +184 -159
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +49 -335
- package/src/kernel/index.ts +12 -1
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/release/build-lib.ts +9 -0
- package/src/runtime/dev-request-log.ts +29 -11
- package/src/term.test.ts +76 -0
- package/src/term.ts +166 -3
package/src/client/sse.ts
CHANGED
|
@@ -4,33 +4,30 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
export
|
|
7
|
+
/** One parsed SSE data frame. */
|
|
8
|
+
export interface ParsedSseFrame {
|
|
9
|
+
readonly event: unknown;
|
|
10
|
+
readonly id: string | undefined;
|
|
11
|
+
}
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
*
|
|
14
|
+
* Shared frame pump. `[DONE]` and abort end the iterator without throwing.
|
|
12
15
|
*
|
|
13
|
-
* @param res - Fetch response
|
|
14
|
-
* @param onEvent - Frame handler
|
|
16
|
+
* @param res - Fetch response
|
|
15
17
|
* @param signal - Abort signal
|
|
16
18
|
* @param onOpen - Called after content-type validation
|
|
17
19
|
*/
|
|
18
|
-
export async function
|
|
20
|
+
export async function* iterateSseFrames(
|
|
19
21
|
res: Response,
|
|
20
|
-
onEvent: SseFrameHandler,
|
|
21
22
|
signal: AbortSignal,
|
|
22
23
|
onOpen?: () => void,
|
|
23
|
-
):
|
|
24
|
+
): AsyncGenerator<ParsedSseFrame> {
|
|
24
25
|
if (signal.aborted) return;
|
|
25
26
|
const ct = res.headers.get("content-type") ?? "";
|
|
26
|
-
if (!res.ok || !res.body) {
|
|
27
|
+
if (!res.ok || !res.body || !ct.includes("text/event-stream")) {
|
|
27
28
|
const text = await res.text().catch(() => "");
|
|
28
29
|
throw sseError(res.status, text);
|
|
29
30
|
}
|
|
30
|
-
if (!ct.includes("text/event-stream")) {
|
|
31
|
-
const text = await res.text().catch(() => "");
|
|
32
|
-
throw sseError(res.status, text || `Expected text/event-stream, got ${ct || "none"}`);
|
|
33
|
-
}
|
|
34
31
|
onOpen?.();
|
|
35
32
|
const reader = res.body.getReader();
|
|
36
33
|
const dec = new TextDecoder();
|
|
@@ -39,14 +36,15 @@ export async function readSse(
|
|
|
39
36
|
for (;;) {
|
|
40
37
|
if (signal.aborted) return;
|
|
41
38
|
const { done, value } = await reader.read();
|
|
42
|
-
if (done)
|
|
39
|
+
if (done) return;
|
|
43
40
|
buf += dec.decode(value, { stream: true });
|
|
44
41
|
let sep = buf.indexOf("\n\n");
|
|
45
42
|
while (sep >= 0) {
|
|
46
43
|
const raw = buf.slice(0, sep);
|
|
47
44
|
buf = buf.slice(sep + 2);
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
45
|
+
const frame = parseFrame(raw);
|
|
46
|
+
if (frame === "done" || signal.aborted) return;
|
|
47
|
+
if (frame) yield frame;
|
|
50
48
|
sep = buf.indexOf("\n\n");
|
|
51
49
|
}
|
|
52
50
|
}
|
|
@@ -56,65 +54,21 @@ export async function readSse(
|
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
/**
|
|
59
|
-
*
|
|
57
|
+
* Parse one SSE block. `"done"` stops the pump; `null` skips empty blocks.
|
|
60
58
|
*
|
|
61
|
-
* @param
|
|
62
|
-
* @param signal - Abort signal
|
|
59
|
+
* @param raw - Text between blank lines
|
|
63
60
|
*/
|
|
64
|
-
|
|
65
|
-
const queue: unknown[] = [];
|
|
66
|
-
let wake: (() => void) | undefined;
|
|
67
|
-
let done = false;
|
|
68
|
-
let err: unknown;
|
|
69
|
-
|
|
70
|
-
const pump = readSse(
|
|
71
|
-
res,
|
|
72
|
-
(event) => {
|
|
73
|
-
queue.push(event);
|
|
74
|
-
wake?.();
|
|
75
|
-
},
|
|
76
|
-
signal,
|
|
77
|
-
)
|
|
78
|
-
.then(() => {
|
|
79
|
-
done = true;
|
|
80
|
-
wake?.();
|
|
81
|
-
})
|
|
82
|
-
.catch((e) => {
|
|
83
|
-
err = e;
|
|
84
|
-
done = true;
|
|
85
|
-
wake?.();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
for (;;) {
|
|
90
|
-
while (queue.length > 0) {
|
|
91
|
-
yield queue.shift();
|
|
92
|
-
}
|
|
93
|
-
if (done) break;
|
|
94
|
-
await new Promise<void>((r) => {
|
|
95
|
-
wake = r;
|
|
96
|
-
});
|
|
97
|
-
wake = undefined;
|
|
98
|
-
}
|
|
99
|
-
if (err) throw err;
|
|
100
|
-
} finally {
|
|
101
|
-
await pump.catch(() => {});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function dispatchFrame(raw: string, onEvent: SseFrameHandler, signal: AbortSignal): boolean {
|
|
61
|
+
function parseFrame(raw: string): ParsedSseFrame | "done" | null {
|
|
106
62
|
const dataLines: string[] = [];
|
|
107
63
|
let id: string | undefined;
|
|
108
64
|
for (const line of raw.split("\n")) {
|
|
109
65
|
if (line.startsWith("id:")) id = line.slice(3).replace(/^ /, "");
|
|
110
66
|
if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
111
67
|
}
|
|
112
|
-
if (dataLines.length === 0) return
|
|
68
|
+
if (dataLines.length === 0) return null;
|
|
113
69
|
const data = dataLines.join("\n");
|
|
114
|
-
if (data === "[DONE]") return
|
|
115
|
-
|
|
116
|
-
onEvent(JSON.parse(data) as unknown, id);
|
|
117
|
-
return false;
|
|
70
|
+
if (data === "[DONE]") return "done";
|
|
71
|
+
return { event: JSON.parse(data) as unknown, id };
|
|
118
72
|
}
|
|
119
73
|
|
|
120
74
|
/** Build an Error with optional HTTP status. */
|
|
@@ -124,8 +78,12 @@ export function sseError(status: number, body: string): Error {
|
|
|
124
78
|
try {
|
|
125
79
|
const json: unknown = JSON.parse(body);
|
|
126
80
|
if (json !== null && typeof json === "object" && "error" in json) {
|
|
127
|
-
const err = (
|
|
128
|
-
|
|
81
|
+
const err = (
|
|
82
|
+
json as {
|
|
83
|
+
error?: { code?: string; message?: string; data?: { message?: string } };
|
|
84
|
+
}
|
|
85
|
+
).error;
|
|
86
|
+
message = err?.message ?? err?.data?.message ?? err?.code ?? message;
|
|
129
87
|
}
|
|
130
88
|
} catch {
|
|
131
89
|
message = body.slice(0, 200);
|
package/src/client/stream.ts
CHANGED
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import type { ClientFetch,
|
|
7
|
+
import { iterateSseFrames, sseError } from "./sse.ts";
|
|
8
|
+
import type { ClientFetch, ClientOptions, ClientRouteMap } from "./types.ts";
|
|
9
|
+
import {
|
|
10
|
+
applyAuthHeader,
|
|
11
|
+
applyHeaderBag,
|
|
12
|
+
interpolatePath,
|
|
13
|
+
methodAndPath,
|
|
14
|
+
resolveHeaders,
|
|
15
|
+
toQuery,
|
|
16
|
+
walkContracts,
|
|
17
|
+
} from "./wire.ts";
|
|
9
18
|
|
|
10
19
|
/** Flow id → REST route for stream-only (non-live) SSE. */
|
|
11
20
|
export type StreamByFlow = Readonly<
|
|
@@ -19,20 +28,12 @@ export type StreamByFlow = Readonly<
|
|
|
19
28
|
*/
|
|
20
29
|
export function flattenStreamRoutes($routes: ClientRouteMap | undefined): StreamByFlow {
|
|
21
30
|
const out: Record<string, { readonly method: string; readonly path: string }> = {};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if ("live" in contract && typeof contract.live === "string") continue;
|
|
29
|
-
const method = "method" in contract ? contract.method : undefined;
|
|
30
|
-
const path = "path" in contract ? contract.path : undefined;
|
|
31
|
-
if (typeof method === "string" && typeof path === "string") {
|
|
32
|
-
out[`${unit}.${flow}`] = { method, path };
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
31
|
+
walkContracts($routes, (unit, flow, contract) => {
|
|
32
|
+
if (!("stream" in contract) || contract.stream !== true) return;
|
|
33
|
+
if ("live" in contract && typeof contract.live === "string") return;
|
|
34
|
+
const route = methodAndPath(contract);
|
|
35
|
+
if (route) out[`${unit}.${flow}`] = route;
|
|
36
|
+
});
|
|
36
37
|
return out;
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -51,36 +52,21 @@ export async function* openStream(
|
|
|
51
52
|
opts: ClientOptions,
|
|
52
53
|
): AsyncGenerator<unknown> {
|
|
53
54
|
const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
55
|
+
const signal = opts.signal ?? new AbortController().signal;
|
|
54
56
|
let refreshed = false;
|
|
55
57
|
for (;;) {
|
|
56
|
-
const ctrl = new AbortController();
|
|
57
|
-
const parent = opts.signal;
|
|
58
|
-
if (parent) {
|
|
59
|
-
if (parent.aborted) {
|
|
60
|
-
ctrl.abort();
|
|
61
|
-
} else {
|
|
62
|
-
parent.addEventListener("abort", () => ctrl.abort(), { once: true });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
58
|
const { url, method, body } = streamRequest(base, route.method, route.path, input);
|
|
66
59
|
const headers = new Headers({ accept: "text/event-stream" });
|
|
67
|
-
|
|
68
|
-
applyHeaders(headers, extra);
|
|
60
|
+
applyHeaderBag(headers, await resolveHeaders(opts));
|
|
69
61
|
if (body !== undefined && !headers.has("content-type")) {
|
|
70
62
|
headers.set("content-type", "application/json");
|
|
71
63
|
}
|
|
72
|
-
|
|
73
|
-
opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
|
|
74
|
-
? await opts.auth.getToken()
|
|
75
|
-
: undefined;
|
|
76
|
-
if (token && !headers.has("authorization")) {
|
|
77
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
78
|
-
}
|
|
64
|
+
await applyAuthHeader(headers, opts);
|
|
79
65
|
const res = await fetchFn(url, {
|
|
80
66
|
method,
|
|
81
67
|
headers,
|
|
82
68
|
body,
|
|
83
|
-
signal
|
|
69
|
+
signal,
|
|
84
70
|
...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
|
|
85
71
|
});
|
|
86
72
|
if (
|
|
@@ -98,7 +84,7 @@ export async function* openStream(
|
|
|
98
84
|
const text = await res.text().catch(() => "");
|
|
99
85
|
throw sseError(res.status, text);
|
|
100
86
|
}
|
|
101
|
-
|
|
87
|
+
for await (const frame of iterateSseFrames(res, signal)) yield frame.event;
|
|
102
88
|
return;
|
|
103
89
|
}
|
|
104
90
|
}
|
|
@@ -109,39 +95,11 @@ function streamRequest(
|
|
|
109
95
|
path: string,
|
|
110
96
|
input: unknown,
|
|
111
97
|
): { url: string; method: string; body: string | undefined } {
|
|
112
|
-
const
|
|
113
|
-
input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
|
|
114
|
-
let pathOut = path;
|
|
115
|
-
const rest: Record<string, unknown> = {};
|
|
116
|
-
for (const [k, v] of Object.entries(params)) {
|
|
117
|
-
const token = `:${k}`;
|
|
118
|
-
if (pathOut.includes(token)) {
|
|
119
|
-
pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
|
|
120
|
-
} else {
|
|
121
|
-
rest[k] = v;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
98
|
+
const { path: pathOut, rest } = interpolatePath(path, input);
|
|
124
99
|
const upper = method.toUpperCase();
|
|
125
|
-
let body: string | undefined;
|
|
126
100
|
if (upper === "GET" || upper === "HEAD") {
|
|
127
|
-
|
|
128
|
-
for (const [k, v] of Object.entries(rest)) {
|
|
129
|
-
if (v !== undefined) {
|
|
130
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const qs = query.length ? `?${query.join("&")}` : "";
|
|
134
|
-
return { url: `${base}${pathOut}${qs}`, method: upper, body: undefined };
|
|
101
|
+
return { url: `${base}${pathOut}${toQuery(rest)}`, method: upper, body: undefined };
|
|
135
102
|
}
|
|
136
|
-
body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
|
|
103
|
+
const body = JSON.stringify(Object.keys(rest).length > 0 ? rest : (input ?? {}));
|
|
137
104
|
return { url: `${base}${pathOut}`, method: upper, body };
|
|
138
105
|
}
|
|
139
|
-
|
|
140
|
-
function applyHeaders(headers: Headers, extra: ClientHeaders | undefined): void {
|
|
141
|
-
if (!extra) return;
|
|
142
|
-
if (Array.isArray(extra)) {
|
|
143
|
-
for (const [k, v] of extra) headers.set(k, v);
|
|
144
|
-
} else {
|
|
145
|
-
for (const [k, v] of Object.entries(extra)) headers.set(k, v);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
@@ -46,6 +46,11 @@ describe("transport — retry", () => {
|
|
|
46
46
|
const { error } = await api.sys.ping();
|
|
47
47
|
expect(error?.code).toBe("TransportError");
|
|
48
48
|
expect(n).toBe(2);
|
|
49
|
+
if (error?.code === "TransportError") {
|
|
50
|
+
expect(typeof error.message).toBe("string");
|
|
51
|
+
expect(error.message.length).toBeGreaterThan(0);
|
|
52
|
+
expect(error.message).toBe(error.data.message);
|
|
53
|
+
}
|
|
49
54
|
});
|
|
50
55
|
|
|
51
56
|
test("structured 5xx envelope is returned (not TransportError)", async () => {
|
|
@@ -139,6 +144,68 @@ describe("transport — auth refresh", () => {
|
|
|
139
144
|
expect(error?.code).toBe("TransportError");
|
|
140
145
|
if (error?.code === "TransportError") {
|
|
141
146
|
expect(error.data.status).toBe(401);
|
|
147
|
+
expect(error.message).toBe(error.data.message);
|
|
148
|
+
expect(error.message).toBe("Invalid JSON (401)");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("transport — TransportError message contract", () => {
|
|
154
|
+
test("empty error body populates matching message fields", async () => {
|
|
155
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
156
|
+
fetch: async () => new Response("", { status: 404 }),
|
|
157
|
+
});
|
|
158
|
+
const { error } = await api.sys.ping();
|
|
159
|
+
expect(error?.code).toBe("TransportError");
|
|
160
|
+
if (error?.code === "TransportError") {
|
|
161
|
+
expect(error.message).toBe("HTTP 404");
|
|
162
|
+
expect(error.message).toBe(error.data.message);
|
|
163
|
+
expect(error.data.status).toBe(404);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("malformed JSON populates matching message fields", async () => {
|
|
168
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
169
|
+
fetch: async () =>
|
|
170
|
+
new Response("not-json", {
|
|
171
|
+
status: 400,
|
|
172
|
+
headers: { "content-type": "application/json" },
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
const { error } = await api.sys.ping();
|
|
176
|
+
expect(error?.code).toBe("TransportError");
|
|
177
|
+
if (error?.code === "TransportError") {
|
|
178
|
+
expect(error.message).toBe("Invalid JSON (400)");
|
|
179
|
+
expect(error.message).toBe(error.data.message);
|
|
180
|
+
expect(error.data.status).toBe(400);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("incomplete proxy path populates matching message fields", async () => {
|
|
185
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
186
|
+
fetch: async () => Response.json({ data: { ok: true }, error: null }),
|
|
187
|
+
});
|
|
188
|
+
// path stops at unit — api.sys() is incomplete
|
|
189
|
+
const result = await (
|
|
190
|
+
api.sys as unknown as () => Promise<{
|
|
191
|
+
error: { code: string; message: string; data: { message: string } };
|
|
192
|
+
}>
|
|
193
|
+
)();
|
|
194
|
+
expect(result.error.code).toBe("TransportError");
|
|
195
|
+
expect(result.error.message).toMatch(/Incomplete path/);
|
|
196
|
+
expect(result.error.message).toBe(result.error.data.message);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("binary error path populates matching message fields", async () => {
|
|
200
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
201
|
+
fetch: async () => new Response("nope", { status: 403 }),
|
|
202
|
+
});
|
|
203
|
+
const { error } = await api.sys.ping({ response: "blob" });
|
|
204
|
+
expect(error?.code).toBe("TransportError");
|
|
205
|
+
if (error?.code === "TransportError") {
|
|
206
|
+
expect(error.message).toBe("HTTP 403");
|
|
207
|
+
expect(error.message).toBe(error.data.message);
|
|
208
|
+
expect(error.data.status).toBe(403);
|
|
142
209
|
}
|
|
143
210
|
});
|
|
144
211
|
});
|
package/src/client/transport.ts
CHANGED
|
@@ -12,6 +12,13 @@ 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
23
|
/** Per-call transport options (binary decode, abort). */
|
|
17
24
|
export interface TransportCallOptions {
|
|
@@ -83,15 +90,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
83
90
|
} catch (err) {
|
|
84
91
|
const transient = isTransient(err);
|
|
85
92
|
if (!transient || attempt >= retries) {
|
|
86
|
-
return
|
|
87
|
-
data: null,
|
|
88
|
-
error: {
|
|
89
|
-
code: "TransportError",
|
|
90
|
-
data: {
|
|
91
|
-
message: err instanceof Error ? err.message : String(err),
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
};
|
|
93
|
+
return transportEnvelope(err instanceof Error ? err.message : String(err));
|
|
95
94
|
}
|
|
96
95
|
await sleep(delay);
|
|
97
96
|
delay *= backoff;
|
|
@@ -102,6 +101,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
|
|
|
102
101
|
};
|
|
103
102
|
}
|
|
104
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Build a {@link ClientEnvelope} for a transport / protocol failure.
|
|
106
|
+
*
|
|
107
|
+
* Guarantees `error.message === error.data.message`.
|
|
108
|
+
*
|
|
109
|
+
* @param message - Human-readable failure text
|
|
110
|
+
* @param status - Optional HTTP status
|
|
111
|
+
*/
|
|
112
|
+
export function transportEnvelope(message: string, status?: number): ClientEnvelope {
|
|
113
|
+
return {
|
|
114
|
+
data: null,
|
|
115
|
+
error: {
|
|
116
|
+
code: "TransportError",
|
|
117
|
+
message,
|
|
118
|
+
data: status !== undefined ? { message, status } : { message },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
function normalizeCallOpts(
|
|
106
124
|
headersOrOpts: ClientHeaders | TransportCallOptions | undefined,
|
|
107
125
|
): TransportCallOptions {
|
|
@@ -143,37 +161,16 @@ async function once(
|
|
|
143
161
|
: rpcRequest(base, key, input);
|
|
144
162
|
|
|
145
163
|
const headers = new Headers();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for (const [k, v] of extra) headers.set(k, v);
|
|
149
|
-
} else if (extra) {
|
|
150
|
-
for (const [k, v] of Object.entries(extra)) headers.set(k, v);
|
|
151
|
-
}
|
|
152
|
-
if (Array.isArray(callHeaders)) {
|
|
153
|
-
for (const [k, v] of callHeaders) headers.set(k, v);
|
|
154
|
-
} else if (callHeaders) {
|
|
155
|
-
for (const [k, v] of Object.entries(callHeaders)) headers.set(k, v);
|
|
156
|
-
}
|
|
164
|
+
applyHeaderBag(headers, await resolveHeaders(opts));
|
|
165
|
+
applyHeaderBag(headers, callHeaders);
|
|
157
166
|
if (body !== undefined && !headers.has("content-type") && typeof body === "string") {
|
|
158
167
|
headers.set("content-type", "application/json");
|
|
159
168
|
}
|
|
169
|
+
await applyAuthHeader(headers, opts);
|
|
160
170
|
|
|
161
|
-
const
|
|
162
|
-
opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
|
|
163
|
-
? await opts.auth.getToken()
|
|
164
|
-
: undefined;
|
|
165
|
-
if (token && !headers.has("authorization")) {
|
|
166
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const signals: AbortSignal[] = [];
|
|
170
|
-
if (opts.signal) signals.push(opts.signal);
|
|
171
|
-
if (opts.timeout !== undefined) {
|
|
172
|
-
const t = AbortSignal.timeout(opts.timeout);
|
|
173
|
-
signals.push(t);
|
|
174
|
-
}
|
|
171
|
+
const timeout = opts.timeout !== undefined ? AbortSignal.timeout(opts.timeout) : undefined;
|
|
175
172
|
const signal =
|
|
176
|
-
|
|
173
|
+
opts.signal && timeout ? AbortSignal.any([opts.signal, timeout]) : (opts.signal ?? timeout);
|
|
177
174
|
|
|
178
175
|
return await fetchFn(url, {
|
|
179
176
|
method,
|
|
@@ -225,47 +222,32 @@ function restRequest(
|
|
|
225
222
|
if (isRawBody(input)) {
|
|
226
223
|
return { url: `${base}${path}`, method: method.toUpperCase(), body: input };
|
|
227
224
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const query: string[] = [];
|
|
232
|
-
const rest: Record<string, unknown> = {};
|
|
233
|
-
|
|
234
|
-
for (const [k, v] of Object.entries(params)) {
|
|
235
|
-
const token = `:${k}`;
|
|
236
|
-
if (pathOut.includes(token)) {
|
|
237
|
-
pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
|
|
238
|
-
} else {
|
|
239
|
-
rest[k] = v;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
225
|
+
const interpolated = interpolatePath(path, input);
|
|
226
|
+
const pathOut = interpolated.path;
|
|
227
|
+
const rest = interpolated.rest;
|
|
242
228
|
|
|
243
229
|
const upper = method.toUpperCase();
|
|
230
|
+
const hasRest = Object.keys(rest).length > 0;
|
|
244
231
|
let body: ClientBodyInit | undefined;
|
|
232
|
+
let qs = "";
|
|
245
233
|
if (upper === "GET" || upper === "HEAD") {
|
|
246
|
-
|
|
247
|
-
if (v !== undefined) {
|
|
248
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
234
|
+
qs = toQuery(rest);
|
|
251
235
|
} else if (upper === "QUERY") {
|
|
252
236
|
// RFC 10008 QUERY always carries JSON content (empty object when only path params).
|
|
253
|
-
body = JSON.stringify(
|
|
254
|
-
} else if (
|
|
255
|
-
body = JSON.stringify(
|
|
237
|
+
body = JSON.stringify(hasRest ? rest : {});
|
|
238
|
+
} else if (hasRest || path === pathOut) {
|
|
239
|
+
body = JSON.stringify(hasRest ? rest : (input ?? {}));
|
|
256
240
|
}
|
|
257
241
|
|
|
258
|
-
const qs = query.length ? `?${query.join("&")}` : "";
|
|
259
242
|
return { url: `${base}${pathOut}${qs}`, method: upper, body };
|
|
260
243
|
}
|
|
261
244
|
|
|
262
245
|
function isRawBody(input: unknown): input is ClientBodyInit {
|
|
263
|
-
if (input
|
|
264
|
-
if (
|
|
265
|
-
if (
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
268
|
-
if (typeof ReadableStream !== "undefined" && input instanceof ReadableStream) return true;
|
|
246
|
+
if (input instanceof Blob) return true;
|
|
247
|
+
if (input instanceof FormData) return true;
|
|
248
|
+
if (input instanceof ArrayBuffer) return true;
|
|
249
|
+
if (ArrayBuffer.isView(input)) return true;
|
|
250
|
+
if (input instanceof ReadableStream) return true;
|
|
269
251
|
return false;
|
|
270
252
|
}
|
|
271
253
|
|
|
@@ -277,29 +259,14 @@ async function decode(res: Response): Promise<ClientEnvelope> {
|
|
|
277
259
|
const text = await res.text();
|
|
278
260
|
if (!text) {
|
|
279
261
|
if (res.ok) return { data: undefined, error: null };
|
|
280
|
-
return {
|
|
281
|
-
data: null,
|
|
282
|
-
error: {
|
|
283
|
-
code: "TransportError",
|
|
284
|
-
data: { message: `HTTP ${res.status}`, status: res.status },
|
|
285
|
-
},
|
|
286
|
-
};
|
|
262
|
+
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
287
263
|
}
|
|
288
264
|
|
|
289
265
|
let json: unknown;
|
|
290
266
|
try {
|
|
291
267
|
json = JSON.parse(text);
|
|
292
268
|
} catch {
|
|
293
|
-
return {
|
|
294
|
-
data: null,
|
|
295
|
-
error: {
|
|
296
|
-
code: "TransportError",
|
|
297
|
-
data: {
|
|
298
|
-
message: `Invalid JSON (${res.status})`,
|
|
299
|
-
status: res.status,
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
};
|
|
269
|
+
return transportEnvelope(`Invalid JSON (${res.status})`, res.status);
|
|
303
270
|
}
|
|
304
271
|
|
|
305
272
|
if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
|
|
@@ -310,47 +277,19 @@ async function decode(res: Response): Promise<ClientEnvelope> {
|
|
|
310
277
|
return { data: json, error: null };
|
|
311
278
|
}
|
|
312
279
|
|
|
313
|
-
return {
|
|
314
|
-
data: null,
|
|
315
|
-
error: {
|
|
316
|
-
code: "TransportError",
|
|
317
|
-
data: { message: `HTTP ${res.status}`, status: res.status },
|
|
318
|
-
},
|
|
319
|
-
};
|
|
280
|
+
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
320
281
|
}
|
|
321
282
|
|
|
322
283
|
async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promise<ClientEnvelope> {
|
|
323
284
|
if (!res.ok) {
|
|
324
|
-
const structured = await
|
|
285
|
+
const structured = await decodeIfEnvelope(res);
|
|
325
286
|
if (structured) return structured;
|
|
326
|
-
return {
|
|
327
|
-
data: null,
|
|
328
|
-
error: {
|
|
329
|
-
code: "TransportError",
|
|
330
|
-
data: { message: `HTTP ${res.status}`, status: res.status },
|
|
331
|
-
},
|
|
332
|
-
};
|
|
287
|
+
return transportEnvelope(`HTTP ${res.status}`, res.status);
|
|
333
288
|
}
|
|
334
289
|
const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
|
|
335
290
|
return { data, error: null };
|
|
336
291
|
}
|
|
337
292
|
|
|
338
|
-
/** Try JSON envelope from an error response without assuming the body is reusable. */
|
|
339
|
-
async function decodeIfEnvelopeClone(res: Response): Promise<ClientEnvelope | null> {
|
|
340
|
-
const text = await res.text();
|
|
341
|
-
if (!text) return null;
|
|
342
|
-
let json: unknown;
|
|
343
|
-
try {
|
|
344
|
-
json = JSON.parse(text);
|
|
345
|
-
} catch {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
|
|
349
|
-
return json as ClientEnvelope;
|
|
350
|
-
}
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
293
|
function isTransient(err: unknown): boolean {
|
|
355
294
|
if (!(err instanceof Error)) return false;
|
|
356
295
|
if (err.name === "AbortError") return false;
|
package/src/client/types.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Local / separate-repo: augment {@link Register} so `createClient(url)` needs no import.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { BuiltinErrorMap } from "../kernel/builtin-errors.ts";
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* One flow's client contract. `in` / `out` / `errors` are phantom (type-only).
|
|
10
12
|
*
|
|
@@ -125,7 +127,8 @@ export interface TransportError {
|
|
|
125
127
|
readonly message: string;
|
|
126
128
|
readonly status?: number;
|
|
127
129
|
};
|
|
128
|
-
|
|
130
|
+
/** Same string as {@link TransportError.data.message}. */
|
|
131
|
+
readonly message: string;
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** Next / previous list request — TanStack `pageParam` / URL bag. */
|
|
@@ -431,12 +434,20 @@ type ContractIn<C> = "in" extends keyof C
|
|
|
431
434
|
/** Pull output from a contract shape. */
|
|
432
435
|
type ContractOut<C> = "out" extends keyof C ? NonNullable<C["out"]> : unknown;
|
|
433
436
|
|
|
434
|
-
/**
|
|
437
|
+
/**
|
|
438
|
+
* Pull error map from a contract shape. Built-in codes are always on; declared
|
|
439
|
+
* keys win. A missing `errors` bag (phantom `Record<string, unknown>`) is
|
|
440
|
+
* treated as unspecified — not as an index signature that wipes the builtins.
|
|
441
|
+
*/
|
|
435
442
|
type ContractErrors<C> = "errors" extends keyof C
|
|
436
|
-
? NonNullable<C["errors"]> extends
|
|
437
|
-
?
|
|
438
|
-
|
|
439
|
-
|
|
443
|
+
? NonNullable<C["errors"]> extends infer E
|
|
444
|
+
? E extends Record<string, unknown>
|
|
445
|
+
? string extends keyof E
|
|
446
|
+
? BuiltinErrorMap
|
|
447
|
+
: Omit<BuiltinErrorMap, keyof E> & E
|
|
448
|
+
: BuiltinErrorMap
|
|
449
|
+
: BuiltinErrorMap
|
|
450
|
+
: BuiltinErrorMap;
|
|
440
451
|
|
|
441
452
|
/**
|
|
442
453
|
* Typed client proxy derived from a route map.
|