@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.
@@ -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
@@ -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": "1.12.0",
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": "^1.12.0"
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`. The released `.fetch(url, init)` bridge remains available as
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?: Omit<ClientOptions, "fetch">): InProcessClient<App> {
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`. Kept as the proxy's per-call transport and the released legacy
94
- // `.fetch` mount bridge; the symbol-keyed mount below is the platform-aware path.
95
- const bridge: FetchFn = (url, init) => Promise.resolve(app.fetch(new Request(url, init)))
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
- const proxy = client<App>("http://nifra.internal", { ...options, fetch: bridge })
99
- // An outer Proxy intercepts only the explicit mount symbol and legacy `fetch` key, delegating every
100
- // typed route segment unchanged (`api.users({ id }).get()`).
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
- const init: RequestInit = { method: verb.toUpperCase(), headers }
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
- if (callOptions?.signal) init.signal = callOptions.signal
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
- try {
191
- response = await doFetch(url, init)
192
- } catch {
193
- return { ok: false, status: 0, data: null, error: { error: "network_error" } }
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 !== undefined) params.set(key, String(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 outcome of a client call. The client never throws — inspect `ok` to branch.
12
- * `data` is the parsed response body, **typed by `ok`**: on success it's the route's
13
- * response type; on failure it's the parsed error body, typed from the route's
14
- * `errors` contract (`unknown` when the route declares none, `null` on a transport
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 Result<Data, ErrData = unknown> =
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
- readonly ok: false
22
- readonly status: number
23
- readonly data: ErrData
24
- readonly error: ApiError
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
@@ -1,4 +1,4 @@
1
- import type { ContractShape, RegistryFor } from "@nifrajs/core"
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
2
2
  import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
3
3
 
4
4
  /**