@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.
- package/dist/client.d.ts +58 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +117 -22
- 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 +22 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +146 -0
- package/dist/ws.js.map +1 -0
- package/package.json +2 -2
- package/src/client.ts +192 -22
- 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 +181 -0
|
@@ -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
|
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,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.
|
|
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.
|
|
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`.
|
|
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?:
|
|
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`.
|
|
94
|
-
//
|
|
95
|
-
const
|
|
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
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|