@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 +21 -0
- package/README.md +30 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +142 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonify.d.ts +14 -0
- package/dist/jsonify.d.ts.map +1 -0
- package/dist/jsonify.js +2 -0
- package/dist/jsonify.js.map +1 -0
- package/dist/result.d.ts +25 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +2 -0
- package/dist/result.js.map +1 -0
- package/dist/routes.d.ts +47 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +14 -0
- package/dist/routes.js.map +1 -0
- package/dist/treaty.d.ts +57 -0
- package/dist/treaty.d.ts.map +1 -0
- package/dist/treaty.js +2 -0
- package/dist/treaty.js.map +1 -0
- package/package.json +57 -0
- package/src/client.ts +205 -0
- package/src/index.ts +17 -0
- package/src/jsonify.ts +27 -0
- package/src/result.ts +17 -0
- package/src/routes.ts +67 -0
- package/src/treaty.ts +105 -0
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/jsonify.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jsonify.js","sourceRoot":"","sources":["../src/jsonify.ts"],"names":[],"mappings":""}
|
package/dist/result.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"result.js","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":""}
|
package/dist/routes.d.ts
ADDED
|
@@ -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"}
|
package/dist/treaty.d.ts
ADDED
|
@@ -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 @@
|
|
|
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>>
|