@nifrajs/web 5.0.0 → 5.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 (171) hide show
  1. package/CHANGELOG.md +2970 -0
  2. package/README.md +8 -0
  3. package/dist/build-plan.d.ts +7 -0
  4. package/dist/build-plan.d.ts.map +1 -1
  5. package/dist/build-plan.js +19 -0
  6. package/dist/build-plan.js.map +1 -1
  7. package/dist/build-vite.d.ts +1 -0
  8. package/dist/build-vite.d.ts.map +1 -1
  9. package/dist/build-vite.js +51 -3
  10. package/dist/build-vite.js.map +1 -1
  11. package/dist/build.d.ts +12 -1
  12. package/dist/build.d.ts.map +1 -1
  13. package/dist/build.js +43 -6
  14. package/dist/build.js.map +1 -1
  15. package/dist/client.d.ts +7 -6
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +39 -22
  18. package/dist/client.js.map +1 -1
  19. package/dist/dev-port.d.ts +3 -2
  20. package/dist/dev-port.d.ts.map +1 -1
  21. package/dist/dev-port.js +2 -6
  22. package/dist/dev-port.js.map +1 -1
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/codegen.d.ts +6 -0
  28. package/dist/internal/codegen.d.ts.map +1 -1
  29. package/dist/internal/codegen.js +23 -5
  30. package/dist/internal/codegen.js.map +1 -1
  31. package/dist/internal/copy-split.d.ts +27 -0
  32. package/dist/internal/copy-split.d.ts.map +1 -0
  33. package/dist/internal/copy-split.js +173 -0
  34. package/dist/internal/copy-split.js.map +1 -0
  35. package/dist/internal/package-entries.d.ts +34 -0
  36. package/dist/internal/package-entries.d.ts.map +1 -0
  37. package/dist/internal/package-entries.js +324 -0
  38. package/dist/internal/package-entries.js.map +1 -0
  39. package/dist/internal/page-execution.d.ts +19 -1
  40. package/dist/internal/page-execution.d.ts.map +1 -1
  41. package/dist/internal/page-execution.js +123 -14
  42. package/dist/internal/page-execution.js.map +1 -1
  43. package/dist/internal/parity.d.ts.map +1 -1
  44. package/dist/internal/parity.js +15 -2
  45. package/dist/internal/parity.js.map +1 -1
  46. package/dist/internal/private-env.d.ts +8 -0
  47. package/dist/internal/private-env.d.ts.map +1 -1
  48. package/dist/internal/private-env.js +25 -10
  49. package/dist/internal/private-env.js.map +1 -1
  50. package/dist/internal/render-document.d.ts +15 -46
  51. package/dist/internal/render-document.d.ts.map +1 -1
  52. package/dist/internal/render-document.js +59 -62
  53. package/dist/internal/render-document.js.map +1 -1
  54. package/dist/internal/route-path.d.ts +6 -0
  55. package/dist/internal/route-path.d.ts.map +1 -1
  56. package/dist/internal/route-path.js +11 -0
  57. package/dist/internal/route-path.js.map +1 -1
  58. package/dist/internal/secret-scan.d.ts.map +1 -1
  59. package/dist/internal/secret-scan.js +103 -1
  60. package/dist/internal/secret-scan.js.map +1 -1
  61. package/dist/internal/typescript-api.d.ts +4 -0
  62. package/dist/internal/typescript-api.d.ts.map +1 -0
  63. package/dist/internal/typescript-api.js +8 -0
  64. package/dist/internal/typescript-api.js.map +1 -0
  65. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  66. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  67. package/dist/internal/vite-plugin-compat.js +82 -0
  68. package/dist/internal/vite-plugin-compat.js.map +1 -0
  69. package/dist/internal/web-app.d.ts +38 -2
  70. package/dist/internal/web-app.d.ts.map +1 -1
  71. package/dist/internal/web-app.js +30 -3
  72. package/dist/internal/web-app.js.map +1 -1
  73. package/dist/internal/without-query.d.ts +4 -0
  74. package/dist/internal/without-query.d.ts.map +1 -0
  75. package/dist/internal/without-query.js +9 -0
  76. package/dist/internal/without-query.js.map +1 -0
  77. package/dist/internal/zone-graph.d.ts.map +1 -1
  78. package/dist/internal/zone-graph.js +17 -6
  79. package/dist/internal/zone-graph.js.map +1 -1
  80. package/dist/isr.d.ts +1 -1
  81. package/dist/isr.d.ts.map +1 -1
  82. package/dist/isr.js +15 -2
  83. package/dist/isr.js.map +1 -1
  84. package/dist/manifest.d.ts +9 -0
  85. package/dist/manifest.d.ts.map +1 -1
  86. package/dist/manifest.js +1 -0
  87. package/dist/manifest.js.map +1 -1
  88. package/dist/plugins/import-glob.d.ts +20 -0
  89. package/dist/plugins/import-glob.d.ts.map +1 -0
  90. package/dist/plugins/import-glob.js +401 -0
  91. package/dist/plugins/import-glob.js.map +1 -0
  92. package/dist/plugins/kit.d.ts +11 -0
  93. package/dist/plugins/kit.d.ts.map +1 -1
  94. package/dist/plugins/kit.js +32 -0
  95. package/dist/plugins/kit.js.map +1 -1
  96. package/dist/plugins/stylex.d.ts.map +1 -1
  97. package/dist/plugins/stylex.js +2 -2
  98. package/dist/plugins/stylex.js.map +1 -1
  99. package/dist/plugins/vite-leak-guard.d.ts +6 -0
  100. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  101. package/dist/plugins/vite-leak-guard.js +80 -7
  102. package/dist/plugins/vite-leak-guard.js.map +1 -1
  103. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  104. package/dist/plugins/vite-zone-guard.js +17 -21
  105. package/dist/plugins/vite-zone-guard.js.map +1 -1
  106. package/dist/plugins/zone-guard.d.ts.map +1 -1
  107. package/dist/plugins/zone-guard.js +44 -3
  108. package/dist/plugins/zone-guard.js.map +1 -1
  109. package/dist/prerender.d.ts +5 -1
  110. package/dist/prerender.d.ts.map +1 -1
  111. package/dist/prerender.js +41 -19
  112. package/dist/prerender.js.map +1 -1
  113. package/dist/render-seam.d.ts +60 -2
  114. package/dist/render-seam.d.ts.map +1 -1
  115. package/dist/render-seam.js +33 -0
  116. package/dist/render-seam.js.map +1 -1
  117. package/dist/router.d.ts +3 -2
  118. package/dist/router.d.ts.map +1 -1
  119. package/dist/router.js +13 -9
  120. package/dist/router.js.map +1 -1
  121. package/dist/seo.d.ts +49 -0
  122. package/dist/seo.d.ts.map +1 -0
  123. package/dist/seo.js +48 -0
  124. package/dist/seo.js.map +1 -0
  125. package/dist/server-components/typescript.d.ts.map +1 -1
  126. package/dist/server-components/typescript.js +2 -7
  127. package/dist/server-components/typescript.js.map +1 -1
  128. package/dist/vite.d.ts +1 -28
  129. package/dist/vite.d.ts.map +1 -1
  130. package/dist/vite.js +3 -69
  131. package/dist/vite.js.map +1 -1
  132. package/dist/zones.d.ts +30 -8
  133. package/dist/zones.d.ts.map +1 -1
  134. package/dist/zones.js +185 -46
  135. package/dist/zones.js.map +1 -1
  136. package/package.json +10 -4
  137. package/src/build-plan.ts +21 -0
  138. package/src/build-vite.ts +60 -3
  139. package/src/build.ts +57 -4
  140. package/src/client.ts +42 -22
  141. package/src/dev-port.ts +4 -10
  142. package/src/index.ts +8 -4
  143. package/src/internal/codegen.ts +32 -3
  144. package/src/internal/copy-split.ts +220 -0
  145. package/src/internal/package-entries.ts +331 -0
  146. package/src/internal/page-execution.ts +150 -13
  147. package/src/internal/parity.ts +15 -2
  148. package/src/internal/private-env.ts +27 -13
  149. package/src/internal/render-document.ts +100 -81
  150. package/src/internal/route-path.ts +16 -0
  151. package/src/internal/secret-scan.ts +101 -1
  152. package/src/internal/typescript-api.ts +11 -0
  153. package/src/internal/vite-plugin-compat.ts +81 -0
  154. package/src/internal/web-app.ts +71 -6
  155. package/src/internal/without-query.ts +8 -0
  156. package/src/internal/zone-graph.ts +19 -6
  157. package/src/isr.ts +13 -2
  158. package/src/manifest.ts +10 -0
  159. package/src/plugins/import-glob.ts +392 -0
  160. package/src/plugins/kit.ts +36 -0
  161. package/src/plugins/stylex.ts +2 -1
  162. package/src/plugins/vite-leak-guard.ts +99 -7
  163. package/src/plugins/vite-zone-guard.ts +21 -19
  164. package/src/plugins/zone-guard.ts +41 -2
  165. package/src/prerender.ts +39 -18
  166. package/src/render-seam.ts +88 -2
  167. package/src/router.ts +16 -11
  168. package/src/seo.ts +68 -0
  169. package/src/server-components/typescript.ts +2 -10
  170. package/src/vite.ts +3 -81
  171. package/src/zones.ts +237 -52
package/src/zones.ts CHANGED
@@ -11,7 +11,15 @@
11
11
  */
12
12
  import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync } from "node:fs"
13
13
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
14
+ import {
15
+ type EntryOwnership,
16
+ type EnvironmentDeclaration,
17
+ entryKey,
18
+ entryOwnership,
19
+ parseEnvironment,
20
+ } from "./internal/package-entries.ts"
14
21
  import { SERVER_FN_MODULE } from "./internal/server-boundary.ts"
22
+ import { withoutQuery } from "./internal/without-query.ts"
15
23
 
16
24
  export type Zone =
17
25
  /** A page or special file under `routes/` - the frontend half of a route. */
@@ -39,12 +47,16 @@ export type Classification =
39
47
  readonly declared?: PackageEnvironment
40
48
  /** A third-party file's path inside `node_modules`: `drizzle-orm/node-postgres/index.js`. */
41
49
  readonly modulePath?: string
50
+ /** The export subpath (`"./react"`) whose own declared side decided this import. */
51
+ readonly entry?: string
42
52
  }
43
53
  | {
44
54
  readonly zone: "error"
45
55
  readonly reason: string
46
56
  /** The undeclared package this error is about: every file in it shares the one fix. */
47
57
  readonly package?: string
58
+ /** The export subpath the import named, when the package's map gives it no side. */
59
+ readonly entry?: string
48
60
  }
49
61
 
50
62
  export type PackageEnvironment = "frontend" | "backend" | "shared" | "library"
@@ -151,11 +163,14 @@ export interface ZoneConfig {
151
163
  * longest prefix over a shorter one, and a package's own declaration over all of them.
152
164
  */
153
165
  readonly packages?: Readonly<Record<string, PackageEnvironment>>
154
- /** App-relative folders holding backend code, beside `backend/` (`"migrations"`). */
166
+ /**
167
+ * App-relative folders or files holding backend code, beside `backend/` (`"migrations"`). A path
168
+ * listed inside another one's folder overrides it: the most specific entry wins.
169
+ */
155
170
  readonly backend?: readonly string[]
156
- /** App-relative folders holding frontend code, beside `frontend/`. */
171
+ /** App-relative folders or files holding frontend code, beside `frontend/`. */
157
172
  readonly frontend?: readonly string[]
158
- /** App-relative folders holding code both sides import, beside `shared/`. */
173
+ /** App-relative folders or files holding code both sides import, beside `shared/`. */
159
174
  readonly shared?: readonly string[]
160
175
  }
161
176
 
@@ -189,9 +204,10 @@ const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined
189
204
  : undefined
190
205
 
191
206
  /**
192
- * Validate a `zones` setting. `source` names where it came from in the error. Folders are plain
193
- * app-relative paths (a trailing `/**` is accepted and dropped); any other glob is refused, so a
194
- * pattern can never match less than its author meant without saying so.
207
+ * Validate a `zones` setting. `source` names where it came from in the error. Folders and files are
208
+ * plain app-relative paths (a trailing `/**` is accepted and dropped); any other glob is refused, so
209
+ * a pattern can never match less than its author meant without saying so. One path under two sides
210
+ * is refused; a path nested in another's folder is not, since the most specific one decides.
195
211
  */
196
212
  export function parseZoneConfig(value: unknown, source: string): ZoneConfig {
197
213
  const fail = (message: string): never => {
@@ -228,7 +244,7 @@ export function parseZoneConfig(value: unknown, source: string): ZoneConfig {
228
244
  if (raw === undefined) continue
229
245
  const entries: readonly unknown[] = Array.isArray(raw)
230
246
  ? raw
231
- : fail(`"${side}" must be an array of app-relative folders`)
247
+ : fail(`"${side}" must be an array of app-relative folders or files`)
232
248
  const folders: string[] = []
233
249
  for (const entry of entries) {
234
250
  const folder = (typeof entry === "string" ? entry : fail(`"${side}" must hold strings`))
@@ -238,18 +254,15 @@ export function parseZoneConfig(value: unknown, source: string): ZoneConfig {
238
254
  .replace(/\/+$/, "")
239
255
  const top = folder.split("/")[0] ?? ""
240
256
  if (folder === "" || folder.startsWith("/") || /^[a-z]:/i.test(folder))
241
- fail(`"${side}" entry "${entry}" must be a folder relative to the app`)
257
+ fail(`"${side}" entry "${entry}" must be a folder or file relative to the app`)
242
258
  if (folder.split("/").includes("..")) fail(`"${side}" entry "${entry}" may not contain ".."`)
243
259
  if (/[*?[\]{}!]/.test(folder))
244
- fail(`"${side}" entry "${entry}" must be a plain folder path (a trailing "/**" is allowed)`)
260
+ fail(`"${side}" entry "${entry}" must be a plain path (a trailing "/**" is allowed)`)
245
261
  if (RESERVED_FOLDERS.has(top) || top.startsWith(".nifra"))
246
262
  fail(`"${side}" entry "${entry}" is inside ${top}/, which already has its own rules`)
247
- for (const [other, otherSide] of owner) {
248
- const nested =
249
- other === folder || other.startsWith(`${folder}/`) || folder.startsWith(`${other}/`)
250
- if (nested && otherSide !== side)
251
- fail(`"${folder}" (${side}) overlaps "${other}" (${otherSide}); a folder has one side`)
252
- }
263
+ const other = owner.get(folder)
264
+ if (other !== undefined && other !== side)
265
+ fail(`"${folder}" is listed as both ${other} and ${side}; a path has one side`)
253
266
  owner.set(folder, side)
254
267
  folders.push(folder)
255
268
  }
@@ -301,8 +314,16 @@ const suggestedPattern = (name: string): string =>
301
314
 
302
315
  export interface ZoneClassifier {
303
316
  readonly appRoot: string
304
- /** Classify a file by its absolute (or appRoot-relative) path. Results are cached per path. */
305
- classify(file: string): Classification
317
+ /**
318
+ * Classify a file by its absolute (or appRoot-relative) path. Results are cached per path.
319
+ *
320
+ * With `specifier`, the import as written that resolved to `file`: when it names a package that
321
+ * gives each export entry its own side (`"nifra": { "environment": { ".": "backend", "./react":
322
+ * "frontend" } }`), the entry it names decides, since two entries may share one file. A specifier
323
+ * naming an entry the map leaves out is an error. The resolved file must belong to that entry, as
324
+ * the package's own imports reach it; one only other entries reach is judged by its path.
325
+ */
326
+ classify(file: string, specifier?: string): Classification
306
327
  }
307
328
 
308
329
  const toPosix = (path: string): string => path.replaceAll("\\", "/")
@@ -356,7 +377,19 @@ function nodeModulesPackage(path: string): string | undefined {
356
377
  interface PackageInfo {
357
378
  readonly dir: string
358
379
  readonly name: string | undefined
359
- readonly environment: string | undefined
380
+ readonly declaration: EnvironmentDeclaration
381
+ readonly manifest: Readonly<Record<string, unknown>>
382
+ }
383
+
384
+ const DECLARATION_FILE = /\.d\.[cm]?ts$/
385
+
386
+ /** The one side a file reached by several mapped entries takes: what each entry's importers may import. */
387
+ function ownersSide(owners: ReadonlySet<PackageEnvironment>): PackageEnvironment {
388
+ const [only] = owners
389
+ if (owners.size === 1 && only !== undefined) return only
390
+ return owners.has("library") || (owners.has("frontend") && owners.has("backend"))
391
+ ? "library"
392
+ : "shared"
360
393
  }
361
394
 
362
395
  /**
@@ -374,11 +407,12 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
374
407
  const configuredFolders: Array<readonly [string, "backend" | "frontend" | "shared"]> = []
375
408
  for (const side of FOLDER_SIDES)
376
409
  for (const folder of zones[side] ?? []) configuredFolders.push([folder, side])
377
- const folderSide = (rel: string): "backend" | "frontend" | "shared" | undefined => {
378
- for (const [folder, side] of configuredFolders)
379
- if (rel === folder || rel.startsWith(`${folder}/`)) return side
380
- return undefined
381
- }
410
+ // Longest first, so the most specific entry covering a file decides its side.
411
+ configuredFolders.sort(([a], [b]) => b.length - a.length)
412
+ const configuredPath = (
413
+ rel: string,
414
+ ): readonly [string, "backend" | "frontend" | "shared"] | undefined =>
415
+ configuredFolders.find(([folder]) => rel === folder || rel.startsWith(`${folder}/`))
382
416
  const cache = new Map<string, Classification>()
383
417
  const packages = new Map<string, PackageInfo | null>()
384
418
 
@@ -395,17 +429,17 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
395
429
  seen.push(current)
396
430
  const manifest = join(current, "package.json")
397
431
  if (existsSync(manifest)) {
398
- let parsed: { name?: unknown; nifra?: { environment?: unknown } } = {}
432
+ let parsed: Readonly<Record<string, unknown>> = {}
399
433
  try {
400
- parsed = JSON.parse(readFileSync(manifest, "utf8")) as typeof parsed
434
+ parsed = recordOf(JSON.parse(readFileSync(manifest, "utf8"))) ?? {}
401
435
  } catch {
402
436
  // An unreadable manifest declares nothing; the caller reports the missing declaration.
403
437
  }
404
438
  const info: PackageInfo = {
405
439
  dir: current,
406
440
  name: typeof parsed.name === "string" ? parsed.name : undefined,
407
- environment:
408
- typeof parsed.nifra?.environment === "string" ? parsed.nifra.environment : undefined,
441
+ declaration: parseEnvironment(recordOf(parsed.nifra)?.environment),
442
+ manifest: parsed,
409
443
  }
410
444
  for (const visited of seen) packages.set(visited, info)
411
445
  return info
@@ -419,15 +453,96 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
419
453
  }
420
454
  }
421
455
 
456
+ const ownerships = new Map<string, EntryOwnership>()
457
+ const ownershipOf = (
458
+ info: PackageInfo,
459
+ entries: Readonly<Record<string, PackageEnvironment>>,
460
+ ): EntryOwnership => {
461
+ let found = ownerships.get(info.dir)
462
+ if (found === undefined) {
463
+ found = entryOwnership(info.dir, info.manifest, entries)
464
+ ownerships.set(info.dir, found)
465
+ }
466
+ return found
467
+ }
468
+
469
+ /** The package `file` is part of, when that package is not the app: installed, or a workspace sibling. */
470
+ const packageOf = (file: string): PackageInfo | undefined => {
471
+ const thirdParty = nodeModulesPackage(file)
472
+ if (thirdParty !== undefined) {
473
+ const at = file.lastIndexOf("/node_modules/") + "/node_modules/".length
474
+ return packageFor(file.slice(0, at + thirdParty.length))
475
+ }
476
+ return isInside(file, appRoot) ? undefined : packageFor(toPosix(dirname(file)))
477
+ }
478
+
479
+ /** What a side declared for `file` (by its package, an entry of it, or the app) makes it. */
480
+ const declaredAs = (
481
+ file: string,
482
+ info: PackageInfo | undefined,
483
+ side: PackageEnvironment,
484
+ entry?: string,
485
+ ): Classification => {
486
+ const thirdParty = nodeModulesPackage(file)
487
+ const named = entry === undefined || info?.name === undefined ? {} : { packageName: info.name }
488
+ const decided = entry === undefined ? {} : { entry }
489
+ if (thirdParty !== undefined) {
490
+ return {
491
+ zone: "library",
492
+ packageName: thirdParty,
493
+ modulePath: file.slice(file.lastIndexOf("/node_modules/") + "/node_modules/".length),
494
+ declared: side,
495
+ ...decided,
496
+ }
497
+ }
498
+ if (side !== "library") return { zone: side, ...named, ...decided }
499
+ return {
500
+ zone: "library",
501
+ ...(info?.name ? { packageName: info.name } : {}),
502
+ declared: "library",
503
+ ...decided,
504
+ }
505
+ }
506
+
507
+ /** A file of a package that declares a side per entry, judged by the entries that reach it. */
508
+ const byOwners = (
509
+ file: string,
510
+ info: PackageInfo,
511
+ entries: Readonly<Record<string, PackageEnvironment>>,
512
+ ): Classification => {
513
+ const ownership = ownershipOf(info, entries)
514
+ const owners = ownership.owners.get(file)
515
+ if (owners !== undefined) return declaredAs(file, info, ownersSide(owners))
516
+ const shown = nodeModulesPackage(file) === undefined ? file : file.slice(info.dir.length + 1)
517
+ const left =
518
+ ownership.unmapped.length === 0
519
+ ? ""
520
+ : ` (the map leaves out ${ownership.unmapped.map((key) => `"${key}"`).join(", ")})`
521
+ return {
522
+ zone: "error",
523
+ reason: `"${shown}" is in package "${info.name ?? info.dir}", whose "nifra.environment" gives each entry its own side, and no mapped entry reaches this file${left}. Import the package through a mapped entry`,
524
+ }
525
+ }
526
+
527
+ const invalidDeclaration = (info: PackageInfo, problem: string): Classification => {
528
+ const reason = `${info.dir}/package.json "nifra.environment": ${problem}`
529
+ return info.name === undefined
530
+ ? { zone: "error", reason }
531
+ : { zone: "error", reason, package: info.name }
532
+ }
533
+
422
534
  const classifyUncached = (file: string): Classification => {
423
535
  if (generated.has(file)) return { zone: "generated" }
424
536
  const thirdParty = nodeModulesPackage(file)
425
537
  if (thirdParty !== undefined) {
426
- const info = packageFor(toPosix(dirname(file)))
538
+ const info = packageOf(file)
539
+ const declaration = info?.declaration ?? { kind: "none" }
540
+ if (info !== undefined && declaration.kind === "invalid")
541
+ return invalidDeclaration(info, declaration.problem)
542
+ if (info !== undefined && declaration.kind === "entries")
543
+ return byOwners(file, info, declaration.entries)
427
544
  const declared =
428
- info?.environment !== undefined && PACKAGE_ENVIRONMENTS.has(info.environment)
429
- ? (info.environment as PackageEnvironment)
430
- : mappedSide(zones.packages, thirdParty)
545
+ declaration.kind === "package" ? declaration.side : mappedSide(zones.packages, thirdParty)
431
546
  return {
432
547
  zone: "library",
433
548
  packageName: thirdParty,
@@ -472,13 +587,20 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
472
587
  if (suffix === "shared") return { zone: "shared" }
473
588
  return { zone: "route-frontend" }
474
589
  }
475
- const folder = ZONE_FOLDERS[top] ?? folderSide(rel)
590
+ const configured = ZONE_FOLDERS[top] === undefined ? configuredPath(rel) : undefined
591
+ const folder = ZONE_FOLDERS[top] ?? configured?.[1]
476
592
  if (folder !== undefined) {
477
593
  if (suffix === undefined || suffix === folder) return { zone: folder }
478
594
  if (suffix === "fn" && folder === "backend") return { zone: "fn" }
595
+ const where =
596
+ configured === undefined
597
+ ? `sits in ${top}/`
598
+ : configured[0] === rel
599
+ ? `is listed as ${folder} in "nifra.zones"`
600
+ : `sits in ${configured[0]}/, which "nifra.zones" lists as ${folder},`
479
601
  return {
480
602
  zone: "error",
481
- reason: `"${rel}" sits in ${top}/ but its ".${suffix}" suffix says otherwise. A file belongs to one side: move it, or drop the suffix`,
603
+ reason: `"${rel}" ${where} but its ".${suffix}" suffix says otherwise. A file belongs to one side: move it, or drop the suffix`,
482
604
  }
483
605
  }
484
606
  if (suffix !== undefined) return { zone: suffix === "fn" ? "fn" : suffix }
@@ -486,19 +608,14 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
486
608
  }
487
609
  // First-party code outside the app: a workspace package, or a relative import out of the app.
488
610
  const info = packageFor(toPosix(dirname(file)))
611
+ const declaration = info?.declaration ?? { kind: "none" }
612
+ if (info !== undefined && declaration.kind === "invalid")
613
+ return invalidDeclaration(info, declaration.problem)
614
+ if (info !== undefined && declaration.kind === "entries")
615
+ return byOwners(file, info, declaration.entries)
489
616
  const environment =
490
- info?.environment !== undefined && PACKAGE_ENVIRONMENTS.has(info.environment)
491
- ? info.environment
492
- : mappedSide(zones.packages, info?.name)
493
- if (environment !== undefined) {
494
- return environment === "library"
495
- ? {
496
- zone: "library",
497
- ...(info?.name ? { packageName: info.name } : {}),
498
- declared: "library",
499
- }
500
- : { zone: environment as Zone }
501
- }
617
+ declaration.kind === "package" ? declaration.side : mappedSide(zones.packages, info?.name)
618
+ if (environment !== undefined) return declaredAs(file, info, environment)
502
619
  const declare = `"nifra": { "environment": "frontend" | "backend" | "shared" | "library" }`
503
620
  if (info?.name === undefined) {
504
621
  return {
@@ -514,16 +631,57 @@ export function createZoneClassifier(options: ZoneClassifierOptions): ZoneClassi
514
631
  }
515
632
  }
516
633
 
634
+ const byPath = (file: string): Classification => {
635
+ const cached = cache.get(file)
636
+ if (cached !== undefined) return cached
637
+ const result = classifyUncached(file)
638
+ cache.set(file, result)
639
+ return result
640
+ }
641
+
642
+ // Keyed by file and subpath: two entries may share one file and declare different sides.
643
+ const imports = new Map<string, Classification>()
644
+ const byImport = (file: string, specifier: string): Classification => {
645
+ const name = specifierPackage(specifier)
646
+ const info = name === undefined ? undefined : packageOf(file)
647
+ if (name === undefined || info?.name !== name || info.declaration.kind !== "entries")
648
+ return byPath(file)
649
+ const subpath = entrySubpath(specifier, name)
650
+ const cacheKey = `${file}\0${subpath}`
651
+ const cached = imports.get(cacheKey)
652
+ if (cached !== undefined) return cached
653
+ const { entries } = info.declaration
654
+ const key = entryKey(entries, subpath)
655
+ const side = key === undefined ? undefined : entries[key]
656
+ let result: Classification
657
+ if (side === undefined) {
658
+ const mapped = Object.keys(entries)
659
+ .map((entry) => `"${entry}"`)
660
+ .join(", ")
661
+ result = {
662
+ zone: "error",
663
+ reason: `"${specifier}" names the "${subpath}" entry of package "${name}", whose "nifra.environment" map gives it no side (it maps ${mapped}). Map the entry, or import one that is mapped`,
664
+ entry: subpath,
665
+ }
666
+ } else {
667
+ // The resolver's pick must belong to the named entry; a file only other entries reach (an alias
668
+ // pointed at it) is judged by its path. A declaration file is types only and erased.
669
+ const owners = ownershipOf(info, entries).owners.get(file)
670
+ result =
671
+ owners !== undefined && !owners.has(side) && !DECLARATION_FILE.test(file)
672
+ ? byPath(file)
673
+ : declaredAs(file, info, side, subpath)
674
+ }
675
+ imports.set(cacheKey, result)
676
+ return result
677
+ }
678
+
517
679
  return {
518
680
  appRoot,
519
- classify(input: string): Classification {
681
+ classify(input: string, specifier?: string): Classification {
520
682
  const absolute = isAbsolute(input) ? input : resolve(appRoot, input)
521
683
  const file = realFile(absolute)
522
- const cached = cache.get(file)
523
- if (cached !== undefined) return cached
524
- const result = classifyUncached(file)
525
- cache.set(file, result)
526
- return result
684
+ return specifier === undefined ? byPath(file) : byImport(file, specifier)
527
685
  },
528
686
  }
529
687
  }
@@ -554,6 +712,27 @@ export function specifierPackage(specifier: string): string | undefined {
554
712
  return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
555
713
  }
556
714
 
715
+ /** The export subpath (`"."`, `"./react"`) a bare specifier of package `name` names. */
716
+ const entrySubpath = (specifier: string, name: string): string =>
717
+ `.${withoutQuery(specifier.slice(name.length))}`
718
+
719
+ /**
720
+ * The side a package's `"nifra": { "environment": ... }` value gives the bare import `specifier` of it:
721
+ * the package-wide string, or the side of the entry it names - the map's exact subpath, else its most
722
+ * specific `*` pattern. `undefined` when it gives none, as a map that leaves the entry out does.
723
+ */
724
+ export function declaredSide(
725
+ environment: unknown,
726
+ specifier: string,
727
+ ): PackageEnvironment | undefined {
728
+ const declaration = parseEnvironment(environment)
729
+ if (declaration.kind === "package") return declaration.side
730
+ const name = specifierPackage(specifier)
731
+ if (declaration.kind !== "entries" || name === undefined) return undefined
732
+ const key = entryKey(declaration.entries, entrySubpath(specifier, name))
733
+ return key === undefined ? undefined : declaration.entries[key]
734
+ }
735
+
557
736
  /**
558
737
  * Why a classified file may not ship to a browser, or `undefined` when it may. A `drizzle-orm` driver
559
738
  * entry is recognized by the import as written (`specifier`) or by the file's path in the package.
@@ -564,13 +743,19 @@ export function browserDenial(
564
743
  ): string | undefined {
565
744
  if (classification.zone === "error") return classification.reason
566
745
  if (!BROWSER_ZONES.has(classification.zone)) {
746
+ if (classification.entry !== undefined)
747
+ return `package "${classification.packageName}" declares its "${classification.entry}" entry ${classification.zone}-only`
567
748
  return classification.zone === "route-backend"
568
749
  ? "it is a route's backend half, which runs on the server only"
569
750
  : "it is backend code"
570
751
  }
571
752
  if (classification.zone !== "library") return undefined
572
753
  const name = classification.packageName
573
- if (classification.declared === "backend") return `package "${name}" declares itself backend-only`
754
+ if (classification.declared === "backend") {
755
+ return classification.entry === undefined
756
+ ? `package "${name}" declares itself backend-only`
757
+ : `package "${name}" declares its "${classification.entry}" entry backend-only`
758
+ }
574
759
  if (name !== undefined && SERVER_PACKAGES.includes(name)) {
575
760
  return `package "${name}" is server code (a driver, server SDK or credential helper)`
576
761
  }