@noverax/courier-build 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +289 -2
- package/lib/config/index.d.ts +420 -0
- package/lib/config/index.js +66 -0
- package/lib/config-file-of-Zkwyi9zf.js +28 -0
- package/lib/config-in-page-message-DLd3f0Jd.js +20 -0
- package/lib/courier-routes-options-BYF7Sfdf.d.cts +13 -0
- package/lib/courier-routes-options-BYF7Sfdf.d.ts +13 -0
- package/lib/engine/worker.d.ts +1 -0
- package/lib/engine/worker.js +551 -0
- package/lib/errno-code-CNx_E80R.js +32 -0
- package/lib/esbuild/index.d.ts +58 -0
- package/lib/esbuild/index.js +191 -0
- package/lib/index-_VUU5N5v.d.ts +134 -0
- package/lib/index.d.ts +101 -0
- package/lib/index.js +4 -0
- package/lib/jest/routes-thread.d.ts +1 -0
- package/lib/jest/routes-thread.js +46 -0
- package/lib/jest/transformer.cjs +121 -0
- package/lib/jest/transformer.d.cts +39 -0
- package/lib/load-config-BeedbpkN.js +1419 -0
- package/lib/metro/index.cjs +25 -0
- package/lib/metro/index.d.cts +55 -0
- package/lib/metro/index.d.ts +67 -0
- package/lib/metro/index.js +105 -0
- package/lib/package-file-CVAKY_Nu.js +39 -0
- package/lib/rollup/index.d.ts +51 -0
- package/lib/rollup/index.js +95 -0
- package/lib/routes-engine-BfwrCuNL.d.ts +117 -0
- package/lib/routes-file-DcpgxYb1.js +206 -0
- package/lib/routes-loader-context-40OKDrQp.d.ts +26 -0
- package/lib/routes-module-code-DO3aII6v.js +402 -0
- package/lib/routes-module-id-CnUjQjYF.js +12 -0
- package/lib/routes-read-error-DQhKIE2F.js +30 -0
- package/lib/routes-rule-CjzX6NS5.js +108 -0
- package/lib/rsbuild/index.d.ts +41 -0
- package/lib/rsbuild/index.js +37 -0
- package/lib/rspack/index.d.ts +3 -0
- package/lib/rspack/index.js +1 -0
- package/lib/shared-engine-CCu7vP_0.js +28 -0
- package/lib/vite/index.d.ts +33 -0
- package/lib/vite/index.js +346 -0
- package/lib/webpack/index.d.ts +4 -0
- package/lib/webpack/index.js +3 -0
- package/lib/webpack/loader.cjs +31 -0
- package/lib/webpack/loader.d.cts +40 -0
- package/lib/webpack/run-routes-loader.d.ts +22 -0
- package/lib/webpack/run-routes-loader.js +103 -0
- package/lib/webpack-B0v9i8lk.js +42 -0
- package/lib/worker-messages-BT2vvNQJ.js +35 -0
- package/package.json +209 -4
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
import { n as isRecord, t as errnoCode } from "./errno-code-CNx_E80R.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
4
|
+
import { closeSync, constants, existsSync, fstatSync, futimesSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CourierError, createLogger } from "@noverax/courier";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { parseJSON5 } from "confbox";
|
|
9
|
+
// src/config/config-candidates.ts
|
|
10
|
+
/** The file names a Courier config may have, in the order they are looked for */
|
|
11
|
+
const CONFIG_BASENAMES = [
|
|
12
|
+
"courier.config.ts",
|
|
13
|
+
"courier.config.mts",
|
|
14
|
+
"courier.config.cts",
|
|
15
|
+
"courier.config.js",
|
|
16
|
+
"courier.config.mjs",
|
|
17
|
+
"courier.config.cjs"
|
|
18
|
+
];
|
|
19
|
+
const PROJECT_TOP_MARKERS = [
|
|
20
|
+
".git",
|
|
21
|
+
".hg",
|
|
22
|
+
".jj",
|
|
23
|
+
"pnpm-workspace.yaml"
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Every path a config is looked for at from a folder, in the order it is
|
|
27
|
+
* looked for: each of {@link CONFIG_BASENAMES} in the folder, then in each
|
|
28
|
+
* folder above it, up to the top of the project — the first folder that
|
|
29
|
+
* holds its repository (`.git`, `.hg`, `.jj`) or is the root of its
|
|
30
|
+
* workspace (`pnpm-workspace.yaml`, or a `package.json` that declares
|
|
31
|
+
* `workspaces`) — or, with no such folder, the root of the file system.
|
|
32
|
+
*
|
|
33
|
+
* @param startDir - The folder to look in first
|
|
34
|
+
* @returns The paths, absolute, whether there or not
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* configCandidates("/repo/apps/web"); // → ["/repo/apps/web/courier.config.ts", …, "/repo/courier.config.cjs"]
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
function configCandidates(startDir) {
|
|
41
|
+
const candidates = [];
|
|
42
|
+
let dir = resolve(startDir);
|
|
43
|
+
while (true) {
|
|
44
|
+
for (const basename of CONFIG_BASENAMES) candidates.push(join(dir, basename));
|
|
45
|
+
const parent = dirname(dir);
|
|
46
|
+
if (parent === dir || isProjectTop(dir)) return candidates;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function isProjectTop(dir) {
|
|
51
|
+
return PROJECT_TOP_MARKERS.some((marker) => existsSync(join(dir, marker))) || declaresWorkspaces(join(dir, "package.json"));
|
|
52
|
+
}
|
|
53
|
+
function declaresWorkspaces(manifest) {
|
|
54
|
+
if (!existsSync(manifest)) return false;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
57
|
+
return isRecord(parsed) && parsed.workspaces !== void 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// src/config/installed-packages.ts
|
|
63
|
+
/**
|
|
64
|
+
* Which packages a project has installed, for the path aliases to stop at.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* The packages installed where `projectRoot` resolves them, each looked up
|
|
68
|
+
* once.
|
|
69
|
+
*
|
|
70
|
+
* @param projectRoot - Directory holding the project's `tsconfig.json`
|
|
71
|
+
* @returns The lookup
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* installedPackages("/app").has("@acme/ui"); // true once it is in node_modules
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
function installedPackages(projectRoot) {
|
|
78
|
+
const require = createRequire(resolve(projectRoot, "tsconfig.json"));
|
|
79
|
+
const known = /* @__PURE__ */ new Map();
|
|
80
|
+
return { has(name) {
|
|
81
|
+
let installed = known.get(name);
|
|
82
|
+
if (installed === void 0) {
|
|
83
|
+
installed = resolves(require, name);
|
|
84
|
+
known.set(name, installed);
|
|
85
|
+
}
|
|
86
|
+
return installed;
|
|
87
|
+
} };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The package a specifier or an alias prefix names: `@scope/name`, or its
|
|
91
|
+
* first segment; `null` when it names none, as `@scope/` does.
|
|
92
|
+
*
|
|
93
|
+
* @param specifier - A specifier, or an alias's text before its wildcard
|
|
94
|
+
* @returns The package's name, or `null`
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* packageNameOf("@acme/ui/button"); // "@acme/ui"
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
function packageNameOf(specifier) {
|
|
101
|
+
const segments = specifier.split("/");
|
|
102
|
+
const wanted = specifier.startsWith("@") ? 2 : 1;
|
|
103
|
+
if (segments.length < wanted) return null;
|
|
104
|
+
const name = segments.slice(0, wanted);
|
|
105
|
+
return name.every((segment) => segment.length > 0) ? name.join("/") : null;
|
|
106
|
+
}
|
|
107
|
+
function resolves(require, name) {
|
|
108
|
+
try {
|
|
109
|
+
require.resolve(name);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return errnoCode(error) !== "MODULE_NOT_FOUND";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// src/config/scope-alias.ts
|
|
116
|
+
/**
|
|
117
|
+
* An alias for a whole scope, `@acme/*`, in the shape the module loader takes.
|
|
118
|
+
*/
|
|
119
|
+
const log$7 = createLogger("Config");
|
|
120
|
+
const MODULE_EXTENSION = /\.(d\.ts|[cm]?[jt]sx?|json)$/;
|
|
121
|
+
/**
|
|
122
|
+
* The scope's name, `@acme`, when `prefix` is an alias's text before the
|
|
123
|
+
* wildcard of a pattern for a whole scope, `@acme/*`.
|
|
124
|
+
*
|
|
125
|
+
* @param prefix - An alias's text before its wildcard
|
|
126
|
+
* @returns The scope, or `undefined` for any other prefix
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* scopeOf("@acme/"); // "@acme"
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
function scopeOf(prefix) {
|
|
133
|
+
return /^(@[^/]+)\/$/.exec(prefix)?.[1];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* One loader alias per name a scope alias leads to, leaving out the names of
|
|
137
|
+
* installed packages. The loader follows an alias whatever it finds there,
|
|
138
|
+
* where TypeScript takes an installed package past it: `@acme/*` to
|
|
139
|
+
* `./src/*` keeps `@acme/utils` on `src/utils` while `@acme/ui`, installed,
|
|
140
|
+
* loads the build it ships.
|
|
141
|
+
*
|
|
142
|
+
* @param scope - The scope, `@acme`
|
|
143
|
+
* @param directory - The directory the alias's `*` stands in, absolute
|
|
144
|
+
* @param installed - The packages the project installed
|
|
145
|
+
* @returns Prefix → absolute target, for each name the directory holds
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* scopeAliases("@acme", "/app/src", installed); // { "@acme/utils": "/app/src/utils" }
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function scopeAliases(scope, directory, installed) {
|
|
152
|
+
const alias = {};
|
|
153
|
+
for (const entry of entriesOf(directory)) {
|
|
154
|
+
const name = entry.replace(MODULE_EXTENSION, "");
|
|
155
|
+
if (!installed.has(`${scope}/${name}`)) alias[`${scope}/${name}`] = join(directory, name);
|
|
156
|
+
}
|
|
157
|
+
return alias;
|
|
158
|
+
}
|
|
159
|
+
function entriesOf(directory) {
|
|
160
|
+
try {
|
|
161
|
+
return readdirSync(directory);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const code = errnoCode(error);
|
|
164
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") log$7.warn(`Could not list ${directory} for a path alias; the alias leads nowhere`, error);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// src/config/read-tsconfig-file.ts
|
|
169
|
+
const log$6 = createLogger("Config");
|
|
170
|
+
function isTextList(value) {
|
|
171
|
+
if (!Array.isArray(value)) return false;
|
|
172
|
+
return value.every((item) => typeof item === "string");
|
|
173
|
+
}
|
|
174
|
+
function readText(path) {
|
|
175
|
+
try {
|
|
176
|
+
if (!statSync(path).isFile()) {
|
|
177
|
+
log$6.warn(`${path} is not a regular file, so its path aliases are ignored`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
return readFileSync(path, "utf-8");
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log$6.warn(`Could not read ${path}, so its path aliases are ignored`, err);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function extendsOf(value, path) {
|
|
187
|
+
if (value === void 0 || value === null) return [];
|
|
188
|
+
if (typeof value === "string") return [value];
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
log$6.warn(`${path}: "extends" is neither a path nor a list of paths, so it is not followed`);
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const entries = value;
|
|
194
|
+
const paths = entries.filter((entry) => typeof entry === "string");
|
|
195
|
+
if (paths.length < entries.length) log$6.warn(`${path}: "extends" lists something other than a path, which is skipped`);
|
|
196
|
+
return paths;
|
|
197
|
+
}
|
|
198
|
+
function pathsOf(value, path) {
|
|
199
|
+
if (value === void 0) return void 0;
|
|
200
|
+
if (!isRecord(value)) {
|
|
201
|
+
log$6.warn(`${path}: "paths" is not an object, so its path aliases are ignored`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const kept = [];
|
|
205
|
+
for (const [pattern, targets] of Object.entries(value)) if (isTextList(targets)) kept.push([pattern, targets]);
|
|
206
|
+
else log$6.warn(`${path}: "paths" maps "${pattern}" to something other than a list of paths, so it is ignored`);
|
|
207
|
+
return Object.fromEntries(kept);
|
|
208
|
+
}
|
|
209
|
+
function optionsOf(value, path) {
|
|
210
|
+
if (value === void 0) return {};
|
|
211
|
+
if (!isRecord(value)) {
|
|
212
|
+
log$6.warn(`${path}: "compilerOptions" is not an object, so its path aliases are ignored`);
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
const { baseUrl } = value;
|
|
216
|
+
const paths = pathsOf(value.paths, path);
|
|
217
|
+
const base = typeof baseUrl === "string" ? baseUrl : void 0;
|
|
218
|
+
if (baseUrl !== void 0 && base === void 0) log$6.warn(`${path}: "baseUrl" is not a path, so its aliases resolve from its own directory`);
|
|
219
|
+
return {
|
|
220
|
+
...base !== void 0 && { baseUrl: base },
|
|
221
|
+
...paths && { paths }
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Read the fields of a tsconfig that its path mapping depends on. A field
|
|
226
|
+
* of another shape than TypeScript takes is left out with a warning, as
|
|
227
|
+
* TypeScript reports it, rather than failing the load of the config that
|
|
228
|
+
* reads the mapping.
|
|
229
|
+
*
|
|
230
|
+
* @param path - The tsconfig
|
|
231
|
+
* @returns What it declares; `null` when it cannot be read or parsed
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* readTsconfigFile("/app/tsconfig.json");
|
|
235
|
+
* // → { extends: ["./tsconfig.base.json"], paths: { "@/*": ["./src/*"] } }
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
function readTsconfigFile(path) {
|
|
239
|
+
const text = readText(path);
|
|
240
|
+
if (text === null) return null;
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = parseJSON5(text);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log$6.warn(`Could not parse ${path}, so its path aliases are ignored`, err);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (!isRecord(parsed)) {
|
|
249
|
+
log$6.warn(`${path} does not hold an object, so its path aliases are ignored`);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
extends: extendsOf(parsed.extends, path),
|
|
254
|
+
...optionsOf(parsed.compilerOptions, path)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// src/config/tsconfig-paths.ts
|
|
258
|
+
/**
|
|
259
|
+
* Read a project's `compilerOptions.paths` without loading TypeScript.
|
|
260
|
+
*
|
|
261
|
+
* The compiler would do this for us, but its API is exactly what a config
|
|
262
|
+
* reader must not depend on: it is an optional peer a JavaScript-only project
|
|
263
|
+
* is entitled not to have, and TypeScript 7 publishes no parser under the
|
|
264
|
+
* package root at all. Reading the file directly costs one JSONC parse.
|
|
265
|
+
*
|
|
266
|
+
* `paths` is not merged down an `extends` chain — TypeScript replaces the map
|
|
267
|
+
* wholesale — so the nearest config that declares one wins, and its targets
|
|
268
|
+
* resolve against its own `baseUrl`, or its own directory when it has none.
|
|
269
|
+
*
|
|
270
|
+
* Parsing is JSON5 rather than strict JSONC: a tsconfig is hand-edited and
|
|
271
|
+
* routinely carries comments and trailing commas, both of which TypeScript
|
|
272
|
+
* accepts. Refusing them would drop a real project's aliases without a word.
|
|
273
|
+
*/
|
|
274
|
+
const log$5 = createLogger("Config");
|
|
275
|
+
/**
|
|
276
|
+
* Read the path mapping that applies to files in `directory`: the one of the
|
|
277
|
+
* nearest `tsconfig.json` at or above it, as TypeScript finds it, so a config
|
|
278
|
+
* file kept below the project root — `deploy/server.config.ts` — keeps every
|
|
279
|
+
* alias the project declares.
|
|
280
|
+
*
|
|
281
|
+
* @param directory - Directory of the file whose imports are resolved
|
|
282
|
+
* @returns The mapping, or `null` when no tsconfig declares one
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```ts
|
|
286
|
+
* const mapping = readTsconfigPaths("/app/deploy");
|
|
287
|
+
* // → { baseUrl: "/app", paths: { "@/*": ["./src/*"] } }
|
|
288
|
+
* ```
|
|
289
|
+
*/
|
|
290
|
+
function readTsconfigPaths(directory) {
|
|
291
|
+
const tsconfig = nearestTsconfig(resolve(directory));
|
|
292
|
+
return tsconfig ? findPaths(tsconfig, /* @__PURE__ */ new Set()) : null;
|
|
293
|
+
}
|
|
294
|
+
function nearestTsconfig(directory) {
|
|
295
|
+
for (let dir = directory;; dir = dirname(dir)) {
|
|
296
|
+
const candidate = resolve(dir, "tsconfig.json");
|
|
297
|
+
if (existsSync(candidate)) return candidate;
|
|
298
|
+
if (dirname(dir) === dir) return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Walk the `extends` chain depth-first, stopping at the first declared map. */
|
|
302
|
+
function findPaths(file, visited) {
|
|
303
|
+
const path = resolve(file);
|
|
304
|
+
if (visited.has(path)) return null;
|
|
305
|
+
visited.add(path);
|
|
306
|
+
const config = readTsconfigFile(path);
|
|
307
|
+
if (!config) return null;
|
|
308
|
+
if (config.paths) return {
|
|
309
|
+
baseUrl: resolve(dirname(path), config.baseUrl ?? "."),
|
|
310
|
+
paths: config.paths
|
|
311
|
+
};
|
|
312
|
+
for (const specifier of [...config.extends].reverse()) {
|
|
313
|
+
const parent = resolveExtends(specifier, dirname(path));
|
|
314
|
+
if (!parent) {
|
|
315
|
+
log$5.warn(`${path} extends "${specifier}", which cannot be found, so its path aliases are not read`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const found = findPaths(parent, visited);
|
|
319
|
+
if (found) return found;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Resolve an `extends` specifier: a path, a directory holding a
|
|
325
|
+
* `tsconfig.json`, or an installed package that ships one.
|
|
326
|
+
*/
|
|
327
|
+
function resolveExtends(specifier, fromDir) {
|
|
328
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) {
|
|
329
|
+
const candidate = resolve(fromDir, specifier);
|
|
330
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
331
|
+
const nested = resolve(candidate, "tsconfig.json");
|
|
332
|
+
return existsSync(nested) ? nested : null;
|
|
333
|
+
}
|
|
334
|
+
if (existsSync(candidate)) return candidate;
|
|
335
|
+
const suffixed = `${candidate}.json`;
|
|
336
|
+
return existsSync(suffixed) ? suffixed : null;
|
|
337
|
+
}
|
|
338
|
+
const requireFrom = createRequire(resolve(fromDir, "tsconfig.json"));
|
|
339
|
+
for (const target of [specifier, `${specifier}/tsconfig.json`]) {
|
|
340
|
+
const found = resolveFrom(requireFrom, target);
|
|
341
|
+
if (found) return found;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function resolveFrom(requireFrom, target) {
|
|
346
|
+
try {
|
|
347
|
+
return requireFrom.resolve(target);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log$5.debug(`"${target}" does not resolve to a tsconfig`, err);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// src/config/path-aliases.ts
|
|
354
|
+
/**
|
|
355
|
+
* Turn a project's tsconfig `paths` into the two shapes that reading a config
|
|
356
|
+
* needs: the alias map the module loader takes, and a lookup the dependency
|
|
357
|
+
* walker uses to follow an aliased import.
|
|
358
|
+
*
|
|
359
|
+
* An alias that shadows an installed package is left out of both. Such an
|
|
360
|
+
* entry redirects a dependency to its sources for the bundler's benefit —
|
|
361
|
+
* `@acme/ui` to `../ui/src`, or a whole scope, `@acme/*` — and following it
|
|
362
|
+
* would load a library's source instead of the build it ships, then treat
|
|
363
|
+
* every file inside it as something the config depends on. A dependency
|
|
364
|
+
* changes when it is reinstalled, not while the developer is editing; the
|
|
365
|
+
* graph stops at the package boundary. A catch-all `*` covers every package,
|
|
366
|
+
* so it is left out too: the loader, which cannot fall back to the package as
|
|
367
|
+
* TypeScript does, would put its target before every path it loads.
|
|
368
|
+
*/
|
|
369
|
+
const EMPTY = {
|
|
370
|
+
loader: {},
|
|
371
|
+
candidatesFor: () => []
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* Read the path aliases that apply to `projectRoot`.
|
|
375
|
+
*
|
|
376
|
+
* @param projectRoot - Directory holding the project's `tsconfig.json`
|
|
377
|
+
* @returns The aliases, empty when the project declares none
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```ts
|
|
381
|
+
* const aliases = readPathAliases("/app");
|
|
382
|
+
* aliases.candidatesFor("@/lib/sources"); // → ["/app/src/lib/sources"]
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
function readPathAliases(projectRoot) {
|
|
386
|
+
const mapping = readTsconfigPaths(projectRoot);
|
|
387
|
+
if (!mapping) return EMPTY;
|
|
388
|
+
const installed = installedPackages(projectRoot);
|
|
389
|
+
const entries = toEntries(mapping).filter((entry) => !shadowsInstalled(entry, installed));
|
|
390
|
+
if (entries.length === 0) return EMPTY;
|
|
391
|
+
return {
|
|
392
|
+
loader: toLoaderAlias(entries, installed),
|
|
393
|
+
candidatesFor: (specifier) => candidatesOf(entries, specifier, installed)
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function shadowsInstalled(entry, installed) {
|
|
397
|
+
if (entry.prefix === "") return true;
|
|
398
|
+
if (entry.wildcard && scopeOf(entry.prefix) !== void 0) return false;
|
|
399
|
+
const name = packageNameOf(entry.prefix);
|
|
400
|
+
return name !== null && installed.has(name);
|
|
401
|
+
}
|
|
402
|
+
function toEntries({ baseUrl, paths }) {
|
|
403
|
+
const entries = [];
|
|
404
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
405
|
+
const star = pattern.indexOf("*");
|
|
406
|
+
entries.push(star === -1 ? {
|
|
407
|
+
prefix: pattern,
|
|
408
|
+
suffix: "",
|
|
409
|
+
wildcard: false,
|
|
410
|
+
base: baseUrl,
|
|
411
|
+
targets
|
|
412
|
+
} : {
|
|
413
|
+
prefix: pattern.slice(0, star),
|
|
414
|
+
suffix: pattern.slice(star + 1),
|
|
415
|
+
wildcard: true,
|
|
416
|
+
base: baseUrl,
|
|
417
|
+
targets
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return entries;
|
|
421
|
+
}
|
|
422
|
+
/** TypeScript picks the single match with the longest prefix before `*`. */
|
|
423
|
+
function candidatesOf(entries, specifier, installed) {
|
|
424
|
+
let best = null;
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
if (!matches(entry, specifier)) continue;
|
|
427
|
+
if (!best || entry.prefix.length > best.prefix.length) best = entry;
|
|
428
|
+
}
|
|
429
|
+
if (!best) return [];
|
|
430
|
+
const { base } = best;
|
|
431
|
+
if (!best.wildcard) return best.targets.map((target) => resolve(base, target));
|
|
432
|
+
const name = scopeOf(best.prefix) === void 0 ? null : packageNameOf(specifier);
|
|
433
|
+
if (name !== null && installed.has(name)) return [];
|
|
434
|
+
const matched = specifier.slice(best.prefix.length, specifier.length - best.suffix.length);
|
|
435
|
+
return best.targets.map((target) => resolve(base, withStarFilled(target, matched)));
|
|
436
|
+
}
|
|
437
|
+
function withStarFilled(target, matched) {
|
|
438
|
+
const star = target.indexOf("*");
|
|
439
|
+
return star === -1 ? target : `${target.slice(0, star)}${matched}${target.slice(star + 1)}`;
|
|
440
|
+
}
|
|
441
|
+
function matches(entry, specifier) {
|
|
442
|
+
if (!entry.wildcard) return specifier === entry.prefix;
|
|
443
|
+
return specifier.length >= entry.prefix.length + entry.suffix.length && specifier.startsWith(entry.prefix) && specifier.endsWith(entry.suffix);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Only a mapping that ends in `*` on both sides translates to a prefix alias;
|
|
447
|
+
* one with a suffix after the wildcard has no equivalent and stays behind. A
|
|
448
|
+
* scope's alias becomes one alias per name it leads to that is not installed.
|
|
449
|
+
*/
|
|
450
|
+
function toLoaderAlias(entries, installed) {
|
|
451
|
+
const alias = {};
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
const written = entry.targets[0];
|
|
454
|
+
if (!written) continue;
|
|
455
|
+
const target = resolve(entry.base, written);
|
|
456
|
+
if (!entry.wildcard) alias[entry.prefix] = target;
|
|
457
|
+
else if (entry.suffix === "" && target.endsWith("*")) {
|
|
458
|
+
const scope = scopeOf(entry.prefix);
|
|
459
|
+
if (scope === void 0) alias[entry.prefix] = target.slice(0, -1);
|
|
460
|
+
else Object.assign(alias, scopeAliases(scope, target.slice(0, -1), installed));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return alias;
|
|
464
|
+
}
|
|
465
|
+
// src/config/config-jiti.ts
|
|
466
|
+
let ownTransform;
|
|
467
|
+
function jitiOwnTransform(createJiti) {
|
|
468
|
+
if (ownTransform) return ownTransform;
|
|
469
|
+
const { transform } = createJiti(fileURLToPath(import.meta.url), {
|
|
470
|
+
fsCache: false,
|
|
471
|
+
moduleCache: false
|
|
472
|
+
}).options;
|
|
473
|
+
if (!transform) throw new Error("jiti created an instance without its own transform");
|
|
474
|
+
ownTransform = transform;
|
|
475
|
+
return transform;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The module loader for Courier's project files — `courier.config.*` and
|
|
479
|
+
* `courier.mocks.*` — with the options both need: the project's tsconfig path
|
|
480
|
+
* aliases, default-export interop, no module cache, and no native import
|
|
481
|
+
* tried before the loader's own.
|
|
482
|
+
*
|
|
483
|
+
* The module cache is off because this is also the reload path: the dev
|
|
484
|
+
* server loads the files again on every edit, and a cached module would hand
|
|
485
|
+
* back the file as it was before the user changed it. Every load therefore
|
|
486
|
+
* reads and evaluates every file again; a `transformCache` only spares the
|
|
487
|
+
* transform of a file whose bytes are unchanged.
|
|
488
|
+
*
|
|
489
|
+
* jiti's native attempt, which imports every module natively before falling
|
|
490
|
+
* back to its own loader, stays off too, whatever `JITI_TRY_NATIVE` says and
|
|
491
|
+
* under Bun, where it defaults to on: a module imported natively is the
|
|
492
|
+
* runtime's first evaluation of it for the life of the process, so an edit to
|
|
493
|
+
* it would never be seen, and the attempt comes before the module cache
|
|
494
|
+
* `evalModule` is handed, so two files evaluated over one cache would each
|
|
495
|
+
* evaluate a module they both import.
|
|
496
|
+
*
|
|
497
|
+
* jiti's own file cache stays off, with or without a `transformCache`:
|
|
498
|
+
* - it writes each entry in place, so concurrent writers can leave an entry
|
|
499
|
+
* torn between two transforms that its check still accepts;
|
|
500
|
+
* - an entry it cannot write makes the whole load throw `EACCES`;
|
|
501
|
+
* - unless the file's own directory has a `node_modules`, it falls back to
|
|
502
|
+
* `os.tmpdir()/jiti`, where another user of the machine can place an entry
|
|
503
|
+
* that is then run;
|
|
504
|
+
* - its key ignores `interopDefault` and the babel options, so a transform
|
|
505
|
+
* made under other options can be handed back;
|
|
506
|
+
* - nothing ever prunes it.
|
|
507
|
+
*
|
|
508
|
+
* jiti is loaded here rather than at the top of a module so a bundler that
|
|
509
|
+
* merely reaches this code does not pull a TypeScript transpiler into its
|
|
510
|
+
* graph; only a process that actually reads such a file pays for it.
|
|
511
|
+
*
|
|
512
|
+
* @param file - Absolute, symlink-resolved path of the file to load; modules
|
|
513
|
+
* and aliases resolve from its directory
|
|
514
|
+
* @param options - Whether to resolve through the project's path aliases
|
|
515
|
+
* (default: yes), and where to reuse transforms (default: nowhere)
|
|
516
|
+
* @returns A jiti instance for that file
|
|
517
|
+
* @example
|
|
518
|
+
* ```ts
|
|
519
|
+
* const transformCache = createTransformCache();
|
|
520
|
+
* const jiti = await createConfigJiti(configFile, { transformCache });
|
|
521
|
+
* const config = await jiti.import(configFile, { default: true });
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
async function createConfigJiti(file, { aliases = true, transformCache, onModule, runTransform } = {}) {
|
|
525
|
+
const { createJiti } = await import("jiti");
|
|
526
|
+
const options = {
|
|
527
|
+
alias: aliases ? readPathAliases(dirname(file)).loader : {},
|
|
528
|
+
interopDefault: true,
|
|
529
|
+
moduleCache: false,
|
|
530
|
+
fsCache: false,
|
|
531
|
+
tryNative: false
|
|
532
|
+
};
|
|
533
|
+
if (!transformCache && !onModule && !runTransform) return createJiti(file, options);
|
|
534
|
+
const compute = jitiOwnTransform(createJiti);
|
|
535
|
+
const cached = transformCache ? (transformOptions) => transformCache.transform(transformOptions, compute) : compute;
|
|
536
|
+
const transform = (given) => {
|
|
537
|
+
const transformOptions = given.filename === void 0 ? given : {
|
|
538
|
+
...given,
|
|
539
|
+
filename: normalize(given.filename)
|
|
540
|
+
};
|
|
541
|
+
if (onModule && transformOptions.filename !== void 0) onModule(transformOptions.filename);
|
|
542
|
+
return runTransform ? runTransform(() => cached(transformOptions)) : cached(transformOptions);
|
|
543
|
+
};
|
|
544
|
+
return createJiti(file, {
|
|
545
|
+
...options,
|
|
546
|
+
transform
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
// src/config/untrusted-config-error.ts
|
|
550
|
+
/**
|
|
551
|
+
* The config nearest a folder lies in a folder above it, and someone else
|
|
552
|
+
* could have put it there: it belongs to another user, or any user may
|
|
553
|
+
* write it. It was not run, as it would run with the rights of whoever
|
|
554
|
+
* builds or serves the project.
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* ```ts
|
|
558
|
+
* throw new UntrustedConfigError("/tmp/courier.config.ts", "/tmp/app", "it belongs to uid 1001");
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
var UntrustedConfigError = class extends CourierError {
|
|
562
|
+
/**
|
|
563
|
+
* @param configFile - The config that was not run, absolute
|
|
564
|
+
* @param folder - The folder it was looked for from
|
|
565
|
+
* @param reason - Why it is not trusted, worded to follow "and"
|
|
566
|
+
*/
|
|
567
|
+
constructor(configFile, folder, reason) {
|
|
568
|
+
super(`${configFile} was not run: it lies above ${folder}, and ${reason}. A config in a folder above the project runs only when it is yours and no one else may change it; move it into the project, or name the project's own config.`, {
|
|
569
|
+
code: "CONFIG_NOT_TRUSTED",
|
|
570
|
+
context: {
|
|
571
|
+
configFile,
|
|
572
|
+
folder
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
// src/config/assert-own-config.ts
|
|
578
|
+
const OTHERS_WRITE = 2;
|
|
579
|
+
/**
|
|
580
|
+
* Refuse a config found in a folder above the one it was looked for from,
|
|
581
|
+
* unless it belongs to this process's user or to the owner of that folder,
|
|
582
|
+
* and no user outside its group may write it. A folder many users share,
|
|
583
|
+
* such as `/tmp`, lets anyone put a config there, and it would run with the
|
|
584
|
+
* rights of whoever builds a project below it. Windows has no owner of that
|
|
585
|
+
* kind, so nothing is refused there.
|
|
586
|
+
*
|
|
587
|
+
* @param configFile - The config found, absolute
|
|
588
|
+
* @param folder - The folder it was looked for from, absolute
|
|
589
|
+
* @throws UntrustedConfigError when someone else could have put it there
|
|
590
|
+
* @example
|
|
591
|
+
* ```ts
|
|
592
|
+
* assertOwnConfig("/repo/courier.config.ts", "/repo/apps/web");
|
|
593
|
+
* ```
|
|
594
|
+
*/
|
|
595
|
+
function assertOwnConfig(configFile, folder) {
|
|
596
|
+
const uid = process.getuid?.();
|
|
597
|
+
if (uid === void 0) return;
|
|
598
|
+
const stats = statSync(configFile);
|
|
599
|
+
const folderOwner = statSync(folder, { throwIfNoEntry: false })?.uid ?? uid;
|
|
600
|
+
if (stats.uid !== uid && stats.uid !== folderOwner) throw new UntrustedConfigError(configFile, folder, `it belongs to uid ${stats.uid}, neither this user (${uid}) nor the owner of the folder (${folderOwner})`);
|
|
601
|
+
if ((stats.mode & OTHERS_WRITE) !== 0) throw new UntrustedConfigError(configFile, folder, "any user may write it");
|
|
602
|
+
}
|
|
603
|
+
// src/config/transform-entry-format.ts
|
|
604
|
+
/**
|
|
605
|
+
* How the first line of a transform cache entry is written.
|
|
606
|
+
*
|
|
607
|
+
* - `prefix`: how every entry starts, whatever the version of its format, so
|
|
608
|
+
* pruning can tell an entry from a file someone else put in the directory.
|
|
609
|
+
* - `version`: the version this code writes; an entry in any other version
|
|
610
|
+
* is ignored and replaced.
|
|
611
|
+
*/
|
|
612
|
+
const TRANSFORM_ENTRY_FORMAT = {
|
|
613
|
+
prefix: "// courier-transform ",
|
|
614
|
+
version: "2"
|
|
615
|
+
};
|
|
616
|
+
// src/config/encode-transform-entry.ts
|
|
617
|
+
/**
|
|
618
|
+
* The bytes of an entry holding `code`, the transform of the input whose
|
|
619
|
+
* digest is `digest`.
|
|
620
|
+
*
|
|
621
|
+
* The first line names the format's version, the input digest, and the byte
|
|
622
|
+
* length and sha256 of the UTF-8 body that follows it, so a reader can tell
|
|
623
|
+
* a complete entry from a truncated or altered one.
|
|
624
|
+
*
|
|
625
|
+
* @param digest - The input digest, as `transformDigest` computes it
|
|
626
|
+
* @param code - The transformed code; well-formed UTF-16, so UTF-8 keeps it exactly
|
|
627
|
+
* @returns The entry's bytes
|
|
628
|
+
* @example
|
|
629
|
+
* ```ts
|
|
630
|
+
* writeFileSync(path, encodeTransformEntry(digest, result.code));
|
|
631
|
+
* ```
|
|
632
|
+
*/
|
|
633
|
+
function encodeTransformEntry(digest, code) {
|
|
634
|
+
const { prefix, version } = TRANSFORM_ENTRY_FORMAT;
|
|
635
|
+
const body = Buffer.from(code, "utf8");
|
|
636
|
+
const bodySha256 = createHash("sha256").update(body).digest("hex");
|
|
637
|
+
const header = `${prefix}${version} ${digest} ${body.length} ${bodySha256}\n`;
|
|
638
|
+
return Buffer.concat([Buffer.from(header, "latin1"), body]);
|
|
639
|
+
}
|
|
640
|
+
// src/config/prune-transform-cache.ts
|
|
641
|
+
const log$4 = createLogger("Config");
|
|
642
|
+
const HOUR_MS = 36e5;
|
|
643
|
+
const ENTRY_MAX_AGE_MS = 720 * HOUR_MS;
|
|
644
|
+
const TEMPORARY_MAX_AGE_MS = HOUR_MS;
|
|
645
|
+
const READ_FLAGS$1 = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
646
|
+
const PREFIX = Buffer.from(TRANSFORM_ENTRY_FORMAT.prefix, "latin1");
|
|
647
|
+
function expiryOf(name) {
|
|
648
|
+
if (name.endsWith(".tmp")) return {
|
|
649
|
+
maxAgeMs: TEMPORARY_MAX_AGE_MS,
|
|
650
|
+
prefix: null
|
|
651
|
+
};
|
|
652
|
+
if (name.endsWith(".js")) return {
|
|
653
|
+
maxAgeMs: ENTRY_MAX_AGE_MS,
|
|
654
|
+
prefix: PREFIX
|
|
655
|
+
};
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
function isExpired(path, { maxAgeMs, prefix }, now) {
|
|
659
|
+
const fd = openSync(path, READ_FLAGS$1);
|
|
660
|
+
try {
|
|
661
|
+
const stats = fstatSync(fd);
|
|
662
|
+
if (!stats.isFile() || now - stats.mtimeMs <= maxAgeMs) return false;
|
|
663
|
+
if (prefix === null) return true;
|
|
664
|
+
const start = Buffer.alloc(prefix.length);
|
|
665
|
+
return readSync(fd, start, 0, prefix.length, 0) === prefix.length && start.equals(prefix);
|
|
666
|
+
} finally {
|
|
667
|
+
closeSync(fd);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Delete what a transform cache directory holds for nothing: entries not
|
|
672
|
+
* read for 30 days, and temporary files a writer abandoned more than an hour
|
|
673
|
+
* ago.
|
|
674
|
+
*
|
|
675
|
+
* Only regular files directly inside the directory are considered, and no
|
|
676
|
+
* link is followed: a link, a subdirectory, or a `.js` file that does not
|
|
677
|
+
* start like an entry is left alone, whatever its age. A file that cannot be
|
|
678
|
+
* judged or deleted, such as one another process deleted first, is logged at
|
|
679
|
+
* debug level and skipped.
|
|
680
|
+
*
|
|
681
|
+
* @param directory - The transform cache directory
|
|
682
|
+
* @param now - The current time in milliseconds (default: `Date.now()`)
|
|
683
|
+
* @example
|
|
684
|
+
* ```ts
|
|
685
|
+
* pruneTransformCache("/app/node_modules/.cache/courier/transform");
|
|
686
|
+
* ```
|
|
687
|
+
*/
|
|
688
|
+
function pruneTransformCache(directory, now = Date.now()) {
|
|
689
|
+
let entries;
|
|
690
|
+
try {
|
|
691
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
692
|
+
} catch (error) {
|
|
693
|
+
log$4.debug(`Transform cache: cannot list ${directory} to prune it`, error);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
for (const entry of entries) {
|
|
697
|
+
const expiry = entry.isFile() ? expiryOf(entry.name) : null;
|
|
698
|
+
if (expiry === null) continue;
|
|
699
|
+
const path = join(directory, entry.name);
|
|
700
|
+
try {
|
|
701
|
+
if (isExpired(path, expiry, now)) unlinkSync(path);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
log$4.debug(`Transform cache: cannot prune ${path}`, error);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// src/config/decode-transform-entry.ts
|
|
708
|
+
const HEADER = new RegExp(`^${TRANSFORM_ENTRY_FORMAT.prefix}${TRANSFORM_ENTRY_FORMAT.version} ([0-9a-f]{64}) (\\d{1,15}) ([0-9a-f]{64})$`);
|
|
709
|
+
const NEWLINE = 10;
|
|
710
|
+
const MAX_HEADER_BYTES = 256;
|
|
711
|
+
function sha256Hex(bytes) {
|
|
712
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Read an entry as the answer for the input whose digest is `digest`.
|
|
716
|
+
*
|
|
717
|
+
* @param bytes - The whole entry, as read from disk
|
|
718
|
+
* @param digest - The digest of the input being transformed
|
|
719
|
+
* @returns Its code when the header names that input and the body matches
|
|
720
|
+
* the header's length and sha256; otherwise why it is not a hit
|
|
721
|
+
* @example
|
|
722
|
+
* ```ts
|
|
723
|
+
* const entry = decodeTransformEntry(readFileSync(path), digest);
|
|
724
|
+
* if (entry.state === "hit") return entry.code;
|
|
725
|
+
* ```
|
|
726
|
+
*/
|
|
727
|
+
function decodeTransformEntry(bytes, digest) {
|
|
728
|
+
const end = bytes.subarray(0, MAX_HEADER_BYTES).indexOf(NEWLINE);
|
|
729
|
+
if (end === -1) return { state: "stale" };
|
|
730
|
+
const header = HEADER.exec(bytes.toString("latin1", 0, end));
|
|
731
|
+
if (header === null || header[1] !== digest) return { state: "stale" };
|
|
732
|
+
const body = bytes.subarray(end + 1);
|
|
733
|
+
if (body.length !== Number(header[2]) || sha256Hex(body) !== header[3]) return { state: "damaged" };
|
|
734
|
+
return {
|
|
735
|
+
state: "hit",
|
|
736
|
+
code: body.toString("utf8")
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
// src/config/why-untrusted.ts
|
|
740
|
+
const SHARED_WRITE = 18;
|
|
741
|
+
/**
|
|
742
|
+
* Why a path cannot be trusted to hold only what this user put there, or
|
|
743
|
+
* `null` when it can.
|
|
744
|
+
*
|
|
745
|
+
* A symbolic link can lead anywhere, and something of another kind is not
|
|
746
|
+
* what the caller expects. On POSIX, a path owned by another user, or one the
|
|
747
|
+
* group or others may write, lets someone else place code that this process
|
|
748
|
+
* would then run. Windows has no owner or mode bits of that kind, so only
|
|
749
|
+
* the first two rules apply there.
|
|
750
|
+
*
|
|
751
|
+
* @param stats - The `lstat` of the path, or the `fstat` of a descriptor opened on it
|
|
752
|
+
* @param kind - What the path should be
|
|
753
|
+
* @param options - Whether write permission for the group and others is accepted (default: no)
|
|
754
|
+
* @returns The reason, worded to follow "is", or `null`
|
|
755
|
+
* @example
|
|
756
|
+
* ```ts
|
|
757
|
+
* const reason = whyUntrusted(lstatSync(nodeModules), "directory");
|
|
758
|
+
* if (reason !== null) log.debug(`${nodeModules} is ${reason}`);
|
|
759
|
+
* ```
|
|
760
|
+
*/
|
|
761
|
+
function whyUntrusted(stats, kind, { allowSharedWrite = false } = {}) {
|
|
762
|
+
if (stats.isSymbolicLink()) return "a symbolic link";
|
|
763
|
+
if (!(kind === "file" ? stats.isFile() : stats.isDirectory())) return `not a ${kind}`;
|
|
764
|
+
const uid = process.getuid?.();
|
|
765
|
+
if (uid === void 0) return null;
|
|
766
|
+
if (stats.uid !== uid) return `owned by uid ${stats.uid}, not ${uid}`;
|
|
767
|
+
if (!allowSharedWrite && (stats.mode & SHARED_WRITE) !== 0) return "writable by its group or others";
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
// src/config/read-transform-entry.ts
|
|
771
|
+
const log$3 = createLogger("Config");
|
|
772
|
+
const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
773
|
+
const ABSENT_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
|
|
774
|
+
const LINK_CODES = /* @__PURE__ */ new Set(["ELOOP", "EMLINK"]);
|
|
775
|
+
function openEntry(path) {
|
|
776
|
+
try {
|
|
777
|
+
return openSync(path, READ_FLAGS);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
const code = errnoCode(error);
|
|
780
|
+
if (code !== void 0 && ABSENT_CODES.has(code)) return null;
|
|
781
|
+
if (code === void 0 || !LINK_CODES.has(code)) throw error;
|
|
782
|
+
log$3.debug(`Transform cache: ignoring ${path}, a symbolic link`);
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function markUsed(fd, path) {
|
|
787
|
+
try {
|
|
788
|
+
const now = /* @__PURE__ */ new Date();
|
|
789
|
+
if (process.platform === "win32") utimesSync(path, now, now);
|
|
790
|
+
else futimesSync(fd, now, now);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
log$3.debug(`Transform cache: cannot mark ${path} as used`, error);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function readOpenEntry(fd, path, digest, mark) {
|
|
796
|
+
const reason = whyUntrusted(fstatSync(fd), "file");
|
|
797
|
+
if (reason !== null) {
|
|
798
|
+
log$3.debug(`Transform cache: ignoring ${path}, ${reason}`);
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
const entry = decodeTransformEntry(readFileSync(fd), digest);
|
|
802
|
+
if (entry.state === "damaged") log$3.debug(`Transform cache: ignoring ${path}, whose body does not match its header`);
|
|
803
|
+
if (entry.state !== "hit") return null;
|
|
804
|
+
if (mark) markUsed(fd, path);
|
|
805
|
+
return entry.code;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Read the transform of one input from its entry.
|
|
809
|
+
*
|
|
810
|
+
* The entry is read through one descriptor, opened without blocking and, on
|
|
811
|
+
* POSIX, without following a link. It is used only when it is a regular
|
|
812
|
+
* file — on POSIX also owned by this user and writable by no one else — and
|
|
813
|
+
* when it holds exactly that input's transform. A link, another kind of
|
|
814
|
+
* file, an untrusted owner or mode, and a damaged body are logged at debug
|
|
815
|
+
* level; a stale or missing entry is not. On Windows a link is followed, and
|
|
816
|
+
* what it leads to is judged like the entry itself.
|
|
817
|
+
*
|
|
818
|
+
* @param path - The entry's path
|
|
819
|
+
* @param digest - The digest of the input being transformed
|
|
820
|
+
* @param mark - Whether a hit updates the entry's modification time, which
|
|
821
|
+
* keeps an entry in use from being pruned
|
|
822
|
+
* @returns The kept code, or `null` on a miss
|
|
823
|
+
* @throws The file system's error for anything else, such as `EACCES`
|
|
824
|
+
* @example
|
|
825
|
+
* ```ts
|
|
826
|
+
* const code = readTransformEntry(join(directory, `${name}.js`), digest, true);
|
|
827
|
+
* ```
|
|
828
|
+
*/
|
|
829
|
+
function readTransformEntry(path, digest, mark) {
|
|
830
|
+
const fd = openEntry(path);
|
|
831
|
+
if (fd === null) return null;
|
|
832
|
+
try {
|
|
833
|
+
return readOpenEntry(fd, path, digest, mark);
|
|
834
|
+
} finally {
|
|
835
|
+
closeSync(fd);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// src/config/write-transform-entry.ts
|
|
839
|
+
const log$2 = createLogger("Config");
|
|
840
|
+
const PASSING_RENAME_CODES = /* @__PURE__ */ new Set([
|
|
841
|
+
"EPERM",
|
|
842
|
+
"EBUSY",
|
|
843
|
+
"EACCES"
|
|
844
|
+
]);
|
|
845
|
+
function heldOpen(error, path) {
|
|
846
|
+
const code = errnoCode(error);
|
|
847
|
+
if (code === void 0 || !PASSING_RENAME_CODES.has(code)) return false;
|
|
848
|
+
return lstatSync(path, { throwIfNoEntry: false })?.isDirectory() !== true;
|
|
849
|
+
}
|
|
850
|
+
function discard(temporary) {
|
|
851
|
+
try {
|
|
852
|
+
unlinkSync(temporary);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
log$2.debug(`Transform cache: cannot remove ${temporary}`, error);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function writeTemporary(temporary, bytes) {
|
|
858
|
+
const fd = openSync(temporary, "wx", 384);
|
|
859
|
+
try {
|
|
860
|
+
try {
|
|
861
|
+
writeFileSync(fd, bytes);
|
|
862
|
+
} finally {
|
|
863
|
+
closeSync(fd);
|
|
864
|
+
}
|
|
865
|
+
} catch (error) {
|
|
866
|
+
discard(temporary);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Replace the entry at `path` with `bytes`, so that a reader sees either the
|
|
872
|
+
* old entry or the whole new one.
|
|
873
|
+
*
|
|
874
|
+
* The bytes go to a temporary file beside the entry, named after it, this
|
|
875
|
+
* process and random bytes, which is then renamed over the entry. On any
|
|
876
|
+
* failure the temporary file is removed. A rename refused because the entry
|
|
877
|
+
* is held open (`EPERM`, `EBUSY` or `EACCES`, while no folder stands in its
|
|
878
|
+
* place) skips this one write, logged at debug level.
|
|
879
|
+
*
|
|
880
|
+
* @param path - The entry's path, in a directory that exists
|
|
881
|
+
* @param bytes - The whole entry
|
|
882
|
+
* @throws The file system's error for any other failure
|
|
883
|
+
* @example
|
|
884
|
+
* ```ts
|
|
885
|
+
* writeTransformEntry(join(directory, `${name}.js`), encodeTransformEntry(digest, code));
|
|
886
|
+
* ```
|
|
887
|
+
*/
|
|
888
|
+
function writeTransformEntry(path, bytes) {
|
|
889
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
890
|
+
writeTemporary(temporary, bytes);
|
|
891
|
+
try {
|
|
892
|
+
renameSync(temporary, path);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
discard(temporary);
|
|
895
|
+
if (!heldOpen(error, path)) throw error;
|
|
896
|
+
log$2.debug(`Transform cache: skipped writing ${path} (${errnoCode(error)})`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// src/config/transform-cache-disk.ts
|
|
900
|
+
const log$1 = createLogger("Config");
|
|
901
|
+
const prunedDirectories = /* @__PURE__ */ new Set();
|
|
902
|
+
const markedEntries = /* @__PURE__ */ new Set();
|
|
903
|
+
/**
|
|
904
|
+
* Keep transforms in `directory`, where the next process can read them.
|
|
905
|
+
*
|
|
906
|
+
* Each entry is named after the sha256 of its key and replaced atomically,
|
|
907
|
+
* so a reader sees a whole entry or none. It is read only when it is a
|
|
908
|
+
* regular file — on POSIX also not a symbolic link, owned by this user and
|
|
909
|
+
* writable by no one else — and when its header names the same input and its
|
|
910
|
+
* body the header's length and sha256. Anything else is a miss, and the
|
|
911
|
+
* write that follows replaces it. The first hit on an entry in a process
|
|
912
|
+
* marks it as used, so pruning spares it.
|
|
913
|
+
*
|
|
914
|
+
* The first write of each cache creates the directory with mode 0700; the
|
|
915
|
+
* first write in the process prunes it. A failure is never fatal: a missing
|
|
916
|
+
* entry is a quiet miss; any other read error is logged once and is a miss.
|
|
917
|
+
* A directory removed while the process runs, as by clearing
|
|
918
|
+
* `node_modules/.cache`, is created again at the next write. A rename refused
|
|
919
|
+
* because the entry is held open skips that one write; any other write error
|
|
920
|
+
* is logged once, naming the directory and the code, and stops this cache's
|
|
921
|
+
* writes while reads go on.
|
|
922
|
+
*
|
|
923
|
+
* A `readOnly` one reads entries by the same rules and changes nothing: its
|
|
924
|
+
* writes are dropped, and a hit leaves the entry's time as it was, so the
|
|
925
|
+
* process that keeps the entries alone decides what pruning spares.
|
|
926
|
+
*
|
|
927
|
+
* @param directory - Where entries are kept; see `findTransformCacheDirectory`
|
|
928
|
+
* @param options - Whether the directory is only read (default: no)
|
|
929
|
+
* @returns The directory's reader and writer
|
|
930
|
+
* @example
|
|
931
|
+
* ```ts
|
|
932
|
+
* const disk = createTransformCacheDisk("/app/node_modules/.cache/courier/transform");
|
|
933
|
+
* const code = disk.read(key, digest) ?? transformAndKeep();
|
|
934
|
+
* ```
|
|
935
|
+
*/
|
|
936
|
+
function createTransformCacheDisk(directory, { readOnly = false } = {}) {
|
|
937
|
+
const root = resolve(directory);
|
|
938
|
+
let readFailureLogged = false;
|
|
939
|
+
let created = false;
|
|
940
|
+
let writable = true;
|
|
941
|
+
function entryPath(key) {
|
|
942
|
+
return join(root, `${createHash("sha256").update(key).digest("hex").slice(0, 40)}.js`);
|
|
943
|
+
}
|
|
944
|
+
function warn(failure, outcome, error) {
|
|
945
|
+
const code = errnoCode(error);
|
|
946
|
+
const message = `Transform cache: cannot ${failure} ${root} (${code ?? "unexpected error"}); ${outcome}`;
|
|
947
|
+
log$1.warn(message, code === void 0 ? error : void 0);
|
|
948
|
+
}
|
|
949
|
+
function stopWriting(error) {
|
|
950
|
+
writable = false;
|
|
951
|
+
warn("write to", "this process keeps new transforms in memory only", error);
|
|
952
|
+
}
|
|
953
|
+
function create() {
|
|
954
|
+
mkdirSync(root, {
|
|
955
|
+
recursive: true,
|
|
956
|
+
mode: 448
|
|
957
|
+
});
|
|
958
|
+
const reason = whyUntrusted(lstatSync(root), "directory");
|
|
959
|
+
if (reason !== null) {
|
|
960
|
+
writable = false;
|
|
961
|
+
log$1.warn(`Transform cache: ${root} is ${reason}; this process keeps new transforms in memory only`);
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
created = true;
|
|
965
|
+
if (prunedDirectories.has(root)) return true;
|
|
966
|
+
prunedDirectories.add(root);
|
|
967
|
+
pruneTransformCache(root);
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
function writeEntry(path, bytes) {
|
|
971
|
+
if (!created && !create()) return;
|
|
972
|
+
try {
|
|
973
|
+
writeTransformEntry(path, bytes);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
if (errnoCode(error) !== "ENOENT") throw error;
|
|
976
|
+
if (create()) writeTransformEntry(path, bytes);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
read(key, digest) {
|
|
981
|
+
const path = entryPath(key);
|
|
982
|
+
const mark = !readOnly && !markedEntries.has(path);
|
|
983
|
+
try {
|
|
984
|
+
const code = readTransformEntry(path, digest, mark);
|
|
985
|
+
if (code !== null && mark) markedEntries.add(path);
|
|
986
|
+
return code;
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (readFailureLogged) log$1.debug(`Transform cache: cannot read ${path}`, error);
|
|
989
|
+
else warn("read entries in", "such modules are transformed instead", error);
|
|
990
|
+
readFailureLogged = true;
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
write(key, digest, code) {
|
|
995
|
+
if (readOnly || !writable || !code.isWellFormed()) return;
|
|
996
|
+
try {
|
|
997
|
+
writeEntry(entryPath(key), encodeTransformEntry(digest, code));
|
|
998
|
+
} catch (error) {
|
|
999
|
+
stopWriting(error);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
// src/config/transform-digest.ts
|
|
1005
|
+
let jitiVersion;
|
|
1006
|
+
function hasVersion(manifest) {
|
|
1007
|
+
return typeof manifest === "object" && manifest !== null && "version" in manifest && typeof manifest.version === "string";
|
|
1008
|
+
}
|
|
1009
|
+
function readJitiVersion() {
|
|
1010
|
+
if (jitiVersion !== void 0) return jitiVersion;
|
|
1011
|
+
const manifest = createRequire(import.meta.url)("jiti/package.json");
|
|
1012
|
+
if (!hasVersion(manifest)) throw new Error("jiti/package.json names no version");
|
|
1013
|
+
jitiVersion = manifest.version;
|
|
1014
|
+
return jitiVersion;
|
|
1015
|
+
}
|
|
1016
|
+
function isPlainData(value) {
|
|
1017
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1018
|
+
if (Array.isArray(value)) return prototype === Array.prototype;
|
|
1019
|
+
return prototype === Object.prototype || prototype === null;
|
|
1020
|
+
}
|
|
1021
|
+
function describe(value, open) {
|
|
1022
|
+
if (value === void 0) return "u";
|
|
1023
|
+
if (value === null) return "n";
|
|
1024
|
+
if (typeof value === "boolean") return value ? "t" : "f";
|
|
1025
|
+
if (typeof value === "number") return Object.is(value, -0) ? "#-0" : `#${value}`;
|
|
1026
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
1027
|
+
if (typeof value !== "object" || open.has(value) || !isPlainData(value)) return null;
|
|
1028
|
+
open.add(value);
|
|
1029
|
+
const entries = [];
|
|
1030
|
+
const fields = Object.entries(value);
|
|
1031
|
+
for (const [key, item] of fields) {
|
|
1032
|
+
const described = describe(item, open);
|
|
1033
|
+
if (described === null) return null;
|
|
1034
|
+
entries.push(`${JSON.stringify(key)}:${described}`);
|
|
1035
|
+
}
|
|
1036
|
+
open.delete(value);
|
|
1037
|
+
const [start, end] = Array.isArray(value) ? ["[", "]"] : ["{", "}"];
|
|
1038
|
+
return `${start}${entries.join(",")}${end}`;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* The digest of everything that shapes jiti's transform of a module: jiti's
|
|
1042
|
+
* version, every option but the source, and the source.
|
|
1043
|
+
*
|
|
1044
|
+
* Two inputs share a digest only when every option is alike and the source is
|
|
1045
|
+
* the same string, code unit for code unit, so a source that is not
|
|
1046
|
+
* well-formed UTF-16 is not confused with the text it would decode to. An
|
|
1047
|
+
* option whose value cannot be told apart from another — a babel plugin given
|
|
1048
|
+
* as a function, a RegExp, a cycle — yields no digest.
|
|
1049
|
+
*
|
|
1050
|
+
* @param options - What jiti hands its transform
|
|
1051
|
+
* @returns A hex sha256, or `null` when an option holds such a value
|
|
1052
|
+
* @example
|
|
1053
|
+
* ```ts
|
|
1054
|
+
* const digest = transformDigest({ filename: "/app/courier.config.ts", source, ts: true });
|
|
1055
|
+
* ```
|
|
1056
|
+
*/
|
|
1057
|
+
function transformDigest({ source, ...rest }) {
|
|
1058
|
+
const described = describe({
|
|
1059
|
+
...rest,
|
|
1060
|
+
async: rest.async === true
|
|
1061
|
+
}, /* @__PURE__ */ new Set());
|
|
1062
|
+
if (described === null) return null;
|
|
1063
|
+
return createHash("sha256").update(`${readJitiVersion()}\0${described}\0`).update(source, "utf16le").digest("hex");
|
|
1064
|
+
}
|
|
1065
|
+
// src/config/transform-cache.ts
|
|
1066
|
+
const DEFAULT_MAX_MEMORY_CHARS = 128 * 2 ** 20;
|
|
1067
|
+
function keyOf({ filename, async }) {
|
|
1068
|
+
if (filename === void 0 || !isAbsolute(filename)) return null;
|
|
1069
|
+
return `${async === true}\0${filename}`;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Create a {@link TransformCache} that keeps transforms in memory for the
|
|
1073
|
+
* life of the process and, given a `directory`, on disk for the next one.
|
|
1074
|
+
*
|
|
1075
|
+
* Each file holds one entry per module system — required and imported are
|
|
1076
|
+
* transformed differently, and a module reached both ways keeps both. An
|
|
1077
|
+
* entry is reused only when the digest of jiti's version, the options and
|
|
1078
|
+
* the source matches, so an edited file is transformed again the first time
|
|
1079
|
+
* it is loaded.
|
|
1080
|
+
*
|
|
1081
|
+
* A transform is looked for in memory, then in the directory, where a hit
|
|
1082
|
+
* is also kept in memory; only when both miss is it computed, and a success
|
|
1083
|
+
* is then kept in both. The directory is shared with every process that
|
|
1084
|
+
* names it, and a directory that cannot be read or written only costs
|
|
1085
|
+
* transforms; see `createTransformCacheDisk` for what an entry must be to be
|
|
1086
|
+
* used. A mock worker, which loads once the modules Studio has just loaded,
|
|
1087
|
+
* opens Studio's directory `readOnly` with `maxMemoryChars: 0`: it reads
|
|
1088
|
+
* what Studio kept, adds nothing to the directory, and holds nothing in
|
|
1089
|
+
* memory past its one load.
|
|
1090
|
+
*
|
|
1091
|
+
* A failed transform, one whose result carries an `error`, is never kept:
|
|
1092
|
+
* babel can fail on bytes it transforms without complaint a moment later,
|
|
1093
|
+
* such as a deeply nested module that overflows the stack while babel is
|
|
1094
|
+
* still cold. Such a file is transformed again on every load, and every
|
|
1095
|
+
* failure reaches jiti with its error, as it does without a cache. A file
|
|
1096
|
+
* named by a relative path, or not named, is transformed every time.
|
|
1097
|
+
*
|
|
1098
|
+
* @param options - The directory (default: none, memory only), the memory
|
|
1099
|
+
* bound (default: 128 Mi characters), and whether the directory is only
|
|
1100
|
+
* read (default: no)
|
|
1101
|
+
* @returns A cache to pass to `createConfigJiti` or `loadCourierConfig`
|
|
1102
|
+
* @example
|
|
1103
|
+
* ```ts
|
|
1104
|
+
* const transformCache = createTransformCache({
|
|
1105
|
+
* directory: findTransformCacheDirectory(projectRoot),
|
|
1106
|
+
* });
|
|
1107
|
+
* const first = await loadCourierConfig({ cwd: projectRoot, transformCache });
|
|
1108
|
+
* // Unchanged modules are not transformed again, here or in the next process:
|
|
1109
|
+
* const second = await loadCourierConfig({ cwd: projectRoot, transformCache });
|
|
1110
|
+
*
|
|
1111
|
+
* // A process that loads once what another has just kept:
|
|
1112
|
+
* const reader = createTransformCache({ directory, readOnly: true, maxMemoryChars: 0 });
|
|
1113
|
+
* ```
|
|
1114
|
+
*/
|
|
1115
|
+
function createTransformCache({ directory, maxMemoryChars = DEFAULT_MAX_MEMORY_CHARS, readOnly = false } = {}) {
|
|
1116
|
+
const disk = directory ? createTransformCacheDisk(directory, { readOnly }) : null;
|
|
1117
|
+
const memo = /* @__PURE__ */ new Map();
|
|
1118
|
+
let memoryChars = 0;
|
|
1119
|
+
function forget(key) {
|
|
1120
|
+
const entry = memo.get(key);
|
|
1121
|
+
if (!entry) return;
|
|
1122
|
+
memo.delete(key);
|
|
1123
|
+
memoryChars -= entry.code.length;
|
|
1124
|
+
}
|
|
1125
|
+
function remember(key, entry) {
|
|
1126
|
+
forget(key);
|
|
1127
|
+
if (maxMemoryChars <= 0 || entry.code.length > maxMemoryChars) return;
|
|
1128
|
+
memo.set(key, entry);
|
|
1129
|
+
memoryChars += entry.code.length;
|
|
1130
|
+
for (const oldest of memo.keys()) {
|
|
1131
|
+
if (memoryChars <= maxMemoryChars) break;
|
|
1132
|
+
forget(oldest);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return { transform(options, compute) {
|
|
1136
|
+
const key = keyOf(options);
|
|
1137
|
+
const digest = key === null ? null : transformDigest(options);
|
|
1138
|
+
if (key === null || digest === null) return compute(options);
|
|
1139
|
+
const hit = memo.get(key);
|
|
1140
|
+
if (hit?.digest === digest) {
|
|
1141
|
+
memo.delete(key);
|
|
1142
|
+
memo.set(key, hit);
|
|
1143
|
+
return { code: hit.code };
|
|
1144
|
+
}
|
|
1145
|
+
const kept = disk === null ? null : disk.read(key, digest);
|
|
1146
|
+
if (kept !== null) {
|
|
1147
|
+
remember(key, {
|
|
1148
|
+
digest,
|
|
1149
|
+
code: kept
|
|
1150
|
+
});
|
|
1151
|
+
return { code: kept };
|
|
1152
|
+
}
|
|
1153
|
+
const result = compute(options);
|
|
1154
|
+
if (result.error !== void 0) return result;
|
|
1155
|
+
remember(key, {
|
|
1156
|
+
digest,
|
|
1157
|
+
code: result.code
|
|
1158
|
+
});
|
|
1159
|
+
disk?.write(key, digest, result.code);
|
|
1160
|
+
return result;
|
|
1161
|
+
} };
|
|
1162
|
+
}
|
|
1163
|
+
// src/config/transform-cache-directory.ts
|
|
1164
|
+
const log = createLogger("Config");
|
|
1165
|
+
/** Values of `COURIER_TRANSFORM_CACHE` that turn the directory off. */
|
|
1166
|
+
const OFF_VALUES = /* @__PURE__ */ new Set([
|
|
1167
|
+
"0",
|
|
1168
|
+
"false",
|
|
1169
|
+
"off"
|
|
1170
|
+
]);
|
|
1171
|
+
/** Values of `COURIER_TRANSFORM_CACHE` that keep it, as leaving it unset does. */
|
|
1172
|
+
const ON_VALUES = /* @__PURE__ */ new Set([
|
|
1173
|
+
"",
|
|
1174
|
+
"1",
|
|
1175
|
+
"true",
|
|
1176
|
+
"on"
|
|
1177
|
+
]);
|
|
1178
|
+
const unknownSettings = /* @__PURE__ */ new Set();
|
|
1179
|
+
function nearestNodeModules(start) {
|
|
1180
|
+
let directory = start;
|
|
1181
|
+
while (true) {
|
|
1182
|
+
const candidate = join(directory, "node_modules");
|
|
1183
|
+
const stats = lstatSync(candidate, { throwIfNoEntry: false });
|
|
1184
|
+
if (stats?.isDirectory() || stats?.isSymbolicLink()) return candidate;
|
|
1185
|
+
const parent = dirname(directory);
|
|
1186
|
+
if (parent === directory) return null;
|
|
1187
|
+
directory = parent;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function whyRefused(levels) {
|
|
1191
|
+
for (const [path, options] of levels) {
|
|
1192
|
+
const stats = lstatSync(path, { throwIfNoEntry: false });
|
|
1193
|
+
const reason = stats === void 0 ? null : whyUntrusted(stats, "directory", options);
|
|
1194
|
+
if (reason !== null) return `${path} is ${reason}`;
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
const TOOL_OWNED = { allowSharedWrite: true };
|
|
1199
|
+
const STUDIO_OWNED = {};
|
|
1200
|
+
const lastRefusals = /* @__PURE__ */ new Map();
|
|
1201
|
+
function refuse(root, reason, error) {
|
|
1202
|
+
const key = error instanceof Error ? `${reason}\0${error.message}` : reason;
|
|
1203
|
+
if (lastRefusals.get(root) === key) return null;
|
|
1204
|
+
lastRefusals.set(root, key);
|
|
1205
|
+
if (error === void 0) log.debug(`Transform cache directory off: ${reason}`);
|
|
1206
|
+
else log.debug(`Transform cache directory off: ${reason}`, error);
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
function locate(projectRoot) {
|
|
1210
|
+
const nodeModules = nearestNodeModules(projectRoot);
|
|
1211
|
+
if (nodeModules === null) return { refusal: `no node_modules at or above ${projectRoot}` };
|
|
1212
|
+
const cache = join(nodeModules, ".cache");
|
|
1213
|
+
const courier = join(cache, "courier");
|
|
1214
|
+
const directory = join(courier, "transform");
|
|
1215
|
+
const refusal = whyRefused([
|
|
1216
|
+
[nodeModules, TOOL_OWNED],
|
|
1217
|
+
[cache, TOOL_OWNED],
|
|
1218
|
+
[courier, STUDIO_OWNED],
|
|
1219
|
+
[directory, STUDIO_OWNED]
|
|
1220
|
+
]);
|
|
1221
|
+
return refusal === null ? { directory } : { refusal };
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Where a project's transforms are kept across processes:
|
|
1225
|
+
* `.cache/courier/transform` in the nearest `node_modules` directory at or
|
|
1226
|
+
* above the project root.
|
|
1227
|
+
*
|
|
1228
|
+
* The nearest one is taken, not the one module resolution would reach: under
|
|
1229
|
+
* pnpm that lies inside `.pnpm`, and what keeps the directory safe is the
|
|
1230
|
+
* rule below, not where it is. Every file in it holds code that Studio, mock
|
|
1231
|
+
* workers and build plugins run, so on POSIX the directory is refused when
|
|
1232
|
+
* `node_modules`, `.cache`, `courier` or `transform` — those that exist — is
|
|
1233
|
+
* a symbolic link or belongs to another user, and when `courier` or
|
|
1234
|
+
* `transform`, which only Studio creates, is writable by its group or
|
|
1235
|
+
* others; any of these lets someone else replace or remove entries, or put
|
|
1236
|
+
* the directory somewhere they control. `node_modules` and `.cache` may be
|
|
1237
|
+
* group-writable, as package managers leave them under a umask of 002:
|
|
1238
|
+
* whoever can write `node_modules` can replace any package in it already. On
|
|
1239
|
+
* Windows only links and other kinds of file are refused.
|
|
1240
|
+
*
|
|
1241
|
+
* There is none when no `node_modules` exists, as with Yarn Plug'n'Play, or
|
|
1242
|
+
* when `COURIER_TRANSFORM_CACHE` is `0`, `false` or `off` in any case. The
|
|
1243
|
+
* system temporary directory, which every user of the machine may write, is
|
|
1244
|
+
* never used instead. Why there is none is logged at debug level, once for
|
|
1245
|
+
* each root until the reason changes, however often the root is looked up.
|
|
1246
|
+
*
|
|
1247
|
+
* @param projectRoot - The directory holding `courier.config.*`
|
|
1248
|
+
* @param env - The environment to read `COURIER_TRANSFORM_CACHE` from
|
|
1249
|
+
* (default: `process.env`)
|
|
1250
|
+
* @returns The directory, which may not exist yet, or `null` for none
|
|
1251
|
+
* @example
|
|
1252
|
+
* ```ts
|
|
1253
|
+
* const transformCache = createTransformCache({
|
|
1254
|
+
* directory: findTransformCacheDirectory(projectRoot),
|
|
1255
|
+
* });
|
|
1256
|
+
* ```
|
|
1257
|
+
*/
|
|
1258
|
+
function findTransformCacheDirectory(projectRoot, env = process.env) {
|
|
1259
|
+
const root = resolve(projectRoot);
|
|
1260
|
+
const setting = env.COURIER_TRANSFORM_CACHE?.trim().toLowerCase();
|
|
1261
|
+
if (setting !== void 0 && OFF_VALUES.has(setting)) return refuse(root, `COURIER_TRANSFORM_CACHE=${setting}`);
|
|
1262
|
+
if (setting !== void 0 && !ON_VALUES.has(setting) && !unknownSettings.has(setting)) {
|
|
1263
|
+
unknownSettings.add(setting);
|
|
1264
|
+
log.warn(`COURIER_TRANSFORM_CACHE=${setting} is not one of 0, false, off, 1, true or on; the transform cache stays on`);
|
|
1265
|
+
}
|
|
1266
|
+
let located;
|
|
1267
|
+
try {
|
|
1268
|
+
located = locate(root);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
return refuse(root, `cannot inspect the directories above ${root}`, error);
|
|
1271
|
+
}
|
|
1272
|
+
if (located.refusal !== void 0) return refuse(root, located.refusal);
|
|
1273
|
+
lastRefusals.delete(root);
|
|
1274
|
+
return located.directory;
|
|
1275
|
+
}
|
|
1276
|
+
// src/config/load-config.ts
|
|
1277
|
+
/**
|
|
1278
|
+
* Unified `courier.config.{ts,mts,cts,js,mjs,cjs}` loader — the single
|
|
1279
|
+
* canonical way to resolve and load a user's config file.
|
|
1280
|
+
*
|
|
1281
|
+
* Two-stage design:
|
|
1282
|
+
*
|
|
1283
|
+
* 1. **File discovery** — walk up from `cwd` to the top of the project
|
|
1284
|
+
* looking for a `courier.config.*` file, using nothing but Node builtins.
|
|
1285
|
+
*
|
|
1286
|
+
* 2. **File loading** — `jiti` imports the file, transpiling a TypeScript
|
|
1287
|
+
* config transparently across Node / Next.js dev / tsx / bun / deno and
|
|
1288
|
+
* resolving the project's `tsconfig` path aliases.
|
|
1289
|
+
*
|
|
1290
|
+
* Every consumer resolves the config through this one code path:
|
|
1291
|
+
*
|
|
1292
|
+
* - The Studio dev server (`@noverax/courier-studio`) wraps this loader and
|
|
1293
|
+
* adds dev-only concerns on top (validation, source-key stamping,
|
|
1294
|
+
* dependency extraction for the fs watcher, rich error reporting).
|
|
1295
|
+
*
|
|
1296
|
+
* - The user-app runtime (`attachRuntime` on the server side) calls it
|
|
1297
|
+
* directly to discover and register sources without requiring the user
|
|
1298
|
+
* to import the config file manually.
|
|
1299
|
+
*
|
|
1300
|
+
* - `courier-cli`, the standalone server and the MCP server load the
|
|
1301
|
+
* project's config with it, and the bundler plugins read a page's routes
|
|
1302
|
+
* from the config it loads.
|
|
1303
|
+
*
|
|
1304
|
+
* A missing config is not an error: `null` lets the runtime caller fall back
|
|
1305
|
+
* to adapter auto-discovery. A config that exists and cannot be loaded is a
|
|
1306
|
+
* different thing entirely and throws, because reporting it as "no config"
|
|
1307
|
+
* sends the reader looking for a file that is right where they left it.
|
|
1308
|
+
*/
|
|
1309
|
+
/**
|
|
1310
|
+
* Walk up from `startDir` looking for a `courier.config.*` file, up to the
|
|
1311
|
+
* top of the project: the folder that holds its repository or is the root
|
|
1312
|
+
* of its workspace (see {@link configCandidates}). A config found in a folder
|
|
1313
|
+
* above `startDir` is taken only when it belongs to this user or to the
|
|
1314
|
+
* owner of `startDir`, and no user outside its group may write it: a folder
|
|
1315
|
+
* many users share, such as `/tmp`, would otherwise run anyone's config.
|
|
1316
|
+
*
|
|
1317
|
+
* @param startDir - The directory to look in first
|
|
1318
|
+
* @returns The absolute path of the first match, or `null` when no directory
|
|
1319
|
+
* up to the top of the project holds one
|
|
1320
|
+
* @throws UntrustedConfigError when the nearest config lies above `startDir`
|
|
1321
|
+
* and someone else could have put it there
|
|
1322
|
+
* @example
|
|
1323
|
+
* ```ts
|
|
1324
|
+
* findConfigFile("/app/src"); // → "/app/courier.config.ts", or null
|
|
1325
|
+
* ```
|
|
1326
|
+
*/
|
|
1327
|
+
function findConfigFile(startDir) {
|
|
1328
|
+
const folder = resolve(startDir);
|
|
1329
|
+
const found = configCandidates(folder).find((candidate) => existsSync(candidate));
|
|
1330
|
+
if (found === void 0) return null;
|
|
1331
|
+
if (dirname(found) !== folder) assertOwnConfig(found, folder);
|
|
1332
|
+
return found;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Find a config file in `cwd` itself, without walking up.
|
|
1336
|
+
*
|
|
1337
|
+
* @param cwd - The directory to look in
|
|
1338
|
+
* @returns The first of {@link CONFIG_BASENAMES} present there, or `null`
|
|
1339
|
+
* @example
|
|
1340
|
+
* ```ts
|
|
1341
|
+
* findConfigFileAt(process.cwd()); // → "/app/courier.config.ts", or null
|
|
1342
|
+
* ```
|
|
1343
|
+
*/
|
|
1344
|
+
function findConfigFileAt(cwd) {
|
|
1345
|
+
for (const basename of CONFIG_BASENAMES) {
|
|
1346
|
+
const candidate = resolve(cwd, basename);
|
|
1347
|
+
if (existsSync(candidate)) return candidate;
|
|
1348
|
+
}
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Find and load a `courier.config.*` file.
|
|
1353
|
+
*
|
|
1354
|
+
* Returns `null` when no config file exists, so a caller can fall back.
|
|
1355
|
+
* Throws when a file was found and loading it failed, reporting what the
|
|
1356
|
+
* loader reported — for a TypeScript config, usually an unresolvable import.
|
|
1357
|
+
*
|
|
1358
|
+
* @param options - Where to look, and where to reuse transforms
|
|
1359
|
+
* @returns The config and its canonical path, or `null` when there is no config
|
|
1360
|
+
* @example
|
|
1361
|
+
* ```ts
|
|
1362
|
+
* const loaded = await loadCourierConfig({ cwd: projectRoot, requireAtCwd: true });
|
|
1363
|
+
* ```
|
|
1364
|
+
*/
|
|
1365
|
+
async function loadCourierConfig(options = {}) {
|
|
1366
|
+
const startDir = resolve(options.cwd ?? process.cwd());
|
|
1367
|
+
const found = options.requireAtCwd ? findConfigFileAt(startDir) : findConfigFile(startDir);
|
|
1368
|
+
if (!found) return null;
|
|
1369
|
+
return loadCourierConfigFile(found, {
|
|
1370
|
+
transformCache: options.transformCache ?? (options.projectTransforms === true ? projectTransformCache(dirname(found)) : void 0),
|
|
1371
|
+
onModule: options.onModule
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* The transforms kept in a project's `node_modules/.cache` — the ones Studio
|
|
1376
|
+
* keeps — for a process that loads the project's modules once: it reuses
|
|
1377
|
+
* them, keeps there the ones it makes, and holds none in memory.
|
|
1378
|
+
*
|
|
1379
|
+
* @param projectRoot - The project's root
|
|
1380
|
+
* @returns The cache, or `undefined` when the project has no directory to keep them in
|
|
1381
|
+
* @example
|
|
1382
|
+
* ```ts
|
|
1383
|
+
* await loadCourierConfigFile(file, { transformCache: projectTransformCache(dirname(file)) });
|
|
1384
|
+
* ```
|
|
1385
|
+
*/
|
|
1386
|
+
function projectTransformCache(projectRoot) {
|
|
1387
|
+
const directory = findTransformCacheDirectory(projectRoot);
|
|
1388
|
+
return directory === null ? void 0 : createTransformCache({
|
|
1389
|
+
directory,
|
|
1390
|
+
maxMemoryChars: 0
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Load one config file, found by whatever means the caller chose — the
|
|
1395
|
+
* standalone server takes it from `--config`.
|
|
1396
|
+
*
|
|
1397
|
+
* @param file - Absolute path of an existing config file
|
|
1398
|
+
* @param options - How the loader resolves imports and where it reuses
|
|
1399
|
+
* transforms, see {@link ConfigJitiOptions}, and what runs before the config
|
|
1400
|
+
* @returns The config and the canonical path it was loaded from
|
|
1401
|
+
* @throws What the loader reported when the file will not load — for a
|
|
1402
|
+
* TypeScript config, usually an unresolvable import
|
|
1403
|
+
* @example
|
|
1404
|
+
* ```ts
|
|
1405
|
+
* const { config } = await loadCourierConfigFile("/app/courier.config.ts", {
|
|
1406
|
+
* transformCache: createTransformCache(),
|
|
1407
|
+
* });
|
|
1408
|
+
* ```
|
|
1409
|
+
*/
|
|
1410
|
+
async function loadCourierConfigFile(file, { beforeRun, ...options } = {}) {
|
|
1411
|
+
const configFile = realpathSync(file);
|
|
1412
|
+
const jiti = await createConfigJiti(configFile, options);
|
|
1413
|
+
beforeRun?.();
|
|
1414
|
+
return {
|
|
1415
|
+
config: await jiti.import(configFile, { default: true }),
|
|
1416
|
+
configFile
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
export { projectTransformCache as a, UntrustedConfigError as c, CONFIG_BASENAMES as d, configCandidates as f, loadCourierConfigFile as i, createConfigJiti as l, findConfigFileAt as n, findTransformCacheDirectory as o, loadCourierConfig as r, createTransformCache as s, findConfigFile as t, readPathAliases as u };
|