@nifrajs/web 2.2.0 → 2.4.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.
Files changed (179) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-diagnostics.d.ts +20 -0
  23. package/dist/dev-diagnostics.d.ts.map +1 -0
  24. package/dist/dev-diagnostics.js +34 -0
  25. package/dist/dev-diagnostics.js.map +1 -0
  26. package/dist/dev-error.d.ts +10 -13
  27. package/dist/dev-error.d.ts.map +1 -1
  28. package/dist/dev-error.js +51 -37
  29. package/dist/dev-error.js.map +1 -1
  30. package/dist/dev-port.d.ts +3 -3
  31. package/dist/dev-port.d.ts.map +1 -1
  32. package/dist/dev-port.js +10 -4
  33. package/dist/dev-port.js.map +1 -1
  34. package/dist/dev.d.ts +4 -4
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +28 -12
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic.d.ts +93 -0
  39. package/dist/diagnostic.d.ts.map +1 -0
  40. package/dist/diagnostic.js +205 -0
  41. package/dist/diagnostic.js.map +1 -0
  42. package/dist/draft.d.ts +12 -12
  43. package/dist/draft.js +11 -11
  44. package/dist/fn-state.d.ts +56 -0
  45. package/dist/fn-state.d.ts.map +1 -0
  46. package/dist/fn-state.js +77 -0
  47. package/dist/fn-state.js.map +1 -0
  48. package/dist/fn.d.ts +111 -0
  49. package/dist/fn.d.ts.map +1 -0
  50. package/dist/fn.js +125 -0
  51. package/dist/fn.js.map +1 -0
  52. package/dist/fonts-google.d.ts +9 -9
  53. package/dist/fonts-google.js +10 -10
  54. package/dist/fonts.d.ts +7 -7
  55. package/dist/fonts.js +1 -1
  56. package/dist/forms.d.ts +8 -8
  57. package/dist/fs.d.ts +1 -1
  58. package/dist/fs.js +1 -1
  59. package/dist/index.d.ts +151 -70
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +317 -139
  62. package/dist/index.js.map +1 -1
  63. package/dist/internal/head-attributes.d.ts +15 -0
  64. package/dist/internal/head-attributes.d.ts.map +1 -0
  65. package/dist/internal/head-attributes.js +99 -0
  66. package/dist/internal/head-attributes.js.map +1 -0
  67. package/dist/internal/script-types.d.ts +14 -0
  68. package/dist/internal/script-types.d.ts.map +1 -0
  69. package/dist/internal/script-types.js +17 -0
  70. package/dist/internal/script-types.js.map +1 -0
  71. package/dist/internal/server-boundary.d.ts +60 -0
  72. package/dist/internal/server-boundary.d.ts.map +1 -0
  73. package/dist/internal/server-boundary.js +174 -0
  74. package/dist/internal/server-boundary.js.map +1 -0
  75. package/dist/internal/timing-safe-equal.d.ts +1 -1
  76. package/dist/internal/timing-safe-equal.js +1 -1
  77. package/dist/internal/vite-import.d.ts +63 -0
  78. package/dist/internal/vite-import.d.ts.map +1 -0
  79. package/dist/internal/vite-import.js +94 -0
  80. package/dist/internal/vite-import.js.map +1 -0
  81. package/dist/islands.d.ts +5 -5
  82. package/dist/islands.js +5 -5
  83. package/dist/isr.d.ts +15 -15
  84. package/dist/isr.js +7 -7
  85. package/dist/manifest.d.ts +73 -35
  86. package/dist/manifest.d.ts.map +1 -1
  87. package/dist/manifest.js +10 -16
  88. package/dist/manifest.js.map +1 -1
  89. package/dist/module-graph.d.ts +5 -5
  90. package/dist/module-graph.js +4 -4
  91. package/dist/navigation.d.ts +120 -5
  92. package/dist/navigation.d.ts.map +1 -1
  93. package/dist/navigation.js +37 -5
  94. package/dist/navigation.js.map +1 -1
  95. package/dist/plugins/css-modules.d.ts +3 -3
  96. package/dist/plugins/css-modules.js +0 -0
  97. package/dist/plugins/kit.d.ts +8 -8
  98. package/dist/plugins/kit.js +9 -9
  99. package/dist/plugins/scss.js +3 -3
  100. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  101. package/dist/plugins/vite-leak-guard.js +1 -1
  102. package/dist/plugins/vite-server-fn.d.ts +23 -0
  103. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  104. package/dist/plugins/vite-server-fn.js +51 -0
  105. package/dist/plugins/vite-server-fn.js.map +1 -0
  106. package/dist/plugins/vite-server-only.d.ts +49 -0
  107. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  108. package/dist/plugins/vite-server-only.js +57 -0
  109. package/dist/plugins/vite-server-only.js.map +1 -0
  110. package/dist/prerender.d.ts +5 -5
  111. package/dist/prerender.js +7 -7
  112. package/dist/query.d.ts +10 -10
  113. package/dist/query.js +15 -15
  114. package/dist/route-manifest.d.ts +1 -1
  115. package/dist/route-manifest.js +2 -2
  116. package/dist/router.d.ts +30 -21
  117. package/dist/router.d.ts.map +1 -1
  118. package/dist/router.js +62 -32
  119. package/dist/router.js.map +1 -1
  120. package/dist/search.d.ts +94 -0
  121. package/dist/search.d.ts.map +1 -0
  122. package/dist/search.js +232 -0
  123. package/dist/search.js.map +1 -0
  124. package/dist/server-only.d.ts +6 -6
  125. package/dist/server-only.js +7 -7
  126. package/dist/service-worker.d.ts +56 -0
  127. package/dist/service-worker.d.ts.map +1 -0
  128. package/dist/service-worker.js +127 -0
  129. package/dist/service-worker.js.map +1 -0
  130. package/dist/vite.d.ts +16 -14
  131. package/dist/vite.d.ts.map +1 -1
  132. package/dist/vite.js +51 -24
  133. package/dist/vite.js.map +1 -1
  134. package/package.json +38 -4
  135. package/server-manifest.d.ts +31 -0
  136. package/src/build-vite.ts +24 -77
  137. package/src/build.ts +137 -105
  138. package/src/bun-dev-entry.ts +1 -1
  139. package/src/chunk-names.ts +1 -1
  140. package/src/client.ts +215 -32
  141. package/src/conformance.ts +3 -3
  142. package/src/deferred.ts +17 -17
  143. package/src/dev-diagnostics.ts +47 -0
  144. package/src/dev-error.ts +64 -33
  145. package/src/dev-port.ts +17 -5
  146. package/src/dev.ts +36 -20
  147. package/src/diagnostic.ts +284 -0
  148. package/src/draft.ts +15 -15
  149. package/src/fn-state.ts +107 -0
  150. package/src/fn.ts +199 -0
  151. package/src/fonts-google.ts +13 -13
  152. package/src/fonts.ts +7 -7
  153. package/src/forms.ts +8 -8
  154. package/src/fs.ts +2 -2
  155. package/src/index.ts +460 -181
  156. package/src/internal/head-attributes.ts +109 -0
  157. package/src/internal/script-types.ts +18 -0
  158. package/src/internal/server-boundary.ts +187 -0
  159. package/src/internal/timing-safe-equal.ts +1 -1
  160. package/src/internal/vite-import.ts +92 -0
  161. package/src/islands.ts +7 -7
  162. package/src/isr.ts +17 -17
  163. package/src/manifest.ts +83 -42
  164. package/src/module-graph.ts +6 -6
  165. package/src/navigation.ts +151 -5
  166. package/src/plugins/css-modules.ts +0 -0
  167. package/src/plugins/kit.ts +11 -11
  168. package/src/plugins/scss.ts +3 -3
  169. package/src/plugins/vite-leak-guard.ts +2 -2
  170. package/src/plugins/vite-server-fn.ts +67 -0
  171. package/src/plugins/vite-server-only.ts +75 -0
  172. package/src/prerender.ts +10 -10
  173. package/src/query.ts +22 -22
  174. package/src/route-manifest.ts +3 -3
  175. package/src/router.ts +98 -45
  176. package/src/search.ts +297 -0
  177. package/src/server-only.ts +7 -7
  178. package/src/service-worker.ts +165 -0
  179. package/src/vite.ts +61 -32
package/src/draft.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Draft / preview mode — let an editor see unpublished content by flipping a signed cookie that
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 — exactly like Next's `draftMode().enable()`.
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) => {
@@ -26,11 +26,11 @@ import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
26
26
  /** The cookie name nifra uses for draft/preview mode. */
27
27
  export const DRAFT_COOKIE = "__nifra_draft"
28
28
 
29
- // The signed cookie's payload is a fixed marker — a *valid signature* (not the value) is the signal
29
+ // The signed cookie's payload is a fixed marker - a *valid signature* (not the value) is the signal
30
30
  // that the server issued this via enableDraft(). Nothing secret is stored in it.
31
31
  const DRAFT_MARKER = "1"
32
32
 
33
- /** The response-cookie surface `enableDraft`/`disableDraft` need — nifra's `c.set`. Structural, so any
33
+ /** The response-cookie surface `enableDraft`/`disableDraft` need - nifra's `c.set`. Structural, so any
34
34
  * nifra handler context satisfies it without importing the full `Context`. */
35
35
  export interface DraftCookieControls {
36
36
  cookie(name: string, value: string, options?: CookieOptions): void
@@ -38,18 +38,18 @@ export interface DraftCookieControls {
38
38
  }
39
39
 
40
40
  export interface EnableDraftOptions {
41
- /** Cookie lifetime in **seconds** (default `3600` = 1h). Keep it short — draft is an editor session. */
41
+ /** Cookie lifetime in **seconds** (default `3600` = 1h). Keep it short - draft is an editor session. */
42
42
  readonly maxAgeSeconds?: number
43
43
  /** Cookie `Path` (default `"/"`). */
44
44
  readonly path?: string
45
- /** Override the `Secure` attribute (defaults to `true` — secure-by-default). Pass `false` only for
45
+ /** Override the `Secure` attribute (defaults to `true` - secure-by-default). Pass `false` only for
46
46
  * local `http://` dev, where a `Secure` cookie isn't stored. */
47
47
  readonly secure?: boolean
48
48
  }
49
49
 
50
50
  /**
51
51
  * Turn draft mode **on** for this client by setting a signed, HttpOnly `__nifra_draft` cookie. Call it
52
- * from a route you've already authorized. `secret` signs the cookie — pass the SAME secret to
52
+ * from a route you've already authorized. `secret` signs the cookie - pass the SAME secret to
53
53
  * `createWebApp({ draftSecret })` and `withISR({ draftSecret })` so the framework can verify it.
54
54
  */
55
55
  export async function enableDraft(
@@ -68,7 +68,7 @@ export async function enableDraft(
68
68
  *
69
69
  * `secure` is resolved here rather than left to the caller: `serializeCookie` is pure and applies
70
70
  * **no** security defaults, so the `previewEndpoint` path would silently emit a cookie without
71
- * `Secure` — over plain HTTP that hands the draft cookie to any network observer. Passing it
71
+ * `Secure` - over plain HTTP that hands the draft cookie to any network observer. Passing it
72
72
  * explicitly makes both paths identical instead of one inheriting a default the other never sees.
73
73
  */
74
74
  function draftCookieOptions(options: EnableDraftOptions): CookieOptions {
@@ -106,7 +106,7 @@ export async function isDraftEnabled(request: Request, secret: string): Promise<
106
106
  *
107
107
  * A redirect target that arrives in the query string is attacker-controlled: anyone can mint a link
108
108
  * to your own preview route and choose where the editor lands. "Starts with `/`" is the check people
109
- * write and it is not enough — `//evil.com` is a protocol-relative URL and `/\evil.com` is normalized
109
+ * write and it is not enough - `//evil.com` is a protocol-relative URL and `/\evil.com` is normalized
110
110
  * to one by browsers, so both start with a slash and navigate off-site. Control characters are refused
111
111
  * as well, since a newline in a `Location` header splits the response.
112
112
  */
@@ -124,7 +124,7 @@ function safeRedirectPath(value: string): string | null {
124
124
  export interface PreviewEndpointOptions {
125
125
  /** Shared secret the preview link must carry. Compared in **constant time**. */
126
126
  readonly secret: string
127
- /** Secret that **signs** the draft cookie — the same one passed to `createWebApp({ draftSecret })`
127
+ /** Secret that **signs** the draft cookie - the same one passed to `createWebApp({ draftSecret })`
128
128
  * and `withISR({ draftSecret })`. Keep it distinct from {@link secret}: that one travels in URLs
129
129
  * (logs, `Referer`, browser history), this one never leaves the server. */
130
130
  readonly draftSecret: string
@@ -140,17 +140,17 @@ export interface PreviewEndpointOptions {
140
140
  }
141
141
 
142
142
  /**
143
- * A **preview / draft-mode entry point** — a `fetch` handler that checks a preview token, turns draft
143
+ * A **preview / draft-mode entry point** - a `fetch` handler that checks a preview token, turns draft
144
144
  * mode on, and redirects the editor to the page they wanted. `GET` with `?token=<secret>&to=/some/path`;
145
145
  * mount it on a nifra route, e.g. `app.get("/api/preview", (c) => handler(c.req))`.
146
146
  *
147
- * This is the link-borne sibling of `revalidateEndpoint`. It exists because the alternative — telling
148
- * you to gate the route yourself — means hand-rolling two checks that are easy to get subtly wrong and
147
+ * This is the link-borne sibling of `revalidateEndpoint`. It exists because the alternative - telling
148
+ * you to gate the route yourself - means hand-rolling two checks that are easy to get subtly wrong and
149
149
  * that fail silently when you do: the token compare must not exit early on the first wrong character,
150
150
  * and the `?to=` destination must not be allowed to point off-site. Both are handled here.
151
151
  *
152
152
  * A CMS "Preview" button is a plain link, so the token has to ride the query string. That has a cost
153
- * no endpoint can remove — the secret lands in server logs, the `Referer` header, and browser history.
153
+ * no endpoint can remove - the secret lands in server logs, the `Referer` header, and browser history.
154
154
  * Use a preview token minted for that purpose, rotate it, and never reuse a token that grants anything
155
155
  * beyond draft mode.
156
156
  *
@@ -0,0 +1,107 @@
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
+
25
+ /** What a component renders from. */
26
+ export interface ServerFnState<Output> {
27
+ /** A call is in flight. */
28
+ readonly pending: boolean
29
+ /** The most recent successful result, kept across a later pending call so the UI need not flicker. */
30
+ readonly data: Output | undefined
31
+ /** The most recent failure, cleared when a call succeeds. */
32
+ readonly error: Error | undefined
33
+ }
34
+
35
+ /** A subscribable call site. One per component instance, created by the framework binding. */
36
+ export interface ServerFnStore<Input, Output> {
37
+ /** Register a listener; returns the unsubscribe. */
38
+ subscribe(listener: () => void): () => void
39
+ /**
40
+ * The current state. The SAME object is returned until something changes, because
41
+ * `useSyncExternalStore` and its equivalents compare by reference - a fresh object each call is an
42
+ * infinite render loop.
43
+ */
44
+ snapshot(): ServerFnState<Output>
45
+ /** Invoke the server function. Records state, and rejects on failure. */
46
+ call(input: Input): Promise<Output>
47
+ /** Back to idle, discarding data and error. */
48
+ reset(): void
49
+ }
50
+
51
+ const IDLE: ServerFnState<never> = { pending: false, data: undefined, error: undefined }
52
+
53
+ /** The idle state, shared so a server render and the first client render agree by reference. */
54
+ export function idleServerFnState<Output>(): ServerFnState<Output> {
55
+ return IDLE as ServerFnState<Output>
56
+ }
57
+
58
+ const asError = (cause: unknown): Error =>
59
+ cause instanceof Error ? cause : new Error(String(cause), { cause })
60
+
61
+ /**
62
+ * Wrap a server function (or any async function) in a subscribable pending/error state.
63
+ *
64
+ * Framework-free by design: the adapters call this and subscribe with their own primitive.
65
+ */
66
+ export function createServerFnStore<Input, Output>(
67
+ fn: (input: Input) => Promise<Output> | Output,
68
+ ): ServerFnStore<Input, Output> {
69
+ const listeners = new Set<() => void>()
70
+ let state: ServerFnState<Output> = idleServerFnState<Output>()
71
+ /** Identifies the newest call, so an older one landing later cannot write over it. */
72
+ let generation = 0
73
+
74
+ const set = (next: ServerFnState<Output>): void => {
75
+ state = next
76
+ for (const listener of listeners) listener()
77
+ }
78
+
79
+ return {
80
+ subscribe(listener) {
81
+ listeners.add(listener)
82
+ return () => {
83
+ listeners.delete(listener)
84
+ }
85
+ },
86
+ snapshot: () => state,
87
+ async call(input) {
88
+ const mine = ++generation
89
+ // `data` is deliberately kept while pending: dropping it makes a list blank on every refetch.
90
+ set({ pending: true, data: state.data, error: undefined })
91
+ try {
92
+ const data = await fn(input)
93
+ if (mine === generation) set({ pending: false, data, error: undefined })
94
+ return data
95
+ } catch (cause) {
96
+ const error = asError(cause)
97
+ if (mine === generation) set({ pending: false, data: state.data, error })
98
+ throw error
99
+ }
100
+ },
101
+ reset() {
102
+ // Bump the generation so a call already in flight cannot resurrect the state it was reset from.
103
+ generation += 1
104
+ set(idleServerFnState<Output>())
105
+ },
106
+ }
107
+ }
package/src/fn.ts ADDED
@@ -0,0 +1,199 @@
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 {
46
+ type Context,
47
+ isSameOriginRequest,
48
+ type RouteSchema,
49
+ type StandardSchemaV1,
50
+ } from "@nifrajs/core/server"
51
+
52
+ /** The URL prefix every mounted function lives under. Namespaced per mount, then by export name. */
53
+ export const SERVER_FN_PREFIX = "/_nifra/fn"
54
+
55
+ type MaybePromise<T> = T | Promise<T>
56
+
57
+ /** What a server function declares about itself. */
58
+ export interface ServerFnConfig<Input> {
59
+ /**
60
+ * Validates the single argument. Omit it and the function takes no argument - never "any argument":
61
+ * the caller controls this value completely, so an unvalidated one is an open door.
62
+ */
63
+ readonly input?: StandardSchemaV1<unknown, Input>
64
+ /** Effect tokens, forwarded to the route so `nifra assure` and the effect ledger see them. */
65
+ readonly capabilities?: readonly string[]
66
+ }
67
+
68
+ /**
69
+ * A declared server function. Callable directly on the server (the same value your own server-side
70
+ * code can await); on the client, phase 2's build transform replaces this module with typed stubs
71
+ * that POST to the mounted route.
72
+ *
73
+ * This is the SERVER declaration type, so context is required. Client builds replace the module with
74
+ * a one-argument {@link ClientServerFn}; UI bindings accept either shape and adapt at that boundary.
75
+ * Keeping the types separate prevents a direct server call from type-checking while handing the
76
+ * declaration `undefined` for a context its implementation requires.
77
+ */
78
+ export interface ServerFn<Input, Output> {
79
+ (input: Input, context: Context): MaybePromise<Output>
80
+ readonly [SERVER_FN]: ServerFnConfig<Input>
81
+ }
82
+
83
+ /** The one-argument callable emitted into a client bundle for a {@link ServerFn}. */
84
+ export type ClientServerFn<Input, Output> = (input: Input) => MaybePromise<Output>
85
+
86
+ /** A UI binding boundary: source declarations and generated client stubs are both accepted. */
87
+ export type ServerFnReference<Input, Output> =
88
+ | ServerFn<Input, Output>
89
+ | ClientServerFn<Input, Output>
90
+
91
+ /** Brand identifying a value produced by {@link serverFn}, so mounting cannot pick up stray exports. */
92
+ export const SERVER_FN: unique symbol = Symbol.for("@nifrajs/web/server-fn")
93
+
94
+ /**
95
+ * Declare a server function.
96
+ *
97
+ * export const addTodo = serverFn(
98
+ * { input: t.object({ text: t.string({ minLength: 1 }) }), capabilities: ["db.write"] },
99
+ * async ({ text }, c) => db.todos.insert({ text }),
100
+ * )
101
+ *
102
+ * The second argument receives the validated input and the ordinary nifra `Context` - `c.env`,
103
+ * `c.clientIp`, `c.budget`, cookies, and the capability guard are all present, because this is a route.
104
+ */
105
+ export function serverFn<Input = void, Output = unknown>(
106
+ config: ServerFnConfig<Input>,
107
+ fn: (input: Input, context: Context) => MaybePromise<Output>,
108
+ ): ServerFn<Input, Output> {
109
+ return Object.assign(fn, { [SERVER_FN]: config }) as ServerFn<Input, Output>
110
+ }
111
+
112
+ const isServerFn = (value: unknown): value is ServerFn<unknown, unknown> =>
113
+ typeof value === "function" && SERVER_FN in (value as object)
114
+
115
+ /** A namespace segment: lowercase, dot/dash separated. Constrained so a mount cannot invent a path. */
116
+ const NAMESPACE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
117
+ /** An export name that is safe in a URL path without escaping. */
118
+ const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/
119
+
120
+ /**
121
+ * Same-origin, from `@nifrajs/core` so this seam and the WebSocket handshake cannot answer differently
122
+ * for one request. Host must match; the Origin's scheme may be equal or stronger, never weaker - which
123
+ * is what keeps a TLS-terminating proxy working without reading a forwarded header.
124
+ */
125
+ const sameOrigin = isSameOriginRequest
126
+
127
+ /** The minimum a server needs to expose for functions to be mounted onto it. */
128
+ export interface ServerFnHost {
129
+ register(
130
+ method: "POST",
131
+ path: string,
132
+ schema: RouteSchema | undefined,
133
+ handler: (context: never) => unknown,
134
+ ): void
135
+ }
136
+
137
+ /** Every server function a module exports, keyed by export name. */
138
+ export type ServerFnModule = Readonly<Record<string, unknown>>
139
+
140
+ /**
141
+ * Mount a module's server functions under `namespace`, returning a plugin for `app.use(...)`.
142
+ *
143
+ * import * as todos from "./actions/todos.fn"
144
+ * app.use(serverFunctions("todos", todos)) // -> POST /_nifra/fn/todos/addTodo
145
+ *
146
+ * The namespace is explicit rather than derived from the file path: a path-derived one would put the
147
+ * build machine's layout in a public URL, and this keeps the route readable in logs and greppable in
148
+ * the codebase. Phase 2's transform supplies it automatically from the file's location in the project.
149
+ *
150
+ * Exports that are not server functions are ignored, so `export type` and helpers can live alongside.
151
+ */
152
+ export function serverFunctions<S extends ServerFnHost>(
153
+ namespace: string,
154
+ module: ServerFnModule,
155
+ ): (app: S) => S {
156
+ if (!NAMESPACE.test(namespace)) {
157
+ throw new Error(
158
+ `[nifra/fn] invalid server-function namespace ${JSON.stringify(namespace)} - use lowercase dot/dash segments (it becomes a URL path).`,
159
+ )
160
+ }
161
+ const mounted = Object.entries(module).filter(
162
+ (entry): entry is [string, ServerFn<unknown, unknown>] => isServerFn(entry[1]),
163
+ )
164
+
165
+ const apply = (app: S): S => {
166
+ for (const [name, fn] of mounted) {
167
+ if (!EXPORT_NAME.test(name)) {
168
+ throw new Error(
169
+ `[nifra/fn] server function ${JSON.stringify(name)} in namespace ${JSON.stringify(namespace)} is not a valid identifier.`,
170
+ )
171
+ }
172
+ const config = fn[SERVER_FN]
173
+ const schema: RouteSchema = {
174
+ ...(config.input !== undefined ? { body: config.input } : {}),
175
+ ...(config.capabilities !== undefined ? { capabilities: config.capabilities } : {}),
176
+ }
177
+ app.register("POST", `${SERVER_FN_PREFIX}/${namespace}/${name}`, schema, ((c: Context) => {
178
+ // Ordered cheapest-first, and BEFORE the declared function runs. The body has already been
179
+ // parsed by the schema layer, but parsing is not a side effect - nothing the caller asked for
180
+ // has happened yet, so rejecting here is a complete refusal.
181
+ const request = c.req
182
+ const contentType = request.headers.get("content-type") ?? ""
183
+ if (!contentType.includes("application/json")) {
184
+ // A form cannot send this content type cross-origin without a preflight. Rejecting anything
185
+ // else is what makes form-driven CSRF against a server function impossible rather than
186
+ // merely unlikely.
187
+ return c.json({ ok: false, error: "unsupported_media_type" }, 415)
188
+ }
189
+ const origin = request.headers.get("origin")
190
+ if (origin !== null && !sameOrigin(origin, request)) {
191
+ return c.json({ ok: false, error: "forbidden_origin" }, 403)
192
+ }
193
+ return fn(config.input === undefined ? (undefined as never) : (c.body as never), c)
194
+ }) as (context: never) => unknown)
195
+ }
196
+ return app
197
+ }
198
+ return Object.assign(apply, { pluginName: `nifra:server-fn:${namespace}` })
199
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Build-time Google Fonts automation — the `next/font/google` equivalent. At **build time** (never on
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} — no runtime CDN
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 — run once at build time (e.g. a prebuild step)
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** — a tampered/MITM'd stylesheet
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
  */
@@ -30,7 +30,7 @@ import type { LinkDescriptor } from "./manifest.ts"
30
30
  export interface GoogleFontOptions {
31
31
  /** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
32
32
  readonly family: string
33
- /** Weights to request — numbers (`400`), numeric strings, a variable range (`"100 900"`), or the
33
+ /** Weights to request - numbers (`400`), numeric strings, a variable range (`"100 900"`), or the
34
34
  * keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
35
35
  readonly weights?: readonly (number | string)[]
36
36
  /** Styles to request. Defaults to `["normal"]`. */
@@ -42,7 +42,7 @@ export interface GoogleFontOptions {
42
42
  /** `font-display` strategy for the generated faces. Defaults to `"swap"`. */
43
43
  readonly display?: FontDisplay
44
44
  /** Glyph subsetting: request only the glyphs needed to render exactly this text (Google's `&text=`).
45
- * Ideal for a logo/heading font — produces one tiny file. */
45
+ * Ideal for a logo/heading font - produces one tiny file. */
46
46
  readonly text?: string
47
47
  /** CLS metric overrides forwarded to every generated `@font-face` (the layout-shift fix). */
48
48
  readonly sizeAdjust?: string
@@ -81,7 +81,7 @@ export interface LoadGoogleFontResult {
81
81
  readonly css: string
82
82
  /** Every file written to `outDir`. */
83
83
  readonly assets: readonly FontAsset[]
84
- /** `fontPreload()` link-attribute sets — spread the ones you want into a layout's `meta.link`.
84
+ /** `fontPreload()` link-attribute sets - spread the ones you want into a layout's `meta.link`.
85
85
  * Preloading *every* weight/subset is wasteful; usually preload just the primary subset + weight. */
86
86
  readonly preloads: readonly LinkDescriptor[]
87
87
  }
@@ -121,7 +121,7 @@ const VALID_DISPLAY: ReadonlySet<string> = new Set([
121
121
  "optional",
122
122
  ])
123
123
 
124
- /** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL — the only host we'll download from. */
124
+ /** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL - the only host we'll download from. */
125
125
  export function isAllowedFontUrl(raw: string): boolean {
126
126
  let url: URL
127
127
  try {
@@ -148,7 +148,7 @@ function validateFamily(family: string): string {
148
148
  function normalizeWeight(weight: number | string): string {
149
149
  if (typeof weight === "number") {
150
150
  if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
151
- throw new Error(`loadGoogleFont: invalid weight ${weight} (1–1000)`)
151
+ throw new Error(`loadGoogleFont: invalid weight ${weight} (1-1000)`)
152
152
  }
153
153
  return String(weight)
154
154
  }
@@ -221,7 +221,7 @@ function declOf(body: string, prop: string): string | undefined {
221
221
  }
222
222
 
223
223
  /** Parse Google's stylesheet into structured faces, capturing the `/* subset *​/` label that precedes
224
- * each `@font-face`. Pure — exported so callers can run their own download/write pipeline. */
224
+ * each `@font-face`. Pure - exported so callers can run their own download/write pipeline. */
225
225
  export function parseGoogleFontCss(css: string): ParsedFontFace[] {
226
226
  const faces: ParsedFontFace[] = []
227
227
  for (const match of css.matchAll(FACE_RE)) {
@@ -250,7 +250,7 @@ export function parseGoogleFontCss(css: string): ParsedFontFace[] {
250
250
  return faces
251
251
  }
252
252
 
253
- /** First 16 hex chars of the SHA-256 of the bytes — a content hash for an immutable, cache-busting
253
+ /** First 16 hex chars of the SHA-256 of the bytes - a content hash for an immutable, cache-busting
254
254
  * filename. Web Crypto, so it runs identically on Bun/Node/edge build hosts. `Uint8Array<ArrayBuffer>`
255
255
  * (not the generic `ArrayBufferLike`) to satisfy WebCrypto's `BufferSource` under TS 5.7+ generics. */
256
256
  async function contentHash(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
@@ -267,7 +267,7 @@ const slug = (s: string): string =>
267
267
  .replace(/[^a-z0-9]+/g, "-")
268
268
  .replace(/^-+|-+$/g, "")
269
269
 
270
- /** Host of a URL for error messages — we never echo the full untrusted URL+path. */
270
+ /** Host of a URL for error messages - we never echo the full untrusted URL+path. */
271
271
  const hostOf = (raw: string): string => {
272
272
  try {
273
273
  return new URL(raw).host
@@ -330,7 +330,7 @@ export async function loadGoogleFont(
330
330
  `loadGoogleFont: Google returned no @font-face rules for ${JSON.stringify(family)}`,
331
331
  )
332
332
  }
333
- // Filter to requested named subsets — but only when not glyph-subsetting (text mode labels faces
333
+ // Filter to requested named subsets - but only when not glyph-subsetting (text mode labels faces
334
334
  // `[0]`, `[1]`, … which carry no subset name).
335
335
  if (wantedSubsets && wantedSubsets.length > 0 && options.text === undefined) {
336
336
  faces = faces.filter((f) => wantedSubsets.includes(f.subset))
package/src/fonts.ts CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Font optimization primitives — generate a **CLS-safe `@font-face`** rule and the matching
2
+ * Font optimization primitives - generate a **CLS-safe `@font-face`** rule and the matching
3
3
  * **preload `<link>`** for a self-hosted font. The two fixes that matter most (the ones `next/font`
4
4
  * automates) are right here: `font-display: swap` (text paints immediately, no invisible-text FOIT)
5
5
  * plus a preload so the file downloads with the document, and optional metric overrides
6
6
  * (`size-adjust`/`ascent-override`/…) so swapping from the fallback to the web font doesn't shift layout.
7
7
  *
8
- * Self-host the file (drop a `.woff2` in your assets — never hotlink Google's CDN), then:
8
+ * Self-host the file (drop a `.woff2` in your assets - never hotlink Google's CDN), then:
9
9
  *
10
- * // fonts.css (imported by your app — the CSS pipeline bundles + links it)
10
+ * // fonts.css (imported by your app - the CSS pipeline bundles + links it)
11
11
  * import { fontFace } from "@nifrajs/web"
12
12
  * export default fontFace({ family: "Inter", src: [{ url: "/fonts/inter.woff2" }], weight: "100 900" })
13
13
  *
14
- * // a root layout's meta — becomes a <link rel="preload" as="font"> in <head>
14
+ * // a root layout's meta - becomes a <link rel="preload" as="font"> in <head>
15
15
  * export const meta = { link: [fontPreload({ href: "/fonts/inter.woff2" })] }
16
16
  */
17
17
  import type { LinkDescriptor } from "./manifest.ts"
@@ -35,7 +35,7 @@ export interface FontFace {
35
35
  readonly style?: "normal" | "italic" | "oblique"
36
36
  /** Defaults to `"swap"` (no flash of invisible text). */
37
37
  readonly display?: FontDisplay
38
- /** Restrict the glyphs this face covers, e.g. `"U+0000-00FF"` — lets the browser skip the download
38
+ /** Restrict the glyphs this face covers, e.g. `"U+0000-00FF"` - lets the browser skip the download
39
39
  * for pages without those characters. */
40
40
  readonly unicodeRange?: string
41
41
  /** Metric overrides that eliminate layout shift when the fallback swaps to the web font (the
@@ -124,14 +124,14 @@ export interface FontPreloadInput {
124
124
  readonly href: string
125
125
  /** MIME type (`font/woff2`, …). Inferred from the extension when omitted. */
126
126
  readonly type?: string
127
- /** `crossorigin` mode — defaults to `"anonymous"`. Fonts are always fetched in CORS mode, and a
127
+ /** `crossorigin` mode - defaults to `"anonymous"`. Fonts are always fetched in CORS mode, and a
128
128
  * preload whose `crossorigin` doesn't match the actual fetch is **wasted** (downloaded twice), so
129
129
  * this is on by default. */
130
130
  readonly crossOrigin?: "anonymous" | "use-credentials"
131
131
  }
132
132
 
133
133
  /**
134
- * Build a font preload as a `<link>` attribute set for a route/layout's `meta.link` — nifra injects it
134
+ * Build a font preload as a `<link>` attribute set for a route/layout's `meta.link` - nifra injects it
135
135
  * into `<head>` (`<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous">`). Values are
136
136
  * escaped at injection by the head renderer. Preloading the font file removes a render-blocking round
137
137
  * trip (the browser would otherwise discover the font only after parsing the CSS).
package/src/forms.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `@nifrajs/web/forms` — a typed form ↔ backend-schema binding. The three artifacts that must agree —
3
- * a form's `<input name>`, the action's `formData.get`, and the typed-client payload — all key off the
2
+ * `@nifrajs/web/forms` - a typed form ↔ backend-schema binding. The three artifacts that must agree -
3
+ * a form's `<input name>`, the action's `formData.get`, and the typed-client payload - all key off the
4
4
  * ONE route body schema, derived purely from `typeof backend`. A typo'd field name, or a field the
5
5
  * schema doesn't have, becomes a COMPILE error instead of a silent runtime empty.
6
6
  *
@@ -8,14 +8,14 @@
8
8
  * import { formFor } from "@nifrajs/web/forms"
9
9
  *
10
10
  * const f = formFor<typeof backend, "/todos">() // method defaults to "post"
11
- * // In the component (any framework — spread the props onto an input):
11
+ * // In the component (any framework - spread the props onto an input):
12
12
  * <input {...f.field("text")} /> // "text" ∈ the body's keys, else a compile error
13
13
  * // In the action:
14
14
  * const text = f.read(await request.formData(), "text") // an orphan key won't compile
15
15
  * await api.todos.post({ text }) // payload already typed by nifra
16
16
  *
17
17
  * Framework-agnostic + dependency-free: no codegen, no schema is bundled into the client, and the
18
- * runtime is a trivial pass-through — all the work is in the types. It checks the field KEY, never the
18
+ * runtime is a trivial pass-through - all the work is in the types. It checks the field KEY, never the
19
19
  * MEANING (a `field("email")` collecting a phone is still valid here); that stays an app/AI concern.
20
20
  */
21
21
  import type { RouteInfo, Server } from "@nifrajs/core/server"
@@ -28,7 +28,7 @@ type FormEntries = ReturnType<FormData["getAll"]>
28
28
  /** The route registry baked into a server's type (`typeof app`). */
29
29
  type RegistryOf<App> = App extends Server<infer R, infer _Ctx> ? R : never
30
30
 
31
- /** Every route path the app declares — constrains `Path`, so a wrong path is itself a type error. */
31
+ /** Every route path the app declares - constrains `Path`, so a wrong path is itself a type error. */
32
32
  export type RoutePaths<App> = keyof RegistryOf<App> & string
33
33
 
34
34
  /** The body object type of `App`'s `Method Path` route (`never` when the route declares no body). */
@@ -42,7 +42,7 @@ export type RouteBody<
42
42
  : never
43
43
  : never
44
44
 
45
- /** The valid field names for that route's body — the schema's keys as a string union. */
45
+ /** The valid field names for that route's body - the schema's keys as a string union. */
46
46
  export type FieldKey<App, Path extends string, Method extends string> = [
47
47
  RouteBody<App, Path, Method>,
48
48
  ] extends [never]
@@ -53,13 +53,13 @@ export type FieldKey<App, Path extends string, Method extends string> = [
53
53
  export type FieldProps = Record<string, unknown>
54
54
 
55
55
  export interface FormHandle<App, Path extends string, Method extends string> {
56
- /** Input props for a schema field. `name` is constrained to the body's keys — a typo, or a field the
56
+ /** Input props for a schema field. `name` is constrained to the body's keys - a typo, or a field the
57
57
  * schema doesn't have, is a COMPILE error. Spread onto an element: `<input {...f.field("text")} />`. */
58
58
  field<K extends FieldKey<App, Path, Method>>(
59
59
  name: K,
60
60
  props?: FieldProps,
61
61
  ): FieldProps & { name: K }
62
- /** `formData.get`, key-constrained to the body's fields — reading an orphan key won't compile. */
62
+ /** `formData.get`, key-constrained to the body's fields - reading an orphan key won't compile. */
63
63
  read<K extends FieldKey<App, Path, Method>>(form: FormData, name: K): FormEntry
64
64
  /** `formData.getAll`, key-checked the same way. */
65
65
  readAll<K extends FieldKey<App, Path, Method>>(form: FormData, name: K): FormEntries
package/src/fs.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @nifrajs/web/fs — filesystem route discovery. Separated from the main entry so `app.fetch`
2
+ * @nifrajs/web/fs - filesystem route discovery. Separated from the main entry so `app.fetch`
3
3
  * (and the rest of @nifrajs/web) stays fs-free + edge-portable: this runs at build/startup
4
4
  * only. It scans a routes dir and feeds the pure `buildManifest`.
5
5
  */
@@ -8,7 +8,7 @@ import { buildManifest, type Manifest, type RouteModule } from "./manifest.ts"
8
8
 
9
9
  /** Options for {@link discoverRoutes}. */
10
10
  export interface DiscoverRoutesOptions {
11
- /** Appended (as `?<importQuery>`) to each route's dynamic import — the dev server sets a
11
+ /** Appended (as `?<importQuery>`) to each route's dynamic import - the dev server sets a
12
12
  * changing value to bust Bun's module cache so SSR picks up edited route files. Ignored when
13
13
  * {@link load} is supplied, which does its own cache handling. */
14
14
  readonly importQuery?: string