@nifrajs/client 1.12.0 → 2.0.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/dist/client.d.ts +48 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +96 -19
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/result.d.ts +34 -10
- package/dist/result.d.ts.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/treaty.d.ts +48 -3
- package/dist/treaty.d.ts.map +1 -1
- package/dist/validate-responses.d.ts +61 -0
- package/dist/validate-responses.d.ts.map +1 -0
- package/dist/validate-responses.js +82 -0
- package/dist/validate-responses.js.map +1 -0
- package/dist/ws.d.ts +16 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +133 -0
- package/dist/ws.js.map +1 -0
- package/package.json +2 -2
- package/src/client.ts +153 -19
- package/src/index.ts +5 -0
- package/src/result.ts +91 -13
- package/src/routes.ts +1 -1
- package/src/treaty.ts +51 -3
- package/src/validate-responses.ts +121 -0
- package/src/ws.ts +154 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime response-contract assertion for the in-process test client
|
|
3
|
+
* (`testClient(app, { validateResponses: true })`).
|
|
4
|
+
*
|
|
5
|
+
* The `response` / `errors` schemas on a route are a compile-time contract on the handler; nothing
|
|
6
|
+
* re-checks the bytes that actually leave the app. This wrapper closes that gap in tests: every
|
|
7
|
+
* JSON response is validated against the route's declared schema for its status - `response` for
|
|
8
|
+
* 2xx, `errors[status]` for declared failures - and a mismatch THROWS (failing the test loudly)
|
|
9
|
+
* instead of letting a drifted payload pass. Responses with no declared schema for their status,
|
|
10
|
+
* non-JSON bodies (SSE, streams, HTML), and 204/205/HEAD are passed through untouched.
|
|
11
|
+
*
|
|
12
|
+
* Route lookup reuses the framework's own `Router` over `app.routes()`, so pattern matching can
|
|
13
|
+
* never drift from what the server itself does.
|
|
14
|
+
*/
|
|
15
|
+
import { Router } from "@nifrajs/core/router";
|
|
16
|
+
/**
|
|
17
|
+
* A response body that broke its route's declared contract. Thrown THROUGH the "never throws"
|
|
18
|
+
* client on purpose: this is a test assertion about the server's honesty, not a call outcome the
|
|
19
|
+
* caller should branch on - swallowing it into a `Result` would let the drift pass the test.
|
|
20
|
+
*/
|
|
21
|
+
export class ResponseContractViolation extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ResponseContractViolation";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function formatIssues(issues) {
|
|
28
|
+
return issues
|
|
29
|
+
.map((issue) => {
|
|
30
|
+
const path = (issue.path ?? [])
|
|
31
|
+
.map((seg) => String(typeof seg === "object" && seg !== null ? seg.key : seg))
|
|
32
|
+
.join(".");
|
|
33
|
+
return path === "" ? issue.message : `${path}: ${issue.message}`;
|
|
34
|
+
})
|
|
35
|
+
.join("; ");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wrap the in-process bridge so every response is checked against its route's declared contract.
|
|
39
|
+
* Throws at wrap time when the app cannot enumerate routes - a misconfiguration, not a soft skip.
|
|
40
|
+
*/
|
|
41
|
+
export function withResponseValidation(app, bridge) {
|
|
42
|
+
const routesOf = app.routes;
|
|
43
|
+
if (typeof routesOf !== "function") {
|
|
44
|
+
throw new Error("validateResponses needs an app that exposes .routes() (a nifra server); this app does not");
|
|
45
|
+
}
|
|
46
|
+
// One router per client, built once: match by the server's own engine, payload = the schema.
|
|
47
|
+
const router = new Router();
|
|
48
|
+
for (const route of routesOf.call(app)) {
|
|
49
|
+
router.add(route.method, route.path, route.schema);
|
|
50
|
+
}
|
|
51
|
+
return async (url, init) => {
|
|
52
|
+
const response = await bridge(url, init);
|
|
53
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
54
|
+
const status = response.status;
|
|
55
|
+
if (method === "HEAD" || status === 204 || status === 205)
|
|
56
|
+
return response;
|
|
57
|
+
const match = router.find(method, new URL(url).pathname);
|
|
58
|
+
if (!match.found)
|
|
59
|
+
return response;
|
|
60
|
+
const schema = match.payload;
|
|
61
|
+
const declared = status >= 200 && status < 300 ? schema?.response : schema?.errors?.[status];
|
|
62
|
+
if (declared === undefined)
|
|
63
|
+
return response;
|
|
64
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
65
|
+
if (!contentType.includes("application/json"))
|
|
66
|
+
return response;
|
|
67
|
+
// Clone so the caller still gets an unconsumed body; unparseable JSON is the transport's problem.
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await response.clone().json();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
const result = await declared["~standard"].validate(body);
|
|
76
|
+
if (result.issues !== undefined) {
|
|
77
|
+
throw new ResponseContractViolation(`response contract violation: ${method} ${new URL(url).pathname} → ${status}: ${formatIssues(result.issues)}`);
|
|
78
|
+
}
|
|
79
|
+
return response;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=validate-responses.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-responses.js","sourceRoot":"","sources":["../src/validate-responses.ts"],"names":[],"mappings":"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"}
|
package/dist/ws.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed client's WebSocket transport - the runtime behind `api.chat.ws()` for routes declared
|
|
3
|
+
* with `app.ws()`. Uses the platform's global `WebSocket` (browser, Bun, Node 22+, Deno, workerd),
|
|
4
|
+
* mapping the client's `http(s)` base to `ws(s)`. Frames are JSON on the typed surface: `send`
|
|
5
|
+
* encodes, `messages()`/`onMessage` parse text frames (binary frames bypass the typed contract -
|
|
6
|
+
* read them off `raw`). Sends before the socket opens are queued, so the handle never throws.
|
|
7
|
+
*/
|
|
8
|
+
import type { WsCallOptions, WsHandle } from "./treaty.js";
|
|
9
|
+
/** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
|
|
10
|
+
export declare const NO_SOCKET: unique symbol;
|
|
11
|
+
interface WsRuntimeOptions extends WsCallOptions {
|
|
12
|
+
headers?: Record<string, string> | undefined;
|
|
13
|
+
}
|
|
14
|
+
export declare function openWebSocket(base: string, path: string, callOptions: WsRuntimeOptions | undefined, noSocket: boolean): WsHandle<unknown, unknown>;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=ws.d.ts.map
|
package/dist/ws.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws.d.ts","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE1D,kGAAkG;AAClG,eAAO,MAAM,SAAS,eAA0C,CAAA;AAEhE,UAAU,gBAAiB,SAAQ,aAAa;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAA;CAC7C;AAWD,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,gBAAgB,GAAG,SAAS,EACzC,QAAQ,EAAE,OAAO,GAChB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CA0H5B"}
|
package/dist/ws.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed client's WebSocket transport - the runtime behind `api.chat.ws()` for routes declared
|
|
3
|
+
* with `app.ws()`. Uses the platform's global `WebSocket` (browser, Bun, Node 22+, Deno, workerd),
|
|
4
|
+
* mapping the client's `http(s)` base to `ws(s)`. Frames are JSON on the typed surface: `send`
|
|
5
|
+
* encodes, `messages()`/`onMessage` parse text frames (binary frames bypass the typed contract -
|
|
6
|
+
* read them off `raw`). Sends before the socket opens are queued, so the handle never throws.
|
|
7
|
+
*/
|
|
8
|
+
/** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
|
|
9
|
+
export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket");
|
|
10
|
+
function toWsUrl(base, path, query) {
|
|
11
|
+
const url = new URL(base + (path === "" ? "/" : path));
|
|
12
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
13
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
14
|
+
url.searchParams.set(key, String(value));
|
|
15
|
+
}
|
|
16
|
+
return url.toString();
|
|
17
|
+
}
|
|
18
|
+
export function openWebSocket(base, path, callOptions, noSocket) {
|
|
19
|
+
if (noSocket) {
|
|
20
|
+
throw new Error("the in-process client cannot open WebSockets (app.fetch has no socket) - listen() the app and connect with client(baseUrl)");
|
|
21
|
+
}
|
|
22
|
+
const WebSocketImpl = globalThis.WebSocket;
|
|
23
|
+
if (WebSocketImpl === undefined) {
|
|
24
|
+
throw new Error("no global WebSocket in this runtime - .ws() needs one (browser, Bun, Node 22+)");
|
|
25
|
+
}
|
|
26
|
+
const url = toWsUrl(base, path, callOptions?.query);
|
|
27
|
+
const socket = callOptions?.protocols !== undefined
|
|
28
|
+
? new WebSocketImpl(url, callOptions.protocols)
|
|
29
|
+
: new WebSocketImpl(url);
|
|
30
|
+
const sendQueue = [];
|
|
31
|
+
const listeners = new Set();
|
|
32
|
+
const opened = new Promise((resolve, reject) => {
|
|
33
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
34
|
+
socket.addEventListener("error", (event) => {
|
|
35
|
+
callOptions?.onError?.(event);
|
|
36
|
+
reject(new Error("websocket_connect_failed"));
|
|
37
|
+
}, { once: true });
|
|
38
|
+
});
|
|
39
|
+
// The rejection is surfaced via `opened` (and `onError`) only for those who look - an unconsumed
|
|
40
|
+
// `opened` on a failed connect must not crash the process with an unhandled rejection.
|
|
41
|
+
opened.catch(() => { });
|
|
42
|
+
socket.addEventListener("open", () => {
|
|
43
|
+
for (const frame of sendQueue)
|
|
44
|
+
socket.send(frame);
|
|
45
|
+
sendQueue.length = 0;
|
|
46
|
+
});
|
|
47
|
+
socket.addEventListener("message", (event) => {
|
|
48
|
+
if (typeof event.data !== "string")
|
|
49
|
+
return; // binary frames live off the typed contract
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(event.data);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return; // a non-JSON text frame is outside the contract too
|
|
56
|
+
}
|
|
57
|
+
for (const listener of listeners)
|
|
58
|
+
listener(parsed);
|
|
59
|
+
});
|
|
60
|
+
if (callOptions?.signal !== undefined) {
|
|
61
|
+
if (callOptions.signal.aborted)
|
|
62
|
+
socket.close();
|
|
63
|
+
else
|
|
64
|
+
callOptions.signal.addEventListener("abort", () => socket.close(), { once: true });
|
|
65
|
+
}
|
|
66
|
+
const onMessage = (callback) => {
|
|
67
|
+
listeners.add(callback);
|
|
68
|
+
return () => listeners.delete(callback);
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
raw: socket,
|
|
72
|
+
opened,
|
|
73
|
+
send(message) {
|
|
74
|
+
const frame = JSON.stringify(message);
|
|
75
|
+
if (socket.readyState === WebSocketImpl.OPEN)
|
|
76
|
+
socket.send(frame);
|
|
77
|
+
else
|
|
78
|
+
sendQueue.push(frame);
|
|
79
|
+
},
|
|
80
|
+
close(code, reason) {
|
|
81
|
+
socket.close(code, reason);
|
|
82
|
+
},
|
|
83
|
+
onMessage,
|
|
84
|
+
messages(options) {
|
|
85
|
+
const buffered = [];
|
|
86
|
+
let pending;
|
|
87
|
+
let done = false;
|
|
88
|
+
const push = (message) => {
|
|
89
|
+
if (pending !== undefined) {
|
|
90
|
+
const resolve = pending;
|
|
91
|
+
pending = undefined;
|
|
92
|
+
resolve({ value: message, done: false });
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
buffered.push(message);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const end = () => {
|
|
99
|
+
if (done)
|
|
100
|
+
return;
|
|
101
|
+
done = true;
|
|
102
|
+
unsubscribe();
|
|
103
|
+
pending?.({ value: undefined, done: true });
|
|
104
|
+
pending = undefined;
|
|
105
|
+
};
|
|
106
|
+
const unsubscribe = onMessage(push);
|
|
107
|
+
socket.addEventListener("close", end, { once: true });
|
|
108
|
+
socket.addEventListener("error", end, { once: true });
|
|
109
|
+
options?.signal?.addEventListener("abort", end, { once: true });
|
|
110
|
+
const iterator = {
|
|
111
|
+
[Symbol.asyncIterator]() {
|
|
112
|
+
return this;
|
|
113
|
+
},
|
|
114
|
+
next() {
|
|
115
|
+
if (buffered.length > 0) {
|
|
116
|
+
return Promise.resolve({ value: buffered.shift(), done: false });
|
|
117
|
+
}
|
|
118
|
+
if (done)
|
|
119
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
pending = resolve;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
return() {
|
|
125
|
+
end();
|
|
126
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return iterator;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=ws.js.map
|
package/dist/ws.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws.js","sourceRoot":"","sources":["../src/ws.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,kGAAkG;AAClG,MAAM,CAAC,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAA;AAMhE,SAAS,OAAO,CAAC,IAAY,EAAE,IAAY,EAAE,KAA6B;IACxE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACtD,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAA;IACzD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC;QACvD,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;IAC1C,CAAC;IACD,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAA;AACvB,CAAC;AAED,MAAM,UAAU,aAAa,CAC3B,IAAY,EACZ,IAAY,EACZ,WAAyC,EACzC,QAAiB;IAEjB,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,4HAA4H,CAC7H,CAAA;IACH,CAAC;IACD,MAAM,aAAa,GAAI,UAA+C,CAAC,SAAS,CAAA;IAChF,IAAI,aAAa,KAAK,SAAS,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CACb,gFAAgF,CACjF,CAAA;IACH,CAAC;IAED,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,CAAC,CAAA;IACnD,MAAM,MAAM,GACV,WAAW,EAAE,SAAS,KAAK,SAAS;QAClC,CAAC,CAAC,IAAI,aAAa,CAAC,GAAG,EAAE,WAAW,CAAC,SAAS,CAAC;QAC/C,CAAC,CAAC,IAAI,aAAa,CAAC,GAAG,CAAC,CAAA;IAE5B,MAAM,SAAS,GAAa,EAAE,CAAA;IAC9B,MAAM,SAAS,GAAG,IAAI,GAAG,EAA8B,CAAA;IAEvD,MAAM,MAAM,GAAG,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACnD,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;QAChE,MAAM,CAAC,gBAAgB,CACrB,OAAO,EACP,CAAC,KAAK,EAAE,EAAE;YACR,WAAW,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,CAAA;YAC7B,MAAM,CAAC,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC,CAAA;QAC/C,CAAC,EACD,EAAE,IAAI,EAAE,IAAI,EAAE,CACf,CAAA;IACH,CAAC,CAAC,CAAA;IACF,iGAAiG;IACjG,uFAAuF;IACvF,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAEtB,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;QACnC,KAAK,MAAM,KAAK,IAAI,SAAS;YAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjD,SAAS,CAAC,MAAM,GAAG,CAAC,CAAA;IACtB,CAAC,CAAC,CAAA;IACF,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,CAAC,KAAmB,EAAE,EAAE;QACzD,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAM,CAAC,4CAA4C;QACvF,IAAI,MAAe,CAAA;QACnB,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACjC,CAAC;QAAC,MAAM,CAAC;YACP,OAAM,CAAC,oDAAoD;QAC7D,CAAC;QACD,KAAK,MAAM,QAAQ,IAAI,SAAS;YAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACpD,CAAC,CAAC,CAAA;IAEF,IAAI,WAAW,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;QACtC,IAAI,WAAW,CAAC,MAAM,CAAC,OAAO;YAAE,MAAM,CAAC,KAAK,EAAE,CAAA;;YACzC,WAAW,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;IACzF,CAAC;IAED,MAAM,SAAS,GAAG,CAAC,QAAoC,EAAgB,EAAE;QACvE,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QACvB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IACzC,CAAC,CAAA;IAED,OAAO;QACL,GAAG,EAAE,MAAM;QACX,MAAM;QACN,IAAI,CAAC,OAAgB;YACnB,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;YACrC,IAAI,MAAM,CAAC,UAAU,KAAK,aAAa,CAAC,IAAI;gBAAE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;;gBAC3D,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QAC5B,CAAC;QACD,KAAK,CAAC,IAAa,EAAE,MAAe;YAClC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;QAC5B,CAAC;QACD,SAAS;QACT,QAAQ,CAAC,OAAkC;YACzC,MAAM,QAAQ,GAAc,EAAE,CAAA;YAC9B,IAAI,OAAgE,CAAA;YACpE,IAAI,IAAI,GAAG,KAAK,CAAA;YAEhB,MAAM,IAAI,GAAG,CAAC,OAAgB,EAAQ,EAAE;gBACtC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;oBAC1B,MAAM,OAAO,GAAG,OAAO,CAAA;oBACvB,OAAO,GAAG,SAAS,CAAA;oBACnB,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;gBAC1C,CAAC;qBAAM,CAAC;oBACN,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;gBACxB,CAAC;YACH,CAAC,CAAA;YACD,MAAM,GAAG,GAAG,GAAS,EAAE;gBACrB,IAAI,IAAI;oBAAE,OAAM;gBAChB,IAAI,GAAG,IAAI,CAAA;gBACX,WAAW,EAAE,CAAA;gBACb,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC3C,OAAO,GAAG,SAAS,CAAA;YACrB,CAAC,CAAA;YAED,MAAM,WAAW,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;YACnC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;YACrD,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;YACrD,OAAO,EAAE,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;YAE/D,MAAM,QAAQ,GAAmC;gBAC/C,CAAC,MAAM,CAAC,aAAa,CAAC;oBACpB,OAAO,IAAI,CAAA;gBACb,CAAC;gBACD,IAAI;oBACF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;wBACxB,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;oBAClE,CAAC;oBACD,IAAI,IAAI;wBAAE,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;oBAClE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;wBAC7B,OAAO,GAAG,OAAO,CAAA;oBACnB,CAAC,CAAC,CAAA;gBACJ,CAAC;gBACD,MAAM;oBACJ,GAAG,EAAE,CAAA;oBACL,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC1D,CAAC;aACF,CAAA;YACD,OAAO,QAAQ,CAAA;QACjB,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Browser-safe, end-to-end-typed client for @nifrajs/core servers (Eden-style proxy).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@nifrajs/core": "^
|
|
42
|
+
"@nifrajs/core": "^2.0.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@nifrajs/core": "workspace:*",
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContractShape, RegistryFor } from "@nifrajs/core"
|
|
1
|
+
import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
|
|
2
2
|
import {
|
|
3
3
|
type BackendMount,
|
|
4
4
|
type BackendMountHandler,
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from "@nifrajs/core/mount"
|
|
7
7
|
import type { ApiError, Result } from "./result.ts"
|
|
8
8
|
import type { Subscription, Treaty, TreatyFromRegistry } from "./treaty.ts"
|
|
9
|
+
import { ResponseContractViolation, withResponseValidation } from "./validate-responses.ts"
|
|
10
|
+
import { NO_SOCKET, openWebSocket } from "./ws.ts"
|
|
9
11
|
|
|
10
12
|
const HTTP_VERBS: ReadonlySet<string> = new Set([
|
|
11
13
|
"get",
|
|
@@ -24,16 +26,93 @@ const BODY_VERBS: ReadonlySet<string> = new Set(["post", "put", "patch"])
|
|
|
24
26
|
*/
|
|
25
27
|
export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
|
|
26
28
|
|
|
29
|
+
type MaybePromise<T> = T | Promise<T>
|
|
30
|
+
|
|
31
|
+
/** Safe retry policy. Off unless `retry` is set; retries ONLY idempotent methods and transient 5xx —
|
|
32
|
+
* never a 4xx/429 and never a non-idempotent method, so a retry can't double a side effect. */
|
|
33
|
+
export interface ClientRetryOptions {
|
|
34
|
+
/** Max RETRIES after the first attempt. Default 2. */
|
|
35
|
+
readonly attempts?: number
|
|
36
|
+
/** Response statuses to retry. Default `[502, 503, 504]`. 4xx/429 are never added by default. */
|
|
37
|
+
readonly on?: readonly number[]
|
|
38
|
+
/** Methods eligible for retry. Default the idempotent set `GET/HEAD/OPTIONS/PUT/DELETE`. */
|
|
39
|
+
readonly methods?: readonly string[]
|
|
40
|
+
/** Delay (ms) before retry `n` (1-based). Default exponential backoff with jitter, capped at 3s. */
|
|
41
|
+
readonly backoff?: (attempt: number) => number
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
export interface ClientOptions {
|
|
28
45
|
/** Headers sent on every request (a per-call `headers` option is merged on top). */
|
|
29
46
|
readonly headers?: Record<string, string>
|
|
30
47
|
/** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
|
|
31
48
|
readonly fetch?: FetchFn
|
|
49
|
+
/**
|
|
50
|
+
* Runs before each request (including each retry). Return a header map to MERGE onto the outgoing
|
|
51
|
+
* request — the place to inject a fresh auth token. `await`ed, so async token refresh works.
|
|
52
|
+
*/
|
|
53
|
+
readonly onRequest?: (request: {
|
|
54
|
+
readonly url: string
|
|
55
|
+
readonly method: string
|
|
56
|
+
readonly headers: Record<string, string>
|
|
57
|
+
readonly body: unknown
|
|
58
|
+
}) => MaybePromise<Record<string, string> | undefined>
|
|
59
|
+
/** Runs after a response arrives (once per call, on the final response). Observe-only. */
|
|
60
|
+
readonly onResponse?: (response: {
|
|
61
|
+
readonly url: string
|
|
62
|
+
readonly method: string
|
|
63
|
+
readonly response: Response
|
|
64
|
+
}) => MaybePromise<void>
|
|
65
|
+
/** Abort a call that takes longer than this many ms. Surfaces as `{ ok: false, status: 0 }` with a
|
|
66
|
+
* `timeout` error, never a throw. Combined with a per-call `signal`. */
|
|
67
|
+
readonly timeoutMs?: number
|
|
68
|
+
/** Enable safe automatic retries. See {@link ClientRetryOptions}. */
|
|
69
|
+
readonly retry?: ClientRetryOptions
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_RETRY_STATUSES: readonly number[] = [502, 503, 504]
|
|
73
|
+
const IDEMPOTENT_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]
|
|
74
|
+
|
|
75
|
+
function defaultBackoff(attempt: number): number {
|
|
76
|
+
return Math.min(300 * 2 ** (attempt - 1), 3000) + Math.random() * 100
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function delay(ms: number): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
setTimeout(resolve, ms)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Combine a per-call signal with a timeout into one signal; also returns the timeout signal so the
|
|
86
|
+
* caller can tell a timeout abort from a caller abort. */
|
|
87
|
+
function buildSignal(
|
|
88
|
+
userSignal: AbortSignal | undefined,
|
|
89
|
+
timeoutMs: number | undefined,
|
|
90
|
+
): { signal?: AbortSignal | undefined; timeout?: AbortSignal | undefined } {
|
|
91
|
+
const timeout =
|
|
92
|
+
timeoutMs !== undefined && typeof AbortSignal.timeout === "function"
|
|
93
|
+
? AbortSignal.timeout(timeoutMs)
|
|
94
|
+
: undefined
|
|
95
|
+
const parts = [userSignal, timeout].filter((s): s is AbortSignal => s !== undefined)
|
|
96
|
+
if (parts.length === 0) return {}
|
|
97
|
+
if (parts.length === 1) return { signal: parts[0], timeout }
|
|
98
|
+
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(parts) : parts[0]
|
|
99
|
+
return { signal, timeout }
|
|
32
100
|
}
|
|
33
101
|
|
|
34
102
|
/** Typed route client plus the explicit platform-aware backend mount capability. */
|
|
35
103
|
export type InProcessClient<App> = Treaty<App> & BackendMount
|
|
36
104
|
|
|
105
|
+
export interface InProcessClientOptions extends Omit<ClientOptions, "fetch"> {
|
|
106
|
+
/**
|
|
107
|
+
* Assert every JSON response against the route's declared contract — `schema.response` for 2xx,
|
|
108
|
+
* `schema.errors[status]` for declared failures — and THROW on mismatch, so a handler whose real
|
|
109
|
+
* output drifts from its schema fails the test instead of passing silently. Statuses with no
|
|
110
|
+
* declared schema, non-JSON bodies, and 204/205/HEAD pass through unchecked. Test-focused: the
|
|
111
|
+
* check parses a clone of each JSON body, a cost that belongs in tests, not production hot paths.
|
|
112
|
+
*/
|
|
113
|
+
readonly validateResponses?: boolean
|
|
114
|
+
}
|
|
115
|
+
|
|
37
116
|
interface CallOptions {
|
|
38
117
|
readonly query?: Record<string, unknown>
|
|
39
118
|
readonly headers?: Record<string, string>
|
|
@@ -83,25 +162,30 @@ export function client(
|
|
|
83
162
|
*
|
|
84
163
|
* The returned proxy also implements the explicit symbol-keyed {@link BackendMount} interface, so
|
|
85
164
|
* `createWebApp({ api: inProcessClient(backend) })` can auto-mount the backend while forwarding the
|
|
86
|
-
* outer runtime's `env` and `waitUntil`.
|
|
87
|
-
* a compatibility surface for custom integrations, but the platform-aware mount never relies on it.
|
|
165
|
+
* outer runtime's `env` and `waitUntil`.
|
|
88
166
|
*/
|
|
89
167
|
export function inProcessClient<
|
|
90
168
|
App extends { fetch(request: Request): Response | Promise<Response> },
|
|
91
|
-
>(app: App, options?:
|
|
169
|
+
>(app: App, options?: InProcessClientOptions): InProcessClient<App> {
|
|
92
170
|
// The in-process bridge: the client speaks `fetch(url, init)` (the `FetchFn` shape) while the app's
|
|
93
|
-
// own `fetch` takes a `Request`.
|
|
94
|
-
//
|
|
95
|
-
const
|
|
171
|
+
// own `fetch` takes a `Request`. It is the proxy's per-call transport; the symbol-keyed mount below
|
|
172
|
+
// is the platform-aware auto-mount path.
|
|
173
|
+
const direct: FetchFn = (url, init) => Promise.resolve(app.fetch(new Request(url, init)))
|
|
174
|
+
const bridge = options?.validateResponses === true ? withResponseValidation(app, direct) : direct
|
|
96
175
|
const mount: BackendMountHandler = (request, platform) =>
|
|
97
176
|
Promise.resolve((app.fetch as BackendMountHandler)(request, platform))
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
177
|
+
// NO_SOCKET marks the options so a typed `.ws()` call fails with a real explanation — an
|
|
178
|
+
// in-process app has no socket to upgrade — instead of dialing ws://nifra.internal into the void.
|
|
179
|
+
const proxy = client<App>("http://nifra.internal", {
|
|
180
|
+
...options,
|
|
181
|
+
fetch: bridge,
|
|
182
|
+
[NO_SOCKET]: true,
|
|
183
|
+
} as ClientOptions)
|
|
184
|
+
// An outer Proxy intercepts only the explicit mount symbol, delegating every typed route segment
|
|
185
|
+
// unchanged (`api.users({ id }).get()`).
|
|
101
186
|
return new Proxy(proxy as object, {
|
|
102
187
|
get(targetProxy, key, receiver) {
|
|
103
188
|
if (key === NIFRA_BACKEND_MOUNT) return mount
|
|
104
|
-
if (key === "fetch") return bridge
|
|
105
189
|
return Reflect.get(targetProxy, key, receiver)
|
|
106
190
|
},
|
|
107
191
|
}) as InProcessClient<App>
|
|
@@ -141,6 +225,16 @@ function createProxy(base: string, path: string, options: ClientOptions): unknow
|
|
|
141
225
|
return (onEvent: (event: unknown) => void, subscribeOptions?: SubscribeCallOptions) =>
|
|
142
226
|
subscribeSse(base, path, onEvent, subscribeOptions, options)
|
|
143
227
|
}
|
|
228
|
+
// Typed WebSocket handle for `app.ws()` routes — `ws` is a reserved proxy key like `subscribe`.
|
|
229
|
+
if (key === "ws") {
|
|
230
|
+
return (wsOptions?: Parameters<typeof openWebSocket>[2]) =>
|
|
231
|
+
openWebSocket(
|
|
232
|
+
base,
|
|
233
|
+
path,
|
|
234
|
+
{ headers: options.headers, ...wsOptions },
|
|
235
|
+
NO_SOCKET in options,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
144
238
|
// `index` addresses the root path "/" and adds no segment.
|
|
145
239
|
return createProxy(base, key === "index" ? path : `${path}/${key}`, options)
|
|
146
240
|
},
|
|
@@ -177,22 +271,57 @@ async function execute(
|
|
|
177
271
|
const query = callOptions?.query ? buildQuery(callOptions.query) : ""
|
|
178
272
|
if (query !== "") url += `?${query}`
|
|
179
273
|
|
|
274
|
+
const method = verb.toUpperCase()
|
|
180
275
|
const headers: Record<string, string> = { ...options.headers, ...callOptions?.headers }
|
|
181
|
-
|
|
276
|
+
if (options.onRequest !== undefined) {
|
|
277
|
+
const extra = await options.onRequest({ url, method, headers, body })
|
|
278
|
+
if (extra !== undefined) Object.assign(headers, extra)
|
|
279
|
+
}
|
|
280
|
+
const init: RequestInit = { method, headers }
|
|
182
281
|
if (body !== undefined) {
|
|
183
282
|
init.body = JSON.stringify(body)
|
|
184
283
|
headers["content-type"] = "application/json"
|
|
185
284
|
}
|
|
186
|
-
|
|
187
|
-
|
|
285
|
+
const { signal, timeout } = buildSignal(callOptions?.signal, options.timeoutMs)
|
|
286
|
+
if (signal !== undefined) init.signal = signal
|
|
287
|
+
|
|
288
|
+
// Safe retry: only when configured, only for idempotent methods + transient statuses, so a retry
|
|
289
|
+
// can never duplicate a side effect (a POST is never retried unless the app opts its method in).
|
|
290
|
+
const retry = options.retry
|
|
291
|
+
const maxRetries = retry === undefined ? 0 : (retry.attempts ?? 2)
|
|
292
|
+
const retryStatuses = new Set(retry?.on ?? DEFAULT_RETRY_STATUSES)
|
|
293
|
+
const retryMethods = new Set((retry?.methods ?? IDEMPOTENT_METHODS).map((m) => m.toUpperCase()))
|
|
294
|
+
const methodRetryable = retryMethods.has(method)
|
|
295
|
+
const backoff = retry?.backoff ?? defaultBackoff
|
|
188
296
|
const doFetch = options.fetch ?? fetch
|
|
297
|
+
|
|
189
298
|
let response: Response
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
299
|
+
let attempt = 0
|
|
300
|
+
for (;;) {
|
|
301
|
+
try {
|
|
302
|
+
response = await doFetch(url, init)
|
|
303
|
+
} catch (error) {
|
|
304
|
+
// A contract violation is a test assertion (validateResponses), not a call outcome — let it
|
|
305
|
+
// fail the test instead of degrading into a `Result` the test would happily branch on.
|
|
306
|
+
if (error instanceof ResponseContractViolation) throw error
|
|
307
|
+
if (methodRetryable && attempt < maxRetries) {
|
|
308
|
+
attempt += 1
|
|
309
|
+
await delay(backoff(attempt))
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
const code = timeout?.aborted === true ? "timeout" : "network_error"
|
|
313
|
+
return { ok: false, status: 0, data: null, error: { error: code } }
|
|
314
|
+
}
|
|
315
|
+
if (methodRetryable && attempt < maxRetries && retryStatuses.has(response.status)) {
|
|
316
|
+
attempt += 1
|
|
317
|
+
await delay(backoff(attempt))
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
break
|
|
194
321
|
}
|
|
195
322
|
|
|
323
|
+
if (options.onResponse !== undefined) await options.onResponse({ url, method, response })
|
|
324
|
+
|
|
196
325
|
const data = await parseBody(response)
|
|
197
326
|
if (response.ok) {
|
|
198
327
|
return { ok: true, status: response.status, data, error: null }
|
|
@@ -394,7 +523,12 @@ function subscribeSse(
|
|
|
394
523
|
function buildQuery(query: Record<string, unknown>): string {
|
|
395
524
|
const params = new URLSearchParams()
|
|
396
525
|
for (const [key, value] of Object.entries(query)) {
|
|
397
|
-
if (value
|
|
526
|
+
if (value === undefined) continue
|
|
527
|
+
if (Array.isArray(value)) {
|
|
528
|
+
for (const item of value) params.append(key, String(item))
|
|
529
|
+
} else {
|
|
530
|
+
params.set(key, String(value))
|
|
531
|
+
}
|
|
398
532
|
}
|
|
399
533
|
return params.toString()
|
|
400
534
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export {
|
|
8
8
|
type ClientOptions,
|
|
9
|
+
type ClientRetryOptions,
|
|
9
10
|
client,
|
|
10
11
|
type FetchFn,
|
|
11
12
|
type InProcessClient,
|
|
13
|
+
type InProcessClientOptions,
|
|
12
14
|
inProcessClient,
|
|
13
15
|
testClient,
|
|
14
16
|
} from "./client.ts"
|
|
@@ -21,4 +23,7 @@ export type {
|
|
|
21
23
|
Subscription,
|
|
22
24
|
Treaty,
|
|
23
25
|
TreatyFromRegistry,
|
|
26
|
+
WsCallOptions,
|
|
27
|
+
WsHandle,
|
|
24
28
|
} from "./treaty.ts"
|
|
29
|
+
export { ResponseContractViolation } from "./validate-responses.ts"
|
package/src/result.ts
CHANGED
|
@@ -8,18 +8,96 @@ export interface ApiError {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* error with no response body). `error` is always the server's normalized
|
|
16
|
-
* `{ error, issues? }` summary on failure (or a transport error), `null` on success.
|
|
11
|
+
* The failure statuses a call can carry (the IANA registry's 3xx/4xx/5xx set). Finite on purpose:
|
|
12
|
+
* the undeclared-status fallback arm of {@link Result} is `Exclude<ErrorStatus, declared>` - with a
|
|
13
|
+
* bare `number` there instead, `status === 404` could not narrow `data` to the declared 404 body,
|
|
14
|
+
* because 404 would still inhabit the fallback arm.
|
|
17
15
|
*/
|
|
18
|
-
export type
|
|
16
|
+
export type ErrorStatus =
|
|
17
|
+
| 300
|
|
18
|
+
| 301
|
|
19
|
+
| 302
|
|
20
|
+
| 303
|
|
21
|
+
| 304
|
|
22
|
+
| 305
|
|
23
|
+
| 306
|
|
24
|
+
| 307
|
|
25
|
+
| 308
|
|
26
|
+
| 400
|
|
27
|
+
| 401
|
|
28
|
+
| 402
|
|
29
|
+
| 403
|
|
30
|
+
| 404
|
|
31
|
+
| 405
|
|
32
|
+
| 406
|
|
33
|
+
| 407
|
|
34
|
+
| 408
|
|
35
|
+
| 409
|
|
36
|
+
| 410
|
|
37
|
+
| 411
|
|
38
|
+
| 412
|
|
39
|
+
| 413
|
|
40
|
+
| 414
|
|
41
|
+
| 415
|
|
42
|
+
| 416
|
|
43
|
+
| 417
|
|
44
|
+
| 418
|
|
45
|
+
| 421
|
|
46
|
+
| 422
|
|
47
|
+
| 423
|
|
48
|
+
| 424
|
|
49
|
+
| 425
|
|
50
|
+
| 426
|
|
51
|
+
| 428
|
|
52
|
+
| 429
|
|
53
|
+
| 431
|
|
54
|
+
| 451
|
|
55
|
+
| 500
|
|
56
|
+
| 501
|
|
57
|
+
| 502
|
|
58
|
+
| 503
|
|
59
|
+
| 504
|
|
60
|
+
| 505
|
|
61
|
+
| 506
|
|
62
|
+
| 507
|
|
63
|
+
| 508
|
|
64
|
+
| 510
|
|
65
|
+
| 511
|
|
66
|
+
|
|
67
|
+
/** One failure arm per declared error status: `status` is the literal, `data` its declared body. */
|
|
68
|
+
type DeclaredFailures<Errors extends Record<number, unknown>> = {
|
|
69
|
+
[K in Extract<keyof Errors, number>]: {
|
|
70
|
+
readonly ok: false
|
|
71
|
+
readonly status: K
|
|
72
|
+
readonly data: Errors[K]
|
|
73
|
+
readonly error: ApiError
|
|
74
|
+
}
|
|
75
|
+
}[Extract<keyof Errors, number>]
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The outcome of a client call. The client never throws - inspect `ok` to branch.
|
|
79
|
+
*
|
|
80
|
+
* On success, `data` is the route's response type. On failure the union is DISCRIMINATED BY
|
|
81
|
+
* `status` when the route declares an `errors` record: `status === 404` narrows `data` to the
|
|
82
|
+
* declared 404 body; every undeclared status (plus `0` for a transport error with no response)
|
|
83
|
+
* falls into a fallback arm whose `data` is `unknown`. A route with no `errors` contract keeps a
|
|
84
|
+
* single failure arm with `data: Errors` (`unknown`). `error` is always the server's normalized
|
|
85
|
+
* `{ error, issues? }` summary on failure, `null` on success.
|
|
86
|
+
*/
|
|
87
|
+
export type Result<Data, Errors = unknown> =
|
|
19
88
|
| { readonly ok: true; readonly status: number; readonly data: Data; readonly error: null }
|
|
20
|
-
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
89
|
+
| ([Errors] extends [Record<number, unknown>]
|
|
90
|
+
?
|
|
91
|
+
| DeclaredFailures<Errors>
|
|
92
|
+
| {
|
|
93
|
+
readonly ok: false
|
|
94
|
+
readonly status: Exclude<ErrorStatus, Extract<keyof Errors, number>> | 0
|
|
95
|
+
readonly data: unknown
|
|
96
|
+
readonly error: ApiError
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
readonly ok: false
|
|
100
|
+
readonly status: number
|
|
101
|
+
readonly data: Errors
|
|
102
|
+
readonly error: ApiError
|
|
103
|
+
})
|
package/src/routes.ts
CHANGED