@nifrajs/client 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nifra Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @nifrajs/client
2
+
3
+ A browser-safe, **end-to-end-typed** client for [nifra](../../README.md) servers — an
4
+ Eden-style proxy that **never throws**.
5
+
6
+ ```sh
7
+ bun add @nifrajs/client
8
+ ```
9
+
10
+ ```ts
11
+ import { client } from "@nifrajs/client"
12
+ import type { App } from "./server" // `typeof app` from @nifrajs/core
13
+
14
+ const api = client<App>("http://localhost:3000")
15
+
16
+ const { ok, status, data, error } = await api.users({ id: "42" }).get()
17
+ // ^? typed from the server route
18
+ ```
19
+
20
+ - **Zero codegen.** Types flow from `typeof app` (coupled) — or from a contract via
21
+ `client(contract, url)` (decoupled), with no dependency on the server's source.
22
+ - **Proxy chaining.** Path segments are properties, `:params` are calls, verbs are
23
+ methods: `api.users({ id }).posts({ postId }).get()`. The root is `api.index`.
24
+ - **Result, never exceptions.** Every call resolves to `{ ok, status, data, error }`,
25
+ so the failure path is in the types. Bodies are positional (`api.users.post({ name })`);
26
+ pass `{ query, headers, signal }` as call options.
27
+ - **Environment-agnostic.** No Bun/Node APIs — runs in the browser, workers, or any
28
+ runtime with `fetch`.
29
+
30
+ ESM-only. MIT.
@@ -0,0 +1,56 @@
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core";
2
+ import type { Treaty, TreatyFromRegistry } from "./treaty.js";
3
+ /**
4
+ * The fetch shape the client needs — looser than `typeof fetch` so an in-process bridge or a
5
+ * test mock satisfies it without the extra members (`.preconnect`, overloads) of the global.
6
+ */
7
+ export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>;
8
+ export interface ClientOptions {
9
+ /** Headers sent on every request (a per-call `headers` option is merged on top). */
10
+ readonly headers?: Record<string, string>;
11
+ /** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
12
+ readonly fetch?: FetchFn;
13
+ }
14
+ /**
15
+ * Create an end-to-end-typed client for a nifra server. Two modes:
16
+ *
17
+ * // coupled — typed from the server's type (`typeof app`)
18
+ * const api = client<typeof app>("http://localhost:3000")
19
+ *
20
+ * // decoupled — typed from a contract VALUE, no server import
21
+ * const api = client(contract, "https://api.example.com")
22
+ *
23
+ * const { data, error } = await api.users({ id: "1" }).get()
24
+ *
25
+ * Both return the same Eden-style proxy. Browser-safe: uses only `fetch` /
26
+ * `Proxy` / `URL`, never the server runtime. Never throws — network and non-2xx
27
+ * responses surface as a `Result` `error`.
28
+ */
29
+ export declare function client<App>(baseUrl: string, options?: ClientOptions): Treaty<App>;
30
+ export declare function client<const C extends ContractShape>(contract: C, baseUrl: string, options?: ClientOptions): TreatyFromRegistry<RegistryFor<C>>;
31
+ /**
32
+ * A {@link client} whose `fetch` calls a nifra app's own `fetch` in-process — no network, full
33
+ * lifecycle (validation, middleware, contracts). For SSR loaders. Typed from `App` exactly like
34
+ * the network client. The `(url, init) → Request` bridge is required because the client calls
35
+ * `fetch(url, init)` while `app.fetch` takes a `Request`.
36
+ */
37
+ export declare function inProcessClient<App extends {
38
+ fetch(request: Request): Response | Promise<Response>;
39
+ }>(app: App, options?: Omit<ClientOptions, "fetch">): Treaty<App>;
40
+ /**
41
+ * The in-process test client — the Fastify-`inject` / supertest equivalent for nifra. Drives the
42
+ * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
43
+ * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw — branch on
44
+ * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
45
+ *
46
+ * ```ts
47
+ * import { testClient } from "@nifrajs/client"
48
+ * import { app } from "../src/app"
49
+ *
50
+ * const api = testClient<typeof app>(app)
51
+ * const res = await api.users({ id: "42" }).get()
52
+ * expect(res.ok && res.data.id).toBe("42")
53
+ * ```
54
+ */
55
+ export declare const testClient: typeof inProcessClient;
56
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE/D,OAAO,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAa7D;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAE9E,MAAM,WAAW,aAAa;IAC5B,oFAAoF;IACpF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACzC,+FAA+F;IAC/F,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CACzB;AAQD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAAC,GAAG,CAAC,CAAA;AAClF,wBAAgB,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,aAAa,EAClD,QAAQ,EAAE,CAAC,EACX,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,aAAa,GACtB,kBAAkB,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAA;AAerC;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,GAAG,SAAS;IAAE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAK/D;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,UAAU,wBAAkB,CAAA"}
package/dist/client.js ADDED
@@ -0,0 +1,142 @@
1
+ const HTTP_VERBS = new Set([
2
+ "get",
3
+ "post",
4
+ "put",
5
+ "patch",
6
+ "delete",
7
+ "head",
8
+ "options",
9
+ ]);
10
+ const BODY_VERBS = new Set(["post", "put", "patch"]);
11
+ export function client(arg1, arg2, arg3) {
12
+ const baseUrl = typeof arg1 === "string" ? arg1 : arg2;
13
+ const options = (typeof arg1 === "string" ? arg2 : arg3) ?? {};
14
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
15
+ // The contract value (decoupled mode) is only a type carrier here; the runtime
16
+ // proxy needs just the base URL. (Client-side validation from the contract's
17
+ // schemas is a future enhancement.)
18
+ return createProxy(base, "", options);
19
+ }
20
+ /**
21
+ * A {@link client} whose `fetch` calls a nifra app's own `fetch` in-process — no network, full
22
+ * lifecycle (validation, middleware, contracts). For SSR loaders. Typed from `App` exactly like
23
+ * the network client. The `(url, init) → Request` bridge is required because the client calls
24
+ * `fetch(url, init)` while `app.fetch` takes a `Request`.
25
+ */
26
+ export function inProcessClient(app, options) {
27
+ return client("http://nifra.internal", {
28
+ ...options,
29
+ fetch: (url, init) => Promise.resolve(app.fetch(new Request(url, init))),
30
+ });
31
+ }
32
+ /**
33
+ * The in-process test client — the Fastify-`inject` / supertest equivalent for nifra. Drives the
34
+ * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
35
+ * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw — branch on
36
+ * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
37
+ *
38
+ * ```ts
39
+ * import { testClient } from "@nifrajs/client"
40
+ * import { app } from "../src/app"
41
+ *
42
+ * const api = testClient<typeof app>(app)
43
+ * const res = await api.users({ id: "42" }).get()
44
+ * expect(res.ok && res.data.id).toBe("42")
45
+ * ```
46
+ */
47
+ export const testClient = inProcessClient;
48
+ function createProxy(base, path, options) {
49
+ const target = () => { };
50
+ return new Proxy(target, {
51
+ get(_target, key) {
52
+ // `then` guard: keep an un-awaited node proxy from looking like a thenable.
53
+ if (typeof key !== "string" || key === "then")
54
+ return undefined;
55
+ if (HTTP_VERBS.has(key.toLowerCase())) {
56
+ return (...args) => execute(base, path, key.toLowerCase(), args, options);
57
+ }
58
+ // `index` addresses the root path "/" and adds no segment.
59
+ return createProxy(base, key === "index" ? path : `${path}/${key}`, options);
60
+ },
61
+ apply(_target, _thisArg, args) {
62
+ // A param call (`api.users({ id })`): append the single value, encoded — encoding "/"
63
+ // round-trips through the server's decode, so wildcards and params share this one path.
64
+ // The Treaty type requires the object, but the runtime must stay graceful: a no-arg call
65
+ // (`api.users()`) or empty object (`api.users({})`) must NOT throw — the client's contract is
66
+ // to never throw — nor synthesize an `"undefined"` path segment. With no value, fall through
67
+ // to the bare path, which the server resolves as an ordinary `{ ok: false }` (404) Result.
68
+ const first = args[0];
69
+ const value = first !== null && typeof first === "object"
70
+ ? Object.values(first)[0]
71
+ : first;
72
+ if (value === undefined || value === null)
73
+ return createProxy(base, path, options);
74
+ return createProxy(base, `${path}/${encodeURIComponent(String(value))}`, options);
75
+ },
76
+ });
77
+ }
78
+ async function execute(base, path, verb, args, options) {
79
+ const isBodyVerb = BODY_VERBS.has(verb);
80
+ const body = isBodyVerb ? args[0] : undefined;
81
+ const callOptions = (isBodyVerb ? args[1] : args[0]);
82
+ let url = base + (path === "" ? "/" : path);
83
+ const query = callOptions?.query ? buildQuery(callOptions.query) : "";
84
+ if (query !== "")
85
+ url += `?${query}`;
86
+ const headers = { ...options.headers, ...callOptions?.headers };
87
+ const init = { method: verb.toUpperCase(), headers };
88
+ if (body !== undefined) {
89
+ init.body = JSON.stringify(body);
90
+ headers["content-type"] = "application/json";
91
+ }
92
+ if (callOptions?.signal)
93
+ init.signal = callOptions.signal;
94
+ const doFetch = options.fetch ?? fetch;
95
+ let response;
96
+ try {
97
+ response = await doFetch(url, init);
98
+ }
99
+ catch {
100
+ return { ok: false, status: 0, data: null, error: { error: "network_error" } };
101
+ }
102
+ const data = await parseBody(response);
103
+ if (response.ok) {
104
+ return { ok: true, status: response.status, data, error: null };
105
+ }
106
+ return { ok: false, status: response.status, data: null, error: toApiError(data) };
107
+ }
108
+ function buildQuery(query) {
109
+ const params = new URLSearchParams();
110
+ for (const [key, value] of Object.entries(query)) {
111
+ if (value !== undefined)
112
+ params.set(key, String(value));
113
+ }
114
+ return params.toString();
115
+ }
116
+ async function parseBody(response) {
117
+ if (response.status === 204)
118
+ return undefined;
119
+ const text = await response.text();
120
+ if (text === "")
121
+ return undefined;
122
+ try {
123
+ return JSON.parse(text);
124
+ }
125
+ catch {
126
+ return text; // a non-JSON body (e.g. a plain-text Response) is returned as-is
127
+ }
128
+ }
129
+ function toApiError(data) {
130
+ if (typeof data === "object" &&
131
+ data !== null &&
132
+ "error" in data &&
133
+ typeof data.error === "string") {
134
+ // Trust the server's `{ error, issues }` shape (it's what nifra emits).
135
+ const issues = "issues" in data && Array.isArray(data.issues)
136
+ ? data.issues
137
+ : undefined;
138
+ return issues !== undefined ? { error: data.error, issues } : { error: data.error };
139
+ }
140
+ return { error: "request_failed" };
141
+ }
142
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAIA,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC;IAC9C,KAAK;IACL,MAAM;IACN,KAAK;IACL,OAAO;IACP,QAAQ;IACR,MAAM;IACN,SAAS;CACV,CAAC,CAAA;AACF,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAA;AA0CzE,MAAM,UAAU,MAAM,CACpB,IAA4B,EAC5B,IAA6B,EAC7B,IAAoB;IAEpB,MAAM,OAAO,GAAG,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAE,IAAe,CAAA;IAClE,MAAM,OAAO,GAAG,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAE,IAAkC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;IAC7F,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IACnE,+EAA+E;IAC/E,6EAA6E;IAC7E,oCAAoC;IACpC,OAAO,WAAW,CAAC,IAAI,EAAE,EAAE,EAAE,OAAO,CAAC,CAAA;AACvC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAE7B,GAAQ,EAAE,OAAsC;IAChD,OAAO,MAAM,CAAM,uBAAuB,EAAE;QAC1C,GAAG,OAAO;QACV,KAAK,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;KACzE,CAAC,CAAA;AACJ,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,eAAe,CAAA;AAEzC,SAAS,WAAW,CAAC,IAAY,EAAE,IAAY,EAAE,OAAsB;IACrE,MAAM,MAAM,GAAG,GAAS,EAAE,GAAE,CAAC,CAAA;IAC7B,OAAO,IAAI,KAAK,CAAC,MAAM,EAAE;QACvB,GAAG,CAAC,OAAO,EAAE,GAAG;YACd,4EAA4E;YAC5E,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,MAAM;gBAAE,OAAO,SAAS,CAAA;YAC/D,IAAI,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC;gBACtC,OAAO,CAAC,GAAG,IAAe,EAA4B,EAAE,CACtD,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;YACzD,CAAC;YACD,2DAA2D;YAC3D,OAAO,WAAW,CAAC,IAAI,EAAE,GAAG,KAAK,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,GAAG,EAAE,EAAE,OAAO,CAAC,CAAA;QAC9E,CAAC;QACD,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI;YAC3B,sFAAsF;YACtF,wFAAwF;YACxF,yFAAyF;YACzF,8FAA8F;YAC9F,6FAA6F;YAC7F,2FAA2F;YAC3F,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;YACrB,MAAM,KAAK,GACT,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;gBACzC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,KAAgC,CAAC,CAAC,CAAC,CAAC;gBACpD,CAAC,CAAC,KAAK,CAAA;YACX,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;YAClF,OAAO,WAAW,CAAC,IAAI,EAAE,GAAG,IAAI,IAAI,kBAAkB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QACnF,CAAC;KACF,CAAC,CAAA;AACJ,CAAC;AAED,KAAK,UAAU,OAAO,CACpB,IAAY,EACZ,IAAY,EACZ,IAAY,EACZ,IAAe,EACf,OAAsB;IAEtB,MAAM,UAAU,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IACvC,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAC7C,MAAM,WAAW,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAA4B,CAAA;IAE/E,IAAI,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAC3C,MAAM,KAAK,GAAG,WAAW,EAAE,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IACrE,IAAI,KAAK,KAAK,EAAE;QAAE,GAAG,IAAI,IAAI,KAAK,EAAE,CAAA;IAEpC,MAAM,OAAO,GAA2B,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,GAAG,WAAW,EAAE,OAAO,EAAE,CAAA;IACvF,MAAM,IAAI,GAAgB,EAAE,MAAM,EAAE,IAAI,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,CAAA;IACjE,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;QAChC,OAAO,CAAC,cAAc,CAAC,GAAG,kBAAkB,CAAA;IAC9C,CAAC;IACD,IAAI,WAAW,EAAE,MAAM;QAAE,IAAI,CAAC,MAAM,GAAG,WAAW,CAAC,MAAM,CAAA;IAEzD,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,KAAK,CAAA;IACtC,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;IACrC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,CAAA;IAChF,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAA;IACtC,IAAI,QAAQ,CAAC,EAAE,EAAE,CAAC;QAChB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IACjE,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,CAAC,IAAI,CAAC,EAAE,CAAA;AACpF,CAAC;AAED,SAAS,UAAU,CAAC,KAA8B;IAChD,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAA;IACpC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACjD,IAAI,KAAK,KAAK,SAAS;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;IACzD,CAAC;IACD,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAA;AAC1B,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,QAAkB;IACzC,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO,SAAS,CAAA;IAC7C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAClC,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,SAAS,CAAA;IACjC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA,CAAC,iEAAiE;IAC/E,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,IAAa;IAC/B,IACE,OAAO,IAAI,KAAK,QAAQ;QACxB,IAAI,KAAK,IAAI;QACb,OAAO,IAAI,IAAI;QACf,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,EAC9B,CAAC;QACD,wEAAwE;QACxE,MAAM,MAAM,GACV,QAAQ,IAAI,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC;YAC5C,CAAC,CAAE,IAAI,CAAC,MAA6B;YACrC,CAAC,CAAC,SAAS,CAAA;QACf,OAAO,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAA;IACrF,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,gBAAgB,EAAE,CAAA;AACpC,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @nifrajs/client — browser-safe, end-to-end-typed client for @nifrajs/core servers.
3
+ *
4
+ * `client<typeof app>(url)` returns an Eden-style proxy whose calls are typed
5
+ * from the server's accumulated route registry, with zero codegen.
6
+ */
7
+ export { type ClientOptions, client, type FetchFn, inProcessClient, testClient, } from "./client.js";
8
+ export type { Jsonify } from "./jsonify.js";
9
+ export type { ApiError, Result } from "./result.js";
10
+ export type { ActionArgs, ActionData, ApiProxy, LoaderArgs, LoaderData } from "./routes.js";
11
+ export type { RegistryOf, Treaty, TreatyFromRegistry } from "./treaty.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,KAAK,aAAa,EAClB,MAAM,EACN,KAAK,OAAO,EACZ,eAAe,EACf,UAAU,GACX,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAC3C,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACnD,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAC3F,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @nifrajs/client — browser-safe, end-to-end-typed client for @nifrajs/core servers.
3
+ *
4
+ * `client<typeof app>(url)` returns an Eden-style proxy whose calls are typed
5
+ * from the server's accumulated route registry, with zero codegen.
6
+ */
7
+ export { client, inProcessClient, testClient, } from "./client.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAEL,MAAM,EAEN,eAAe,EACf,UAAU,GACX,MAAM,aAAa,CAAA"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Maps a value to the shape it takes after a JSON round-trip, so the client's
3
+ * `data` type reflects the wire — not the handler's in-memory return.
4
+ *
5
+ * - `Date` → `string`; `undefined` / functions / `bigint` → dropped from objects
6
+ * - arrays and plain objects recurse; optionality (`?`) is preserved
7
+ *
8
+ * Pragmatic by design: `Map`/`Set`/class instances/custom `toJSON` are treated as
9
+ * plain objects (best-effort), not specially handled. Documented, not magic.
10
+ */
11
+ export type Jsonify<T> = [unknown] extends [T] ? unknown : T extends string | number | boolean | null ? T : T extends Date ? string : T extends bigint ? never : T extends (...args: never[]) => unknown ? never : T extends undefined ? never : T extends ReadonlyArray<infer U> ? Array<Jsonify<U>> : T extends object ? {
12
+ [K in keyof T as [Jsonify<T[K]>] extends [never] ? never : K]: Jsonify<T[K]>;
13
+ } : never;
14
+ //# sourceMappingURL=jsonify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jsonify.d.ts","sourceRoot":"","sources":["../src/jsonify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,OAAO,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAC1C,OAAO,GACP,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GACxC,CAAC,GACD,CAAC,SAAS,IAAI,GACZ,MAAM,GACN,CAAC,SAAS,MAAM,GACd,KAAK,GACL,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,GACrC,KAAK,GACL,CAAC,SAAS,SAAS,GACjB,KAAK,GACL,CAAC,SAAS,aAAa,CAAC,MAAM,CAAC,CAAC,GAC9B,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GACjB,CAAC,SAAS,MAAM,GACd;KAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GAChF,KAAK,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=jsonify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jsonify.js","sourceRoot":"","sources":["../src/jsonify.ts"],"names":[],"mappings":""}
@@ -0,0 +1,25 @@
1
+ /** A structured API error, mirroring the server's `{ ok: false, error, issues }`. */
2
+ export interface ApiError {
3
+ readonly error: string;
4
+ readonly issues?: ReadonlyArray<{
5
+ readonly message: string;
6
+ readonly path?: ReadonlyArray<string>;
7
+ }>;
8
+ }
9
+ /**
10
+ * The outcome of a client call. The client never throws — inspect `ok` (or
11
+ * `error`) to branch. On success `data` is the (JSON-shaped) response body; on
12
+ * failure `error` carries the server's structured error or a transport error.
13
+ */
14
+ export type Result<Data> = {
15
+ readonly ok: true;
16
+ readonly status: number;
17
+ readonly data: Data;
18
+ readonly error: null;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly status: number;
22
+ readonly data: null;
23
+ readonly error: ApiError;
24
+ };
25
+ //# sourceMappingURL=result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"result.d.ts","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;QAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;QACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;KACtC,CAAC,CAAA;CACH;AAED;;;;GAIG;AACH,MAAM,MAAM,MAAM,CAAC,IAAI,IACnB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACzF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAA;CAAE,CAAA"}
package/dist/result.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=result.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"result.js","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":""}
@@ -0,0 +1,47 @@
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core";
2
+ import type { Treaty, TreatyFromRegistry } from "./treaty.js";
3
+ /**
4
+ * The typed client proxy for an API type — either a server type (`typeof app`, coupled) or a
5
+ * contract value type (decoupled). Graduating a loader from `typeof app` to a versioned
6
+ * contract is just changing this one type argument; the loader body is identical.
7
+ */
8
+ export type ApiProxy<Api> = Api extends ContractShape ? TreatyFromRegistry<RegistryFor<Api>> : Treaty<Api>;
9
+ /**
10
+ * Context a route `loader` receives: the route params, the request, a typed in-process `api` (an
11
+ * {@link ApiProxy} for the app contract `Api`), and the platform `env`. Pair with `inProcessClient`.
12
+ */
13
+ export interface LoaderArgs<Api, Env = unknown> {
14
+ readonly params: Record<string, string>;
15
+ readonly request: Request;
16
+ readonly api: ApiProxy<Api>;
17
+ /**
18
+ * Platform bindings (Workers `env` — KV/D1/secrets), forwarded from the request `c.env`.
19
+ * `undefined` off-edge (Bun/Node/Deno). Declare the shape via the second type argument —
20
+ * `LoaderArgs<typeof app, Env>` (the same `Env` the backend's `server<Env>()` uses) — to read it
21
+ * typed; otherwise `unknown`. Validate at the trust boundary before use.
22
+ */
23
+ readonly env: Env;
24
+ /** `true` when the request carries a valid draft/preview cookie (when the app sets a `draftSecret`;
25
+ * otherwise always `false`). Branch on it to load unpublished content for editors — see `enableDraft`. */
26
+ readonly draft: boolean;
27
+ }
28
+ /** The (awaited) return of a `loader`, for typing a page component's `data` prop. */
29
+ export type LoaderData<L> = L extends (...args: never[]) => infer R ? Awaited<R> : never;
30
+ /**
31
+ * Context a route `action` (a mutation, run on POST) receives — identical to a loader's:
32
+ * route params, the request (read the form/JSON body off this), and the typed in-process
33
+ * `api` + platform `env`. An action returns either data (surfaced to the page as `actionData`) or a
34
+ * `Response` (e.g. a `redirect(...)` for the Post/Redirect/Get pattern).
35
+ */
36
+ export type ActionArgs<Api, Env = unknown> = LoaderArgs<Api, Env>;
37
+ /**
38
+ * The (awaited) data return of an `action`, for typing a page component's `actionData` prop.
39
+ * A `Response` return (redirect/custom) is excluded — it never reaches the component. A
40
+ * `revalidate(paths, data)` wrapper (from `@nifrajs/web`) is transparent: matched structurally (so this
41
+ * stays decoupled from `@nifrajs/web`) and unwrapped to its inner `data` — what the component receives.
42
+ */
43
+ export type ActionData<A> = A extends (...args: never[]) => infer R ? Awaited<R> extends {
44
+ readonly __nifraRevalidate: readonly string[];
45
+ readonly data: infer D;
46
+ } ? Exclude<D, Response> : Exclude<Awaited<R>, Response> : never;
47
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC/D,OAAO,KAAK,EAAE,MAAM,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAE7D;;;;GAIG;AACH,MAAM,MAAM,QAAQ,CAAC,GAAG,IAAI,GAAG,SAAS,aAAa,GACjD,kBAAkB,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GACpC,MAAM,CAAC,GAAG,CAAC,CAAA;AAEf;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO;IAC5C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAA;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAA;IACjB;8GAC0G;IAC1G,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED,qFAAqF;AACrF,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,CAAA;AAExF;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,IAAI,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;AAEjE;;;;;GAKG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,MAAM,CAAC,GAC/D,OAAO,CAAC,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,GAC1F,OAAO,CAAC,CAAC,EAAE,QAAQ,CAAC,GACpB,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,GAC/B,KAAK,CAAA"}
package/dist/routes.js ADDED
@@ -0,0 +1,14 @@
1
+ export {};
2
+ // Why a type annotation, not a `createRoutes()` factory: a module-level factory call defeats
3
+ // the bundler's tree-shaking (the call is retained, dragging the loader into the client bundle).
4
+ // `LoaderArgs<Api>` is a pure type — it erases — so the loader stays a plain function the client
5
+ // build can drop entirely. Bind the contract once with a shared alias:
6
+ //
7
+ // // app/loaders.ts
8
+ // export type AppLoader = LoaderArgs<typeof backend> // coupled
9
+ // export type AppLoader = LoaderArgs<MyContract> // graduated — same loaders
10
+ //
11
+ // // routes/users/[id].tsx
12
+ // export async function loader({ api, params }: AppLoader) { … } // ctx.api typed
13
+ // export default (props: { data: LoaderData<typeof loader> }) => … // data typed
14
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":";AAuDA,6FAA6F;AAC7F,iGAAiG;AACjG,iGAAiG;AACjG,uEAAuE;AACvE,EAAE;AACF,sBAAsB;AACtB,oEAAoE;AACpE,qFAAqF;AACrF,EAAE;AACF,6BAA6B;AAC7B,sFAAsF;AACtF,oFAAoF"}
@@ -0,0 +1,57 @@
1
+ import type { RouteInfo, Server } from "@nifrajs/core";
2
+ import type { Jsonify } from "./jsonify.js";
3
+ import type { Result } from "./result.js";
4
+ /** Extract the accumulated route registry from a server's type (`typeof app`), ignoring its middleware context. */
5
+ export type RegistryOf<App> = App extends Server<infer R, infer _Ctx> ? R : never;
6
+ type CallOptions<I extends RouteInfo> = {
7
+ query?: [I["query"]] extends [never] ? never : I["query"];
8
+ headers?: Record<string, string>;
9
+ signal?: AbortSignal;
10
+ };
11
+ type IsBodyVerb<M extends string> = M extends "POST" | "PUT" | "PATCH" ? true : false;
12
+ /**
13
+ * A terminal method call. The shape is **verb-aware** so the runtime can read
14
+ * args unambiguously with no route info: body-verbs (POST/PUT/PATCH) always take
15
+ * the body positionally Eden-style (typed `undefined` when the route has no body
16
+ * schema, so it's still slot 0); GET/DELETE/HEAD take only options. Returns a
17
+ * `Result` whose data is the handler's output as it arrives over the wire
18
+ * (`Jsonify`).
19
+ */
20
+ type MethodCall<I extends RouteInfo, BodyVerb extends boolean> = BodyVerb extends true ? [I["body"]] extends [never] ? (body?: undefined, options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>> : (body: I["body"], options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>> : (options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>>;
21
+ type Methods<MethodMap> = {
22
+ [M in keyof MethodMap as Lowercase<M & string>]: MethodCall<MethodMap[M] & RouteInfo, IsBodyVerb<M & string>>;
23
+ };
24
+ type Sub<R, Prefix extends string> = Extract<keyof R, `${Prefix}/${string}`>;
25
+ type NextSeg<Prefix extends string, Path extends string> = Path extends `${Prefix}/${infer Seg}/${string}` ? Seg : Path extends `${Prefix}/${infer Seg}` ? Seg : never;
26
+ type NextSegs<R, Prefix extends string> = {
27
+ [P in Sub<R, Prefix> & string]: NextSeg<Prefix, P>;
28
+ }[Sub<R, Prefix> & string];
29
+ type StaticSegs<R, Prefix extends string> = Exclude<NextSegs<R, Prefix>, `:${string}` | `*${string}` | "">;
30
+ type ParamSeg<R, Prefix extends string> = Extract<NextSegs<R, Prefix>, `:${string}` | `*${string}`>;
31
+ type MethodsAt<R, Prefix extends string> = Prefix extends keyof R ? Methods<R[Prefix]> : unknown;
32
+ type StaticChildren<R, Prefix extends string> = {
33
+ [Seg in StaticSegs<R, Prefix> & string]: TreatyNode<R, `${Prefix}/${Seg}`>;
34
+ };
35
+ type ParamChild<R, Prefix extends string> = [ParamSeg<R, Prefix>] extends [never] ? unknown : ParamSeg<R, Prefix> extends `:${infer Name}` ? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/:${Name}`> : ParamSeg<R, Prefix> extends `*${infer Name}` ? (params: Record<Name extends "" ? "*" : Name, string>) => TreatyNode<R, `${Prefix}/*${Name}`> : unknown;
36
+ type TreatyNode<R, Prefix extends string> = MethodsAt<R, Prefix> & StaticChildren<R, Prefix> & ParamChild<R, Prefix>;
37
+ type RootIndex<R> = "/" extends keyof R ? {
38
+ readonly index: Methods<R["/"]>;
39
+ } : unknown;
40
+ /**
41
+ * The Eden-style proxy type for a route registry — the shared core used by both
42
+ * `Treaty<App>` (coupled, from `typeof app`) and `client(contract, url)`
43
+ * (decoupled, from a contract's `RegistryFor`).
44
+ */
45
+ export type TreatyFromRegistry<R> = TreatyNode<R, ""> & RootIndex<R>;
46
+ /**
47
+ * The Eden-style proxy type for a server. Use a named alias for readable errors:
48
+ *
49
+ * type App = typeof app
50
+ * const api: Treaty<App> = client<App>("http://localhost:3000")
51
+ * await api.users({ id: "1" }).get() // GET /users/:id
52
+ * await api.users.post({ name: "Ada" }) // POST /users (body positional)
53
+ * await api.index.get() // GET /
54
+ */
55
+ export type Treaty<App> = TreatyFromRegistry<RegistryOf<App>>;
56
+ export {};
57
+ //# sourceMappingURL=treaty.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"treaty.d.ts","sourceRoot":"","sources":["../src/treaty.ts"],"names":[],"mappings":"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"}
package/dist/treaty.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=treaty.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"treaty.js","sourceRoot":"","sources":["../src/treaty.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@nifrajs/client",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Browser-safe, end-to-end-typed client for @nifrajs/core servers (Eden-style proxy).",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "edge",
12
+ "ssr",
13
+ "rpc",
14
+ "typed-client",
15
+ "fetch",
16
+ "end-to-end-types"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "bun": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "sideEffects": false,
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
36
+ },
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "@nifrajs/core": "workspace:*"
43
+ },
44
+ "devDependencies": {
45
+ "@nifrajs/core": "workspace:*",
46
+ "@nifrajs/test-utils": "workspace:*"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/nifrajs/nifra.git",
51
+ "directory": "packages/client"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/nifrajs/nifra/issues"
55
+ },
56
+ "homepage": "https://github.com/nifrajs/nifra#readme"
57
+ }
package/src/client.ts ADDED
@@ -0,0 +1,205 @@
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core"
2
+ import type { ApiError, Result } from "./result.ts"
3
+ import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
4
+
5
+ const HTTP_VERBS: ReadonlySet<string> = new Set([
6
+ "get",
7
+ "post",
8
+ "put",
9
+ "patch",
10
+ "delete",
11
+ "head",
12
+ "options",
13
+ ])
14
+ const BODY_VERBS: ReadonlySet<string> = new Set(["post", "put", "patch"])
15
+
16
+ /**
17
+ * The fetch shape the client needs — looser than `typeof fetch` so an in-process bridge or a
18
+ * test mock satisfies it without the extra members (`.preconnect`, overloads) of the global.
19
+ */
20
+ export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
21
+
22
+ export interface ClientOptions {
23
+ /** Headers sent on every request (a per-call `headers` option is merged on top). */
24
+ readonly headers?: Record<string, string>
25
+ /** Override the `fetch` implementation (tests, an in-process bridge, a custom agent, etc.). */
26
+ readonly fetch?: FetchFn
27
+ }
28
+
29
+ interface CallOptions {
30
+ readonly query?: Record<string, unknown>
31
+ readonly headers?: Record<string, string>
32
+ readonly signal?: AbortSignal
33
+ }
34
+
35
+ /**
36
+ * Create an end-to-end-typed client for a nifra server. Two modes:
37
+ *
38
+ * // coupled — typed from the server's type (`typeof app`)
39
+ * const api = client<typeof app>("http://localhost:3000")
40
+ *
41
+ * // decoupled — typed from a contract VALUE, no server import
42
+ * const api = client(contract, "https://api.example.com")
43
+ *
44
+ * const { data, error } = await api.users({ id: "1" }).get()
45
+ *
46
+ * Both return the same Eden-style proxy. Browser-safe: uses only `fetch` /
47
+ * `Proxy` / `URL`, never the server runtime. Never throws — network and non-2xx
48
+ * responses surface as a `Result` `error`.
49
+ */
50
+ export function client<App>(baseUrl: string, options?: ClientOptions): Treaty<App>
51
+ export function client<const C extends ContractShape>(
52
+ contract: C,
53
+ baseUrl: string,
54
+ options?: ClientOptions,
55
+ ): TreatyFromRegistry<RegistryFor<C>>
56
+ export function client(
57
+ arg1: string | ContractShape,
58
+ arg2?: string | ClientOptions,
59
+ arg3?: ClientOptions,
60
+ ): unknown {
61
+ const baseUrl = typeof arg1 === "string" ? arg1 : (arg2 as string)
62
+ const options = (typeof arg1 === "string" ? (arg2 as ClientOptions | undefined) : arg3) ?? {}
63
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl
64
+ // The contract value (decoupled mode) is only a type carrier here; the runtime
65
+ // proxy needs just the base URL. (Client-side validation from the contract's
66
+ // schemas is a future enhancement.)
67
+ return createProxy(base, "", options)
68
+ }
69
+
70
+ /**
71
+ * A {@link client} whose `fetch` calls a nifra app's own `fetch` in-process — no network, full
72
+ * lifecycle (validation, middleware, contracts). For SSR loaders. Typed from `App` exactly like
73
+ * the network client. The `(url, init) → Request` bridge is required because the client calls
74
+ * `fetch(url, init)` while `app.fetch` takes a `Request`.
75
+ */
76
+ export function inProcessClient<
77
+ App extends { fetch(request: Request): Response | Promise<Response> },
78
+ >(app: App, options?: Omit<ClientOptions, "fetch">): Treaty<App> {
79
+ return client<App>("http://nifra.internal", {
80
+ ...options,
81
+ fetch: (url, init) => Promise.resolve(app.fetch(new Request(url, init))),
82
+ })
83
+ }
84
+
85
+ /**
86
+ * The in-process test client — the Fastify-`inject` / supertest equivalent for nifra. Drives the
87
+ * app's own `fetch` directly: no server, no port, no network, the full real lifecycle (validation,
88
+ * middleware, contracts, auth), and end-to-end types from `App`. Calls never throw — branch on
89
+ * `res.ok`. An alias of {@link inProcessClient} with a test-focused name; identical behavior.
90
+ *
91
+ * ```ts
92
+ * import { testClient } from "@nifrajs/client"
93
+ * import { app } from "../src/app"
94
+ *
95
+ * const api = testClient<typeof app>(app)
96
+ * const res = await api.users({ id: "42" }).get()
97
+ * expect(res.ok && res.data.id).toBe("42")
98
+ * ```
99
+ */
100
+ export const testClient = inProcessClient
101
+
102
+ function createProxy(base: string, path: string, options: ClientOptions): unknown {
103
+ const target = (): void => {}
104
+ return new Proxy(target, {
105
+ get(_target, key) {
106
+ // `then` guard: keep an un-awaited node proxy from looking like a thenable.
107
+ if (typeof key !== "string" || key === "then") return undefined
108
+ if (HTTP_VERBS.has(key.toLowerCase())) {
109
+ return (...args: unknown[]): Promise<Result<unknown>> =>
110
+ execute(base, path, key.toLowerCase(), args, options)
111
+ }
112
+ // `index` addresses the root path "/" and adds no segment.
113
+ return createProxy(base, key === "index" ? path : `${path}/${key}`, options)
114
+ },
115
+ apply(_target, _thisArg, args) {
116
+ // A param call (`api.users({ id })`): append the single value, encoded — encoding "/"
117
+ // round-trips through the server's decode, so wildcards and params share this one path.
118
+ // The Treaty type requires the object, but the runtime must stay graceful: a no-arg call
119
+ // (`api.users()`) or empty object (`api.users({})`) must NOT throw — the client's contract is
120
+ // to never throw — nor synthesize an `"undefined"` path segment. With no value, fall through
121
+ // to the bare path, which the server resolves as an ordinary `{ ok: false }` (404) Result.
122
+ const first = args[0]
123
+ const value =
124
+ first !== null && typeof first === "object"
125
+ ? Object.values(first as Record<string, unknown>)[0]
126
+ : first
127
+ if (value === undefined || value === null) return createProxy(base, path, options)
128
+ return createProxy(base, `${path}/${encodeURIComponent(String(value))}`, options)
129
+ },
130
+ })
131
+ }
132
+
133
+ async function execute(
134
+ base: string,
135
+ path: string,
136
+ verb: string,
137
+ args: unknown[],
138
+ options: ClientOptions,
139
+ ): Promise<Result<unknown>> {
140
+ const isBodyVerb = BODY_VERBS.has(verb)
141
+ const body = isBodyVerb ? args[0] : undefined
142
+ const callOptions = (isBodyVerb ? args[1] : args[0]) as CallOptions | undefined
143
+
144
+ let url = base + (path === "" ? "/" : path)
145
+ const query = callOptions?.query ? buildQuery(callOptions.query) : ""
146
+ if (query !== "") url += `?${query}`
147
+
148
+ const headers: Record<string, string> = { ...options.headers, ...callOptions?.headers }
149
+ const init: RequestInit = { method: verb.toUpperCase(), headers }
150
+ if (body !== undefined) {
151
+ init.body = JSON.stringify(body)
152
+ headers["content-type"] = "application/json"
153
+ }
154
+ if (callOptions?.signal) init.signal = callOptions.signal
155
+
156
+ const doFetch = options.fetch ?? fetch
157
+ let response: Response
158
+ try {
159
+ response = await doFetch(url, init)
160
+ } catch {
161
+ return { ok: false, status: 0, data: null, error: { error: "network_error" } }
162
+ }
163
+
164
+ const data = await parseBody(response)
165
+ if (response.ok) {
166
+ return { ok: true, status: response.status, data, error: null }
167
+ }
168
+ return { ok: false, status: response.status, data: null, error: toApiError(data) }
169
+ }
170
+
171
+ function buildQuery(query: Record<string, unknown>): string {
172
+ const params = new URLSearchParams()
173
+ for (const [key, value] of Object.entries(query)) {
174
+ if (value !== undefined) params.set(key, String(value))
175
+ }
176
+ return params.toString()
177
+ }
178
+
179
+ async function parseBody(response: Response): Promise<unknown> {
180
+ if (response.status === 204) return undefined
181
+ const text = await response.text()
182
+ if (text === "") return undefined
183
+ try {
184
+ return JSON.parse(text)
185
+ } catch {
186
+ return text // a non-JSON body (e.g. a plain-text Response) is returned as-is
187
+ }
188
+ }
189
+
190
+ function toApiError(data: unknown): ApiError {
191
+ if (
192
+ typeof data === "object" &&
193
+ data !== null &&
194
+ "error" in data &&
195
+ typeof data.error === "string"
196
+ ) {
197
+ // Trust the server's `{ error, issues }` shape (it's what nifra emits).
198
+ const issues =
199
+ "issues" in data && Array.isArray(data.issues)
200
+ ? (data.issues as ApiError["issues"])
201
+ : undefined
202
+ return issues !== undefined ? { error: data.error, issues } : { error: data.error }
203
+ }
204
+ return { error: "request_failed" }
205
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @nifrajs/client — browser-safe, end-to-end-typed client for @nifrajs/core servers.
3
+ *
4
+ * `client<typeof app>(url)` returns an Eden-style proxy whose calls are typed
5
+ * from the server's accumulated route registry, with zero codegen.
6
+ */
7
+ export {
8
+ type ClientOptions,
9
+ client,
10
+ type FetchFn,
11
+ inProcessClient,
12
+ testClient,
13
+ } from "./client.ts"
14
+ export type { Jsonify } from "./jsonify.ts"
15
+ export type { ApiError, Result } from "./result.ts"
16
+ export type { ActionArgs, ActionData, ApiProxy, LoaderArgs, LoaderData } from "./routes.ts"
17
+ export type { RegistryOf, Treaty, TreatyFromRegistry } from "./treaty.ts"
package/src/jsonify.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Maps a value to the shape it takes after a JSON round-trip, so the client's
3
+ * `data` type reflects the wire — not the handler's in-memory return.
4
+ *
5
+ * - `Date` → `string`; `undefined` / functions / `bigint` → dropped from objects
6
+ * - arrays and plain objects recurse; optionality (`?`) is preserved
7
+ *
8
+ * Pragmatic by design: `Map`/`Set`/class instances/custom `toJSON` are treated as
9
+ * plain objects (best-effort), not specially handled. Documented, not magic.
10
+ */
11
+ export type Jsonify<T> = [unknown] extends [T]
12
+ ? unknown // `unknown`/`any` (e.g. a route with no response schema) stays opaque
13
+ : T extends string | number | boolean | null
14
+ ? T
15
+ : T extends Date
16
+ ? string
17
+ : T extends bigint
18
+ ? never
19
+ : T extends (...args: never[]) => unknown
20
+ ? never
21
+ : T extends undefined
22
+ ? never
23
+ : T extends ReadonlyArray<infer U>
24
+ ? Array<Jsonify<U>>
25
+ : T extends object
26
+ ? { [K in keyof T as [Jsonify<T[K]>] extends [never] ? never : K]: Jsonify<T[K]> }
27
+ : never
package/src/result.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** A structured API error, mirroring the server's `{ ok: false, error, issues }`. */
2
+ export interface ApiError {
3
+ readonly error: string
4
+ readonly issues?: ReadonlyArray<{
5
+ readonly message: string
6
+ readonly path?: ReadonlyArray<string>
7
+ }>
8
+ }
9
+
10
+ /**
11
+ * The outcome of a client call. The client never throws — inspect `ok` (or
12
+ * `error`) to branch. On success `data` is the (JSON-shaped) response body; on
13
+ * failure `error` carries the server's structured error or a transport error.
14
+ */
15
+ export type Result<Data> =
16
+ | { readonly ok: true; readonly status: number; readonly data: Data; readonly error: null }
17
+ | { readonly ok: false; readonly status: number; readonly data: null; readonly error: ApiError }
package/src/routes.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ContractShape, RegistryFor } from "@nifrajs/core"
2
+ import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
3
+
4
+ /**
5
+ * The typed client proxy for an API type — either a server type (`typeof app`, coupled) or a
6
+ * contract value type (decoupled). Graduating a loader from `typeof app` to a versioned
7
+ * contract is just changing this one type argument; the loader body is identical.
8
+ */
9
+ export type ApiProxy<Api> = Api extends ContractShape
10
+ ? TreatyFromRegistry<RegistryFor<Api>>
11
+ : Treaty<Api>
12
+
13
+ /**
14
+ * Context a route `loader` receives: the route params, the request, a typed in-process `api` (an
15
+ * {@link ApiProxy} for the app contract `Api`), and the platform `env`. Pair with `inProcessClient`.
16
+ */
17
+ export interface LoaderArgs<Api, Env = unknown> {
18
+ readonly params: Record<string, string>
19
+ readonly request: Request
20
+ readonly api: ApiProxy<Api>
21
+ /**
22
+ * Platform bindings (Workers `env` — KV/D1/secrets), forwarded from the request `c.env`.
23
+ * `undefined` off-edge (Bun/Node/Deno). Declare the shape via the second type argument —
24
+ * `LoaderArgs<typeof app, Env>` (the same `Env` the backend's `server<Env>()` uses) — to read it
25
+ * typed; otherwise `unknown`. Validate at the trust boundary before use.
26
+ */
27
+ readonly env: Env
28
+ /** `true` when the request carries a valid draft/preview cookie (when the app sets a `draftSecret`;
29
+ * otherwise always `false`). Branch on it to load unpublished content for editors — see `enableDraft`. */
30
+ readonly draft: boolean
31
+ }
32
+
33
+ /** The (awaited) return of a `loader`, for typing a page component's `data` prop. */
34
+ export type LoaderData<L> = L extends (...args: never[]) => infer R ? Awaited<R> : never
35
+
36
+ /**
37
+ * Context a route `action` (a mutation, run on POST) receives — identical to a loader's:
38
+ * route params, the request (read the form/JSON body off this), and the typed in-process
39
+ * `api` + platform `env`. An action returns either data (surfaced to the page as `actionData`) or a
40
+ * `Response` (e.g. a `redirect(...)` for the Post/Redirect/Get pattern).
41
+ */
42
+ export type ActionArgs<Api, Env = unknown> = LoaderArgs<Api, Env>
43
+
44
+ /**
45
+ * The (awaited) data return of an `action`, for typing a page component's `actionData` prop.
46
+ * A `Response` return (redirect/custom) is excluded — it never reaches the component. A
47
+ * `revalidate(paths, data)` wrapper (from `@nifrajs/web`) is transparent: matched structurally (so this
48
+ * stays decoupled from `@nifrajs/web`) and unwrapped to its inner `data` — what the component receives.
49
+ */
50
+ export type ActionData<A> = A extends (...args: never[]) => infer R
51
+ ? Awaited<R> extends { readonly __nifraRevalidate: readonly string[]; readonly data: infer D }
52
+ ? Exclude<D, Response>
53
+ : Exclude<Awaited<R>, Response>
54
+ : never
55
+
56
+ // Why a type annotation, not a `createRoutes()` factory: a module-level factory call defeats
57
+ // the bundler's tree-shaking (the call is retained, dragging the loader into the client bundle).
58
+ // `LoaderArgs<Api>` is a pure type — it erases — so the loader stays a plain function the client
59
+ // build can drop entirely. Bind the contract once with a shared alias:
60
+ //
61
+ // // app/loaders.ts
62
+ // export type AppLoader = LoaderArgs<typeof backend> // coupled
63
+ // export type AppLoader = LoaderArgs<MyContract> // graduated — same loaders
64
+ //
65
+ // // routes/users/[id].tsx
66
+ // export async function loader({ api, params }: AppLoader) { … } // ctx.api typed
67
+ // export default (props: { data: LoaderData<typeof loader> }) => … // data typed
package/src/treaty.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { RouteInfo, Server } from "@nifrajs/core"
2
+ import type { Jsonify } from "./jsonify.ts"
3
+ import type { Result } from "./result.ts"
4
+
5
+ /** Extract the accumulated route registry from a server's type (`typeof app`), ignoring its middleware context. */
6
+ export type RegistryOf<App> = App extends Server<infer R, infer _Ctx> ? R : never
7
+
8
+ // --- per-method call signature ---
9
+
10
+ type CallOptions<I extends RouteInfo> = {
11
+ query?: [I["query"]] extends [never] ? never : I["query"]
12
+ headers?: Record<string, string>
13
+ signal?: AbortSignal
14
+ }
15
+
16
+ type IsBodyVerb<M extends string> = M extends "POST" | "PUT" | "PATCH" ? true : false
17
+
18
+ /**
19
+ * A terminal method call. The shape is **verb-aware** so the runtime can read
20
+ * args unambiguously with no route info: body-verbs (POST/PUT/PATCH) always take
21
+ * the body positionally Eden-style (typed `undefined` when the route has no body
22
+ * schema, so it's still slot 0); GET/DELETE/HEAD take only options. Returns a
23
+ * `Result` whose data is the handler's output as it arrives over the wire
24
+ * (`Jsonify`).
25
+ */
26
+ type MethodCall<I extends RouteInfo, BodyVerb extends boolean> = BodyVerb extends true
27
+ ? [I["body"]] extends [never]
28
+ ? (body?: undefined, options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>>
29
+ : (body: I["body"], options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>>
30
+ : (options?: CallOptions<I>) => Promise<Result<Jsonify<I["output"]>>>
31
+
32
+ type Methods<MethodMap> = {
33
+ [M in keyof MethodMap as Lowercase<M & string>]: MethodCall<
34
+ MethodMap[M] & RouteInfo,
35
+ IsBodyVerb<M & string>
36
+ >
37
+ }
38
+
39
+ // --- path-tree construction over the registry ---
40
+
41
+ type Sub<R, Prefix extends string> = Extract<keyof R, `${Prefix}/${string}`>
42
+
43
+ type NextSeg<
44
+ Prefix extends string,
45
+ Path extends string,
46
+ > = Path extends `${Prefix}/${infer Seg}/${string}`
47
+ ? Seg
48
+ : Path extends `${Prefix}/${infer Seg}`
49
+ ? Seg
50
+ : never
51
+
52
+ type NextSegs<R, Prefix extends string> = {
53
+ [P in Sub<R, Prefix> & string]: NextSeg<Prefix, P>
54
+ }[Sub<R, Prefix> & string]
55
+
56
+ // Static segments exclude params (`:`), wildcards (`*`), and the root's empty (``).
57
+ type StaticSegs<R, Prefix extends string> = Exclude<
58
+ NextSegs<R, Prefix>,
59
+ `:${string}` | `*${string}` | ""
60
+ >
61
+ type ParamSeg<R, Prefix extends string> = Extract<NextSegs<R, Prefix>, `:${string}` | `*${string}`>
62
+
63
+ // `unknown` (not `{}`/`never`) is the intersection identity, so empty branches
64
+ // don't poison the node — and we never `Prettify` the node (it would strip the
65
+ // param call-signature and trip TS2456 on the recursion).
66
+ type MethodsAt<R, Prefix extends string> = Prefix extends keyof R ? Methods<R[Prefix]> : unknown
67
+
68
+ type StaticChildren<R, Prefix extends string> = {
69
+ [Seg in StaticSegs<R, Prefix> & string]: TreatyNode<R, `${Prefix}/${Seg}`>
70
+ }
71
+
72
+ type ParamChild<R, Prefix extends string> = [ParamSeg<R, Prefix>] extends [never]
73
+ ? unknown
74
+ : ParamSeg<R, Prefix> extends `:${infer Name}`
75
+ ? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/:${Name}`>
76
+ : ParamSeg<R, Prefix> extends `*${infer Name}`
77
+ ? (
78
+ params: Record<Name extends "" ? "*" : Name, string>,
79
+ ) => TreatyNode<R, `${Prefix}/*${Name}`>
80
+ : unknown
81
+
82
+ type TreatyNode<R, Prefix extends string> = MethodsAt<R, Prefix> &
83
+ StaticChildren<R, Prefix> &
84
+ ParamChild<R, Prefix>
85
+
86
+ // The root path "/" is reached as `api.index.get()` (Eden convention).
87
+ type RootIndex<R> = "/" extends keyof R ? { readonly index: Methods<R["/"]> } : unknown
88
+
89
+ /**
90
+ * The Eden-style proxy type for a route registry — the shared core used by both
91
+ * `Treaty<App>` (coupled, from `typeof app`) and `client(contract, url)`
92
+ * (decoupled, from a contract's `RegistryFor`).
93
+ */
94
+ export type TreatyFromRegistry<R> = TreatyNode<R, ""> & RootIndex<R>
95
+
96
+ /**
97
+ * The Eden-style proxy type for a server. Use a named alias for readable errors:
98
+ *
99
+ * type App = typeof app
100
+ * const api: Treaty<App> = client<App>("http://localhost:3000")
101
+ * await api.users({ id: "1" }).get() // GET /users/:id
102
+ * await api.users.post({ name: "Ada" }) // POST /users (body positional)
103
+ * await api.index.get() // GET /
104
+ */
105
+ export type Treaty<App> = TreatyFromRegistry<RegistryOf<App>>