@nifrajs/web 2.14.1 → 3.2.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 (145) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-plan.d.ts +228 -0
  6. package/dist/build-plan.d.ts.map +1 -0
  7. package/dist/build-plan.js +514 -0
  8. package/dist/build-plan.js.map +1 -0
  9. package/dist/build-vite.d.ts +2 -1
  10. package/dist/build-vite.d.ts.map +1 -1
  11. package/dist/build-vite.js +5 -3
  12. package/dist/build-vite.js.map +1 -1
  13. package/dist/build.d.ts +2 -197
  14. package/dist/build.d.ts.map +1 -1
  15. package/dist/build.js +92 -515
  16. package/dist/build.js.map +1 -1
  17. package/dist/chunk-names.d.ts.map +1 -1
  18. package/dist/chunk-names.js +3 -2
  19. package/dist/chunk-names.js.map +1 -1
  20. package/dist/dev-ssr-graph.d.ts.map +1 -1
  21. package/dist/dev-ssr-graph.js +63 -28
  22. package/dist/dev-ssr-graph.js.map +1 -1
  23. package/dist/diagnostic.d.ts.map +1 -1
  24. package/dist/diagnostic.js +9 -2
  25. package/dist/diagnostic.js.map +1 -1
  26. package/dist/fonts-google.d.ts.map +1 -1
  27. package/dist/fonts-google.js +3 -1
  28. package/dist/fonts-google.js.map +1 -1
  29. package/dist/fs.d.ts.map +1 -1
  30. package/dist/fs.js +2 -1
  31. package/dist/fs.js.map +1 -1
  32. package/dist/index.d.ts +16 -589
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +23 -1824
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/codegen.d.ts +68 -0
  37. package/dist/internal/codegen.d.ts.map +1 -0
  38. package/dist/internal/codegen.js +317 -0
  39. package/dist/internal/codegen.js.map +1 -0
  40. package/dist/internal/fetcher-runtime.d.ts +19 -0
  41. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  42. package/dist/internal/fetcher-runtime.js +26 -0
  43. package/dist/internal/fetcher-runtime.js.map +1 -0
  44. package/dist/internal/head-merge.d.ts +2 -0
  45. package/dist/internal/head-merge.d.ts.map +1 -1
  46. package/dist/internal/head-merge.js +17 -1
  47. package/dist/internal/head-merge.js.map +1 -1
  48. package/dist/internal/identity-policy.d.ts +51 -0
  49. package/dist/internal/identity-policy.d.ts.map +1 -0
  50. package/dist/internal/identity-policy.js +67 -0
  51. package/dist/internal/identity-policy.js.map +1 -0
  52. package/dist/internal/parity.d.ts +104 -8
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +472 -56
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/query-runtime.d.ts +8 -0
  57. package/dist/internal/query-runtime.d.ts.map +1 -0
  58. package/dist/internal/query-runtime.js +29 -0
  59. package/dist/internal/query-runtime.js.map +1 -0
  60. package/dist/internal/render-document.d.ts +372 -0
  61. package/dist/internal/render-document.d.ts.map +1 -0
  62. package/dist/internal/render-document.js +744 -0
  63. package/dist/internal/render-document.js.map +1 -0
  64. package/dist/internal/runtime-contract.d.ts +48 -0
  65. package/dist/internal/runtime-contract.d.ts.map +1 -0
  66. package/dist/internal/runtime-contract.js +27 -0
  67. package/dist/internal/runtime-contract.js.map +1 -0
  68. package/dist/internal/web-app.d.ts +171 -0
  69. package/dist/internal/web-app.d.ts.map +1 -0
  70. package/dist/internal/web-app.js +844 -0
  71. package/dist/internal/web-app.js.map +1 -0
  72. package/dist/islands.d.ts +50 -2
  73. package/dist/islands.d.ts.map +1 -1
  74. package/dist/islands.js +96 -36
  75. package/dist/islands.js.map +1 -1
  76. package/dist/manifest.d.ts +41 -6
  77. package/dist/manifest.d.ts.map +1 -1
  78. package/dist/manifest.js.map +1 -1
  79. package/dist/module-graph.d.ts.map +1 -1
  80. package/dist/module-graph.js +22 -9
  81. package/dist/module-graph.js.map +1 -1
  82. package/dist/nano.d.ts +125 -0
  83. package/dist/nano.d.ts.map +1 -0
  84. package/dist/nano.js +190 -0
  85. package/dist/nano.js.map +1 -0
  86. package/dist/plugins/css-modules.js +2 -2
  87. package/dist/plugins/css-modules.js.map +1 -1
  88. package/dist/plugins/kit.d.ts +12 -0
  89. package/dist/plugins/kit.d.ts.map +1 -1
  90. package/dist/plugins/kit.js +62 -12
  91. package/dist/plugins/kit.js.map +1 -1
  92. package/dist/plugins/postcss.js +2 -2
  93. package/dist/plugins/postcss.js.map +1 -1
  94. package/dist/plugins/scss.js +2 -2
  95. package/dist/plugins/scss.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts +70 -0
  97. package/dist/plugins/stylex.d.ts.map +1 -0
  98. package/dist/plugins/stylex.js +166 -0
  99. package/dist/plugins/stylex.js.map +1 -0
  100. package/dist/plugins/svg.js +2 -2
  101. package/dist/plugins/svg.js.map +1 -1
  102. package/dist/render-seam.d.ts +5 -0
  103. package/dist/render-seam.d.ts.map +1 -1
  104. package/dist/render-seam.js +10 -0
  105. package/dist/render-seam.js.map +1 -1
  106. package/dist/router.d.ts +11 -1
  107. package/dist/router.d.ts.map +1 -1
  108. package/dist/router.js +254 -47
  109. package/dist/router.js.map +1 -1
  110. package/dist/vite.d.ts.map +1 -1
  111. package/dist/vite.js +34 -16
  112. package/dist/vite.js.map +1 -1
  113. package/package.json +49 -2
  114. package/src/boundary.ts +407 -0
  115. package/src/build-plan.ts +741 -0
  116. package/src/build-vite.ts +6 -7
  117. package/src/build.ts +120 -730
  118. package/src/chunk-names.ts +3 -2
  119. package/src/dev-ssr-graph.ts +63 -28
  120. package/src/diagnostic.ts +14 -2
  121. package/src/fonts-google.ts +3 -1
  122. package/src/fs.ts +2 -1
  123. package/src/index.ts +107 -2553
  124. package/src/internal/codegen.ts +400 -0
  125. package/src/internal/fetcher-runtime.ts +36 -0
  126. package/src/internal/head-merge.ts +19 -1
  127. package/src/internal/identity-policy.ts +94 -0
  128. package/src/internal/parity.ts +603 -71
  129. package/src/internal/query-runtime.ts +31 -0
  130. package/src/internal/render-document.ts +1116 -0
  131. package/src/internal/runtime-contract.ts +54 -0
  132. package/src/internal/web-app.ts +1114 -0
  133. package/src/islands.ts +106 -40
  134. package/src/manifest.ts +50 -3
  135. package/src/module-graph.ts +23 -9
  136. package/src/nano.ts +246 -0
  137. package/src/plugins/css-modules.ts +2 -2
  138. package/src/plugins/kit.ts +65 -12
  139. package/src/plugins/postcss.ts +2 -2
  140. package/src/plugins/scss.ts +2 -2
  141. package/src/plugins/stylex.ts +298 -0
  142. package/src/plugins/svg.ts +2 -2
  143. package/src/render-seam.ts +6 -0
  144. package/src/router.ts +293 -51
  145. package/src/vite.ts +41 -15
package/src/islands.ts CHANGED
@@ -15,8 +15,13 @@
15
15
  * the route's `islandScripts`.
16
16
  */
17
17
 
18
- /** When an island's enhancer runs. Default `load`. */
19
- export type IslandStrategy = "load" | "idle" | "visible"
18
+ import {
19
+ type IslandStrategy,
20
+ MAX_MEDIA_QUERY_LENGTH,
21
+ scheduleTrigger,
22
+ } from "@nifrajs/island-trigger"
23
+
24
+ export { type IslandStrategy, MAX_MEDIA_QUERY_LENGTH, scheduleTrigger }
20
25
 
21
26
  /** Optional teardown an enhancer returns (remove listeners/observers); run on `dispose()`. */
22
27
  export type IslandCleanup = () => void
@@ -29,7 +34,27 @@ export type IslandCleanup = () => void
29
34
  // biome-ignore lint/suspicious/noConfusingVoidType: `void` = "no cleanup returned", like React's EffectCallback - cleanup is optional.
30
35
  export type IslandEnhancer<P = unknown> = (el: HTMLElement, props: P) => IslandCleanup | void
31
36
 
32
- const NOOP = (): void => {}
37
+ /**
38
+ * Author a typed enhancer. Pure identity at runtime (zero cost, tree-shaken away) - its only job is
39
+ * to pin the `data-props` shape so `el` and `props` are typed inside the body and the enhancer is
40
+ * assignable to `mountIslands`. Prefer this over an inline arrow whenever an island reads props:
41
+ *
42
+ * const counter = defineIsland<{ start: number }>((el, props) => {
43
+ * let n = props.start // props typed, no cast
44
+ * const out = el.querySelector("output")!
45
+ * const onClick = () => { out.textContent = String(++n) }
46
+ * el.querySelector("button")?.addEventListener("click", onClick)
47
+ * return () => el.querySelector("button")?.removeEventListener("click", onClick)
48
+ * })
49
+ * mountIslands({ counter })
50
+ *
51
+ * The props type is a contract, not a validator - `data-props` is JSON authored by your own SSR, so
52
+ * it is trusted framing, not user input. Keep the enhancer body imperative: read the DOM, wire
53
+ * listeners, return cleanup. No hidden reactivity to get wrong.
54
+ */
55
+ export function defineIsland<P = unknown>(enhancer: IslandEnhancer<P>): IslandEnhancer<P> {
56
+ return enhancer
57
+ }
33
58
 
34
59
  /** Parse an island's inline `data-props` JSON; malformed/absent → `undefined` (never throws). */
35
60
  function readProps(el: HTMLElement): unknown {
@@ -42,40 +67,6 @@ function readProps(el: HTMLElement): unknown {
42
67
  }
43
68
  }
44
69
 
45
- /** Run `run` per the island's strategy; returns a disposer that cancels a not-yet-fired trigger. */
46
- function whenStrategy(el: HTMLElement, strategy: IslandStrategy, run: () => void): () => void {
47
- if (strategy === "idle") {
48
- const ric = globalThis.requestIdleCallback
49
- if (ric === undefined) {
50
- const t = setTimeout(run, 1)
51
- return () => clearTimeout(t)
52
- }
53
- const handle = ric(run)
54
- return () => globalThis.cancelIdleCallback?.(handle)
55
- }
56
- if (strategy === "visible") {
57
- // No IntersectionObserver (old engine / SSR smoke) → degrade to immediate, never skip the island.
58
- if (typeof IntersectionObserver === "undefined") {
59
- run()
60
- return NOOP
61
- }
62
- const io = new IntersectionObserver((entries, obs) => {
63
- for (const entry of entries) {
64
- if (entry.isIntersecting) {
65
- obs.disconnect()
66
- run()
67
- return
68
- }
69
- }
70
- })
71
- io.observe(el)
72
- return () => io.disconnect()
73
- }
74
- // "load" (and any unknown value) → run immediately on mount.
75
- run()
76
- return NOOP
77
- }
78
-
79
70
  /**
80
71
  * Find every `<nifra-island data-id>` under `root` (default `document`) and enhance each with the
81
72
  * matching enhancer, honoring its `data-strategy`. An island whose `id` has no enhancer is left as
@@ -96,8 +87,20 @@ export function mountIslands(
96
87
  if (enhancer === undefined) continue // no enhancer for this id → leave it as inert SSR markup
97
88
  const props = readProps(el)
98
89
  const strategyAttr = el.dataset.strategy
99
- const strategy: IslandStrategy =
100
- strategyAttr === "idle" || strategyAttr === "visible" ? strategyAttr : "load"
90
+ const strategy: IslandStrategy | undefined =
91
+ strategyAttr === "idle" || strategyAttr === "visible" || strategyAttr === "load"
92
+ ? strategyAttr
93
+ : strategyAttr === "media" &&
94
+ el.dataset.media !== undefined &&
95
+ el.dataset.media.length > 0 &&
96
+ el.dataset.media.length <= MAX_MEDIA_QUERY_LENGTH
97
+ ? { media: el.dataset.media }
98
+ : strategyAttr === undefined
99
+ ? "load"
100
+ : undefined
101
+ // Invalid media markers remain inert rather than silently becoming eager. Their SSR content stays
102
+ // usable, and untrusted HTML cannot turn an oversized query into an unexpected trigger.
103
+ if (strategy === undefined) continue
101
104
  const run = (): void => {
102
105
  try {
103
106
  const cleanup = enhancer(el, props)
@@ -107,7 +110,7 @@ export function mountIslands(
107
110
  console.error(`[nifra/islands] enhancer "${id}" failed:`, err)
108
111
  }
109
112
  }
110
- disposers.push(whenStrategy(el, strategy, run))
113
+ disposers.push(scheduleTrigger(strategy, run, { target: el }))
111
114
  }
112
115
  return () => {
113
116
  for (const dispose of disposers) {
@@ -119,3 +122,66 @@ export function mountIslands(
119
122
  }
120
123
  }
121
124
  }
125
+
126
+ /** Unsubscribe one bus handler; calling twice is a no-op. Also returned so an enhancer can hand it
127
+ * straight back as its `IslandCleanup`. */
128
+ export type IslandBusUnsubscribe = () => void
129
+
130
+ /**
131
+ * A typed publish/subscribe channel for coordinating islands that must talk to each other without a
132
+ * shared reactive store - a cart badge reacting to an "add to cart" island, a filter island driving
133
+ * a results island. Create ONE bus in your mount entry and close over it in each enhancer; there is
134
+ * no implicit global, so concurrent renders and tests never cross-talk.
135
+ *
136
+ * type Events = { "cart:add": { sku: string }; "cart:count": number }
137
+ * const bus = createIslandBus<Events>()
138
+ * mountIslands({
139
+ * addBtn: defineIsland((el) => {
140
+ * const onClick = () => bus.emit("cart:add", { sku: el.dataset.sku! })
141
+ * el.addEventListener("click", onClick)
142
+ * return () => el.removeEventListener("click", onClick)
143
+ * }),
144
+ * badge: defineIsland((el) => bus.on("cart:count", (n) => { el.textContent = String(n) })),
145
+ * })
146
+ *
147
+ * `on` returns its own unsubscribe, so an enhancer whose only job is to listen can `return bus.on(...)`
148
+ * directly as its cleanup. A handler that throws is isolated - it never blocks the other subscribers
149
+ * or the `emit` caller. Synchronous, in-memory, no DOM dependency (safe to construct under SSR); it
150
+ * carries no history, so a subscriber only sees events emitted after it subscribed.
151
+ */
152
+ export function createIslandBus<
153
+ Events extends Record<string, unknown> = Record<string, unknown>,
154
+ >(): {
155
+ emit<K extends keyof Events>(type: K, detail: Events[K]): void
156
+ on<K extends keyof Events>(type: K, handler: (detail: Events[K]) => void): IslandBusUnsubscribe
157
+ } {
158
+ const channels = new Map<keyof Events, Set<(detail: never) => void>>()
159
+ return {
160
+ emit(type, detail) {
161
+ const handlers = channels.get(type)
162
+ if (handlers === undefined) return
163
+ // Snapshot so a handler that (un)subscribes mid-dispatch cannot corrupt this iteration.
164
+ for (const handler of [...handlers]) {
165
+ try {
166
+ ;(handler as (d: Events[typeof type]) => void)(detail)
167
+ } catch (err) {
168
+ console.error(`[nifra/islands] bus handler for "${String(type)}" failed:`, err)
169
+ }
170
+ }
171
+ },
172
+ on(type, handler) {
173
+ let handlers = channels.get(type)
174
+ if (handlers === undefined) {
175
+ handlers = new Set()
176
+ channels.set(type, handlers)
177
+ }
178
+ handlers.add(handler as (detail: never) => void)
179
+ return () => {
180
+ const set = channels.get(type)
181
+ if (set === undefined) return
182
+ set.delete(handler as (detail: never) => void)
183
+ if (set.size === 0) channels.delete(type)
184
+ }
185
+ },
186
+ }
187
+ }
package/src/manifest.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
6
6
  */
7
7
  import type { StandardSchemaV1 } from "@nifrajs/core/server"
8
+ import type { BoundaryDescriptor, BoundaryRegistration } from "./boundary.ts"
8
9
 
9
10
  /** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
10
11
  * per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
@@ -30,11 +31,51 @@ export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
30
31
 
31
32
  /**
32
33
  * A route's optional mutation, run on POST. Shares the loader context (params/request/api);
33
- * read the form/JSON body off `request`. Returns either a `Response` (e.g. a redirect -
34
- * passed straight through) or data, surfaced to the page component as `actionData`.
34
+ * read the form/JSON body off `request`. Returns either a control-flow value (a `redirect()`, a
35
+ * `status(...)` render, or a hand-rolled `Response` - all passed straight through) or data,
36
+ * surfaced to the page component as `actionData`.
35
37
  */
36
38
  export type Action = (ctx: LoaderContext) => unknown | Promise<unknown>
37
39
 
40
+ /** The body a client action may prepare for the server action. The server must validate it again. */
41
+ export type ClientRequestBody = NonNullable<RequestInit["body"]>
42
+
43
+ /** Safe, client-visible context for a client loader. It intentionally exposes no Request or headers. */
44
+ export interface ClientLoaderArgs {
45
+ readonly url: string
46
+ readonly params: Readonly<Record<string, string>>
47
+ readonly signal: AbortSignal
48
+ /** Lazily obtains the server loader result; repeated calls share one per-navigation request. */
49
+ readonly serverLoader: () => Promise<unknown>
50
+ }
51
+
52
+ /** A client-only post-hydration data loader. Its return value replaces the route's rendered data. */
53
+ export type ClientLoader = (args: ClientLoaderArgs) => unknown | Promise<unknown>
54
+
55
+ /** Safe, client-visible context for a client action. No secrets or raw request headers cross this seam. */
56
+ export interface ClientActionArgs extends ClientLoaderArgs {
57
+ readonly body: ClientRequestBody
58
+ }
59
+
60
+ /** Client action preparation. `body` is sent as untrusted input; `optimisticData` is never sent. */
61
+ export interface ClientActionResult {
62
+ readonly body?: ClientRequestBody
63
+ readonly optimisticData?: unknown
64
+ }
65
+
66
+ /** A client-only action wrapper; it never replaces the server action. */
67
+ export type ClientAction = (
68
+ args: ClientActionArgs,
69
+ ) => ClientActionResult | void | Promise<ClientActionResult | void>
70
+
71
+ /** Client hooks populated by the generated route entry after a route chunk loads. */
72
+ export interface ClientRouteHooks {
73
+ readonly clientLoader?: ClientLoader
74
+ readonly clientAction?: ClientAction
75
+ /** Neutral boundary descriptors needed for soft-navigation interception; no server loader crosses. */
76
+ readonly boundaries?: readonly BoundaryDescriptor[]
77
+ }
78
+
38
79
  /**
39
80
  * One `<link>` tag's attributes for a route/layout's `meta.link`. The common HTML `<link>` attributes
40
81
  * are spelled out and **optional** so a typed partial like `{ rel, href, hreflang }` is assignable -
@@ -191,6 +232,12 @@ export interface RouteModule {
191
232
  readonly default: unknown
192
233
  readonly loader?: Loader
193
234
  readonly action?: Action
235
+ /** Optional client-only loader. It runs after hydration and on client navigations. */
236
+ readonly clientLoader?: ClientLoader
237
+ /** Optional client-only submit wrapper. The server action remains mandatory for mutations. */
238
+ readonly clientAction?: ClientAction
239
+ /** Named async boundaries owned by this route. The adapter renders their neutral state seam. */
240
+ readonly boundaries?: readonly BoundaryRegistration[]
194
241
  /** A Standard Schema validating this route's URL search params. When present, `ctx.search` is parsed +
195
242
  * validated against it (failing closed to the schema's defaults on invalid input); type it into the
196
243
  * loader with `LoaderArgs<Api, Env, typeof searchSchema>`. */
@@ -249,7 +296,7 @@ export interface RouteModule {
249
296
  * overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
250
297
  export interface LayoutEntry {
251
298
  readonly file: string
252
- readonly load: () => Promise<{ default: unknown; meta?: MetaInput }>
299
+ readonly load: () => Promise<RouteModule>
253
300
  }
254
301
 
255
302
  /** One matched route: pattern, nested layout ids (outermost → innermost), source file, loader. */
@@ -44,6 +44,11 @@ export interface ClientModuleGraph {
44
44
  readonly chunks: Readonly<Record<string, GraphChunk>>
45
45
  }
46
46
 
47
+ /** Module ids are graph identifiers, not filesystem paths exposed to an OS API. Bun uses `\\` on
48
+ * Windows, while Rollup and the guards use `/` for suffix matching and stable diagnostics. Normalize at
49
+ * each adapter boundary so every consumer sees one graph shape. */
50
+ const normalizeModuleId = (id: string): string => id.replaceAll("\\", "/")
51
+
47
52
  /** The slice of Bun's metafile this seam consumes. Not yet in `@types/bun`; shape per the docs. */
48
53
  export interface BunMetafileLike {
49
54
  readonly inputs?: Readonly<
@@ -66,14 +71,23 @@ export interface BunMetafileLike {
66
71
  */
67
72
  export function fromBunMetafile(meta: BunMetafileLike | undefined): ClientModuleGraph {
68
73
  const modules: Record<string, GraphModule> = {}
69
- for (const [id, input] of Object.entries(meta?.inputs ?? {})) {
70
- modules[id] = { imports: input.imports ?? [] }
74
+ for (const [rawId, input] of Object.entries(meta?.inputs ?? {})) {
75
+ const id = normalizeModuleId(rawId)
76
+ modules[id] = {
77
+ imports: (input.imports ?? []).map((im) => ({
78
+ ...(im.path === undefined ? {} : { path: normalizeModuleId(im.path) }),
79
+ ...(im.original === undefined ? {} : { original: im.original }),
80
+ })),
81
+ }
71
82
  }
72
83
  const chunks: Record<string, GraphChunk> = {}
73
- for (const [path, output] of Object.entries(meta?.outputs ?? {})) {
84
+ for (const [rawPath, output] of Object.entries(meta?.outputs ?? {})) {
85
+ const path = normalizeModuleId(rawPath)
74
86
  chunks[path] = {
75
- ...(output.entryPoint !== undefined ? { entryPoint: output.entryPoint } : {}),
76
- modules: Object.keys(output.inputs ?? {}),
87
+ ...(output.entryPoint !== undefined
88
+ ? { entryPoint: normalizeModuleId(output.entryPoint) }
89
+ : {}),
90
+ modules: Object.keys(output.inputs ?? {}).map(normalizeModuleId),
77
91
  }
78
92
  }
79
93
  return { modules, chunks }
@@ -117,11 +131,11 @@ export function fromRollupBundle(
117
131
  // Assets (CSS, copied files) have no module graph; `type` is absent only on hand-built test inputs,
118
132
  // which are always chunks, so treat "not explicitly an asset" as a chunk.
119
133
  if (output.type === "asset") continue
120
- const moduleIds = output.moduleIds ?? []
134
+ const moduleIds = (output.moduleIds ?? []).map(normalizeModuleId)
121
135
  // Start from the real modules; `node:` builtins get appended below.
122
136
  const chunkModules: string[] = [...moduleIds]
123
137
  for (const id of moduleIds) {
124
- const imports = importsOf(id).map((path) => ({ path }))
138
+ const imports = importsOf(id).map((path) => ({ path: normalizeModuleId(path) }))
125
139
  if (modules[id] === undefined) modules[id] = { imports }
126
140
  // The guards locate a leak by finding the builtin IN a chunk's module list - Bun BUNDLES the `node:`
127
141
  // polyfill, so the builtin appears there as an input. Rollup EXTERNALIZES `node:`, so it never lands
@@ -135,10 +149,10 @@ export function fromRollupBundle(
135
149
  if (modules[im.path] === undefined) modules[im.path] = { imports: [] }
136
150
  }
137
151
  }
138
- chunks[fileName] = {
152
+ chunks[normalizeModuleId(fileName)] = {
139
153
  // `facadeModuleId` is null for a shared/common chunk (no single entry) - omit it then, matching
140
154
  // how a non-entry Bun output omits `entryPoint`, so the guards' entry set stays the real entries.
141
- ...(output.facadeModuleId ? { entryPoint: output.facadeModuleId } : {}),
155
+ ...(output.facadeModuleId ? { entryPoint: normalizeModuleId(output.facadeModuleId) } : {}),
142
156
  modules: chunkModules,
143
157
  }
144
158
  }
package/src/nano.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `@nifrajs/web/nano` - the smallest state layer that still lets an island drive a keyed list.
3
+ *
4
+ * The islands lane (`@nifrajs/web/islands`) is imperative and correct-by-construction, but it has no
5
+ * state primitive: every DOM update is hand-written, and a list means rebuilding the whole container.
6
+ * nano adds a small, closed set of primitives and stops:
7
+ *
8
+ * - `signal(v)` - a current-value cell you read with `.get()` and write with `.set()`.
9
+ * - `computed(fn, [deps])` - a derived cell. Its dependencies are DECLARED, never auto-tracked.
10
+ * - `resource(fetcher, [deps])` - an async cell with an explicit pending/error/ready value. This is
11
+ * nano's answer to "suspense": no thrown promise, no magic boundary, just a value you can bind.
12
+ * - `bind` / `bindList` / `bindResource` - explicit, one-directional DOM bindings that each return
13
+ * their teardown.
14
+ *
15
+ * The design rule is the whole point: every reactive edge is a call you can see - a `bind(...)` or a
16
+ * `computed(fn, [deps])`. There is no re-render scope, no VDOM, no template compiler, and no effect
17
+ * that silently subscribes to whatever it happened to read. That is what removes the three failure
18
+ * modes an AI (or a human) hits in a framework - stale closures, wrong effect deps, hydration
19
+ * mismatch - and, just as importantly, it is what makes every remaining mistake STATICALLY
20
+ * detectable: a missing dep is `[deps]` that doesn't list a signal the body reads; a leak is a `bind`
21
+ * whose cleanup isn't returned. A framework's reactivity can't be linted like that; nano's can.
22
+ *
23
+ * Where nano stops: no client router and no nested/composable view state (a component tree that mounts
24
+ * and unmounts its own children). When an app needs those, it has outgrown a vanilla page - reach for
25
+ * a framework adapter (`@nifrajs/web-preact`), not more nano.
26
+ *
27
+ * Browser code: it touches the DOM and is meant to run in an island enhancer, never under SSR.
28
+ */
29
+
30
+ /** A value you can read now and be told about later. Both `signal` and `computed` are `Readable`, so
31
+ * `bind`/`bindList` accept either. */
32
+ export interface Readable<T> {
33
+ get(): T
34
+ subscribe(listener: (value: T) => void): () => void
35
+ }
36
+
37
+ /** A writable cell. `set` notifies subscribers only when the value actually changes (`Object.is`), so
38
+ * a redundant write costs nothing. */
39
+ export interface Signal<T> extends Readable<T> {
40
+ set(value: T): void
41
+ }
42
+
43
+ /** Notify a snapshot of the current listeners, so a listener that (un)subscribes mid-dispatch does not
44
+ * corrupt the walk. A throwing listener is isolated - it never blocks the others. */
45
+ function notify<T>(listeners: Set<(value: T) => void>, value: T): void {
46
+ for (const listener of [...listeners]) {
47
+ try {
48
+ listener(value)
49
+ } catch (error) {
50
+ console.error("[nifra/nano] a subscriber threw:", error)
51
+ }
52
+ }
53
+ }
54
+
55
+ /** A current-value cell. `signal(0)` -> `.get()` reads, `.set(1)` writes and notifies. */
56
+ export function signal<T>(initial: T): Signal<T> {
57
+ let value = initial
58
+ const listeners = new Set<(value: T) => void>()
59
+ return {
60
+ get: () => value,
61
+ set(next: T) {
62
+ if (Object.is(next, value)) return
63
+ value = next
64
+ notify(listeners, value)
65
+ },
66
+ subscribe(listener) {
67
+ listeners.add(listener)
68
+ return () => {
69
+ listeners.delete(listener)
70
+ }
71
+ },
72
+ }
73
+ }
74
+
75
+ /**
76
+ * A derived cell. `computed(() => a.get() + b.get(), [a, b])` recomputes whenever a declared
77
+ * dependency changes and notifies its own subscribers when the derived value changes.
78
+ *
79
+ * The `deps` array is mandatory and explicit ON PURPOSE - it is the contract a linter checks against
80
+ * the signals the body reads (`NF-C023`). Auto-tracking would remove the array but reintroduce the
81
+ * exact "why didn't this update" bug that makes framework reactivity hard to get right.
82
+ */
83
+ export function computed<T>(compute: () => T, deps: readonly Readable<unknown>[]): Readable<T> {
84
+ let value = compute()
85
+ const listeners = new Set<(value: T) => void>()
86
+ const recompute = (): void => {
87
+ const next = compute()
88
+ if (Object.is(next, value)) return
89
+ value = next
90
+ notify(listeners, value)
91
+ }
92
+ // A computed with a live subscriber must itself be subscribed to its deps; to keep the prototype
93
+ // simple it subscribes eagerly for its whole life (fine for the island-scoped, few-signal case).
94
+ for (const dep of deps) dep.subscribe(recompute)
95
+ return {
96
+ get: () => value,
97
+ subscribe(listener) {
98
+ listeners.add(listener)
99
+ return () => {
100
+ listeners.delete(listener)
101
+ }
102
+ },
103
+ }
104
+ }
105
+
106
+ /** The three states of an async cell. A discriminated union on `status` so a consumer must handle
107
+ * every branch - there is no "value that might secretly be loading", the failure mode of ad-hoc
108
+ * `isLoading` booleans. `value`/`error` are narrowed by `status`. */
109
+ export type ResourceState<T> =
110
+ | { readonly status: "pending"; readonly value: undefined; readonly error: undefined }
111
+ | { readonly status: "ready"; readonly value: T; readonly error: undefined }
112
+ | { readonly status: "error"; readonly value: undefined; readonly error: unknown }
113
+
114
+ /** An async cell: a `Readable` of `ResourceState<T>` plus `refetch()`. This is nano's answer to
115
+ * "suspense" - an explicit pending/error/ready value, never a thrown promise or a magic boundary. */
116
+ export interface Resource<T> extends Readable<ResourceState<T>> {
117
+ refetch(): void
118
+ }
119
+
120
+ /**
121
+ * An async derived cell. `resource(fetcher, [deps])` runs `fetcher` immediately and again whenever a
122
+ * declared dependency changes, exposing the result as an explicit `{ status, value, error }`.
123
+ *
124
+ * Two footguns are handled here so they cannot be got wrong by hand:
125
+ * - Races: each run holds a generation; a stale run's resolution is dropped, and its `AbortSignal` is
126
+ * aborted, so an earlier-started-later-finishing fetch never overwrites a newer one.
127
+ * - Deps: like `computed`, dependencies are DECLARED (`NF-C023` checks the fetcher's `.get()` reads
128
+ * against the array). A `resource` that reads a signal its deps omit won't refetch when it changes.
129
+ */
130
+ export function resource<T>(
131
+ fetcher: (signal: AbortSignal) => Promise<T>,
132
+ deps: readonly Readable<unknown>[] = [],
133
+ ): Resource<T> {
134
+ const state = signal<ResourceState<T>>({ status: "pending", value: undefined, error: undefined })
135
+ let generation = 0
136
+ let controller: AbortController | undefined
137
+ const run = (): void => {
138
+ const mine = ++generation
139
+ controller?.abort()
140
+ controller = new AbortController()
141
+ const active = controller
142
+ state.set({ status: "pending", value: undefined, error: undefined })
143
+ fetcher(active.signal).then(
144
+ (value) => {
145
+ if (mine === generation) state.set({ status: "ready", value, error: undefined })
146
+ },
147
+ (error) => {
148
+ // A stale run (superseded) or one we aborted must not surface as an error state.
149
+ if (mine === generation && !active.signal.aborted)
150
+ state.set({ status: "error", value: undefined, error })
151
+ },
152
+ )
153
+ }
154
+ for (const dep of deps) dep.subscribe(run)
155
+ run()
156
+ return {
157
+ get: () => state.get(),
158
+ subscribe: (listener) => state.subscribe(listener),
159
+ refetch: run,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Bind one element to a source: `apply(el, value)` runs once immediately and again on every change.
165
+ * Returns the unsubscribe - hand it back as the island's cleanup (or collect several).
166
+ *
167
+ * bind(count, remaining, (el, n) => { el.textContent = String(n) })
168
+ */
169
+ export function bind<T>(
170
+ el: HTMLElement,
171
+ source: Readable<T>,
172
+ apply: (el: HTMLElement, value: T) => void,
173
+ ): () => void {
174
+ apply(el, source.get())
175
+ return source.subscribe((value) => apply(el, value))
176
+ }
177
+
178
+ /** How a `bindList` turns items into keyed DOM. `key` MUST be stable and unique per item (never the
179
+ * array index) - it is what lets add/remove/reorder touch only the changed rows. */
180
+ export interface BindListOptions<T> {
181
+ key(item: T): string | number
182
+ create(item: T): HTMLElement
183
+ update?(el: HTMLElement, item: T): void
184
+ }
185
+
186
+ /**
187
+ * Bind a list signal to a container with keyed reconciliation: new items are `create`d, surviving
188
+ * items are `update`d in place (keeping focus, scroll, and selection), removed items are detached,
189
+ * and the children are ordered to match the array. Returns the unsubscribe.
190
+ *
191
+ * This is the one imperative task too error-prone to hand-write each time - it is why nano exists on
192
+ * top of islands rather than leaving you to rebuild the container on every change.
193
+ */
194
+ export function bindList<T>(
195
+ source: Readable<readonly T[]>,
196
+ container: HTMLElement,
197
+ options: BindListOptions<T>,
198
+ ): () => void {
199
+ let nodes = new Map<string | number, HTMLElement>()
200
+ const render = (items: readonly T[]): void => {
201
+ const next = new Map<string | number, HTMLElement>()
202
+ for (const item of items) {
203
+ const key = options.key(item)
204
+ const el = nodes.get(key) ?? options.create(item)
205
+ options.update?.(el, item)
206
+ next.set(key, el)
207
+ }
208
+ for (const [key, el] of nodes) if (!next.has(key)) el.remove()
209
+ // Append in item order; appending an already-attached node MOVES it, so the final order matches.
210
+ for (const item of items) container.appendChild(next.get(options.key(item)) as HTMLElement)
211
+ nodes = next
212
+ }
213
+ render(source.get())
214
+ return source.subscribe(render)
215
+ }
216
+
217
+ /** How a `bindResource` maps each async state to the DOM. `ready` is required (there is always a
218
+ * success shape to render); `pending`/`error` are optional. Every branch is explicit - no hidden
219
+ * "still loading" state can slip through as a rendered `undefined`. */
220
+ export interface BindResourceHandlers<T> {
221
+ ready(el: HTMLElement, value: T): void
222
+ pending?(el: HTMLElement): void
223
+ error?(el: HTMLElement, error: unknown): void
224
+ }
225
+
226
+ /**
227
+ * Bind an element to a `resource`, dispatching on `status`. Like `bind`, it applies immediately and
228
+ * on every change and returns the unsubscribe - collect it (a discarded disposer is `NF-C021`).
229
+ *
230
+ * bindResource(el, user, {
231
+ * pending: (n) => { n.textContent = "Loading…" },
232
+ * ready: (n, u) => { n.textContent = u.name },
233
+ * error: (n) => { n.textContent = "Failed to load" },
234
+ * })
235
+ */
236
+ export function bindResource<T>(
237
+ el: HTMLElement,
238
+ source: Readable<ResourceState<T>>,
239
+ handlers: BindResourceHandlers<T>,
240
+ ): () => void {
241
+ return bind(el, source, (node, state) => {
242
+ if (state.status === "pending") handlers.pending?.(node)
243
+ else if (state.status === "error") handlers.error?.(node, state.error)
244
+ else handlers.ready(node, state.value)
245
+ })
246
+ }
@@ -20,7 +20,7 @@
20
20
  * Not handled: the bare `:global`/`:local` *switch* form and `composes:` - out of scope by design.
21
21
  */
22
22
  import type { BunPlugin } from "bun"
23
- import { createStylesheetEmitter, hash8, reproduciblePath } from "./kit.ts"
23
+ import { createStylesheetEmitter, hash8, normalizeFilePath, reproduciblePath } from "./kit.ts"
24
24
 
25
25
  const STYLE_NS = "nifra-css-module"
26
26
 
@@ -425,7 +425,7 @@ export function cssModulesBunPlugin(generate: "dom" | "ssr"): BunPlugin {
425
425
  setup(build) {
426
426
  const stylesheet = createStylesheetEmitter(build, STYLE_NS)
427
427
  build.onLoad({ filter: /\.module\.css(\?|$)/ }, async (args) => {
428
- const path = args.path.split("?")[0] ?? args.path
428
+ const path = normalizeFilePath(args.path)
429
429
  const source = await Bun.file(path).text()
430
430
  // Hash scoped names off the cwd-relative path so they're reproducible across machines/CI.
431
431
  const { exports, css } = transformCssModule(source, reproduciblePath(path))