@nifrajs/web 2.12.1 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/web",
3
- "version": "2.12.1",
3
+ "version": "2.14.1",
4
4
  "description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
5
5
  "keywords": [
6
6
  "bun",
@@ -154,6 +154,11 @@
154
154
  "bun": "./src/fn-state.ts",
155
155
  "types": "./dist/fn-state.d.ts",
156
156
  "default": "./dist/fn-state.js"
157
+ },
158
+ "./internal/parity": {
159
+ "bun": "./src/internal/parity.ts",
160
+ "types": "./dist/internal/parity.d.ts",
161
+ "default": "./dist/internal/parity.js"
157
162
  }
158
163
  },
159
164
  "files": [
@@ -171,7 +176,7 @@
171
176
  "access": "public"
172
177
  },
173
178
  "dependencies": {
174
- "@nifrajs/core": "2.12.1"
179
+ "@nifrajs/core": "2.14.1"
175
180
  },
176
181
  "peerDependencies": {
177
182
  "sass": "^1.0.0",
package/src/build-vite.ts CHANGED
@@ -33,6 +33,11 @@ import {
33
33
  } from "./build.ts"
34
34
  import { discoverRoutes } from "./fs.ts"
35
35
  import { generateClientEntry, generateServerManifest } from "./index.ts"
36
+ import {
37
+ assertDevelopmentProductionParity,
38
+ assertIdentityParity,
39
+ collectDevelopmentParityInput,
40
+ } from "./internal/parity.ts"
36
41
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
37
42
  import { importVite, isViteUnresolved } from "./internal/vite-import.ts"
38
43
  import { scopedName } from "./plugins/css-modules.ts"
@@ -128,6 +133,7 @@ export interface BuildClientViteOptions extends Omit<BuildClientOptions, "plugin
128
133
  export async function buildClientVite(options: BuildClientViteOptions): Promise<BuildManifest> {
129
134
  const { routesDir, outDir, clientModule } = options
130
135
  const root = resolvePath(options.root ?? dirname(routesDir))
136
+ await assertIdentityParity(root)
131
137
  const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`)
132
138
  const publicPath = options.publicPath ?? "/assets/"
133
139
  mkdirSync(outDir, { recursive: true })
@@ -325,6 +331,7 @@ export async function buildClientVite(options: BuildClientViteOptions): Promise<
325
331
  ...(css.length > 0 ? { css } : {}),
326
332
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
327
333
  }
334
+ assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
328
335
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
329
336
  return manifest
330
337
  }
package/src/build.ts CHANGED
@@ -12,6 +12,11 @@ import { type BunPlugin, Glob } from "bun"
12
12
  import { sanitizeOutputNames } from "./chunk-names.ts"
13
13
  import { discoverRoutes } from "./fs.ts"
14
14
  import { generateClientEntry, generateServerManifest } from "./index.ts"
15
+ import {
16
+ assertDevelopmentProductionParity,
17
+ assertIdentityParity,
18
+ collectDevelopmentParityInput,
19
+ } from "./internal/parity.ts"
15
20
  import {
16
21
  generateServerFnStub,
17
22
  SERVER_FN_MODULE,
@@ -784,28 +789,36 @@ export interface ManifestDrift {
784
789
  readonly extra: readonly string[]
785
790
  }
786
791
 
787
- // The route-relative specifiers the generated manifest imports. Both shapes `generateServerManifest`
788
- // emits are matched: eager `import * as m0 from "./routes/x.tsx"` and lazy `() => import("./routes/x")`.
789
- // Captures the path inside the quotes; the caller strips the routes-dir prefix to compare with discovery.
790
- // `\s*(?:\(\s*)?` rather than `\s*\(?\s*`: two adjacent unbounded whitespace runs with an optional
791
- // token between them are ambiguous, and that ambiguity backtracks quadratically on a long space run.
792
- const MANIFEST_IMPORT = /(?:import\s+\*\s+as\s+\w+\s+from|import)\s*(?:\(\s*)?["']([^"']+)["']\)?/g
792
+ // The route-relative file keys the generated manifest's route map declares. Both shapes are matched by
793
+ // their VALUE: eager ` "routes/x.tsx": m0,` and lazy ` "routes/x.tsx": () => import("./routes/x"),`.
794
+ // The KEY (not the import specifier) is the route identity `discoverRoutes` produces, extension and all;
795
+ // the import specifier is emitted extensionless (so a bare `tsc` compiles the manifest) and could not be
796
+ // mapped back to the exact `.tsx`/`.ts` key. The baked `routeStyles` JSON is a single line, so its keys
797
+ // are never at an indented line start and never match. `[ \t]+` (not `\s+`) so the `m` anchor stays on
798
+ // the same line and one route-map entry cannot span two lines of source.
799
+ const MANIFEST_ROUTE_ENTRY = /^[ \t]+(["'])([^"'\n]+)\1\s*:\s*(?:m\d+\b|\(\s*\)\s*=>\s*import\b)/gm
800
+ // The lazy shape declares `const loaders`, the eager shape `const modules`. Anchored at a line start,
801
+ // NOT `.includes("const loaders =")`: the real emit is `const loaders: Record<...> = {`, whose `:` type
802
+ // annotation sits between the name and the `=`, so the ` =` substring never matched and every lazy
803
+ // manifest was re-synced as eager (silently converting `import()` splitting into a single eager bundle).
804
+ const MANIFEST_IS_LAZY = /^const loaders\b/m
793
805
  // The baked client-entry line: `export const clientEntry = "…"`.
794
806
  const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/
795
807
 
796
808
  /**
797
- * Extract the route-relative file list the committed server-manifest imports, normalized to the same
798
- * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
799
- * specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
800
- * default `resolve` emits). Only import specifiers under that prefix are route files; the
801
- * `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
809
+ * Extract the route-relative file list a committed server-manifest declares, as the same
810
+ * `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). Reads the route map's KEYS,
811
+ * which carry the file extension and no directory prefix - exactly discovery's shape - so the result is
812
+ * independent of the specifier prefix the manifest happened to import with. The `@nifrajs/web` import and
813
+ * the baked `clientEntry`/`styles`/`routeStyles` lines carry no route-map entry and are ignored.
814
+ *
815
+ * `_routesPrefix` is accepted for call-site compatibility but no longer needed: the keys are already
816
+ * prefix-free. Pure - operates on source text.
802
817
  */
803
- export function parseManifestRouteFiles(source: string, routesPrefix = "./routes/"): string[] {
818
+ export function parseManifestRouteFiles(source: string, _routesPrefix?: string): string[] {
804
819
  const files = new Set<string>()
805
- for (const match of source.matchAll(MANIFEST_IMPORT)) {
806
- const spec = match[1]
807
- if (spec === undefined || !spec.startsWith(routesPrefix)) continue
808
- files.add(spec.slice(routesPrefix.length))
820
+ for (const match of source.matchAll(MANIFEST_ROUTE_ENTRY)) {
821
+ if (match[2] !== undefined) files.add(match[2])
809
822
  }
810
823
  return [...files].sort()
811
824
  }
@@ -880,7 +893,7 @@ export function resyncServerManifestSource(
880
893
  clientEntry: parseManifestClientEntry(source) ?? "",
881
894
  styles: parseManifestStyles(source),
882
895
  routeStyles: parseManifestRouteStyles(source),
883
- lazy: source.includes("const loaders ="),
896
+ lazy: MANIFEST_IS_LAZY.test(source),
884
897
  })
885
898
  }
886
899
 
@@ -941,6 +954,8 @@ export function formatManifestDrift(
941
954
  */
942
955
  export async function buildClient(options: BuildClientOptions): Promise<BuildManifest> {
943
956
  const { routesDir, outDir, clientModule } = options
957
+ const root = resolvePath(dirname(routesDir))
958
+ await assertIdentityParity(root)
944
959
  const resolve = options.resolve ?? ((file: string) => `${routesDir}/${file}`)
945
960
  const publicPath = options.publicPath ?? "/assets/"
946
961
  mkdirSync(outDir, { recursive: true })
@@ -1141,6 +1156,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
1141
1156
  ...(css.length > 0 ? { css } : {}),
1142
1157
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
1143
1158
  }
1159
+ assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest)
1144
1160
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2))
1145
1161
  return manifest
1146
1162
  }
@@ -87,7 +87,7 @@ const appSourceFilter = (root: string): RegExp =>
87
87
  * including `export ... from`. What it matches is only a candidate: a hit counts as an import only if the
88
88
  * transpiler's own scan reported the same specifier, which is what keeps a lookalike string literal out.
89
89
  */
90
- const SPECIFIER = /\b(from|import|require)(\s*\(?\s*)(["'])([^"'\n]+)\3/g
90
+ const SPECIFIER = /\b(from|import|require)((?:\s*\()?\s*)(["'])([^"'\n]+)\3/g
91
91
 
92
92
  export interface SsrGraphOptions {
93
93
  /** The app root. Only files under it (and outside `node_modules`) are tracked. */
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
  }
package/src/index.ts CHANGED
@@ -2617,6 +2617,18 @@ export interface GenerateServerManifestOptions {
2617
2617
  * `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
2618
2618
  * entry (see `buildServer` in `@nifrajs/web/build`).
2619
2619
  */
2620
+ // Route source files are emitted as EXTENSIONLESS import specifiers. An explicit `.tsx`/`.ts` in an
2621
+ // import is a hard error under a plain `tsc` (TS5097) unless `allowImportingTsExtensions` is set - which
2622
+ // only the nifra template tsconfig does - so the generated manifest failed a bare `tsc` with one error
2623
+ // per route. Every bundler and `moduleResolution` still resolves the extensionless form to the source
2624
+ // file, so dropping the extension costs nothing at build time and makes the output portable. Only a
2625
+ // known TS/JS source extension is stripped; the map KEY keeps its extension (it is the route identifier
2626
+ // `buildManifest` matches, not an import path).
2627
+ const SOURCE_EXTENSION = /\.(?:[mc]?tsx?|[mc]?jsx?)$/
2628
+ function moduleSpecifier(resolved: string): string {
2629
+ return resolved.replace(SOURCE_EXTENSION, "")
2630
+ }
2631
+
2620
2632
  export function generateServerManifest(
2621
2633
  manifest: Manifest,
2622
2634
  options: GenerateServerManifestOptions,
@@ -2636,7 +2648,7 @@ export function generateServerManifest(
2636
2648
  "// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
2637
2649
  "// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
2638
2650
  "// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
2639
- 'import { buildManifest } from "@nifrajs/web"',
2651
+ 'import { buildManifest, type RouteModule } from "@nifrajs/web"',
2640
2652
  ]
2641
2653
  const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
2642
2654
  // Baked stylesheet URLs so the server entry can pass them to createWebApp → <link rel="stylesheet">.
@@ -2648,11 +2660,12 @@ export function generateServerManifest(
2648
2660
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
2649
2661
  // `buildManifest` expects; the importer it builds calls the per-file loader.
2650
2662
  const loaders = files.map(
2651
- (file) => ` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(resolve(file))}),`,
2663
+ (file) =>
2664
+ ` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(moduleSpecifier(resolve(file)))}),`,
2652
2665
  )
2653
2666
  return `${[
2654
2667
  ...header,
2655
- "const loaders = {",
2668
+ "const loaders: Record<string, () => Promise<RouteModule>> = {",
2656
2669
  ...loaders,
2657
2670
  "}",
2658
2671
  clientEntryLine,
@@ -2663,12 +2676,14 @@ export function generateServerManifest(
2663
2676
  }
2664
2677
  // Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
2665
2678
  // identifiers are collision-proof regardless of filename.
2666
- const imports = files.map((file, i) => `import * as m${i} from ${JSON.stringify(resolve(file))}`)
2679
+ const imports = files.map(
2680
+ (file, i) => `import * as m${i} from ${JSON.stringify(moduleSpecifier(resolve(file)))}`,
2681
+ )
2667
2682
  const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
2668
2683
  return `${[
2669
2684
  ...header,
2670
2685
  ...imports,
2671
- "const modules = {",
2686
+ "const modules: Record<string, RouteModule> = {",
2672
2687
  ...entries,
2673
2688
  "}",
2674
2689
  clientEntryLine,