@warlock.js/web 5.0.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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/esm/build/contribution.d.mts +55 -0
- package/esm/build/contribution.mjs +132 -0
- package/esm/build/contribution.mjs.map +1 -0
- package/esm/build/discover-pages.mjs +294 -0
- package/esm/build/discover-pages.mjs.map +1 -0
- package/esm/build/generate-client-registry.mjs +123 -0
- package/esm/build/generate-client-registry.mjs.map +1 -0
- package/esm/build/generate-pages-barrel.mjs +330 -0
- package/esm/build/generate-pages-barrel.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs +212 -0
- package/esm/build/read-route-exports.mjs.map +1 -0
- package/esm/client/build-hydrated-tree.mjs +113 -0
- package/esm/client/build-hydrated-tree.mjs.map +1 -0
- package/esm/client/hydrate-page.mjs +73 -0
- package/esm/client/hydrate-page.mjs.map +1 -0
- package/esm/client/navigation/current-route.d.mts +91 -0
- package/esm/client/navigation/current-route.mjs +66 -0
- package/esm/client/navigation/current-route.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +97 -0
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +196 -0
- package/esm/client/navigation/navigation-root.mjs.map +1 -0
- package/esm/client/navigation/prefetch.mjs +110 -0
- package/esm/client/navigation/prefetch.mjs.map +1 -0
- package/esm/client/navigation/refresh.d.mts +40 -0
- package/esm/client/navigation/refresh.mjs +146 -0
- package/esm/client/navigation/refresh.mjs.map +1 -0
- package/esm/client/navigation/verbs.d.mts +55 -0
- package/esm/client/navigation/verbs.mjs +66 -0
- package/esm/client/navigation/verbs.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +4 -0
- package/esm/client/runtime/index.mjs +4 -0
- package/esm/client/runtime/manifest.d.mts +8 -0
- package/esm/client/runtime/manifest.mjs +77 -0
- package/esm/client/runtime/manifest.mjs.map +1 -0
- package/esm/client/runtime/matcher.d.mts +7 -0
- package/esm/client/runtime/matcher.mjs +169 -0
- package/esm/client/runtime/matcher.mjs.map +1 -0
- package/esm/client/runtime/types.d.mts +22 -0
- package/esm/components/default-app.mjs +32 -0
- package/esm/components/default-app.mjs.map +1 -0
- package/esm/components/document-context.d.mts +1 -0
- package/esm/components/document-context.mjs +24 -0
- package/esm/components/document-context.mjs.map +1 -0
- package/esm/components/head.d.mts +14 -0
- package/esm/components/head.mjs +63 -0
- package/esm/components/head.mjs.map +1 -0
- package/esm/components/link.d.mts +157 -0
- package/esm/components/link.mjs +194 -0
- package/esm/components/link.mjs.map +1 -0
- package/esm/components/scripts.d.mts +14 -0
- package/esm/components/scripts.mjs +21 -0
- package/esm/components/scripts.mjs.map +1 -0
- package/esm/connector/index.d.mts +6 -0
- package/esm/connector/index.mjs +7 -0
- package/esm/context.d.mts +107 -0
- package/esm/hydration/index.d.mts +1 -0
- package/esm/hydration/index.mjs +33 -0
- package/esm/hydration/index.mjs.map +1 -0
- package/esm/hydration-payload.d.mts +1 -0
- package/esm/hydration-payload.mjs +72 -0
- package/esm/hydration-payload.mjs.map +1 -0
- package/esm/index.d.mts +35 -0
- package/esm/index.mjs +12 -0
- package/esm/loaders.d.mts +47 -0
- package/esm/metadata.d.mts +54 -0
- package/esm/props.d.mts +42 -0
- package/esm/route.d.mts +30 -0
- package/esm/routing/compose-route-path.mjs +33 -0
- package/esm/routing/compose-route-path.mjs.map +1 -0
- package/esm/routing/data-request.mjs +52 -0
- package/esm/routing/data-request.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs +78 -0
- package/esm/routing/layout-policy.mjs.map +1 -0
- package/esm/routing/navigator.mjs +21 -0
- package/esm/routing/navigator.mjs.map +1 -0
- package/esm/routing/query-string.d.mts +250 -0
- package/esm/routing/query-string.mjs +340 -0
- package/esm/routing/query-string.mjs.map +1 -0
- package/esm/routing/route-identity.mjs +68 -0
- package/esm/routing/route-identity.mjs.map +1 -0
- package/esm/routing/route-table.d.mts +34 -0
- package/esm/routing/route-table.mjs +182 -0
- package/esm/routing/route-table.mjs.map +1 -0
- package/esm/routing/router-events.d.mts +159 -0
- package/esm/routing/router-events.mjs +83 -0
- package/esm/routing/router-events.mjs.map +1 -0
- package/esm/server/buffered-response.mjs +115 -0
- package/esm/server/buffered-response.mjs.map +1 -0
- package/esm/server/build-hydration-payload.mjs +27 -0
- package/esm/server/build-hydration-payload.mjs.map +1 -0
- package/esm/server/client-asset-url-prefix.d.mts +22 -0
- package/esm/server/client-asset-url-prefix.mjs +23 -0
- package/esm/server/client-asset-url-prefix.mjs.map +1 -0
- package/esm/server/create-page-module-loader.mjs +49 -0
- package/esm/server/create-page-module-loader.mjs.map +1 -0
- package/esm/server/create-page-route-handler.mjs +107 -0
- package/esm/server/create-page-route-handler.mjs.map +1 -0
- package/esm/server/dev-server.mjs +167 -0
- package/esm/server/dev-server.mjs.map +1 -0
- package/esm/server/execute-page-request.d.mts +1 -0
- package/esm/server/execute-page-request.mjs +166 -0
- package/esm/server/execute-page-request.mjs.map +1 -0
- package/esm/server/execute-page-request.types.d.mts +1 -0
- package/esm/server/hydration-client-url.d.mts +37 -0
- package/esm/server/hydration-client-url.mjs +101 -0
- package/esm/server/hydration-client-url.mjs.map +1 -0
- package/esm/server/index.mjs +13 -0
- package/esm/server/install-page-routes-from-manifest.mjs +133 -0
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -0
- package/esm/server/install-page-routes.d.mts +1 -0
- package/esm/server/install-page-routes.mjs +6 -0
- package/esm/server/install-production-page-routes.mjs +55 -0
- package/esm/server/install-production-page-routes.mjs.map +1 -0
- package/esm/server/match-page-route.mjs +48 -0
- package/esm/server/match-page-route.mjs.map +1 -0
- package/esm/server/page-context.d.mts +1 -0
- package/esm/server/page-context.mjs +33 -0
- package/esm/server/page-context.mjs.map +1 -0
- package/esm/server/page-manifest.d.mts +102 -0
- package/esm/server/page-manifest.mjs +31 -0
- package/esm/server/page-manifest.mjs.map +1 -0
- package/esm/server/render-page.mjs +214 -0
- package/esm/server/render-page.mjs.map +1 -0
- package/esm/server/resolve-page-metadata.mjs +33 -0
- package/esm/server/resolve-page-metadata.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +27 -0
- package/esm/server/resolve-validation-data.mjs.map +1 -0
- package/esm/server/settle-page-response.d.mts +1 -0
- package/esm/server/settle-page-response.mjs +90 -0
- package/esm/server/settle-page-response.mjs.map +1 -0
- package/esm/server/stylesheet-urls.mjs +65 -0
- package/esm/server/stylesheet-urls.mjs.map +1 -0
- package/esm/server/web-connector-factory.d.mts +25 -0
- package/esm/server/web-connector-factory.mjs +121 -0
- package/esm/server/web-connector-factory.mjs.map +1 -0
- package/esm/server/web-connector.d.mts +34 -0
- package/esm/server/web-connector.mjs +516 -0
- package/esm/server/web-connector.mjs.map +1 -0
- package/esm/shared.d.mts +22 -0
- package/esm/shared.mjs +223 -0
- package/esm/shared.mjs.map +1 -0
- package/esm/validation.d.mts +31 -0
- package/esm/vite/app-convention-aliases.mjs +43 -0
- package/esm/vite/app-convention-aliases.mjs.map +1 -0
- package/esm/vite/build-client.d.mts +38 -0
- package/esm/vite/build-client.mjs +72 -0
- package/esm/vite/build-client.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +57 -0
- package/esm/vite/gate-a-resolve.mjs +834 -0
- package/esm/vite/gate-a-resolve.mjs.map +1 -0
- package/esm/vite/gate-b-secrets.d.mts +57 -0
- package/esm/vite/gate-b-secrets.mjs +299 -0
- package/esm/vite/gate-b-secrets.mjs.map +1 -0
- package/esm/vite/gate-c-verify.d.mts +118 -0
- package/esm/vite/gate-c-verify.mjs +263 -0
- package/esm/vite/gate-c-verify.mjs.map +1 -0
- package/esm/vite/hydration-entries.d.mts +17 -0
- package/esm/vite/hydration-entries.mjs +45 -0
- package/esm/vite/hydration-entries.mjs.map +1 -0
- package/esm/vite/index.d.mts +126 -0
- package/esm/vite/index.mjs +146 -0
- package/esm/vite/index.mjs.map +1 -0
- package/esm/vite/page-registry-plugin.d.mts +43 -0
- package/esm/vite/page-registry-plugin.mjs +138 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -0
- package/esm/vite/projection.d.mts +31 -0
- package/esm/vite/projection.mjs +384 -0
- package/esm/vite/projection.mjs.map +1 -0
- package/llms-full.txt +1041 -0
- package/llms.txt +21 -0
- package/package.json +77 -0
- package/skills/add-web-to-an-app/SKILL.md +103 -0
- package/skills/create-a-page/SKILL.md +141 -0
- package/skills/load-page-data/SKILL.md +183 -0
- package/skills/navigate-on-the-client/SKILL.md +182 -0
- package/skills/serve-styles/SKILL.md +145 -0
- package/skills/use-layouts/SKILL.md +121 -0
- package/skills/write-the-root/SKILL.md +131 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import { builtinModules } from "node:module";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse } from "@babel/parser";
|
|
6
|
+
|
|
7
|
+
//#region ../web/src/vite/gate-a-resolve.ts
|
|
8
|
+
/**
|
|
9
|
+
* Gate A — `resolveId` refusal for the client build.
|
|
10
|
+
*
|
|
11
|
+
* Resolution fails for import PATHS that can never be client-safe:
|
|
12
|
+
* 1. Node builtins (`fs`, `crypto`, `node:*`, ...) — see `isNodeBuiltin` for
|
|
13
|
+
* why the set is ASKED of Node rather than written down, and
|
|
14
|
+
* `externalizedBuiltins` for the one route that reaches a chunk
|
|
15
|
+
* without passing through `resolveId` at all. This rule is judged for
|
|
16
|
+
* EVERY importer, in or out of `GOVERNED_SCOPES` — see its note below.
|
|
17
|
+
* 2. `@warlock.js/*` / `@mongez/*` packages whose `package.json` carries
|
|
18
|
+
* `"warlock": { "environment": "server" }`. An ABSENT marker is NOT a
|
|
19
|
+
* refusal — see the burden note on `createEnvironmentClassifier`. Judged
|
|
20
|
+
* only for imports whose importer is inside the app root — see
|
|
21
|
+
* `isInsideAppRoot` note below. An import statement carrying no VALUE
|
|
22
|
+
* binding is not refused but ERASED — see the type-only note on
|
|
23
|
+
* `recordImportKinds`.
|
|
24
|
+
* 3. Server-only modules declared by FILE NAME: `*.server.ts` files,
|
|
25
|
+
* anything under a `.server/` directory, and — for the app's OWN source
|
|
26
|
+
* only — anything under a plain `server/` directory segment.
|
|
27
|
+
* 4. Local modules outside a `$module/web/` (or `src/web/`) folder that are
|
|
28
|
+
* not a recognized universal surface (`*.page.tsx`, `layout.tsx`). The app
|
|
29
|
+
* root (`src/web/root.tsx`) is admitted by the `web/` FOLDER rule above,
|
|
30
|
+
* not by its name — see the note in `isRecognizedUniversalSurface`. A
|
|
31
|
+
* specifier that omits its file extension is judged on the file it will
|
|
32
|
+
* actually load, not on the extensionless string — see
|
|
33
|
+
* `completeLocalModulePath`.
|
|
34
|
+
* 5. The `server-only` npm package — a module importing it has declared
|
|
35
|
+
* itself server-side and cannot reach the browser. Judged by NAME, right
|
|
36
|
+
* after rule 1 and ahead of rule 2's scope check; see
|
|
37
|
+
* `BOUNDARY_DECLARATION_PACKAGES`.
|
|
38
|
+
*
|
|
39
|
+
* Gate B (inline secrets), Gate C (output verification) and the SSR mirror
|
|
40
|
+
* rule (`*.client.*` refusal / `clientOnly()`) are NOT this gate. Do not
|
|
41
|
+
* extend this file to cover them; they are separate, later slices.
|
|
42
|
+
*/
|
|
43
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
/**
|
|
45
|
+
* Node's OWN list of its builtins, read at runtime from `node:module`, never
|
|
46
|
+
* copied into this file. A deny-list of names would be correct on the day it
|
|
47
|
+
* was written and wrong on the day Node ships the next builtin — `node:sqlite`
|
|
48
|
+
* and `node:test` are both recent enough to have missed any list this repo
|
|
49
|
+
* could have hand-typed — and a fence that fails open on names nobody has
|
|
50
|
+
* added yet is a fence with an expiry date nobody is watching. Asking the
|
|
51
|
+
* runtime costs one import and cannot drift: the process doing the bundling is
|
|
52
|
+
* the same process whose builtins are at issue.
|
|
53
|
+
*
|
|
54
|
+
* The `node:`-prefixed forms are handled separately (see `isNodeBuiltin`), so
|
|
55
|
+
* this set only needs the names as `builtinModules` reports them.
|
|
56
|
+
*/
|
|
57
|
+
const NODE_BUILTINS = new Set(builtinModules);
|
|
58
|
+
/**
|
|
59
|
+
* Every SPELLING of every builtin — bare and `node:`-prefixed — derived from
|
|
60
|
+
* the set above rather than enumerated. Used only by
|
|
61
|
+
* `externalizedBuiltins`, which has to interrogate a predicate it does
|
|
62
|
+
* not control and therefore needs concrete strings to ask about. `node:test`,
|
|
63
|
+
* `node:sea` and friends are reported by `builtinModules` ALREADY prefixed and
|
|
64
|
+
* have no bare form at all, so they are passed through unchanged.
|
|
65
|
+
*/
|
|
66
|
+
const NODE_BUILTIN_SPELLINGS = [...NODE_BUILTINS].flatMap((name) => name.startsWith("node:") ? [name] : [name, `node:${name}`]);
|
|
67
|
+
/**
|
|
68
|
+
* Scopes whose `package.json` Gate A will read for a `warlock.environment`
|
|
69
|
+
* marker. Third-party packages outside these two scopes (react, lodash, ...)
|
|
70
|
+
* are never asked for this field — they are judged solely by Gate A's other,
|
|
71
|
+
* import-based rules. Note that a governed package is only REFUSED when it
|
|
72
|
+
* declares `"server"`; being in scope is not itself a refusal.
|
|
73
|
+
*/
|
|
74
|
+
const GOVERNED_SCOPES = ["@warlock.js/", "@mongez/"];
|
|
75
|
+
function isGovernedScope(source) {
|
|
76
|
+
return GOVERNED_SCOPES.some((scope) => source.startsWith(scope));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The package root of a scoped specifier, e.g. `@warlock.js/core/db` ->
|
|
80
|
+
* `@warlock.js/core`, `@warlock.js/core` -> `@warlock.js/core`.
|
|
81
|
+
*/
|
|
82
|
+
function governedPackageNameOf(source) {
|
|
83
|
+
const [scope, name] = source.split("/");
|
|
84
|
+
return `${scope}/${name}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Indexes every `@warlock.js/*` / `@mongez/*` workspace member's
|
|
88
|
+
* `package.json` path from the monorepo root's `package.json` `workspaces`
|
|
89
|
+
* array (`48cb4ae7` discipline — enumerated, never hand-typed). Walks up from
|
|
90
|
+
* this file looking for the workspace root (identified by
|
|
91
|
+
* `name: "warlock-workspace"`) so it works whether this module runs from
|
|
92
|
+
* `web/src/vite/` (dev) or a compiled `web/esm/vite/` output. Returns
|
|
93
|
+
* `undefined` when no monorepo root is reachable — e.g. this package
|
|
94
|
+
* installed standalone in a consuming app — in which case governed packages
|
|
95
|
+
* are located via `node_modules` instead (see `findNodeModulesPackageJson`).
|
|
96
|
+
*/
|
|
97
|
+
function findWorkspaceIndex(startDir) {
|
|
98
|
+
let dir = startDir;
|
|
99
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
100
|
+
const candidate = path.join(dir, "package.json");
|
|
101
|
+
if (existsSync(candidate)) try {
|
|
102
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
103
|
+
if (pkg?.name === "warlock-workspace" && Array.isArray(pkg.workspaces)) {
|
|
104
|
+
const packageJsonByName = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const workspace of pkg.workspaces) {
|
|
106
|
+
const workspacePkgPath = path.join(dir, workspace, "package.json");
|
|
107
|
+
if (!existsSync(workspacePkgPath)) continue;
|
|
108
|
+
try {
|
|
109
|
+
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, "utf-8"));
|
|
110
|
+
if (typeof workspacePkg.name === "string" && isGovernedScope(`${workspacePkg.name}/`)) packageJsonByName.set(workspacePkg.name, workspacePkgPath);
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
return { packageJsonByName };
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
const parent = path.dirname(dir);
|
|
117
|
+
if (parent === dir) break;
|
|
118
|
+
dir = parent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Locates a governed-scope package's `package.json` the ordinary Node
|
|
123
|
+
* resolution way — walking up `node_modules` directories from `startDir` —
|
|
124
|
+
* for packages that are not monorepo workspace members (published
|
|
125
|
+
* dependencies, e.g. `@mongez/reinforcements`, or any governed package when
|
|
126
|
+
* this module runs standalone outside the monorepo).
|
|
127
|
+
*/
|
|
128
|
+
function findNodeModulesPackageJson(pkgName, startDir) {
|
|
129
|
+
let dir = startDir;
|
|
130
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
131
|
+
const candidate = path.join(dir, "node_modules", pkgName, "package.json");
|
|
132
|
+
if (existsSync(candidate)) return candidate;
|
|
133
|
+
const parent = path.dirname(dir);
|
|
134
|
+
if (parent === dir) break;
|
|
135
|
+
dir = parent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The governed-scope package name owning `normalized` (an already-`/`-
|
|
140
|
+
* normalized absolute path), found by taking the LAST `node_modules/`
|
|
141
|
+
* segment — nested `node_modules` (a dependency's own vendored copy) must
|
|
142
|
+
* resolve to the nearest enclosing package, not the outermost one. Returns
|
|
143
|
+
* `undefined` for paths with no `node_modules` segment, or whose owning
|
|
144
|
+
* package isn't in a governed scope.
|
|
145
|
+
*/
|
|
146
|
+
function packageNameFromNodeModulesPath(normalized) {
|
|
147
|
+
const segments = normalized.split("node_modules/");
|
|
148
|
+
if (segments.length < 2) return void 0;
|
|
149
|
+
const afterLast = segments[segments.length - 1];
|
|
150
|
+
const parts = afterLast.split("/");
|
|
151
|
+
const name = afterLast.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
152
|
+
return isGovernedScope(`${name}/`) ? name : void 0;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The two npm packages whose entire published API is a declaration of which
|
|
156
|
+
* side of the client/server boundary their importer belongs to: importing
|
|
157
|
+
* `server-only` says "this module must never reach the browser", importing
|
|
158
|
+
* `client-only` says the mirror. Mapped to the environment each one declares
|
|
159
|
+
* of its IMPORTER.
|
|
160
|
+
*
|
|
161
|
+
* Recognized by NAME, ahead of rule 2's scope check, for a reason that is not
|
|
162
|
+
* a special case: both sit outside `GOVERNED_SCOPES`, so `environmentOf` is
|
|
163
|
+
* never consulted for them under ANY default — no marker resolution, current
|
|
164
|
+
* or future, can ever classify them. Their name IS the marker, and reading it
|
|
165
|
+
* is the same move rule 3 makes when it reads `.server.ts` off a file name.
|
|
166
|
+
*
|
|
167
|
+
* Only a `"server"` declaration is a violation HERE. `client-only` is
|
|
168
|
+
* recognized and deliberately permitted: this is the client build, so a module
|
|
169
|
+
* declaring itself client-side is exactly where it belongs. Judging it is the
|
|
170
|
+
* SSR mirror rule's job, and that gate is not this file (see the header note).
|
|
171
|
+
*/
|
|
172
|
+
const BOUNDARY_DECLARATION_PACKAGES = {
|
|
173
|
+
"server-only": "server",
|
|
174
|
+
"client-only": "client"
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* The boundary-declaration package a specifier reaches, if any — matching the
|
|
178
|
+
* package itself or a subpath of it (`server-only/empty` is a real published
|
|
179
|
+
* entry point), never a merely similar name like `server-only-utils`.
|
|
180
|
+
*/
|
|
181
|
+
function boundaryDeclarationOf(source) {
|
|
182
|
+
for (const [name, declares] of Object.entries(BOUNDARY_DECLARATION_PACKAGES)) if (source === name || source.startsWith(`${name}/`)) return {
|
|
183
|
+
name,
|
|
184
|
+
declares
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Whether a specifier names a Node builtin, in either spelling.
|
|
189
|
+
*
|
|
190
|
+
* The `node:` prefix is answered by SHAPE, not by membership: the prefix is
|
|
191
|
+
* reserved by Node for builtins and nothing else can ever legally claim it, so
|
|
192
|
+
* `node:anything` is refused even when `builtinModules` has never heard of it.
|
|
193
|
+
* That covers the prefix-only builtins (`node:test`, `node:sqlite`, `node:sea`)
|
|
194
|
+
* on Node versions whose `builtinModules` omits them, and every builtin Node
|
|
195
|
+
* has not shipped yet, without this file knowing a single one of their names.
|
|
196
|
+
*
|
|
197
|
+
* Bare names fall through to Node's own set, which already carries the subpath
|
|
198
|
+
* entries (`fs/promises`, `stream/web`, `timers/promises`, ...) as first-class
|
|
199
|
+
* members — they need no prefix matching of their own.
|
|
200
|
+
*/
|
|
201
|
+
function isNodeBuiltin(source) {
|
|
202
|
+
if (source.startsWith("node:")) return true;
|
|
203
|
+
return NODE_BUILTINS.has(source);
|
|
204
|
+
}
|
|
205
|
+
function normalize(filePath) {
|
|
206
|
+
return filePath.replace(/\\/g, "/");
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A plain `server/` path segment, tested against a path ALREADY made relative
|
|
210
|
+
* to the app root — never against the absolute path. An app checked out at
|
|
211
|
+
* `D:/work/server/shop` would otherwise have every one of its own files match.
|
|
212
|
+
*/
|
|
213
|
+
const APP_SERVER_SEGMENT = /(^|\/)server(\/|$)/;
|
|
214
|
+
function isServerFile(resolvedPath, appRoot) {
|
|
215
|
+
const normalized = normalize(resolvedPath);
|
|
216
|
+
if (/\.server(\.[jt]sx?)?$/.test(normalized)) return true;
|
|
217
|
+
if (/(^|\/)\.server(\/|$)/.test(normalized)) return true;
|
|
218
|
+
if (isAppSourcePath(resolvedPath, appRoot)) {
|
|
219
|
+
if (APP_SERVER_SEGMENT.test(normalize(path.relative(appRoot, resolvedPath)))) return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Rule 4's subject set: local files that CARRY CODE. Deliberately the same
|
|
225
|
+
* shape as `PARSEABLE_MODULE_EXTENSIONS` below — one notion of "this is a
|
|
226
|
+
* JS/TS module" for the whole file — which also picks up `.mts`/`.cts`, absent
|
|
227
|
+
* from the earlier hand-spelled `/\.(tsx?|jsx?|mjs|cjs)$/`. `.mts` is a real
|
|
228
|
+
* module extension Vite resolves, so a file wearing it was walking past rule 4
|
|
229
|
+
* with its extension written out in full.
|
|
230
|
+
*
|
|
231
|
+
* Every extension `IMPLICIT_MODULE_EXTENSIONS` can append MUST match this —
|
|
232
|
+
* see the note there.
|
|
233
|
+
*/
|
|
234
|
+
const LOCAL_MODULE_EXTENSIONS = /\.([cm]?[jt]sx?)$/;
|
|
235
|
+
/**
|
|
236
|
+
* The extensions a local specifier may leave off, in Vite's own
|
|
237
|
+
* `resolve.extensions` order.
|
|
238
|
+
*
|
|
239
|
+
* `.json` is deliberately absent: it is not code-carrying, so completing to it
|
|
240
|
+
* would only hand rule 4 a path it exempts one line later, and the exemption is
|
|
241
|
+
* the correct answer for a data file.
|
|
242
|
+
*
|
|
243
|
+
* The list and `LOCAL_MODULE_EXTENSIONS` have to agree in one direction: an
|
|
244
|
+
* extension this list can PRODUCE but that regex does not RECOGNIZE is a fresh
|
|
245
|
+
* escape hatch of exactly the shape this completion exists to close — the
|
|
246
|
+
* judged path would gain a suffix and still be waved through. Pinned by case 32
|
|
247
|
+
* in the spec. The order only decides which of two same-named files gets
|
|
248
|
+
* judged, and every rule downstream (`isServerFile`, `isWithinModuleWebFolder`,
|
|
249
|
+
* `isRecognizedUniversalSurface`) answers identically for `x.ts` and `x.js`, so
|
|
250
|
+
* matching Vite is for least surprise rather than for correctness.
|
|
251
|
+
*/
|
|
252
|
+
const IMPLICIT_MODULE_EXTENSIONS = [
|
|
253
|
+
".mjs",
|
|
254
|
+
".js",
|
|
255
|
+
".mts",
|
|
256
|
+
".ts",
|
|
257
|
+
".jsx",
|
|
258
|
+
".tsx",
|
|
259
|
+
".cjs",
|
|
260
|
+
".cts"
|
|
261
|
+
];
|
|
262
|
+
function isFile(candidate) {
|
|
263
|
+
try {
|
|
264
|
+
return statSync(candidate).isFile();
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The file a local specifier will ACTUALLY load, given the path `resolveId`
|
|
271
|
+
* derived from it with a raw `path.resolve`.
|
|
272
|
+
*
|
|
273
|
+
* Without this, rule 4 was escapable by writing the import the ordinary way.
|
|
274
|
+
* `resolveId` runs before anything appends an extension, so `./services/helper`
|
|
275
|
+
* arrives as a path ending in `helper`; rule 4 asks for a code extension —
|
|
276
|
+
* rightly, since it has no business judging `./logo.css` — finds none, and
|
|
277
|
+
* declines to judge at all. `./x.middleware` slipped the same way while LOOKING
|
|
278
|
+
* like it had an extension, and so did `./services` resolving to
|
|
279
|
+
* `services/index.ts`. Measured: all three built clean (spec cases 25-28, 32).
|
|
280
|
+
*
|
|
281
|
+
* Asked of the FILESYSTEM rather than answered from the string, because the
|
|
282
|
+
* string cannot tell an omitted extension from a real one: `.middleware` is not
|
|
283
|
+
* a code extension and not an asset extension either, and only the disk knows
|
|
284
|
+
* whether `thing.middleware.ts` is sitting there. Probing `<path><ext>` and
|
|
285
|
+
* then `<path>/index<ext>` is what Vite is about to do anyway, so the rule
|
|
286
|
+
* judges the file that will really be bundled.
|
|
287
|
+
*
|
|
288
|
+
* Deliberately NOT cached. A handful of `stat` calls per local specifier is
|
|
289
|
+
* what every resolver in the pipeline already spends, and a cache would go
|
|
290
|
+
* stale in watch mode exactly when a file is created or deleted — which is the
|
|
291
|
+
* moment a fence must not be answering from memory.
|
|
292
|
+
*
|
|
293
|
+
* Three early exits, each a case where completion has nothing to add or no
|
|
294
|
+
* business guessing:
|
|
295
|
+
* - a non-absolute path (a bare package specifier — rules 1/2's business)
|
|
296
|
+
* or a virtual `\0` id;
|
|
297
|
+
* - a query suffix (`./helper?raw`, `./thing?worker`). The query changes what
|
|
298
|
+
* the id MEANS to Vite, and inventing a file for it is a separate decision
|
|
299
|
+
* from this one. Rule 4 still does not judge these — a known gap, narrower
|
|
300
|
+
* than the one being closed here, and not widened by guesswork;
|
|
301
|
+
* - a path that already carries a code extension, or that already exists as a
|
|
302
|
+
* real non-code file (an asset — `./theme.css`), where the path on hand is
|
|
303
|
+
* the answer.
|
|
304
|
+
*/
|
|
305
|
+
function completeLocalModulePath(judgedPath) {
|
|
306
|
+
if (!path.isAbsolute(judgedPath)) return judgedPath;
|
|
307
|
+
if (judgedPath.includes("?") || judgedPath.includes("\0")) return judgedPath;
|
|
308
|
+
if (LOCAL_MODULE_EXTENSIONS.test(judgedPath)) return judgedPath;
|
|
309
|
+
if (path.extname(judgedPath) !== "" && isFile(judgedPath)) return judgedPath;
|
|
310
|
+
for (const extension of IMPLICIT_MODULE_EXTENSIONS) {
|
|
311
|
+
const candidate = `${judgedPath}${extension}`;
|
|
312
|
+
if (isFile(candidate)) return candidate;
|
|
313
|
+
}
|
|
314
|
+
for (const extension of IMPLICIT_MODULE_EXTENSIONS) {
|
|
315
|
+
const candidate = path.join(judgedPath, `index${extension}`);
|
|
316
|
+
if (isFile(candidate)) return candidate;
|
|
317
|
+
}
|
|
318
|
+
return judgedPath;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Whether a judged path is the APP'S OWN SOURCE, as opposed to a dependency's
|
|
322
|
+
* internals. This is Gate A's single definition of that boundary — rules 3
|
|
323
|
+
* and 4 both call it, and `packageNameForFilePath` draws the same line for
|
|
324
|
+
* Gate C. Two conditions, both already required by rule 4 before this was
|
|
325
|
+
* extracted:
|
|
326
|
+
*
|
|
327
|
+
* - inside `appRoot` (a monorepo sibling or any other path resolution has
|
|
328
|
+
* left the app root for is that package's own business), and
|
|
329
|
+
* - not under a `node_modules/` segment. Nested `node_modules` inside
|
|
330
|
+
* `appRoot` is the ORDINARY case for a dependency, and Vite's optimized
|
|
331
|
+
* dependency chunks (`<appRoot>/node_modules/.vite/deps`) even import one
|
|
332
|
+
* another relatively.
|
|
333
|
+
*
|
|
334
|
+
* Bare package specifiers never reach here as app source: for those the
|
|
335
|
+
* "judged path" is still the specifier string itself (see `resolveId`), which
|
|
336
|
+
* is not absolute. They are rule 2's business.
|
|
337
|
+
*/
|
|
338
|
+
function isAppSourcePath(resolvedPath, appRoot) {
|
|
339
|
+
if (!path.isAbsolute(resolvedPath)) return false;
|
|
340
|
+
if (!isInsideAppRoot(resolvedPath, appRoot)) return false;
|
|
341
|
+
return !normalize(resolvedPath).includes("/node_modules/");
|
|
342
|
+
}
|
|
343
|
+
function isInsideAppRoot(resolvedPath, appRoot) {
|
|
344
|
+
const relative = path.relative(appRoot, resolvedPath);
|
|
345
|
+
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
346
|
+
}
|
|
347
|
+
/** Files whose source Gate A will parse for import kinds. */
|
|
348
|
+
const PARSEABLE_MODULE_EXTENSIONS = /\.([cm]?[jt]sx?)$/;
|
|
349
|
+
/** Cheap pre-filter: no governed scope in the text, nothing to classify. */
|
|
350
|
+
const GOVERNED_SCOPE_MENTION = /@warlock\.js\/|@mongez\//;
|
|
351
|
+
/**
|
|
352
|
+
* The id Gate A resolves a type-only edge into a server-only package to. `\0`
|
|
353
|
+
* is the virtual-module convention, which also keeps Gate C from trying to
|
|
354
|
+
* attribute this id to a package on disk (`packageNameForFilePath` returns
|
|
355
|
+
* `undefined` for any id containing `\0` — see its note).
|
|
356
|
+
*/
|
|
357
|
+
const TYPE_ONLY_ERASED_ID = "\0warlock:type-only-erased";
|
|
358
|
+
/**
|
|
359
|
+
* Rollup module ids carry query suffixes (`?v=`, `?used`, ...) that differ
|
|
360
|
+
* between the `transform` id and a later `importer`. Both sides key through
|
|
361
|
+
* here so they agree.
|
|
362
|
+
*/
|
|
363
|
+
function moduleKey(id) {
|
|
364
|
+
return normalize(id.split("?")[0]);
|
|
365
|
+
}
|
|
366
|
+
function parserPluginsFor(id) {
|
|
367
|
+
return /\.[cm]?ts$/.test(moduleKey(id)) ? ["typescript"] : ["typescript", "jsx"];
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Whether an `ImportDeclaration`'s specifier list is entirely type-only.
|
|
371
|
+
*
|
|
372
|
+
* Both spellings count, because both are erased: the statement form
|
|
373
|
+
* (`import type { X } from "P"`, `decl.importKind === "type"`) and the inline
|
|
374
|
+
* form (`import { type X } from "P"`, every specifier's own `importKind`).
|
|
375
|
+
* The inline form is the one that matters in practice — under
|
|
376
|
+
* `verbatimModuleSyntax` TypeScript/esbuild emit it VERBATIM as
|
|
377
|
+
* `import {} from "P"`, so it is the only one that survives to `resolveId` at
|
|
378
|
+
* all.
|
|
379
|
+
*
|
|
380
|
+
* An EMPTY specifier list (`import "P"`) is deliberately `"value"`: a bare
|
|
381
|
+
* side-effect import is a real runtime edge that the author wrote on purpose,
|
|
382
|
+
* and it is indistinguishable — post-esbuild — from the erased form. Treating
|
|
383
|
+
* it as type-only would turn this carve-out into a hole big enough to smuggle
|
|
384
|
+
* a whole server package through.
|
|
385
|
+
*/
|
|
386
|
+
function importDeclarationKind(decl) {
|
|
387
|
+
if (decl.importKind === "type") return "type";
|
|
388
|
+
if (decl.specifiers.length === 0) return "value";
|
|
389
|
+
return decl.specifiers.every((spec) => spec.importKind === "type") ? "type" : "value";
|
|
390
|
+
}
|
|
391
|
+
function exportDeclarationKind(stmt) {
|
|
392
|
+
if (stmt.exportKind === "type") return "type";
|
|
393
|
+
const specifiers = stmt.specifiers ?? [];
|
|
394
|
+
if (specifiers.length === 0) return "value";
|
|
395
|
+
return specifiers.every((spec) => spec.exportKind === "type") ? "type" : "value";
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Every specifier reached by a RUNTIME expression rather than a static
|
|
399
|
+
* import/export statement — `import("P")` and `require("P")` — found by a
|
|
400
|
+
* duck-typed walk of the whole program, not just its top level, because both
|
|
401
|
+
* are ordinary expressions and can sit anywhere.
|
|
402
|
+
*
|
|
403
|
+
* These are unconditionally `"value"`. A dynamic import of a server-only
|
|
404
|
+
* package is exactly the edge rule 2 exists to refuse, and it reaches
|
|
405
|
+
* `resolveId` with the same specifier string a static import would, so
|
|
406
|
+
* without this walk a file could hold `import { type X } from "P"` beside
|
|
407
|
+
* `await import("P")` and have the static statement's kind exempt the dynamic
|
|
408
|
+
* one.
|
|
409
|
+
*/
|
|
410
|
+
function collectRuntimeSpecifiers(node, into) {
|
|
411
|
+
if (!node || typeof node !== "object") return;
|
|
412
|
+
if (Array.isArray(node)) {
|
|
413
|
+
for (const item of node) collectRuntimeSpecifiers(item, into);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const record = node;
|
|
417
|
+
if (typeof record.type !== "string") return;
|
|
418
|
+
if (record.type === "CallExpression" || record.type === "ImportExpression") {
|
|
419
|
+
const callee = record.callee;
|
|
420
|
+
if (record.type === "ImportExpression" || callee?.type === "Import" || callee?.type === "Identifier" && callee.name === "require") {
|
|
421
|
+
const arg = record.source ?? record.arguments?.[0];
|
|
422
|
+
if (arg?.type === "StringLiteral" && typeof arg.value === "string") into.add(arg.value);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const key of Object.keys(record)) {
|
|
426
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
427
|
+
if (key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "extra") continue;
|
|
428
|
+
collectRuntimeSpecifiers(record[key], into);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Classifies every specifier `code` imports as `"value"` or `"type"`. Returns
|
|
433
|
+
* `undefined` when the source could not be parsed — the caller reads that as
|
|
434
|
+
* "no information", which leaves rule 2 refusing exactly as it does today.
|
|
435
|
+
*/
|
|
436
|
+
function classifyImportKinds(code, id) {
|
|
437
|
+
let ast;
|
|
438
|
+
try {
|
|
439
|
+
ast = parse(code, {
|
|
440
|
+
sourceType: "module",
|
|
441
|
+
plugins: parserPluginsFor(id),
|
|
442
|
+
errorRecovery: false
|
|
443
|
+
});
|
|
444
|
+
} catch {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
448
|
+
const record = (source, kind) => {
|
|
449
|
+
if (kind === "value" || !kinds.has(source)) kinds.set(source, kind);
|
|
450
|
+
};
|
|
451
|
+
for (const stmt of ast.program.body) {
|
|
452
|
+
if (stmt.type === "ImportDeclaration") {
|
|
453
|
+
record(stmt.source.value, importDeclarationKind(stmt));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportAllDeclaration") && stmt.source) record(stmt.source.value, exportDeclarationKind(stmt));
|
|
457
|
+
}
|
|
458
|
+
const runtimeSpecifiers = /* @__PURE__ */ new Set();
|
|
459
|
+
collectRuntimeSpecifiers(ast.program.body, runtimeSpecifiers);
|
|
460
|
+
for (const source of runtimeSpecifiers) kinds.set(source, "value");
|
|
461
|
+
return kinds;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Rule 2's predicate on its own, extracted so `resolveId` can ask the question
|
|
465
|
+
* a SECOND time after `ruleViolation` has declined to refuse: a type-only edge
|
|
466
|
+
* into a server-only package is not a violation, but it is not an ordinary
|
|
467
|
+
* import either — it is erased (see `TYPE_ONLY_ERASED_ID`). Asking separately,
|
|
468
|
+
* rather than short-circuiting inside `ruleViolation`, is what keeps rules 3
|
|
469
|
+
* and 4 authoritative over a type-only specifier: `@warlock.js/core/db.server`
|
|
470
|
+
* imported for its types alone is still refused, by rule 3, because
|
|
471
|
+
* `ruleViolation` still runs to completion first.
|
|
472
|
+
*/
|
|
473
|
+
function isServerMarkedGovernedPackage(source, importer, environmentOf, appRoot) {
|
|
474
|
+
if (!isGovernedScope(source)) return false;
|
|
475
|
+
if (importer && !isInsideAppRoot(importer, appRoot)) return false;
|
|
476
|
+
return environmentOf(governedPackageNameOf(source)) === "server";
|
|
477
|
+
}
|
|
478
|
+
function ruleViolation(source, resolvedPath, importer, environmentOf, appRoot, isTypeOnlyEdge) {
|
|
479
|
+
if (isNodeBuiltin(source)) return {
|
|
480
|
+
cause: `"${source}" is a Node.js builtin module and cannot run in the browser.`,
|
|
481
|
+
fix: `Move the code that needs "${source}" into a *.server.ts file or a server-only loader/controller, and pass only serializable data to the client.`
|
|
482
|
+
};
|
|
483
|
+
const boundaryDeclaration = boundaryDeclarationOf(source);
|
|
484
|
+
if (boundaryDeclaration?.declares === "server") {
|
|
485
|
+
const declarer = importer ? displayName(importer) : source;
|
|
486
|
+
return {
|
|
487
|
+
cause: `"${declarer}" imports "${boundaryDeclaration.name}" — that package publishes no API at all, only a declaration: any module importing it is server-side and must never be bundled for the browser.`,
|
|
488
|
+
fix: `Move the server work in "${declarer}" into a *.server.ts file, a server/ folder, a loader, or a controller — the client only needs the serialized data it returns — or, if this module is genuinely browser-safe, remove its "${boundaryDeclaration.name}" import.`
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (!isTypeOnlyEdge && isServerMarkedGovernedPackage(source, importer, environmentOf, appRoot)) {
|
|
492
|
+
const pkgName = governedPackageNameOf(source);
|
|
493
|
+
return {
|
|
494
|
+
cause: `"${source}" resolves into ${pkgName}, a server-only ${pkgName.startsWith("@warlock.js/") ? "@warlock.js" : "@mongez"} package — it declares "warlock": { "environment": "server" } in its package.json.`,
|
|
495
|
+
fix: `Move this import behind a *.server.ts file, a server/ folder, a loader, or a controller — the client only needs the serialized data it returns — or, if ${pkgName} is genuinely universal/client-safe, change its marker to "warlock": { "environment": "universal" } (or "client").`
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (isServerFile(resolvedPath, appRoot)) return {
|
|
499
|
+
cause: `"${source}" is a server-only file — its name declares it: it matches *.server.ts, or it lives under a .server/ directory, or under a server/ folder in this app's source.`,
|
|
500
|
+
fix: `Import the data this file produces through a loader instead of importing the server file directly, or move the shared logic into a universal helper under $module/web/ — outside any server/ folder and without a .server suffix.`
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The label one hop of an import chain carries.
|
|
505
|
+
*
|
|
506
|
+
* A basename is enough for the app's own source, where file names are chosen
|
|
507
|
+
* and distinct. It is not enough for a dependency: a large share of
|
|
508
|
+
* node_modules resolves to a file literally called `index.js`, and a chain
|
|
509
|
+
* reading `page.tsx → index.js → node:fs` names the culprit for nobody — which
|
|
510
|
+
* is the failure mode where people switch the fence off instead of fixing the
|
|
511
|
+
* import. Anything under a `node_modules/` segment is therefore labelled with
|
|
512
|
+
* its path from the LAST such segment (`vendor-node-lib/index.js`), so a
|
|
513
|
+
* vendored nested copy reports the package that actually holds the file rather
|
|
514
|
+
* than the one at the top of the tree.
|
|
515
|
+
*/
|
|
516
|
+
function displayName(id) {
|
|
517
|
+
const normalized = normalize(id);
|
|
518
|
+
const segments = normalized.split("node_modules/");
|
|
519
|
+
if (segments.length < 2) return path.basename(normalized);
|
|
520
|
+
return segments[segments.length - 1];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Every Node builtin the Rollup config would externalize — empty when none,
|
|
524
|
+
* which is the only shape that lets a build proceed past `buildStart`.
|
|
525
|
+
*
|
|
526
|
+
* This is the one route a builtin can take into a browser chunk without ever
|
|
527
|
+
* being offered to `resolveId`: Rollup consults `external` FIRST, and an
|
|
528
|
+
* externalized specifier is emitted into the output verbatim — measured, on
|
|
529
|
+
* this gate's own fixture, as a clean build whose chunk still opens with
|
|
530
|
+
* `import { readFileSync } from "node:fs"`. No `resolveId` rule can close that,
|
|
531
|
+
* because the hook is never called; the check has to happen against the config
|
|
532
|
+
* itself, before any module is loaded.
|
|
533
|
+
*
|
|
534
|
+
* Asked as a QUESTION of the normalized predicate rather than by reading an
|
|
535
|
+
* array, because `external` may legitimately be a string, a RegExp, an array of
|
|
536
|
+
* either, or a function — Rollup normalizes all of them into the function
|
|
537
|
+
* handed to `buildStart`, and asking it about each builtin spelling is the only
|
|
538
|
+
* form of the check that covers every shape. A predicate that throws on an
|
|
539
|
+
* unexpected shape of argument is read as "not external": this guard exists to
|
|
540
|
+
* catch a config that says yes, and inventing a refusal out of someone else's
|
|
541
|
+
* exception would be a false positive with no evidence behind it.
|
|
542
|
+
*
|
|
543
|
+
* This fires on CONFIGURATION, not on an actual import — nothing need import
|
|
544
|
+
* `fs` for it to trip. That is on purpose: the config has already decided the
|
|
545
|
+
* fence does not apply, and a build that would silently pass today only because
|
|
546
|
+
* no import happens to exist yet is not a build anyone should trust tomorrow.
|
|
547
|
+
*/
|
|
548
|
+
function externalizedBuiltins(isExternal) {
|
|
549
|
+
if (typeof isExternal !== "function") return [];
|
|
550
|
+
const asks = isExternal;
|
|
551
|
+
return NODE_BUILTIN_SPELLINGS.filter((specifier) => {
|
|
552
|
+
try {
|
|
553
|
+
return asks(specifier, void 0, false) === true;
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The `warlock.environment` marker classifier (rule 2). Extracted from
|
|
561
|
+
* `gateAResolve` so Gate C can classify packages it finds in the EMITTED
|
|
562
|
+
* bundle graph with the identical resolution logic — same workspace index,
|
|
563
|
+
* same `node_modules` fallback, same default — rather than a second,
|
|
564
|
+
* potentially-drifting implementation.
|
|
565
|
+
*
|
|
566
|
+
* An ABSENT marker classifies as `"universal"`, i.e. allowed. This is a
|
|
567
|
+
* deliberate reversal of the earlier burden-inversion default. A marker is
|
|
568
|
+
* shipped metadata: making silence mean "refuse" meant every browser-safe
|
|
569
|
+
* package we own needed a marker AND a release before any app could boot, and
|
|
570
|
+
* a package the app does not control (`@mongez/react-atom`) could not be
|
|
571
|
+
* imported at all. Refusal is now driven by information that is PRESENT — an
|
|
572
|
+
* explicit `"server"` marker, or a server-only file name (rule 3) — which the
|
|
573
|
+
* app author can always supply themselves without waiting on a release. The
|
|
574
|
+
* marker mechanism itself is untouched and still authoritative when set.
|
|
575
|
+
*/
|
|
576
|
+
function createEnvironmentClassifier(options = {}) {
|
|
577
|
+
const forcedServerPackages = options.serverPackages ? new Set(options.serverPackages) : void 0;
|
|
578
|
+
const appRoot = path.resolve(options.appRoot ?? process.cwd());
|
|
579
|
+
const workspaceIndex = findWorkspaceIndex(__dirname);
|
|
580
|
+
const environmentCache = /* @__PURE__ */ new Map();
|
|
581
|
+
function environmentOf(pkgName) {
|
|
582
|
+
if (forcedServerPackages?.has(pkgName)) return "server";
|
|
583
|
+
const cached = environmentCache.get(pkgName);
|
|
584
|
+
if (cached) return cached;
|
|
585
|
+
const packageJsonPath = workspaceIndex?.packageJsonByName.get(pkgName) ?? findNodeModulesPackageJson(pkgName, appRoot);
|
|
586
|
+
let environment = "universal";
|
|
587
|
+
if (packageJsonPath) try {
|
|
588
|
+
const marker = JSON.parse(readFileSync(packageJsonPath, "utf-8"))?.warlock?.environment;
|
|
589
|
+
if (marker === "server" || marker === "universal" || marker === "client") environment = marker;
|
|
590
|
+
} catch {}
|
|
591
|
+
environmentCache.set(pkgName, environment);
|
|
592
|
+
return environment;
|
|
593
|
+
}
|
|
594
|
+
function packageNameForFilePath(absPath) {
|
|
595
|
+
if (absPath.includes("\0")) return void 0;
|
|
596
|
+
const normalized = normalize(path.resolve(absPath));
|
|
597
|
+
const nodeModulesMatch = packageNameFromNodeModulesPath(normalized);
|
|
598
|
+
if (nodeModulesMatch) return nodeModulesMatch;
|
|
599
|
+
if (isInsideAppRoot(normalized, appRoot)) return void 0;
|
|
600
|
+
if (workspaceIndex) for (const [name, pkgJsonPath] of workspaceIndex.packageJsonByName) {
|
|
601
|
+
const root = `${normalize(path.dirname(pkgJsonPath))}/`;
|
|
602
|
+
if (normalized.startsWith(root)) return name;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
appRoot,
|
|
607
|
+
environmentOf,
|
|
608
|
+
packageNameForFilePath
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The client-build Vite plugin. `vite` is only imported for its types
|
|
613
|
+
* (`import type`), so this module carries no runtime dependency on `vite`
|
|
614
|
+
* being installed — it is a `peerDependenciesMeta.optional` peer.
|
|
615
|
+
*/
|
|
616
|
+
function gateAResolve(options = {}) {
|
|
617
|
+
const { appRoot, environmentOf } = createEnvironmentClassifier(options);
|
|
618
|
+
const importerOf = /* @__PURE__ */ new Map();
|
|
619
|
+
/**
|
|
620
|
+
* Module key -> that module's specifier classification, filled by the
|
|
621
|
+
* `transform` hook below and read by `resolveId`.
|
|
622
|
+
*
|
|
623
|
+
* This is the answer to the one structural problem rule 2 has: `resolveId`
|
|
624
|
+
* receives a specifier STRING and an importer path, never an AST, so it
|
|
625
|
+
* cannot see for itself whether an import was type-only. The information
|
|
626
|
+
* exists exactly once in the pipeline — in the module's PRE-esbuild
|
|
627
|
+
* TypeScript source — so it is captured there and carried forward.
|
|
628
|
+
*
|
|
629
|
+
* Three routes were available; this is why the other two lost:
|
|
630
|
+
*
|
|
631
|
+
* - Recognizing the POST-esbuild shape (`import {} from "P"`) from inside
|
|
632
|
+
* `resolveId`. Rejected as unsound, not merely awkward: esbuild emits the
|
|
633
|
+
* identical `import {} from "P"` for a genuine bare side-effect import
|
|
634
|
+
* under some settings, so the shape cannot separate "erased type import"
|
|
635
|
+
* from "the author asked for this package's side effects". That is a
|
|
636
|
+
* false NEGATIVE in a security fence — the one direction of error this
|
|
637
|
+
* gate must never make — and it would additionally depend on
|
|
638
|
+
* `getModuleInfo(importer).code` being populated during a dependency's
|
|
639
|
+
* `resolveId`, which is undocumented Rollup ordering.
|
|
640
|
+
* - Moving rule 2 wholesale into a `transform` pass. Rejected as too wide
|
|
641
|
+
* for this change: `transform` never runs for entry, virtual or
|
|
642
|
+
* externalized importers, so rule 2 would silently stop judging edges it
|
|
643
|
+
* judges today, and the import-chain machinery (`importerOf`) is built
|
|
644
|
+
* out of `resolveId`'s importer argument. Gate A is a `resolveId` fence
|
|
645
|
+
* by identity; only the type-only QUESTION moves, never the refusal.
|
|
646
|
+
* - Reusing `projection.ts`'s existing parse (`projectModule`). Rejected as
|
|
647
|
+
* a coupling, though it is the right TECHNIQUE and is copied here:
|
|
648
|
+
* projection parses only `*.page.tsx`/`layout`/`root`, records binding
|
|
649
|
+
* READERS rather than import KINDS, and rewrites code. Gate A must work
|
|
650
|
+
* standalone — every case in `gate-a-resolve.spec.ts` runs
|
|
651
|
+
* `gateAResolve()` with no projection in the array — so it cannot depend
|
|
652
|
+
* on another plugin having run.
|
|
653
|
+
*
|
|
654
|
+
* A module absent from this map (unparseable, never transformed, not JS/TS)
|
|
655
|
+
* yields no `"type"` answer, so rule 2 refuses exactly as it does today.
|
|
656
|
+
* Missing information fails CLOSED.
|
|
657
|
+
*/
|
|
658
|
+
const importKindsByModule = /* @__PURE__ */ new Map();
|
|
659
|
+
function recordImportKinds(code, id) {
|
|
660
|
+
const key = moduleKey(id);
|
|
661
|
+
if (key.includes("\0")) return;
|
|
662
|
+
if (!PARSEABLE_MODULE_EXTENSIONS.test(key)) return;
|
|
663
|
+
if (!GOVERNED_SCOPE_MENTION.test(code)) return;
|
|
664
|
+
const kinds = classifyImportKinds(code, key);
|
|
665
|
+
if (!kinds) return;
|
|
666
|
+
const existing = importKindsByModule.get(key);
|
|
667
|
+
if (!existing) {
|
|
668
|
+
importKindsByModule.set(key, kinds);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
for (const [source, kind] of kinds) if (kind === "value" || !existing.has(source)) existing.set(source, kind);
|
|
672
|
+
}
|
|
673
|
+
function isTypeOnlyEdge(source, importer) {
|
|
674
|
+
if (!importer) return false;
|
|
675
|
+
return importKindsByModule.get(moduleKey(importer))?.get(source) === "type";
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* How many `resolve.alias` entries this build declares, captured from the
|
|
679
|
+
* RESOLVED Vite config (`configResolved`) rather than guessed.
|
|
680
|
+
*
|
|
681
|
+
* Purely diagnostic, and it is the one thing the alias table can tell Gate A
|
|
682
|
+
* that the resolver cannot. The gate does not match aliases itself — see the
|
|
683
|
+
* note on the alias pass in `resolveId` for why asking Vite's own resolver
|
|
684
|
+
* beats re-deriving `@rollup/plugin-alias`'s find/replacement semantics. But
|
|
685
|
+
* when a specifier resolves to NOTHING, the count is the difference between
|
|
686
|
+
* "a dependency is missing" and "this build never installed the alias table
|
|
687
|
+
* its own tsconfig `paths` describe" — which is exactly the v5 production
|
|
688
|
+
* client build's state, and the actionable half of that warning.
|
|
689
|
+
*
|
|
690
|
+
* `undefined` when `configResolved` never fired (plain Rollup, or a unit
|
|
691
|
+
* harness), which the message reports as unknown rather than as zero.
|
|
692
|
+
*/
|
|
693
|
+
let declaredAliasCount;
|
|
694
|
+
function buildChain(importer) {
|
|
695
|
+
const chain = [];
|
|
696
|
+
let current = importer;
|
|
697
|
+
const seen = /* @__PURE__ */ new Set();
|
|
698
|
+
while (current && !seen.has(current)) {
|
|
699
|
+
seen.add(current);
|
|
700
|
+
chain.unshift(displayName(current));
|
|
701
|
+
current = importerOf.get(current);
|
|
702
|
+
}
|
|
703
|
+
return chain;
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
name: "warlock:gate-a-resolve",
|
|
707
|
+
enforce: "pre",
|
|
708
|
+
/** Records the alias-table size; see `declaredAliasCount`. Judges nothing. */
|
|
709
|
+
configResolved(config) {
|
|
710
|
+
const alias = config.resolve?.alias;
|
|
711
|
+
if (Array.isArray(alias)) declaredAliasCount = alias.length;
|
|
712
|
+
else if (alias && typeof alias === "object") declaredAliasCount = Object.keys(alias).length;
|
|
713
|
+
else declaredAliasCount = 0;
|
|
714
|
+
},
|
|
715
|
+
/**
|
|
716
|
+
* The config-level half of rule 1 — see `externalizedBuiltins` for why a
|
|
717
|
+
* `resolveId` fence cannot cover it on its own. Runs once per build, before
|
|
718
|
+
* the first module is loaded, so the refusal lands before anything has been
|
|
719
|
+
* bundled.
|
|
720
|
+
*/
|
|
721
|
+
buildStart(inputOptions) {
|
|
722
|
+
const externalized = externalizedBuiltins(inputOptions.external);
|
|
723
|
+
if (externalized.length === 0) return;
|
|
724
|
+
const shown = externalized.slice(0, 5).join(", ");
|
|
725
|
+
const rest = externalized.length > 5 ? `, and ${externalized.length - 5} more` : "";
|
|
726
|
+
this.error([
|
|
727
|
+
`Gate A refused this build: its Rollup "external" config would let Node builtins through the fence.`,
|
|
728
|
+
``,
|
|
729
|
+
`Externalized builtins: ${shown}${rest}`,
|
|
730
|
+
`Cause: Rollup consults "external" BEFORE any resolveId hook, so an externalized builtin is never offered to Gate A and is emitted into the browser chunk verbatim (e.g. import { readFileSync } from "node:fs"). No import of one has to exist yet — the configuration alone has already disabled rule 1.`,
|
|
731
|
+
`Fix: remove the Node builtins from build.rollupOptions.external (keeping any non-builtin entries), and move the code that needs them into a *.server.ts file, a server/ folder, a loader, or a controller. If this build targets Node rather than the browser, it should not be running Gate A at all.`
|
|
732
|
+
].join("\n"));
|
|
733
|
+
},
|
|
734
|
+
/**
|
|
735
|
+
* Records import kinds; never rewrites a byte (`null` return). `enforce:
|
|
736
|
+
* "pre"` is what makes this correct as well as early — it puts this hook
|
|
737
|
+
* ahead of Vite's own `vite:esbuild` TypeScript transform, so `code` here
|
|
738
|
+
* is the ORIGINAL source with `type` modifiers still on it. After esbuild
|
|
739
|
+
* the distinction no longer exists in the text.
|
|
740
|
+
*
|
|
741
|
+
* Runs before `resolveId` fires for any of this module's own specifiers:
|
|
742
|
+
* Rollup must parse a module's post-transform source to discover what to
|
|
743
|
+
* resolve next. That ordering is an OBSERVED Rollup behaviour, pinned by
|
|
744
|
+
* `index.spec.ts`'s "D.3 hook ordering pin" and documented at length in
|
|
745
|
+
* `index.ts`. If it ever inverts, this map is simply empty when
|
|
746
|
+
* `resolveId` reads it and rule 2 goes back to refusing type-only imports
|
|
747
|
+
* — a loud false positive, never a silent leak.
|
|
748
|
+
*/
|
|
749
|
+
transform(code, id) {
|
|
750
|
+
recordImportKinds(code, id);
|
|
751
|
+
return null;
|
|
752
|
+
},
|
|
753
|
+
/**
|
|
754
|
+
* The erased stand-in a type-only edge into a server-only package resolves
|
|
755
|
+
* to. It must exist rather than the import simply being permitted, and the
|
|
756
|
+
* fixture measures why: `import { type Request } from "@warlock.js/core"`
|
|
757
|
+
* under `verbatimModuleSyntax` is emitted as `import {} from
|
|
758
|
+
* "@warlock.js/core"`, and Rollup honours that as a side-effect edge — the
|
|
759
|
+
* package's module-level code lands in the client chunk even though not one
|
|
760
|
+
* binding is read from it. Permitting the import would therefore trade a
|
|
761
|
+
* false positive for the exact silent leak the `server` marker exists to
|
|
762
|
+
* close. Resolving to an empty module gives both: a clean build, and
|
|
763
|
+
* nothing of the server package in the browser.
|
|
764
|
+
*/
|
|
765
|
+
load(id) {
|
|
766
|
+
return id === TYPE_ONLY_ERASED_ID ? "export {};" : null;
|
|
767
|
+
},
|
|
768
|
+
async resolveId(source, importer, resolveOptions) {
|
|
769
|
+
const isRelativeOrAbsolute = source.startsWith(".") || path.isAbsolute(source);
|
|
770
|
+
const judgedPath = completeLocalModulePath(isRelativeOrAbsolute && importer ? path.resolve(path.dirname(importer), source) : source);
|
|
771
|
+
if (source === TYPE_ONLY_ERASED_ID) return source;
|
|
772
|
+
const typeOnly = isTypeOnlyEdge(source, importer);
|
|
773
|
+
const violation = ruleViolation(source, judgedPath, importer, environmentOf, appRoot, typeOnly);
|
|
774
|
+
if (violation) {
|
|
775
|
+
const chain = [...buildChain(importer), source].join(" → ");
|
|
776
|
+
this.error([
|
|
777
|
+
`Gate A refused an import: forbidden dependency reaches the client bundle.`,
|
|
778
|
+
``,
|
|
779
|
+
`Import chain: ${chain}`,
|
|
780
|
+
`File: ${importer ?? source}`,
|
|
781
|
+
`Cause: ${violation.cause}`,
|
|
782
|
+
`Fix: ${violation.fix}`
|
|
783
|
+
].join("\n"));
|
|
784
|
+
}
|
|
785
|
+
if (typeOnly && isServerMarkedGovernedPackage(source, importer, environmentOf, appRoot)) return TYPE_ONLY_ERASED_ID;
|
|
786
|
+
const resolved = await this.resolve(source, importer, {
|
|
787
|
+
...resolveOptions,
|
|
788
|
+
skipSelf: true
|
|
789
|
+
});
|
|
790
|
+
if (resolved && importer) importerOf.set(resolved.id, importer);
|
|
791
|
+
const isBareSpecifier = !isRelativeOrAbsolute && !source.includes("\0");
|
|
792
|
+
if (isBareSpecifier && resolved && !resolved.external) {
|
|
793
|
+
const aliasPath = completeLocalModulePath(moduleKey(resolved.id));
|
|
794
|
+
if (isAppSourcePath(aliasPath, appRoot)) {
|
|
795
|
+
const aliasViolation = ruleViolation(source, aliasPath, importer, environmentOf, appRoot, typeOnly);
|
|
796
|
+
if (aliasViolation) this.warn({
|
|
797
|
+
message: [
|
|
798
|
+
`Gate A WOULD refuse this import — reported as a WARNING, not enforced yet.`,
|
|
799
|
+
``,
|
|
800
|
+
`Import chain: ${[...buildChain(importer), source].join(" → ")}`,
|
|
801
|
+
`File: ${importer ?? source}`,
|
|
802
|
+
`Specifier: "${source}"`,
|
|
803
|
+
`Resolves to: ${aliasPath}`,
|
|
804
|
+
`Cause: ${aliasViolation.cause}`,
|
|
805
|
+
`Fix: ${aliasViolation.fix}`,
|
|
806
|
+
`Why only a warning: this specifier is written in ALIAS form. The identical import spelled relatively or absolutely is REFUSED today — the spelling was the only difference — and rules 3 and 4 have only just started judging this form. Every line like this is either an import to fix or a rule to correct; when there are none left, this becomes a refusal.`
|
|
807
|
+
].join("\n"),
|
|
808
|
+
id: importer
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (isBareSpecifier && !resolved) {
|
|
813
|
+
const aliasTable = declaredAliasCount === void 0 ? `this build's resolve.alias was never reported to the gate` : `this build's resolve.alias declares ${declaredAliasCount} ${declaredAliasCount === 1 ? "entry" : "entries"}`;
|
|
814
|
+
this.warn({
|
|
815
|
+
message: [
|
|
816
|
+
`Gate A could not judge this import — and it is going into the browser chunk unjudged.`,
|
|
817
|
+
``,
|
|
818
|
+
`Import chain: ${[...buildChain(importer), source].join(" → ")}`,
|
|
819
|
+
`File: ${importer ?? source}`,
|
|
820
|
+
`Specifier: "${source}"`,
|
|
821
|
+
`Cause: nothing in this build resolves "${source}", so Rollup treats it as an external dependency and writes the import statement into the client chunk verbatim. Rules 3 and 4 judge a FILE; with no resolution there is no file, so this import crossed the fence unexamined rather than being allowed by it (${aliasTable}).`,
|
|
822
|
+
`Fix: if this is an app-convention alias specifier (a tsconfig "paths" entry such as app/* or web/*), give the CLIENT build the matching resolve.alias entry the dev server already installs, so the specifier resolves to a real file and Gate A can judge it. Otherwise install the missing package, or write the import relatively.`
|
|
823
|
+
].join("\n"),
|
|
824
|
+
id: importer
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return resolved ?? null;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
//#endregion
|
|
833
|
+
export { createEnvironmentClassifier, gateAResolve };
|
|
834
|
+
//# sourceMappingURL=gate-a-resolve.mjs.map
|