@nifrajs/web 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
package/dist/draft.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Draft / preview mode
|
|
2
|
+
* Draft / preview mode - let an editor see unpublished content by flipping a signed cookie that
|
|
3
3
|
* loaders read (`ctx.draft`) and ISR bypasses (fresh render, never cached). The cookie is **HMAC-signed**
|
|
4
|
-
* (so a visitor can't forge it) and **HttpOnly** (JS can't read it). You gate `enableDraft` yourself
|
|
5
|
-
* behind an editor login or a `?token=` check
|
|
4
|
+
* (so a visitor can't forge it) and **HttpOnly** (JS can't read it). You gate `enableDraft` yourself -
|
|
5
|
+
* behind an editor login or a `?token=` check - exactly like Next's `draftMode().enable()`.
|
|
6
6
|
*
|
|
7
7
|
* // a route you've protected (e.g. checked ?token= against a secret):
|
|
8
8
|
* app.get("/api/draft", async (c) => {
|
|
@@ -14,28 +14,41 @@
|
|
|
14
14
|
* createWebApp({ ..., draftSecret: env.DRAFT_SECRET }) // → loaders get ctx.draft
|
|
15
15
|
* withISR(app, { ..., draftSecret: env.DRAFT_SECRET }) // → editors bypass the cache
|
|
16
16
|
*/
|
|
17
|
-
import { parseCookies, signValue, unsignValue } from "@nifrajs/core/server";
|
|
17
|
+
import { parseCookies, serializeCookie, signValue, unsignValue, } from "@nifrajs/core/server";
|
|
18
|
+
import { timingSafeEqual } from "./internal/timing-safe-equal.js";
|
|
18
19
|
/** The cookie name nifra uses for draft/preview mode. */
|
|
19
20
|
export const DRAFT_COOKIE = "__nifra_draft";
|
|
20
|
-
// The signed cookie's payload is a fixed marker
|
|
21
|
+
// The signed cookie's payload is a fixed marker - a *valid signature* (not the value) is the signal
|
|
21
22
|
// that the server issued this via enableDraft(). Nothing secret is stored in it.
|
|
22
23
|
const DRAFT_MARKER = "1";
|
|
23
24
|
/**
|
|
24
25
|
* Turn draft mode **on** for this client by setting a signed, HttpOnly `__nifra_draft` cookie. Call it
|
|
25
|
-
* from a route you've already authorized. `secret` signs the cookie
|
|
26
|
+
* from a route you've already authorized. `secret` signs the cookie - pass the SAME secret to
|
|
26
27
|
* `createWebApp({ draftSecret })` and `withISR({ draftSecret })` so the framework can verify it.
|
|
27
28
|
*/
|
|
28
29
|
export async function enableDraft(c, secret, options = {}) {
|
|
29
30
|
const signed = await signValue(DRAFT_MARKER, secret);
|
|
31
|
+
c.set.cookie(DRAFT_COOKIE, signed, draftCookieOptions(options));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The attributes draft mode always sets. Shared by {@link enableDraft} (which writes through
|
|
35
|
+
* `c.set.cookie`) and {@link previewEndpoint} (which serializes its own `Set-Cookie`), so the two
|
|
36
|
+
* can't drift on the properties that make the cookie unforgeable and unreadable from JS.
|
|
37
|
+
*
|
|
38
|
+
* `secure` is resolved here rather than left to the caller: `serializeCookie` is pure and applies
|
|
39
|
+
* **no** security defaults, so the `previewEndpoint` path would silently emit a cookie without
|
|
40
|
+
* `Secure` - over plain HTTP that hands the draft cookie to any network observer. Passing it
|
|
41
|
+
* explicitly makes both paths identical instead of one inheriting a default the other never sees.
|
|
42
|
+
*/
|
|
43
|
+
function draftCookieOptions(options) {
|
|
30
44
|
// Explicit security attributes (don't lean on the context default): signed + HttpOnly + SameSite=Lax.
|
|
31
|
-
|
|
45
|
+
return {
|
|
32
46
|
httpOnly: true,
|
|
33
47
|
sameSite: "lax",
|
|
34
48
|
path: options.path ?? "/",
|
|
35
49
|
maxAge: options.maxAgeSeconds ?? 3600,
|
|
36
|
-
|
|
50
|
+
secure: options.secure ?? true,
|
|
37
51
|
};
|
|
38
|
-
c.set.cookie(DRAFT_COOKIE, signed, cookieOptions);
|
|
39
52
|
}
|
|
40
53
|
/** Turn draft mode **off**: clear the `__nifra_draft` cookie. Match the `path` used in `enableDraft`. */
|
|
41
54
|
export function disableDraft(c, options = {}) {
|
|
@@ -52,4 +65,74 @@ export async function isDraftEnabled(request, secret) {
|
|
|
52
65
|
return false;
|
|
53
66
|
return (await unsignValue(cookie, secret)) !== null;
|
|
54
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Accept only a same-site destination, or `null`.
|
|
70
|
+
*
|
|
71
|
+
* A redirect target that arrives in the query string is attacker-controlled: anyone can mint a link
|
|
72
|
+
* to your own preview route and choose where the editor lands. "Starts with `/`" is the check people
|
|
73
|
+
* write and it is not enough - `//evil.com` is a protocol-relative URL and `/\evil.com` is normalized
|
|
74
|
+
* to one by browsers, so both start with a slash and navigate off-site. Control characters are refused
|
|
75
|
+
* as well, since a newline in a `Location` header splits the response.
|
|
76
|
+
*/
|
|
77
|
+
function safeRedirectPath(value) {
|
|
78
|
+
if (!value.startsWith("/"))
|
|
79
|
+
return null;
|
|
80
|
+
if (value.startsWith("//") || value.startsWith("/\\"))
|
|
81
|
+
return null;
|
|
82
|
+
// Escaped rather than written literally: raw control bytes in source make the file read as binary
|
|
83
|
+
// to ordinary tooling (grep, diffs) and are one reformat away from being silently mangled.
|
|
84
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching them is the point - this IS the check that refuses them.
|
|
85
|
+
if (/[\u0000-\u001f\u007f]/u.test(value))
|
|
86
|
+
return null;
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A **preview / draft-mode entry point** - a `fetch` handler that checks a preview token, turns draft
|
|
91
|
+
* mode on, and redirects the editor to the page they wanted. `GET` with `?token=<secret>&to=/some/path`;
|
|
92
|
+
* mount it on a nifra route, e.g. `app.get("/api/preview", (c) => handler(c.req))`.
|
|
93
|
+
*
|
|
94
|
+
* This is the link-borne sibling of `revalidateEndpoint`. It exists because the alternative - telling
|
|
95
|
+
* you to gate the route yourself - means hand-rolling two checks that are easy to get subtly wrong and
|
|
96
|
+
* that fail silently when you do: the token compare must not exit early on the first wrong character,
|
|
97
|
+
* and the `?to=` destination must not be allowed to point off-site. Both are handled here.
|
|
98
|
+
*
|
|
99
|
+
* A CMS "Preview" button is a plain link, so the token has to ride the query string. That has a cost
|
|
100
|
+
* no endpoint can remove - the secret lands in server logs, the `Referer` header, and browser history.
|
|
101
|
+
* Use a preview token minted for that purpose, rotate it, and never reuse a token that grants anything
|
|
102
|
+
* beyond draft mode.
|
|
103
|
+
*
|
|
104
|
+
* Wrong or missing token → `401`; an off-site `to` → `400`; success → `302` with the signed cookie and
|
|
105
|
+
* `Cache-Control: no-store`, so no shared cache can ever replay one editor's draft session to a visitor.
|
|
106
|
+
*/
|
|
107
|
+
export function previewEndpoint(options) {
|
|
108
|
+
const tokenParam = options.tokenParam ?? "token";
|
|
109
|
+
const redirectParam = options.redirectParam ?? "to";
|
|
110
|
+
const fallbackPath = options.fallbackPath ?? "/";
|
|
111
|
+
// Fail at construction, not on the request that happens to omit `?to=`: a misconfigured fallback is
|
|
112
|
+
// a deploy-time mistake and should surface at boot rather than as a rare 400 in production.
|
|
113
|
+
if (safeRedirectPath(fallbackPath) === null) {
|
|
114
|
+
throw new Error(`[nifra] previewEndpoint fallbackPath must be a site-relative path, got ${JSON.stringify(fallbackPath)}`);
|
|
115
|
+
}
|
|
116
|
+
const cookieOptions = draftCookieOptions(options.cookie ?? {});
|
|
117
|
+
return async (request) => {
|
|
118
|
+
const url = new URL(request.url);
|
|
119
|
+
if (!timingSafeEqual(url.searchParams.get(tokenParam) ?? "", options.secret)) {
|
|
120
|
+
return Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
121
|
+
}
|
|
122
|
+
const requested = url.searchParams.get(redirectParam);
|
|
123
|
+
const destination = requested === null ? fallbackPath : safeRedirectPath(requested);
|
|
124
|
+
if (destination === null) {
|
|
125
|
+
return Response.json({ ok: false, error: "invalid_redirect" }, { status: 400 });
|
|
126
|
+
}
|
|
127
|
+
const signed = await signValue(DRAFT_MARKER, options.draftSecret);
|
|
128
|
+
return new Response(null, {
|
|
129
|
+
status: 302,
|
|
130
|
+
headers: {
|
|
131
|
+
location: destination,
|
|
132
|
+
"set-cookie": serializeCookie(DRAFT_COOKIE, signed, cookieOptions),
|
|
133
|
+
"cache-control": "no-store",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
}
|
|
55
138
|
//# sourceMappingURL=draft.js.map
|
package/dist/draft.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"draft.js","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,
|
|
1
|
+
{"version":3,"file":"draft.js","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/fn-state` - the pending/error state around a server-function call.
|
|
3
|
+
*
|
|
4
|
+
* Calling a server function needs no binding: the client stub is `(input) => Promise<Output>`, and a
|
|
5
|
+
* click handler can await it. What a component usually wants on top is whether a call is in flight,
|
|
6
|
+
* what came back, and what went wrong - which is state, and state is the part every framework spells
|
|
7
|
+
* differently.
|
|
8
|
+
*
|
|
9
|
+
* So the state machine lives here, framework-agnostic, and each adapter contributes only its
|
|
10
|
+
* subscription primitive. Five copies of "is it pending" would drift, and the drift would be five
|
|
11
|
+
* subtly different answers to the same question.
|
|
12
|
+
*
|
|
13
|
+
* ## Two behaviours worth stating
|
|
14
|
+
*
|
|
15
|
+
* **The last call wins.** Concurrent calls are not queued or cancelled; a response that is no longer
|
|
16
|
+
* the newest is discarded rather than written. Without that, a slow first call landing after a fast
|
|
17
|
+
* second one would overwrite fresh data with stale - the classic out-of-order bug, and one that only
|
|
18
|
+
* appears under load, which is to say in production.
|
|
19
|
+
*
|
|
20
|
+
* **`call` still rejects.** The error is recorded for rendering AND the promise rejects, so `await`
|
|
21
|
+
* behaves the way `await` should and a caller who wants to branch can. A caller who only renders from
|
|
22
|
+
* state should attach a `.catch(() => {})`, the same as `useFetcher`'s `submit`.
|
|
23
|
+
*/
|
|
24
|
+
/** What a component renders from. */
|
|
25
|
+
export interface ServerFnState<Output> {
|
|
26
|
+
/** A call is in flight. */
|
|
27
|
+
readonly pending: boolean;
|
|
28
|
+
/** The most recent successful result, kept across a later pending call so the UI need not flicker. */
|
|
29
|
+
readonly data: Output | undefined;
|
|
30
|
+
/** The most recent failure, cleared when a call succeeds. */
|
|
31
|
+
readonly error: Error | undefined;
|
|
32
|
+
}
|
|
33
|
+
/** A subscribable call site. One per component instance, created by the framework binding. */
|
|
34
|
+
export interface ServerFnStore<Input, Output> {
|
|
35
|
+
/** Register a listener; returns the unsubscribe. */
|
|
36
|
+
subscribe(listener: () => void): () => void;
|
|
37
|
+
/**
|
|
38
|
+
* The current state. The SAME object is returned until something changes, because
|
|
39
|
+
* `useSyncExternalStore` and its equivalents compare by reference - a fresh object each call is an
|
|
40
|
+
* infinite render loop.
|
|
41
|
+
*/
|
|
42
|
+
snapshot(): ServerFnState<Output>;
|
|
43
|
+
/** Invoke the server function. Records state, and rejects on failure. */
|
|
44
|
+
call(input: Input): Promise<Output>;
|
|
45
|
+
/** Back to idle, discarding data and error. */
|
|
46
|
+
reset(): void;
|
|
47
|
+
}
|
|
48
|
+
/** The idle state, shared so a server render and the first client render agree by reference. */
|
|
49
|
+
export declare function idleServerFnState<Output>(): ServerFnState<Output>;
|
|
50
|
+
/**
|
|
51
|
+
* Wrap a server function (or any async function) in a subscribable pending/error state.
|
|
52
|
+
*
|
|
53
|
+
* Framework-free by design: the adapters call this and subscribe with their own primitive.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createServerFnStore<Input, Output>(fn: (input: Input) => Promise<Output> | Output): ServerFnStore<Input, Output>;
|
|
56
|
+
//# sourceMappingURL=fn-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fn-state.d.ts","sourceRoot":"","sources":["../src/fn-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,qCAAqC;AACrC,MAAM,WAAW,aAAa,CAAC,MAAM;IACnC,2BAA2B;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,sGAAsG;IACtG,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,CAAA;CAClC;AAED,8FAA8F;AAC9F,MAAM,WAAW,aAAa,CAAC,KAAK,EAAE,MAAM;IAC1C,oDAAoD;IACpD,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;IAC3C;;;;OAIG;IACH,QAAQ,IAAI,aAAa,CAAC,MAAM,CAAC,CAAA;IACjC,yEAAyE;IACzE,IAAI,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACnC,+CAA+C;IAC/C,KAAK,IAAI,IAAI,CAAA;CACd;AAID,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,MAAM,KAAK,aAAa,CAAC,MAAM,CAAC,CAEjE;AAKD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAC/C,EAAE,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,GAC7C,aAAa,CAAC,KAAK,EAAE,MAAM,CAAC,CAuC9B"}
|
package/dist/fn-state.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/fn-state` - the pending/error state around a server-function call.
|
|
3
|
+
*
|
|
4
|
+
* Calling a server function needs no binding: the client stub is `(input) => Promise<Output>`, and a
|
|
5
|
+
* click handler can await it. What a component usually wants on top is whether a call is in flight,
|
|
6
|
+
* what came back, and what went wrong - which is state, and state is the part every framework spells
|
|
7
|
+
* differently.
|
|
8
|
+
*
|
|
9
|
+
* So the state machine lives here, framework-agnostic, and each adapter contributes only its
|
|
10
|
+
* subscription primitive. Five copies of "is it pending" would drift, and the drift would be five
|
|
11
|
+
* subtly different answers to the same question.
|
|
12
|
+
*
|
|
13
|
+
* ## Two behaviours worth stating
|
|
14
|
+
*
|
|
15
|
+
* **The last call wins.** Concurrent calls are not queued or cancelled; a response that is no longer
|
|
16
|
+
* the newest is discarded rather than written. Without that, a slow first call landing after a fast
|
|
17
|
+
* second one would overwrite fresh data with stale - the classic out-of-order bug, and one that only
|
|
18
|
+
* appears under load, which is to say in production.
|
|
19
|
+
*
|
|
20
|
+
* **`call` still rejects.** The error is recorded for rendering AND the promise rejects, so `await`
|
|
21
|
+
* behaves the way `await` should and a caller who wants to branch can. A caller who only renders from
|
|
22
|
+
* state should attach a `.catch(() => {})`, the same as `useFetcher`'s `submit`.
|
|
23
|
+
*/
|
|
24
|
+
const IDLE = { pending: false, data: undefined, error: undefined };
|
|
25
|
+
/** The idle state, shared so a server render and the first client render agree by reference. */
|
|
26
|
+
export function idleServerFnState() {
|
|
27
|
+
return IDLE;
|
|
28
|
+
}
|
|
29
|
+
const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause), { cause });
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a server function (or any async function) in a subscribable pending/error state.
|
|
32
|
+
*
|
|
33
|
+
* Framework-free by design: the adapters call this and subscribe with their own primitive.
|
|
34
|
+
*/
|
|
35
|
+
export function createServerFnStore(fn) {
|
|
36
|
+
const listeners = new Set();
|
|
37
|
+
let state = idleServerFnState();
|
|
38
|
+
/** Identifies the newest call, so an older one landing later cannot write over it. */
|
|
39
|
+
let generation = 0;
|
|
40
|
+
const set = (next) => {
|
|
41
|
+
state = next;
|
|
42
|
+
for (const listener of listeners)
|
|
43
|
+
listener();
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
snapshot: () => state,
|
|
53
|
+
async call(input) {
|
|
54
|
+
const mine = ++generation;
|
|
55
|
+
// `data` is deliberately kept while pending: dropping it makes a list blank on every refetch.
|
|
56
|
+
set({ pending: true, data: state.data, error: undefined });
|
|
57
|
+
try {
|
|
58
|
+
const data = await fn(input);
|
|
59
|
+
if (mine === generation)
|
|
60
|
+
set({ pending: false, data, error: undefined });
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
const error = asError(cause);
|
|
65
|
+
if (mine === generation)
|
|
66
|
+
set({ pending: false, data: state.data, error });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
reset() {
|
|
71
|
+
// Bump the generation so a call already in flight cannot resurrect the state it was reset from.
|
|
72
|
+
generation += 1;
|
|
73
|
+
set(idleServerFnState());
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=fn-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fn-state.js","sourceRoot":"","sources":["../src/fn-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AA4BH,MAAM,IAAI,GAAyB,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,CAAA;AAExF,gGAAgG;AAChG,MAAM,UAAU,iBAAiB;IAC/B,OAAO,IAA6B,CAAA;AACtC,CAAC;AAED,MAAM,OAAO,GAAG,CAAC,KAAc,EAAS,EAAE,CACxC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE,CAAC,CAAA;AAEtE;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,EAA8C;IAE9C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAc,CAAA;IACvC,IAAI,KAAK,GAA0B,iBAAiB,EAAU,CAAA;IAC9D,sFAAsF;IACtF,IAAI,UAAU,GAAG,CAAC,CAAA;IAElB,MAAM,GAAG,GAAG,CAAC,IAA2B,EAAQ,EAAE;QAChD,KAAK,GAAG,IAAI,CAAA;QACZ,KAAK,MAAM,QAAQ,IAAI,SAAS;YAAE,QAAQ,EAAE,CAAA;IAC9C,CAAC,CAAA;IAED,OAAO;QACL,SAAS,CAAC,QAAQ;YAChB,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACvB,OAAO,GAAG,EAAE;gBACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;YAC5B,CAAC,CAAA;QACH,CAAC;QACD,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK;QACrB,KAAK,CAAC,IAAI,CAAC,KAAK;YACd,MAAM,IAAI,GAAG,EAAE,UAAU,CAAA;YACzB,8FAA8F;YAC9F,GAAG,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAA;YAC1D,IAAI,CAAC;gBACH,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,KAAK,CAAC,CAAA;gBAC5B,IAAI,IAAI,KAAK,UAAU;oBAAE,GAAG,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAA;gBACxE,OAAO,IAAI,CAAA;YACb,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;gBAC5B,IAAI,IAAI,KAAK,UAAU;oBAAE,GAAG,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;gBACzE,MAAM,KAAK,CAAA;YACb,CAAC;QACH,CAAC;QACD,KAAK;YACH,gGAAgG;YAChG,UAAU,IAAI,CAAC,CAAA;YACf,GAAG,CAAC,iBAAiB,EAAU,CAAC,CAAA;QAClC,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/dist/fn.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/fn` - server functions: write a function, call it from a component.
|
|
3
|
+
*
|
|
4
|
+
* ## Every server function is a public endpoint
|
|
5
|
+
*
|
|
6
|
+
* This is the first thing to internalise, because the API deliberately reads like a local call. A
|
|
7
|
+
* mounted function is an HTTP route anyone can POST to, with arguments entirely under the caller's
|
|
8
|
+
* control. Its id has to be in the client bundle for the browser to call it, so there is no obscurity
|
|
9
|
+
* to lean on. Treat one exactly as you would a hand-written `app.post`.
|
|
10
|
+
*
|
|
11
|
+
* What follows from that, and what this module does about it:
|
|
12
|
+
*
|
|
13
|
+
* - **Input is validated, always.** `input` is not optional decoration; without a schema a function
|
|
14
|
+
* takes no arguments at all. Unvalidated arguments on a public endpoint are mass assignment.
|
|
15
|
+
* - **`application/json` only.** A cross-origin HTML form can only send urlencoded, multipart or
|
|
16
|
+
* text/plain, so requiring JSON forces a CORS preflight the browser blocks. Both alternatives were
|
|
17
|
+
* measured rather than assumed: a body schema alone still accepts a cross-origin urlencoded form
|
|
18
|
+
* (200, attacker-controlled fields), and `c.boundedJson` alone accepts the `text/plain` trick where
|
|
19
|
+
* a form's `name=value` is crafted to parse as JSON. Neither is sufficient by itself.
|
|
20
|
+
* - **Same-origin only.** A present `Origin` must match the request's own host. Defence in depth
|
|
21
|
+
* behind the JSON requirement, and it costs one comparison. Server functions exist for your own
|
|
22
|
+
* frontend; a different origin calling your backend is the typed-client story, not this one.
|
|
23
|
+
* - **No closures.** A function is a module-level export taking explicit arguments. Next serialises
|
|
24
|
+
* closed-over variables to the browser and back, which it now has to encrypt; refusing the feature
|
|
25
|
+
* removes the whole class rather than defending it.
|
|
26
|
+
*
|
|
27
|
+
* ## The client half
|
|
28
|
+
*
|
|
29
|
+
* A `*.fn.ts` module is never bundled for the browser. The client build replaces it with one stub per
|
|
30
|
+
* export, each POSTing to the route below, so the bodies and everything they import stay on the server.
|
|
31
|
+
*
|
|
32
|
+
* One pipeline cannot do this: `nifra dev --bun`. Bun's dev-server bundler accepts no plugins - a
|
|
33
|
+
* runtime `Bun.plugin` onLoad does not reach it, which was measured rather than assumed - so a `*.fn`
|
|
34
|
+
* module there would ship WHOLE, secrets included. That command refuses to start on an app containing
|
|
35
|
+
* one, the same way it already refuses CSS Modules. `nifra build` and `nifra dev` (Vite) are unaffected.
|
|
36
|
+
*
|
|
37
|
+
* ## Why this is not a new lane
|
|
38
|
+
*
|
|
39
|
+
* A mounted function registers through the ordinary public `register()`, so it is a route like any
|
|
40
|
+
* other and inherits the body cap, schema validation, capability declarations, the effect ledger, and
|
|
41
|
+
* `nifra assure`. Nothing here touches the kernel or the request path, so an app that mounts none of
|
|
42
|
+
* them pays exactly nothing - which is the whole reason to build it this way rather than as a
|
|
43
|
+
* bespoke dispatcher.
|
|
44
|
+
*/
|
|
45
|
+
import { type Context, type RouteSchema, type StandardSchemaV1 } from "@nifrajs/core/server";
|
|
46
|
+
/** The URL prefix every mounted function lives under. Namespaced per mount, then by export name. */
|
|
47
|
+
export declare const SERVER_FN_PREFIX = "/_nifra/fn";
|
|
48
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
49
|
+
/** What a server function declares about itself. */
|
|
50
|
+
export interface ServerFnConfig<Input> {
|
|
51
|
+
/**
|
|
52
|
+
* Validates the single argument. Omit it and the function takes no argument - never "any argument":
|
|
53
|
+
* the caller controls this value completely, so an unvalidated one is an open door.
|
|
54
|
+
*/
|
|
55
|
+
readonly input?: StandardSchemaV1<unknown, Input>;
|
|
56
|
+
/** Effect tokens, forwarded to the route so `nifra assure` and the effect ledger see them. */
|
|
57
|
+
readonly capabilities?: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A declared server function. Callable directly on the server (the same value your own server-side
|
|
61
|
+
* code can await); on the client, phase 2's build transform replaces this module with typed stubs
|
|
62
|
+
* that POST to the mounted route.
|
|
63
|
+
*
|
|
64
|
+
* This is the SERVER declaration type, so context is required. Client builds replace the module with
|
|
65
|
+
* a one-argument {@link ClientServerFn}; UI bindings accept either shape and adapt at that boundary.
|
|
66
|
+
* Keeping the types separate prevents a direct server call from type-checking while handing the
|
|
67
|
+
* declaration `undefined` for a context its implementation requires.
|
|
68
|
+
*/
|
|
69
|
+
export interface ServerFn<Input, Output> {
|
|
70
|
+
(input: Input, context: Context): MaybePromise<Output>;
|
|
71
|
+
readonly [SERVER_FN]: ServerFnConfig<Input>;
|
|
72
|
+
}
|
|
73
|
+
/** The one-argument callable emitted into a client bundle for a {@link ServerFn}. */
|
|
74
|
+
export type ClientServerFn<Input, Output> = (input: Input) => MaybePromise<Output>;
|
|
75
|
+
/** A UI binding boundary: source declarations and generated client stubs are both accepted. */
|
|
76
|
+
export type ServerFnReference<Input, Output> = ServerFn<Input, Output> | ClientServerFn<Input, Output>;
|
|
77
|
+
/** Brand identifying a value produced by {@link serverFn}, so mounting cannot pick up stray exports. */
|
|
78
|
+
export declare const SERVER_FN: unique symbol;
|
|
79
|
+
/**
|
|
80
|
+
* Declare a server function.
|
|
81
|
+
*
|
|
82
|
+
* export const addTodo = serverFn(
|
|
83
|
+
* { input: t.object({ text: t.string({ minLength: 1 }) }), capabilities: ["db.write"] },
|
|
84
|
+
* async ({ text }, c) => db.todos.insert({ text }),
|
|
85
|
+
* )
|
|
86
|
+
*
|
|
87
|
+
* The second argument receives the validated input and the ordinary nifra `Context` - `c.env`,
|
|
88
|
+
* `c.clientIp`, `c.budget`, cookies, and the capability guard are all present, because this is a route.
|
|
89
|
+
*/
|
|
90
|
+
export declare function serverFn<Input = void, Output = unknown>(config: ServerFnConfig<Input>, fn: (input: Input, context: Context) => MaybePromise<Output>): ServerFn<Input, Output>;
|
|
91
|
+
/** The minimum a server needs to expose for functions to be mounted onto it. */
|
|
92
|
+
export interface ServerFnHost {
|
|
93
|
+
register(method: "POST", path: string, schema: RouteSchema | undefined, handler: (context: never) => unknown): void;
|
|
94
|
+
}
|
|
95
|
+
/** Every server function a module exports, keyed by export name. */
|
|
96
|
+
export type ServerFnModule = Readonly<Record<string, unknown>>;
|
|
97
|
+
/**
|
|
98
|
+
* Mount a module's server functions under `namespace`, returning a plugin for `app.use(...)`.
|
|
99
|
+
*
|
|
100
|
+
* import * as todos from "./actions/todos.fn"
|
|
101
|
+
* app.use(serverFunctions("todos", todos)) // -> POST /_nifra/fn/todos/addTodo
|
|
102
|
+
*
|
|
103
|
+
* The namespace is explicit rather than derived from the file path: a path-derived one would put the
|
|
104
|
+
* build machine's layout in a public URL, and this keeps the route readable in logs and greppable in
|
|
105
|
+
* the codebase. Phase 2's transform supplies it automatically from the file's location in the project.
|
|
106
|
+
*
|
|
107
|
+
* Exports that are not server functions are ignored, so `export type` and helpers can live alongside.
|
|
108
|
+
*/
|
|
109
|
+
export declare function serverFunctions<S extends ServerFnHost>(namespace: string, module: ServerFnModule): (app: S) => S;
|
|
110
|
+
export {};
|
|
111
|
+
//# sourceMappingURL=fn.d.ts.map
|
package/dist/fn.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fn.d.ts","sourceRoot":"","sources":["../src/fn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,EACL,KAAK,OAAO,EAEZ,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACtB,MAAM,sBAAsB,CAAA;AAE7B,oGAAoG;AACpG,eAAO,MAAM,gBAAgB,eAAe,CAAA;AAE5C,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;AAErC,oDAAoD;AACpD,MAAM,WAAW,cAAc,CAAC,KAAK;IACnC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;IACjD,8FAA8F;IAC9F,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1C;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,QAAQ,CAAC,KAAK,EAAE,MAAM;IACrC,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAG,YAAY,CAAC,MAAM,CAAC,CAAA;IACtD,QAAQ,CAAC,CAAC,SAAS,CAAC,EAAE,cAAc,CAAC,KAAK,CAAC,CAAA;CAC5C;AAED,qFAAqF;AACrF,MAAM,MAAM,cAAc,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC,KAAK,EAAE,KAAK,KAAK,YAAY,CAAC,MAAM,CAAC,CAAA;AAElF,+FAA+F;AAC/F,MAAM,MAAM,iBAAiB,CAAC,KAAK,EAAE,MAAM,IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,GACvB,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;AAEjC,wGAAwG;AACxG,eAAO,MAAM,SAAS,EAAE,OAAO,MAA6C,CAAA;AAE5E;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,KAAK,GAAG,IAAI,EAAE,MAAM,GAAG,OAAO,EACrD,MAAM,EAAE,cAAc,CAAC,KAAK,CAAC,EAC7B,EAAE,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,KAAK,YAAY,CAAC,MAAM,CAAC,GAC3D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,CAEzB;AAiBD,gFAAgF;AAChF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CACN,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,OAAO,EAAE,CAAC,OAAO,EAAE,KAAK,KAAK,OAAO,GACnC,IAAI,CAAA;CACR;AAED,oEAAoE;AACpE,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;AAE9D;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,YAAY,EACpD,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,GACrB,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CA4Cf"}
|
package/dist/fn.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/fn` - server functions: write a function, call it from a component.
|
|
3
|
+
*
|
|
4
|
+
* ## Every server function is a public endpoint
|
|
5
|
+
*
|
|
6
|
+
* This is the first thing to internalise, because the API deliberately reads like a local call. A
|
|
7
|
+
* mounted function is an HTTP route anyone can POST to, with arguments entirely under the caller's
|
|
8
|
+
* control. Its id has to be in the client bundle for the browser to call it, so there is no obscurity
|
|
9
|
+
* to lean on. Treat one exactly as you would a hand-written `app.post`.
|
|
10
|
+
*
|
|
11
|
+
* What follows from that, and what this module does about it:
|
|
12
|
+
*
|
|
13
|
+
* - **Input is validated, always.** `input` is not optional decoration; without a schema a function
|
|
14
|
+
* takes no arguments at all. Unvalidated arguments on a public endpoint are mass assignment.
|
|
15
|
+
* - **`application/json` only.** A cross-origin HTML form can only send urlencoded, multipart or
|
|
16
|
+
* text/plain, so requiring JSON forces a CORS preflight the browser blocks. Both alternatives were
|
|
17
|
+
* measured rather than assumed: a body schema alone still accepts a cross-origin urlencoded form
|
|
18
|
+
* (200, attacker-controlled fields), and `c.boundedJson` alone accepts the `text/plain` trick where
|
|
19
|
+
* a form's `name=value` is crafted to parse as JSON. Neither is sufficient by itself.
|
|
20
|
+
* - **Same-origin only.** A present `Origin` must match the request's own host. Defence in depth
|
|
21
|
+
* behind the JSON requirement, and it costs one comparison. Server functions exist for your own
|
|
22
|
+
* frontend; a different origin calling your backend is the typed-client story, not this one.
|
|
23
|
+
* - **No closures.** A function is a module-level export taking explicit arguments. Next serialises
|
|
24
|
+
* closed-over variables to the browser and back, which it now has to encrypt; refusing the feature
|
|
25
|
+
* removes the whole class rather than defending it.
|
|
26
|
+
*
|
|
27
|
+
* ## The client half
|
|
28
|
+
*
|
|
29
|
+
* A `*.fn.ts` module is never bundled for the browser. The client build replaces it with one stub per
|
|
30
|
+
* export, each POSTing to the route below, so the bodies and everything they import stay on the server.
|
|
31
|
+
*
|
|
32
|
+
* One pipeline cannot do this: `nifra dev --bun`. Bun's dev-server bundler accepts no plugins - a
|
|
33
|
+
* runtime `Bun.plugin` onLoad does not reach it, which was measured rather than assumed - so a `*.fn`
|
|
34
|
+
* module there would ship WHOLE, secrets included. That command refuses to start on an app containing
|
|
35
|
+
* one, the same way it already refuses CSS Modules. `nifra build` and `nifra dev` (Vite) are unaffected.
|
|
36
|
+
*
|
|
37
|
+
* ## Why this is not a new lane
|
|
38
|
+
*
|
|
39
|
+
* A mounted function registers through the ordinary public `register()`, so it is a route like any
|
|
40
|
+
* other and inherits the body cap, schema validation, capability declarations, the effect ledger, and
|
|
41
|
+
* `nifra assure`. Nothing here touches the kernel or the request path, so an app that mounts none of
|
|
42
|
+
* them pays exactly nothing - which is the whole reason to build it this way rather than as a
|
|
43
|
+
* bespoke dispatcher.
|
|
44
|
+
*/
|
|
45
|
+
import { isSameOriginRequest, } from "@nifrajs/core/server";
|
|
46
|
+
/** The URL prefix every mounted function lives under. Namespaced per mount, then by export name. */
|
|
47
|
+
export const SERVER_FN_PREFIX = "/_nifra/fn";
|
|
48
|
+
/** Brand identifying a value produced by {@link serverFn}, so mounting cannot pick up stray exports. */
|
|
49
|
+
export const SERVER_FN = Symbol.for("@nifrajs/web/server-fn");
|
|
50
|
+
/**
|
|
51
|
+
* Declare a server function.
|
|
52
|
+
*
|
|
53
|
+
* export const addTodo = serverFn(
|
|
54
|
+
* { input: t.object({ text: t.string({ minLength: 1 }) }), capabilities: ["db.write"] },
|
|
55
|
+
* async ({ text }, c) => db.todos.insert({ text }),
|
|
56
|
+
* )
|
|
57
|
+
*
|
|
58
|
+
* The second argument receives the validated input and the ordinary nifra `Context` - `c.env`,
|
|
59
|
+
* `c.clientIp`, `c.budget`, cookies, and the capability guard are all present, because this is a route.
|
|
60
|
+
*/
|
|
61
|
+
export function serverFn(config, fn) {
|
|
62
|
+
return Object.assign(fn, { [SERVER_FN]: config });
|
|
63
|
+
}
|
|
64
|
+
const isServerFn = (value) => typeof value === "function" && SERVER_FN in value;
|
|
65
|
+
/** A namespace segment: lowercase, dot/dash separated. Constrained so a mount cannot invent a path. */
|
|
66
|
+
const NAMESPACE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
|
|
67
|
+
/** An export name that is safe in a URL path without escaping. */
|
|
68
|
+
const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
69
|
+
/**
|
|
70
|
+
* Same-origin, from `@nifrajs/core` so this seam and the WebSocket handshake cannot answer differently
|
|
71
|
+
* for one request. Host must match; the Origin's scheme may be equal or stronger, never weaker - which
|
|
72
|
+
* is what keeps a TLS-terminating proxy working without reading a forwarded header.
|
|
73
|
+
*/
|
|
74
|
+
const sameOrigin = isSameOriginRequest;
|
|
75
|
+
/**
|
|
76
|
+
* Mount a module's server functions under `namespace`, returning a plugin for `app.use(...)`.
|
|
77
|
+
*
|
|
78
|
+
* import * as todos from "./actions/todos.fn"
|
|
79
|
+
* app.use(serverFunctions("todos", todos)) // -> POST /_nifra/fn/todos/addTodo
|
|
80
|
+
*
|
|
81
|
+
* The namespace is explicit rather than derived from the file path: a path-derived one would put the
|
|
82
|
+
* build machine's layout in a public URL, and this keeps the route readable in logs and greppable in
|
|
83
|
+
* the codebase. Phase 2's transform supplies it automatically from the file's location in the project.
|
|
84
|
+
*
|
|
85
|
+
* Exports that are not server functions are ignored, so `export type` and helpers can live alongside.
|
|
86
|
+
*/
|
|
87
|
+
export function serverFunctions(namespace, module) {
|
|
88
|
+
if (!NAMESPACE.test(namespace)) {
|
|
89
|
+
throw new Error(`[nifra/fn] invalid server-function namespace ${JSON.stringify(namespace)} - use lowercase dot/dash segments (it becomes a URL path).`);
|
|
90
|
+
}
|
|
91
|
+
const mounted = Object.entries(module).filter((entry) => isServerFn(entry[1]));
|
|
92
|
+
const apply = (app) => {
|
|
93
|
+
for (const [name, fn] of mounted) {
|
|
94
|
+
if (!EXPORT_NAME.test(name)) {
|
|
95
|
+
throw new Error(`[nifra/fn] server function ${JSON.stringify(name)} in namespace ${JSON.stringify(namespace)} is not a valid identifier.`);
|
|
96
|
+
}
|
|
97
|
+
const config = fn[SERVER_FN];
|
|
98
|
+
const schema = {
|
|
99
|
+
...(config.input !== undefined ? { body: config.input } : {}),
|
|
100
|
+
...(config.capabilities !== undefined ? { capabilities: config.capabilities } : {}),
|
|
101
|
+
};
|
|
102
|
+
app.register("POST", `${SERVER_FN_PREFIX}/${namespace}/${name}`, schema, ((c) => {
|
|
103
|
+
// Ordered cheapest-first, and BEFORE the declared function runs. The body has already been
|
|
104
|
+
// parsed by the schema layer, but parsing is not a side effect - nothing the caller asked for
|
|
105
|
+
// has happened yet, so rejecting here is a complete refusal.
|
|
106
|
+
const request = c.req;
|
|
107
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
108
|
+
if (!contentType.includes("application/json")) {
|
|
109
|
+
// A form cannot send this content type cross-origin without a preflight. Rejecting anything
|
|
110
|
+
// else is what makes form-driven CSRF against a server function impossible rather than
|
|
111
|
+
// merely unlikely.
|
|
112
|
+
return c.json({ ok: false, error: "unsupported_media_type" }, 415);
|
|
113
|
+
}
|
|
114
|
+
const origin = request.headers.get("origin");
|
|
115
|
+
if (origin !== null && !sameOrigin(origin, request)) {
|
|
116
|
+
return c.json({ ok: false, error: "forbidden_origin" }, 403);
|
|
117
|
+
}
|
|
118
|
+
return fn(config.input === undefined ? undefined : c.body, c);
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
return app;
|
|
122
|
+
};
|
|
123
|
+
return Object.assign(apply, { pluginName: `nifra:server-fn:${namespace}` });
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=fn.js.map
|
package/dist/fn.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fn.js","sourceRoot":"","sources":["../src/fn.ts"],"names":[],"mappings":"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"}
|
package/dist/fonts-google.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build-time Google Fonts automation
|
|
2
|
+
* Build-time Google Fonts automation - the `next/font/google` equivalent. At **build time** (never on
|
|
3
3
|
* the request path) it: builds the Google Fonts CSS2 URL, downloads the stylesheet, parses the
|
|
4
4
|
* `@font-face` rules, downloads each `.woff2`, content-hashes it, writes it next to your assets, and
|
|
5
5
|
* hands back a **self-hosted** `@font-face` stylesheet + the matching `<link rel="preload">`s. The
|
|
6
|
-
* result is identical to dropping the files in yourself and calling {@link fontFace}
|
|
6
|
+
* result is identical to dropping the files in yourself and calling {@link fontFace} - no runtime CDN
|
|
7
7
|
* hotlink, no layout shift, hashed filenames for immutable caching.
|
|
8
8
|
*
|
|
9
|
-
* // fonts.build.ts
|
|
9
|
+
* // fonts.build.ts - run once at build time (e.g. a prebuild step)
|
|
10
10
|
* import { loadGoogleFont } from "@nifrajs/web/fonts"
|
|
11
11
|
* const inter = await loadGoogleFont(
|
|
12
12
|
* { family: "Inter", weights: [400, 700], subsets: ["latin"] },
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* // inter.preloads → spread into a root layout's `meta.link`
|
|
17
17
|
*
|
|
18
18
|
* Security: this fetches remote content and writes it to disk, so every input is validated and the
|
|
19
|
-
* font-file host is **allowlisted to `fonts.gstatic.com` over https**
|
|
19
|
+
* font-file host is **allowlisted to `fonts.gstatic.com` over https** - a tampered/MITM'd stylesheet
|
|
20
20
|
* cannot make the build fetch an arbitrary URL (SSRF) or write an attacker-chosen blob. Downloads are
|
|
21
21
|
* size-capped. Filenames are derived only from validated tokens + a content hash (no path traversal).
|
|
22
22
|
*/
|
|
@@ -26,7 +26,7 @@ import type { LinkDescriptor } from "./manifest.js";
|
|
|
26
26
|
export interface GoogleFontOptions {
|
|
27
27
|
/** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
|
|
28
28
|
readonly family: string;
|
|
29
|
-
/** Weights to request
|
|
29
|
+
/** Weights to request - numbers (`400`), numeric strings, a variable range (`"100 900"`), or the
|
|
30
30
|
* keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
|
|
31
31
|
readonly weights?: readonly (number | string)[];
|
|
32
32
|
/** Styles to request. Defaults to `["normal"]`. */
|
|
@@ -38,7 +38,7 @@ export interface GoogleFontOptions {
|
|
|
38
38
|
/** `font-display` strategy for the generated faces. Defaults to `"swap"`. */
|
|
39
39
|
readonly display?: FontDisplay;
|
|
40
40
|
/** Glyph subsetting: request only the glyphs needed to render exactly this text (Google's `&text=`).
|
|
41
|
-
* Ideal for a logo/heading font
|
|
41
|
+
* Ideal for a logo/heading font - produces one tiny file. */
|
|
42
42
|
readonly text?: string;
|
|
43
43
|
/** CLS metric overrides forwarded to every generated `@font-face` (the layout-shift fix). */
|
|
44
44
|
readonly sizeAdjust?: string;
|
|
@@ -77,7 +77,7 @@ export interface LoadGoogleFontResult {
|
|
|
77
77
|
readonly css: string;
|
|
78
78
|
/** Every file written to `outDir`. */
|
|
79
79
|
readonly assets: readonly FontAsset[];
|
|
80
|
-
/** `fontPreload()` link-attribute sets
|
|
80
|
+
/** `fontPreload()` link-attribute sets - spread the ones you want into a layout's `meta.link`.
|
|
81
81
|
* Preloading *every* weight/subset is wasteful; usually preload just the primary subset + weight. */
|
|
82
82
|
readonly preloads: readonly LinkDescriptor[];
|
|
83
83
|
}
|
|
@@ -93,13 +93,13 @@ export interface LoadGoogleFontIO {
|
|
|
93
93
|
/** Per-file download cap in bytes. Defaults to 5 MB (real woff2 are well under 1 MB). */
|
|
94
94
|
readonly maxBytesPerFile?: number;
|
|
95
95
|
}
|
|
96
|
-
/** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL
|
|
96
|
+
/** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL - the only host we'll download from. */
|
|
97
97
|
export declare function isAllowedFontUrl(raw: string): boolean;
|
|
98
98
|
/** Build the Google Fonts CSS2 request URL. Pure + fully validated, so it's safe to feed a dynamic
|
|
99
99
|
* family/weights/text. Exported for advanced callers who fetch + parse the stylesheet themselves. */
|
|
100
100
|
export declare function googleFontsCssUrl(options: GoogleFontOptions): string;
|
|
101
101
|
/** Parse Google's stylesheet into structured faces, capturing the `/* subset */` label that precedes
|
|
102
|
-
* each `@font-face`. Pure
|
|
102
|
+
* each `@font-face`. Pure - exported so callers can run their own download/write pipeline. */
|
|
103
103
|
export declare function parseGoogleFontCss(css: string): ParsedFontFace[];
|
|
104
104
|
/**
|
|
105
105
|
* Download a Google font, self-host it, and return a CLS-safe `@font-face` stylesheet + preloads.
|