@nifrajs/client 1.13.0 → 2.1.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,61 @@
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 type { FetchFn } from "./client.js";
16
+ /** The slice of a Standard Schema this wrapper runs. */
17
+ interface SchemaLike {
18
+ readonly "~standard": {
19
+ readonly validate: (value: unknown) => {
20
+ readonly issues?: ReadonlyArray<IssueLike> | undefined;
21
+ readonly value?: unknown;
22
+ } | Promise<{
23
+ readonly issues?: ReadonlyArray<IssueLike> | undefined;
24
+ readonly value?: unknown;
25
+ }>;
26
+ };
27
+ }
28
+ interface IssueLike {
29
+ readonly message: string;
30
+ readonly path?: ReadonlyArray<PropertyKey | {
31
+ readonly key: PropertyKey;
32
+ }> | undefined;
33
+ }
34
+ interface RouteSchemaLike {
35
+ readonly response?: SchemaLike;
36
+ readonly errors?: Readonly<Record<number, SchemaLike>>;
37
+ }
38
+ interface RouteDescriptorLike {
39
+ readonly method: string;
40
+ readonly path: string;
41
+ readonly schema: RouteSchemaLike | undefined;
42
+ }
43
+ /** What `validateResponses` needs from the app beyond `fetch`: the route table. */
44
+ export interface RouteIntrospectable {
45
+ routes(): ReadonlyArray<RouteDescriptorLike>;
46
+ }
47
+ /**
48
+ * A response body that broke its route's declared contract. Thrown THROUGH the "never throws"
49
+ * client on purpose: this is a test assertion about the server's honesty, not a call outcome the
50
+ * caller should branch on - swallowing it into a `Result` would let the drift pass the test.
51
+ */
52
+ export declare class ResponseContractViolation extends Error {
53
+ constructor(message: string);
54
+ }
55
+ /**
56
+ * Wrap the in-process bridge so every response is checked against its route's declared contract.
57
+ * Throws at wrap time when the app cannot enumerate routes - a misconfiguration, not a soft skip.
58
+ */
59
+ export declare function withResponseValidation(app: unknown, bridge: FetchFn): FetchFn;
60
+ export {};
61
+ //# sourceMappingURL=validate-responses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-responses.d.ts","sourceRoot":"","sources":["../src/validate-responses.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAE1C,wDAAwD;AACxD,UAAU,UAAU;IAClB,QAAQ,CAAC,WAAW,EAAE;QACpB,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAC9B;YAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,SAAS,CAAC;YAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;SAAE,GACpF,OAAO,CAAC;YACN,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,SAAS,CAAA;YACtD,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;SACzB,CAAC,CAAA;KACP,CAAA;CACF;AAED,UAAU,SAAS;IACjB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAA;KAAE,CAAC,GAAG,SAAS,CAAA;CACvF;AAED,UAAU,eAAe;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAA;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAA;CACvD;AAED,UAAU,mBAAmB;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CAAA;CAC7C;AAED,mFAAmF;AACnF,MAAM,WAAW,mBAAmB;IAClC,MAAM,IAAI,aAAa,CAAC,mBAAmB,CAAC,CAAA;CAC7C;AAED;;;;GAIG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;gBACtC,OAAO,EAAE,MAAM;CAI5B;AAaD;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CA0C7E"}
@@ -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,22 @@
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 TransportCodec, type TransportCodecRegistry } from "@nifrajs/core/transport-codec";
9
+ import type { WsCallOptions, WsHandle } from "./treaty.js";
10
+ /** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
11
+ export declare const NO_SOCKET: unique symbol;
12
+ interface WsRuntimeOptions extends WsCallOptions {
13
+ headers?: Record<string, string> | undefined;
14
+ transport?: {
15
+ readonly codec: TransportCodec;
16
+ readonly registry?: TransportCodecRegistry;
17
+ readonly maxBytes?: number;
18
+ };
19
+ }
20
+ export declare function openWebSocket(base: string, path: string, callOptions: WsRuntimeOptions | undefined, noSocket: boolean): WsHandle<unknown, unknown>;
21
+ export {};
22
+ //# 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,EAKL,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC5B,MAAM,+BAA+B,CAAA;AACtC,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;IAC5C,SAAS,CAAC,EAAE;QACV,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;QAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,sBAAsB,CAAA;QAC1C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAC3B,CAAA;CACF;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,CAwI5B"}
package/dist/ws.js ADDED
@@ -0,0 +1,146 @@
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 { createTransportCodecRegistry, decodeTransportFrame, encodeTransportFrame, plainJsonCodec, } from "@nifrajs/core/transport-codec";
9
+ /** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
10
+ export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket");
11
+ function toWsUrl(base, path, query) {
12
+ const url = new URL(base + (path === "" ? "/" : path));
13
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
14
+ for (const [key, value] of Object.entries(query ?? {})) {
15
+ url.searchParams.set(key, String(value));
16
+ }
17
+ return url.toString();
18
+ }
19
+ export function openWebSocket(base, path, callOptions, noSocket) {
20
+ if (noSocket) {
21
+ throw new Error("the in-process client cannot open WebSockets (app.fetch has no socket) - listen() the app and connect with client(baseUrl)");
22
+ }
23
+ const WebSocketImpl = globalThis.WebSocket;
24
+ if (WebSocketImpl === undefined) {
25
+ throw new Error("no global WebSocket in this runtime - .ws() needs one (browser, Bun, Node 22+)");
26
+ }
27
+ const url = toWsUrl(base, path, callOptions?.query);
28
+ const socket = callOptions?.protocols !== undefined
29
+ ? new WebSocketImpl(url, callOptions.protocols)
30
+ : new WebSocketImpl(url);
31
+ const sendQueue = [];
32
+ const listeners = new Set();
33
+ const transport = callOptions?.transport;
34
+ const registry = transport?.registry ??
35
+ (transport === undefined || transport.codec === plainJsonCodec
36
+ ? createTransportCodecRegistry([plainJsonCodec])
37
+ : createTransportCodecRegistry([plainJsonCodec, transport.codec]));
38
+ const opened = new Promise((resolve, reject) => {
39
+ socket.addEventListener("open", () => resolve(), { once: true });
40
+ socket.addEventListener("error", (event) => {
41
+ callOptions?.onError?.(event);
42
+ reject(new Error("websocket_connect_failed"));
43
+ }, { once: true });
44
+ });
45
+ // The rejection is surfaced via `opened` (and `onError`) only for those who look - an unconsumed
46
+ // `opened` on a failed connect must not crash the process with an unhandled rejection.
47
+ opened.catch(() => { });
48
+ socket.addEventListener("open", () => {
49
+ for (const frame of sendQueue)
50
+ socket.send(frame);
51
+ sendQueue.length = 0;
52
+ });
53
+ socket.addEventListener("message", (event) => {
54
+ if (typeof event.data !== "string")
55
+ return; // binary frames live off the typed contract
56
+ let parsed;
57
+ try {
58
+ parsed =
59
+ transport === undefined
60
+ ? JSON.parse(event.data)
61
+ : decodeTransportFrame(event.data, registry, {
62
+ ...(transport.maxBytes === undefined ? {} : { maxBytes: transport.maxBytes }),
63
+ });
64
+ }
65
+ catch {
66
+ return; // a non-JSON text frame is outside the contract too
67
+ }
68
+ for (const listener of listeners)
69
+ listener(parsed);
70
+ });
71
+ if (callOptions?.signal !== undefined) {
72
+ if (callOptions.signal.aborted)
73
+ socket.close();
74
+ else
75
+ callOptions.signal.addEventListener("abort", () => socket.close(), { once: true });
76
+ }
77
+ const onMessage = (callback) => {
78
+ listeners.add(callback);
79
+ return () => listeners.delete(callback);
80
+ };
81
+ return {
82
+ raw: socket,
83
+ opened,
84
+ send(message) {
85
+ const frame = transport === undefined
86
+ ? JSON.stringify(message)
87
+ : encodeTransportFrame(message, transport.codec);
88
+ if (socket.readyState === WebSocketImpl.OPEN)
89
+ socket.send(frame);
90
+ else
91
+ sendQueue.push(frame);
92
+ },
93
+ close(code, reason) {
94
+ socket.close(code, reason);
95
+ },
96
+ onMessage,
97
+ messages(options) {
98
+ const buffered = [];
99
+ let pending;
100
+ let done = false;
101
+ const push = (message) => {
102
+ if (pending !== undefined) {
103
+ const resolve = pending;
104
+ pending = undefined;
105
+ resolve({ value: message, done: false });
106
+ }
107
+ else {
108
+ buffered.push(message);
109
+ }
110
+ };
111
+ const end = () => {
112
+ if (done)
113
+ return;
114
+ done = true;
115
+ unsubscribe();
116
+ pending?.({ value: undefined, done: true });
117
+ pending = undefined;
118
+ };
119
+ const unsubscribe = onMessage(push);
120
+ socket.addEventListener("close", end, { once: true });
121
+ socket.addEventListener("error", end, { once: true });
122
+ options?.signal?.addEventListener("abort", end, { once: true });
123
+ const iterator = {
124
+ [Symbol.asyncIterator]() {
125
+ return this;
126
+ },
127
+ next() {
128
+ if (buffered.length > 0) {
129
+ return Promise.resolve({ value: buffered.shift(), done: false });
130
+ }
131
+ if (done)
132
+ return Promise.resolve({ value: undefined, done: true });
133
+ return new Promise((resolve) => {
134
+ pending = resolve;
135
+ });
136
+ },
137
+ return() {
138
+ end();
139
+ return Promise.resolve({ value: undefined, done: true });
140
+ },
141
+ };
142
+ return iterator;
143
+ },
144
+ };
145
+ }
146
+ //# 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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/client",
3
- "version": "1.13.0",
3
+ "version": "2.1.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.13.0"
42
+ "@nifrajs/core": "^2.1.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@nifrajs/core": "workspace:*",
package/src/client.ts CHANGED
@@ -1,11 +1,20 @@
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,
5
5
  NIFRA_BACKEND_MOUNT,
6
6
  } from "@nifrajs/core/mount"
7
+ import {
8
+ createTransportCodecRegistry,
9
+ decodeTransportResponse,
10
+ plainJsonCodec,
11
+ type TransportCodec,
12
+ type TransportCodecRegistry,
13
+ } from "@nifrajs/core/transport-codec"
7
14
  import type { ApiError, Result } from "./result.ts"
8
15
  import type { Subscription, Treaty, TreatyFromRegistry } from "./treaty.ts"
16
+ import { ResponseContractViolation, withResponseValidation } from "./validate-responses.ts"
17
+ import { NO_SOCKET, openWebSocket } from "./ws.ts"
9
18
 
10
19
  const HTTP_VERBS: ReadonlySet<string> = new Set([
11
20
  "get",
@@ -24,16 +33,102 @@ const BODY_VERBS: ReadonlySet<string> = new Set(["post", "put", "patch"])
24
33
  */
25
34
  export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
26
35
 
36
+ type MaybePromise<T> = T | Promise<T>
37
+
38
+ /** Safe retry policy. Off unless `retry` is set; retries ONLY idempotent methods and transient 5xx —
39
+ * never a 4xx/429 and never a non-idempotent method, so a retry can't double a side effect. */
40
+ export interface ClientRetryOptions {
41
+ /** Max RETRIES after the first attempt. Default 2. */
42
+ readonly attempts?: number
43
+ /** Response statuses to retry. Default `[502, 503, 504]`. 4xx/429 are never added by default. */
44
+ readonly on?: readonly number[]
45
+ /** Methods eligible for retry. Default the idempotent set `GET/HEAD/OPTIONS/PUT/DELETE`. */
46
+ readonly methods?: readonly string[]
47
+ /** Delay (ms) before retry `n` (1-based). Default exponential backoff with jitter, capped at 3s. */
48
+ readonly backoff?: (attempt: number) => number
49
+ }
50
+
27
51
  export interface ClientOptions {
28
52
  /** Headers sent on every request (a per-call `headers` option is merged on top). */
29
53
  readonly headers?: Record<string, string>
30
54
  /** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
31
55
  readonly fetch?: FetchFn
56
+ /**
57
+ * Runs before each request (including each retry). Return a header map to MERGE onto the outgoing
58
+ * request — the place to inject a fresh auth token. `await`ed, so async token refresh works.
59
+ */
60
+ readonly onRequest?: (request: {
61
+ readonly url: string
62
+ readonly method: string
63
+ readonly headers: Record<string, string>
64
+ readonly body: unknown
65
+ }) => MaybePromise<Record<string, string> | undefined>
66
+ /** Runs after a response arrives (once per call, on the final response). Observe-only. */
67
+ readonly onResponse?: (response: {
68
+ readonly url: string
69
+ readonly method: string
70
+ readonly response: Response
71
+ }) => MaybePromise<void>
72
+ /** Abort a call that takes longer than this many ms. Surfaces as `{ ok: false, status: 0 }` with a
73
+ * `timeout` error, never a throw. Combined with a per-call `signal`. */
74
+ readonly timeoutMs?: number
75
+ /** Enable safe automatic retries. See {@link ClientRetryOptions}. */
76
+ readonly retry?: ClientRetryOptions
77
+ /**
78
+ * Opt into a versioned transport representation. Plain JSON remains the default. Responses are
79
+ * decoded through the bounded registry, and WebSocket frames use the same codec.
80
+ */
81
+ readonly transport?: {
82
+ readonly codec: TransportCodec
83
+ readonly registry?: TransportCodecRegistry
84
+ readonly maxBytes?: number
85
+ }
86
+ }
87
+
88
+ const DEFAULT_RETRY_STATUSES: readonly number[] = [502, 503, 504]
89
+ const IDEMPOTENT_METHODS: readonly string[] = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]
90
+
91
+ function defaultBackoff(attempt: number): number {
92
+ return Math.min(300 * 2 ** (attempt - 1), 3000) + Math.random() * 100
93
+ }
94
+
95
+ function delay(ms: number): Promise<void> {
96
+ return new Promise((resolve) => {
97
+ setTimeout(resolve, ms)
98
+ })
99
+ }
100
+
101
+ /** Combine a per-call signal with a timeout into one signal; also returns the timeout signal so the
102
+ * caller can tell a timeout abort from a caller abort. */
103
+ function buildSignal(
104
+ userSignal: AbortSignal | undefined,
105
+ timeoutMs: number | undefined,
106
+ ): { signal?: AbortSignal | undefined; timeout?: AbortSignal | undefined } {
107
+ const timeout =
108
+ timeoutMs !== undefined && typeof AbortSignal.timeout === "function"
109
+ ? AbortSignal.timeout(timeoutMs)
110
+ : undefined
111
+ const parts = [userSignal, timeout].filter((s): s is AbortSignal => s !== undefined)
112
+ if (parts.length === 0) return {}
113
+ if (parts.length === 1) return { signal: parts[0], timeout }
114
+ const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(parts) : parts[0]
115
+ return { signal, timeout }
32
116
  }
33
117
 
34
118
  /** Typed route client plus the explicit platform-aware backend mount capability. */
35
119
  export type InProcessClient<App> = Treaty<App> & BackendMount
36
120
 
121
+ export interface InProcessClientOptions extends Omit<ClientOptions, "fetch"> {
122
+ /**
123
+ * Assert every JSON response against the route's declared contract — `schema.response` for 2xx,
124
+ * `schema.errors[status]` for declared failures — and THROW on mismatch, so a handler whose real
125
+ * output drifts from its schema fails the test instead of passing silently. Statuses with no
126
+ * declared schema, non-JSON bodies, and 204/205/HEAD pass through unchecked. Test-focused: the
127
+ * check parses a clone of each JSON body, a cost that belongs in tests, not production hot paths.
128
+ */
129
+ readonly validateResponses?: boolean
130
+ }
131
+
37
132
  interface CallOptions {
38
133
  readonly query?: Record<string, unknown>
39
134
  readonly headers?: Record<string, string>
@@ -83,25 +178,30 @@ export function client(
83
178
  *
84
179
  * The returned proxy also implements the explicit symbol-keyed {@link BackendMount} interface, so
85
180
  * `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.
181
+ * outer runtime's `env` and `waitUntil`.
88
182
  */
89
183
  export function inProcessClient<
90
184
  App extends { fetch(request: Request): Response | Promise<Response> },
91
- >(app: App, options?: Omit<ClientOptions, "fetch">): InProcessClient<App> {
185
+ >(app: App, options?: InProcessClientOptions): InProcessClient<App> {
92
186
  // 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)))
187
+ // own `fetch` takes a `Request`. It is the proxy's per-call transport; the symbol-keyed mount below
188
+ // is the platform-aware auto-mount path.
189
+ const direct: FetchFn = (url, init) => Promise.resolve(app.fetch(new Request(url, init)))
190
+ const bridge = options?.validateResponses === true ? withResponseValidation(app, direct) : direct
96
191
  const mount: BackendMountHandler = (request, platform) =>
97
192
  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()`).
193
+ // NO_SOCKET marks the options so a typed `.ws()` call fails with a real explanation — an
194
+ // in-process app has no socket to upgrade — instead of dialing ws://nifra.internal into the void.
195
+ const proxy = client<App>("http://nifra.internal", {
196
+ ...options,
197
+ fetch: bridge,
198
+ [NO_SOCKET]: true,
199
+ } as ClientOptions)
200
+ // An outer Proxy intercepts only the explicit mount symbol, delegating every typed route segment
201
+ // unchanged (`api.users({ id }).get()`).
101
202
  return new Proxy(proxy as object, {
102
203
  get(targetProxy, key, receiver) {
103
204
  if (key === NIFRA_BACKEND_MOUNT) return mount
104
- if (key === "fetch") return bridge
105
205
  return Reflect.get(targetProxy, key, receiver)
106
206
  },
107
207
  }) as InProcessClient<App>
@@ -141,6 +241,20 @@ function createProxy(base: string, path: string, options: ClientOptions): unknow
141
241
  return (onEvent: (event: unknown) => void, subscribeOptions?: SubscribeCallOptions) =>
142
242
  subscribeSse(base, path, onEvent, subscribeOptions, options)
143
243
  }
244
+ // Typed WebSocket handle for `app.ws()` routes — `ws` is a reserved proxy key like `subscribe`.
245
+ if (key === "ws") {
246
+ return (wsOptions?: Parameters<typeof openWebSocket>[2]) =>
247
+ openWebSocket(
248
+ base,
249
+ path,
250
+ {
251
+ headers: options.headers,
252
+ ...wsOptions,
253
+ ...(options.transport === undefined ? {} : { transport: options.transport }),
254
+ },
255
+ NO_SOCKET in options,
256
+ )
257
+ }
144
258
  // `index` addresses the root path "/" and adds no segment.
145
259
  return createProxy(base, key === "index" ? path : `${path}/${key}`, options)
146
260
  },
@@ -177,23 +291,60 @@ async function execute(
177
291
  const query = callOptions?.query ? buildQuery(callOptions.query) : ""
178
292
  if (query !== "") url += `?${query}`
179
293
 
294
+ const method = verb.toUpperCase()
180
295
  const headers: Record<string, string> = { ...options.headers, ...callOptions?.headers }
181
- const init: RequestInit = { method: verb.toUpperCase(), headers }
296
+ if (options.onRequest !== undefined) {
297
+ const extra = await options.onRequest({ url, method, headers, body })
298
+ if (extra !== undefined) Object.assign(headers, extra)
299
+ }
300
+ const init: RequestInit = { method, headers }
182
301
  if (body !== undefined) {
183
- init.body = JSON.stringify(body)
184
- headers["content-type"] = "application/json"
302
+ const codec = options.transport?.codec ?? plainJsonCodec
303
+ init.body = codec.encode(body)
304
+ headers["content-type"] = codec.mediaType
305
+ if (options.transport !== undefined) headers.accept ??= codec.mediaType
185
306
  }
186
- if (callOptions?.signal) init.signal = callOptions.signal
187
-
307
+ const { signal, timeout } = buildSignal(callOptions?.signal, options.timeoutMs)
308
+ if (signal !== undefined) init.signal = signal
309
+
310
+ // Safe retry: only when configured, only for idempotent methods + transient statuses, so a retry
311
+ // can never duplicate a side effect (a POST is never retried unless the app opts its method in).
312
+ const retry = options.retry
313
+ const maxRetries = retry === undefined ? 0 : (retry.attempts ?? 2)
314
+ const retryStatuses = new Set(retry?.on ?? DEFAULT_RETRY_STATUSES)
315
+ const retryMethods = new Set((retry?.methods ?? IDEMPOTENT_METHODS).map((m) => m.toUpperCase()))
316
+ const methodRetryable = retryMethods.has(method)
317
+ const backoff = retry?.backoff ?? defaultBackoff
188
318
  const doFetch = options.fetch ?? fetch
319
+
189
320
  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" } }
321
+ let attempt = 0
322
+ for (;;) {
323
+ try {
324
+ response = await doFetch(url, init)
325
+ } catch (error) {
326
+ // A contract violation is a test assertion (validateResponses), not a call outcome — let it
327
+ // fail the test instead of degrading into a `Result` the test would happily branch on.
328
+ if (error instanceof ResponseContractViolation) throw error
329
+ if (methodRetryable && attempt < maxRetries) {
330
+ attempt += 1
331
+ await delay(backoff(attempt))
332
+ continue
333
+ }
334
+ const code = timeout?.aborted === true ? "timeout" : "network_error"
335
+ return { ok: false, status: 0, data: null, error: { error: code } }
336
+ }
337
+ if (methodRetryable && attempt < maxRetries && retryStatuses.has(response.status)) {
338
+ attempt += 1
339
+ await delay(backoff(attempt))
340
+ continue
341
+ }
342
+ break
194
343
  }
195
344
 
196
- const data = await parseBody(response)
345
+ if (options.onResponse !== undefined) await options.onResponse({ url, method, response })
346
+
347
+ const data = await parseBody(response, options)
197
348
  if (response.ok) {
198
349
  return { ok: true, status: response.status, data, error: null }
199
350
  }
@@ -404,8 +555,27 @@ function buildQuery(query: Record<string, unknown>): string {
404
555
  return params.toString()
405
556
  }
406
557
 
407
- async function parseBody(response: Response): Promise<unknown> {
558
+ function transportRegistry(options: ClientOptions): TransportCodecRegistry {
559
+ if (options.transport?.registry !== undefined) return options.transport.registry
560
+ const codec = options.transport?.codec
561
+ return codec === undefined || codec === plainJsonCodec
562
+ ? createTransportCodecRegistry([plainJsonCodec])
563
+ : createTransportCodecRegistry([plainJsonCodec, codec])
564
+ }
565
+
566
+ async function parseBody(response: Response, options: ClientOptions): Promise<unknown> {
408
567
  if (response.status === 204) return undefined
568
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""
569
+ if (
570
+ contentType.startsWith("application/json") ||
571
+ contentType.startsWith("application/vnd.nifra.")
572
+ ) {
573
+ return await decodeTransportResponse(response, transportRegistry(options), {
574
+ ...(options.transport?.maxBytes === undefined
575
+ ? {}
576
+ : { maxBytes: options.transport.maxBytes }),
577
+ })
578
+ }
409
579
  const text = await response.text()
410
580
  if (text === "") return undefined
411
581
  try {