@nifrajs/web 2.13.0 → 3.1.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 (69) 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-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +9 -6
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +57 -31
  11. package/dist/build.js.map +1 -1
  12. package/dist/fn.d.ts +2 -2
  13. package/dist/fn.d.ts.map +1 -1
  14. package/dist/fn.js +2 -2
  15. package/dist/fn.js.map +1 -1
  16. package/dist/index.d.ts +30 -20
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +179 -56
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/fetcher-runtime.d.ts +19 -0
  21. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  22. package/dist/internal/fetcher-runtime.js +26 -0
  23. package/dist/internal/fetcher-runtime.js.map +1 -0
  24. package/dist/internal/identity-policy.d.ts +51 -0
  25. package/dist/internal/identity-policy.d.ts.map +1 -0
  26. package/dist/internal/identity-policy.js +67 -0
  27. package/dist/internal/identity-policy.js.map +1 -0
  28. package/dist/internal/parity.d.ts +105 -7
  29. package/dist/internal/parity.d.ts.map +1 -1
  30. package/dist/internal/parity.js +303 -56
  31. package/dist/internal/parity.js.map +1 -1
  32. package/dist/internal/query-runtime.d.ts +8 -0
  33. package/dist/internal/query-runtime.d.ts.map +1 -0
  34. package/dist/internal/query-runtime.js +29 -0
  35. package/dist/internal/query-runtime.js.map +1 -0
  36. package/dist/islands.d.ts +2 -2
  37. package/dist/islands.d.ts.map +1 -1
  38. package/dist/islands.js +17 -36
  39. package/dist/islands.js.map +1 -1
  40. package/dist/manifest.d.ts +41 -6
  41. package/dist/manifest.d.ts.map +1 -1
  42. package/dist/manifest.js.map +1 -1
  43. package/dist/render-seam.d.ts +5 -0
  44. package/dist/render-seam.d.ts.map +1 -1
  45. package/dist/render-seam.js +10 -0
  46. package/dist/render-seam.js.map +1 -1
  47. package/dist/router.d.ts +10 -0
  48. package/dist/router.d.ts.map +1 -1
  49. package/dist/router.js +233 -26
  50. package/dist/router.js.map +1 -1
  51. package/dist/vite.d.ts +8 -0
  52. package/dist/vite.d.ts.map +1 -1
  53. package/dist/vite.js +51 -13
  54. package/dist/vite.js.map +1 -1
  55. package/package.json +13 -2
  56. package/src/boundary.ts +407 -0
  57. package/src/build-vite.ts +5 -3
  58. package/src/build.ts +61 -31
  59. package/src/fn.ts +6 -6
  60. package/src/index.ts +255 -54
  61. package/src/internal/fetcher-runtime.ts +36 -0
  62. package/src/internal/identity-policy.ts +94 -0
  63. package/src/internal/parity.ts +447 -70
  64. package/src/internal/query-runtime.ts +31 -0
  65. package/src/islands.ts +22 -41
  66. package/src/manifest.ts +50 -3
  67. package/src/render-seam.ts +6 -0
  68. package/src/router.ts +271 -29
  69. package/src/vite.ts +75 -13
package/src/build.ts CHANGED
@@ -8,10 +8,15 @@
8
8
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
9
9
  import { cp, lstat, mkdir, realpath } from "node:fs/promises"
10
10
  import { dirname, join, relative, resolve as resolvePath, sep } from "node:path"
11
+ import {
12
+ singleCopyPlugin as declaredSingleCopyPlugin,
13
+ readSingleCopyDeclaration,
14
+ } from "@nifrajs/core/single-copy"
11
15
  import { type BunPlugin, Glob } from "bun"
12
16
  import { sanitizeOutputNames } from "./chunk-names.ts"
13
17
  import { discoverRoutes } from "./fs.ts"
14
18
  import { generateClientEntry, generateServerManifest } from "./index.ts"
19
+ import { dedupePolicyFor } from "./internal/identity-policy.ts"
15
20
  import {
16
21
  assertDevelopmentProductionParity,
17
22
  assertIdentityParity,
@@ -789,28 +794,36 @@ export interface ManifestDrift {
789
794
  readonly extra: readonly string[]
790
795
  }
791
796
 
792
- // The route-relative specifiers the generated manifest imports. Both shapes `generateServerManifest`
793
- // emits are matched: eager `import * as m0 from "./routes/x.tsx"` and lazy `() => import("./routes/x")`.
794
- // Captures the path inside the quotes; the caller strips the routes-dir prefix to compare with discovery.
795
- // `\s*(?:\(\s*)?` rather than `\s*\(?\s*`: two adjacent unbounded whitespace runs with an optional
796
- // token between them are ambiguous, and that ambiguity backtracks quadratically on a long space run.
797
- const MANIFEST_IMPORT = /(?:import\s+\*\s+as\s+\w+\s+from|import)\s*(?:\(\s*)?["']([^"']+)["']\)?/g
797
+ // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by
798
+ // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`.
799
+ // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all;
800
+ // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be
801
+ // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys
802
+ // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on
803
+ // the same line and one route-map entry cannot span two lines of source.
804
+ const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm
805
+ // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
806
+ // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
807
+ // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
808
+ // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
809
+ const MANIFEST_IS_LAZY = /^const loaders\b/m
798
810
  // The baked client-entry line: `export const clientEntry = "…"`.
799
811
  const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
800
812
 
801
813
  /**
802
- * Extract the route-relative file list the committed server-manifest imports, normalized to the same
803
- * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
804
- * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
805
- * default `resolve` emits). Only import specifiers under that prefix are route files; the
806
- * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
814
+ * Extract the route-relative file list a committed server-manifest declares, as the same
815
+ * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS,
816
+ * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is
817
+ * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and
818
+ * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored.
819
+ *
820
+ * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already
821
+ * prefix-free. Pure - operates on source text.
807
822
  */
808
- export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
823
+ export function parseManifestRouteFiles(source: string, _routesPrefix?: string): string[] {
809
824
  const files = new Set<string>()
810
- for (const match of source.matchAll(MANIFEST_IMPORT)) {
811
- const spec = match[1]
812
- if (spec === undefined || !spec.startsWith(routesPrefix)) continue
813
- files.add(spec.slice(routesPrefix.length))
825
+ for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) {
826
+ if (match[2] !== undefined) files.add(match[2])
814
827
  }
815
828
  return [...files].sort()
816
829
  }
@@ -885,7 +898,7 @@ export function resyncServerManifestSource(
885
898
  clientEntry: parseManifestClientEntry(source) ?? "",
886
899
  styles: parseManifestStyles(source),
887
900
  routeStyles: parseManifestRouteStyles(source),
888
- lazy: source.includes("const loaders ="),
901
+ lazy: MANIFEST_IS_LAZY.test(source),
889
902
  })
890
903
  }
891
904
 
@@ -1007,6 +1020,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1007
1020
  ...buildExtras,
1008
1021
  minify: options.minify ?? true,
1009
1022
  plugins: [
1023
+ ...declaredSingleCopyPlugins(root),
1010
1024
  reactDedupePlugin(routesDir),
1011
1025
  preactDedupePlugin(routesDir),
1012
1026
  svelteDedupePlugin(routesDir),
@@ -1097,12 +1111,17 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1097
1111
  const base = basename(path)
1098
1112
  return base.slice(0, base.lastIndexOf("-")) // strip `-${hash}.css`
1099
1113
  }
1114
+ // `css` is the union of every emitted stylesheet, matching the Vite pipeline (build-vite.ts) so the
1115
+ // field means the same thing on both bundlers. The bootstrap aggregate lists first, preserving the
1116
+ // fallback link order `createWebApp` uses (whole-app stylesheet before any route-scoped one); the
1117
+ // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
1118
+ // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
1119
+ // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
1100
1120
  const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"))
1101
- const aggregate = cssAssets
1102
- .filter((o) => cssNameOf(o.path) === bootstrapName)
1103
- .map((o) => toUrl(o.path))
1104
- const css: readonly string[] =
1105
- aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path))
1121
+ const css: readonly string[] = [
1122
+ ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
1123
+ ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
1124
+ ].map((o) => toUrl(o.path))
1106
1125
 
1107
1126
  // CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
1108
1127
  // `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
@@ -1236,7 +1255,7 @@ const reactDomEdgePlugin = (from: string): BunPlugin => ({
1236
1255
  * condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
1237
1256
  * react-dom's build.
1238
1257
  */
1239
- const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"] as const
1258
+ const REACT_DEDUPE_SPECS = dedupePolicyFor("react").bunSpecs ?? []
1240
1259
  export const reactDedupePlugin = (from: string): BunPlugin => ({
1241
1260
  name: "nifra-react-dedupe",
1242
1261
  setup(build) {
@@ -1264,13 +1283,7 @@ export const reactDedupePlugin = (from: string): BunPlugin => ({
1264
1283
  * skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
1265
1284
  * unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
1266
1285
  */
1267
- const PREACT_DEDUPE_SPECS = [
1268
- "preact",
1269
- "preact/hooks",
1270
- "preact/compat",
1271
- "preact/jsx-runtime",
1272
- "preact/jsx-dev-runtime",
1273
- ] as const
1286
+ const PREACT_DEDUPE_SPECS = dedupePolicyFor("preact").bunSpecs ?? []
1274
1287
  export const preactDedupePlugin = (from: string): BunPlugin => ({
1275
1288
  name: "nifra-preact-dedupe",
1276
1289
  setup(build) {
@@ -1287,6 +1300,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1287
1300
  },
1288
1301
  })
1289
1302
 
1303
+ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($|\/internal\/)/
1290
1304
  /**
1291
1305
  * Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
1292
1306
  * the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
@@ -1305,7 +1319,7 @@ export const preactDedupePlugin = (from: string): BunPlugin => ({
1305
1319
  export const svelteDedupePlugin = (from: string): BunPlugin => ({
1306
1320
  name: "nifra-svelte-dedupe",
1307
1321
  setup(build) {
1308
- build.onResolve({ filter: /^svelte($|\/internal\/)/ }, (args) => {
1322
+ build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
1309
1323
  try {
1310
1324
  return { path: Bun.resolveSync(args.path, from) }
1311
1325
  } catch {
@@ -1315,6 +1329,21 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
1315
1329
  },
1316
1330
  })
1317
1331
 
1332
+ /**
1333
+ * The app's declared single-copy rule, applied to the bundle.
1334
+ *
1335
+ * The three plugins above cover the framework runtimes by name, which is the case that bites hardest
1336
+ * and needs no configuration. This one covers what a name list cannot know: `@nifrajs/*` (two copies
1337
+ * of `@nifrajs/core` are two `Server` classes, and `Server` has private members, so `.merge()` stops
1338
+ * accepting the other's app) and whatever else the app named in
1339
+ * `"nifra": { "singleCopy": [...] }`.
1340
+ *
1341
+ * Nothing declared means no plugin at all - the plan does filesystem work, so it is not built for the
1342
+ * majority of apps that have a single copy of everything and never think about this.
1343
+ */
1344
+ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] =>
1345
+ readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })]
1346
+
1318
1347
  /**
1319
1348
  * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
1320
1349
  * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
@@ -1431,6 +1460,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1431
1460
  // Lazy → one chunk per route (loaded on first request); eager → a single self-contained file.
1432
1461
  splitting: lazy,
1433
1462
  plugins: [
1463
+ ...declaredSingleCopyPlugins(resolvePath(dirname(routesDir))),
1434
1464
  ...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
1435
1465
  reactDedupePlugin(entryDir),
1436
1466
  preactDedupePlugin(entryDir),
package/src/fn.ts CHANGED
@@ -45,7 +45,10 @@
45
45
  * bespoke dispatcher.
46
46
  */
47
47
  import {
48
+ type AnyServer,
48
49
  type Context,
50
+ defineIdentityPlugin,
51
+ type IdentityPlugin,
49
52
  isSameOriginRequest,
50
53
  type RouteSchema,
51
54
  type StandardSchemaV1,
@@ -151,10 +154,7 @@ export type ServerFnModule = Readonly<Record<string, unknown>>
151
154
  *
152
155
  * Exports that are not server functions are ignored, so `export type` and helpers can live alongside.
153
156
  */
154
- export function serverFunctions<S extends ServerFnHost>(
155
- namespace: string,
156
- module: ServerFnModule,
157
- ): (app: S) => S {
157
+ export function serverFunctions(namespace: string, module: ServerFnModule): IdentityPlugin {
158
158
  if (!NAMESPACE.test(namespace)) {
159
159
  throw new Error(
160
160
  `[nifra/fn] invalid server-function namespace ${JSON.stringify(namespace)} - use lowercase dot/dash segments (it becomes a URL path).`,
@@ -164,7 +164,7 @@ export function serverFunctions<S extends ServerFnHost>(
164
164
  (entry): entry is [string, ServerFn<unknown, unknown>] => isServerFn(entry[1]),
165
165
  )
166
166
 
167
- const apply = (app: S): S => {
167
+ const apply = <S extends AnyServer>(app: S): S => {
168
168
  for (const [name, fn] of mounted) {
169
169
  if (!EXPORT_NAME.test(name)) {
170
170
  throw new Error(
@@ -197,5 +197,5 @@ export function serverFunctions<S extends ServerFnHost>(
197
197
  }
198
198
  return app
199
199
  }
200
- return Object.assign(apply, { pluginName: `nifra:server-fn:${namespace}` })
200
+ return defineIdentityPlugin(`nifra:server-fn:${namespace}`, apply)
201
201
  }