@noverax/courier-build 0.0.0-stage → 0.5.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 +295 -2
- package/lib/config/index.d.ts +811 -0
- package/lib/config/index.js +75 -0
- package/lib/config-file-of-Clf_dWUQ.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 +548 -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-barNPfXN.js +2834 -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/read-limits-DUuUaEQM.js +41 -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-DRoqUZ0S.js +206 -0
- package/lib/routes-loader-context-40OKDrQp.d.ts +26 -0
- package/lib/routes-module-code-DYtP7uj1.js +405 -0
- package/lib/routes-module-id-CnUjQjYF.js +12 -0
- package/lib/routes-read-error-DQhKIE2F.js +30 -0
- package/lib/routes-rule-Pu1Tz8Xm.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-D0j7uXg8.js +28 -0
- package/lib/vite/index.d.ts +33 -0
- package/lib/vite/index.js +357 -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-BPxhBYGV.js +42 -0
- package/lib/worker-messages-BT2vvNQJ.js +35 -0
- package/package.json +209 -4
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { t as RoutesReadError } from "./routes-read-error-DQhKIE2F.js";
|
|
2
|
+
import { n as routesModuleCode, r as unreadRoutesModuleCode } from "./routes-module-code-DYtP7uj1.js";
|
|
3
|
+
import { t as errnoCode } from "./errno-code-CNx_E80R.js";
|
|
4
|
+
import { t as sharedEngine } from "./shared-engine-D0j7uXg8.js";
|
|
5
|
+
import { t as configFileOf } from "./config-file-of-Clf_dWUQ.js";
|
|
6
|
+
import { dirname, join, sep } from "node:path";
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
8
|
+
// src/routes-file/follow-routes-inputs.ts
|
|
9
|
+
/**
|
|
10
|
+
* Watch what a reading of the routes read, so a change to any of it reads
|
|
11
|
+
* them again and rewrites the module, which the bundler then builds anew:
|
|
12
|
+
* the folder of each file — an edit saved by replacing the file, as editors
|
|
13
|
+
* save, is seen there, where a watcher on the file itself loses it — and
|
|
14
|
+
* each folder whose entries the reading listed. Packages under
|
|
15
|
+
* `node_modules` are not watched: the bundler is restarted when they
|
|
16
|
+
* change. The watchers hold no process open, and a folder watched already
|
|
17
|
+
* gets no second one.
|
|
18
|
+
*
|
|
19
|
+
* @param routes - The project's routes
|
|
20
|
+
* @param inputs - What the reading read, whether it gave routes or failed
|
|
21
|
+
* @param onChange - Called on each change to any of it
|
|
22
|
+
* @returns Whether a folder is watched now that was not before
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* followRoutesInputs(routes, snapshot, () => refreshSoon(routes));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
function followRoutesInputs(routes, inputs, onChange) {
|
|
29
|
+
const listed = new Set(inputs.listed ?? []);
|
|
30
|
+
let added = false;
|
|
31
|
+
for (const path of [...inputs.files, ...listed]) {
|
|
32
|
+
if (path === routes.file || isPackaged(path)) continue;
|
|
33
|
+
routes.inputs.add(path);
|
|
34
|
+
added = watchFolder(routes, listed.has(path) ? path : dirname(path), onChange) || added;
|
|
35
|
+
}
|
|
36
|
+
return added;
|
|
37
|
+
}
|
|
38
|
+
function watchFolder(routes, folder, onChange) {
|
|
39
|
+
if (routes.watchers.has(folder)) return false;
|
|
40
|
+
let watcher;
|
|
41
|
+
try {
|
|
42
|
+
watcher = watch(folder, { persistent: false }, (_event, name) => {
|
|
43
|
+
const path = name === null ? folder : join(folder, name);
|
|
44
|
+
if (routes.inputs.has(path) || routes.inputs.has(folder)) onChange();
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
watcher.on("error", () => {
|
|
50
|
+
watcher.close();
|
|
51
|
+
routes.watchers.delete(folder);
|
|
52
|
+
});
|
|
53
|
+
routes.watchers.set(folder, watcher);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
function isPackaged(path) {
|
|
57
|
+
return path.includes(`${sep}node_modules${sep}`);
|
|
58
|
+
}
|
|
59
|
+
// src/routes-file/write-routes-file.ts
|
|
60
|
+
const NOT_WRITABLE = /* @__PURE__ */ new Set([
|
|
61
|
+
"EACCES",
|
|
62
|
+
"EPERM",
|
|
63
|
+
"EROFS",
|
|
64
|
+
"ENOTDIR"
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* Write the routes module a bundler takes from disk, when it would change:
|
|
68
|
+
* whole, under a name of its own first, so the bundler never reads it half
|
|
69
|
+
* written, and not at all when it holds the code already, so an unchanged
|
|
70
|
+
* reading builds nothing again.
|
|
71
|
+
*
|
|
72
|
+
* @param file - The module, absolute
|
|
73
|
+
* @param code - Its code
|
|
74
|
+
* @param root - The project's root, for the error
|
|
75
|
+
* @param elsewhere - How the plugin is told to write it elsewhere, for the error
|
|
76
|
+
* @returns Whether the file changed
|
|
77
|
+
* @throws RoutesReadError `ROUTES_NOT_WRITTEN` when its folder cannot hold it
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* writeRoutesFile(file, routesModuleCode(snapshot), "/app", "withCourier(config, { outDir })");
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
function writeRoutesFile(file, code, root, elsewhere) {
|
|
84
|
+
if (contentOf(file) === code) return false;
|
|
85
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
88
|
+
writeFileSync(temporary, code);
|
|
89
|
+
renameSync(temporary, file);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
rmSync(temporary, { force: true });
|
|
93
|
+
const reason = errnoCode(error);
|
|
94
|
+
if (reason === void 0 || !NOT_WRITABLE.has(reason)) throw error;
|
|
95
|
+
throw new RoutesReadError("ROUTES_NOT_WRITTEN", `The routes module cannot be written to ${dirname(file)} (${reason}). Name a folder it can be written to with ${elsewhere}.`, { root });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function contentOf(file) {
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(file, "utf8");
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// src/routes-file/routes-file.ts
|
|
106
|
+
const REGISTRY = Symbol.for("@noverax/courier-build/routes-file");
|
|
107
|
+
const holder = globalThis;
|
|
108
|
+
const SETTLE_MS = 30;
|
|
109
|
+
const STARTING_MS = [250, 2e3];
|
|
110
|
+
const settling = /* @__PURE__ */ new WeakMap();
|
|
111
|
+
/**
|
|
112
|
+
* The routes of a project written to a module, made once per process for
|
|
113
|
+
* the same module and read again on every call: a config the bundler loads
|
|
114
|
+
* again reads them anew, and adds no watcher a folder has already.
|
|
115
|
+
*
|
|
116
|
+
* @param root - The project's root
|
|
117
|
+
* @param config - The config, from the root or absolute, when it is not the one found
|
|
118
|
+
* @param file - The module the bundler takes, absolute
|
|
119
|
+
* @param elsewhere - How the plugin is told to write it elsewhere, for the error
|
|
120
|
+
* @returns The routes, read and written
|
|
121
|
+
* @throws RoutesReadError `ROUTES_NOT_WRITTEN` when the module cannot be written
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const routes = await routesFile("/app", undefined, "/app/node_modules/.cache/@noverax/courier/routes.js", "withCourier(config, { outDir })");
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
async function routesFile(root, config, file, elsewhere) {
|
|
128
|
+
const registry = registryOf();
|
|
129
|
+
let routes = registry.get(file);
|
|
130
|
+
if (routes === void 0) {
|
|
131
|
+
routes = {
|
|
132
|
+
root,
|
|
133
|
+
config,
|
|
134
|
+
file,
|
|
135
|
+
elsewhere,
|
|
136
|
+
engine: sharedEngine({
|
|
137
|
+
root,
|
|
138
|
+
config,
|
|
139
|
+
envFiles: []
|
|
140
|
+
}),
|
|
141
|
+
configFile: void 0,
|
|
142
|
+
failure: void 0,
|
|
143
|
+
inputs: /* @__PURE__ */ new Set(),
|
|
144
|
+
watchers: /* @__PURE__ */ new Map()
|
|
145
|
+
};
|
|
146
|
+
registry.set(file, routes);
|
|
147
|
+
}
|
|
148
|
+
await refreshRoutes(routes);
|
|
149
|
+
return routes;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read the routes again and write the module when they changed. The
|
|
153
|
+
* engine's readings end in the order they began, so the module is left as
|
|
154
|
+
* the last one read it. When the routes cannot be read, those read last
|
|
155
|
+
* stay, and why is kept, for a build to fail with, and told in the
|
|
156
|
+
* bundler's terminal; with none read yet, the module fails where it is
|
|
157
|
+
* imported, saying why.
|
|
158
|
+
*
|
|
159
|
+
* @param routes - The project's routes
|
|
160
|
+
* @throws RoutesReadError `ROUTES_NOT_WRITTEN` when the module cannot be written
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* await refreshRoutes(routes);
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
async function refreshRoutes(routes) {
|
|
167
|
+
const follow = (inputs) => {
|
|
168
|
+
if (!followRoutesInputs(routes, inputs, () => refreshSoon(routes))) return;
|
|
169
|
+
for (const ms of STARTING_MS) setTimeout(() => refreshSoon(routes), ms).unref();
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
const snapshot = await routes.engine.read();
|
|
173
|
+
routes.failure = void 0;
|
|
174
|
+
writeRoutesFile(routes.file, routesModuleCode(snapshot), routes.root, routes.elsewhere);
|
|
175
|
+
follow(snapshot);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof RoutesReadError && error.code === "ROUTES_NOT_WRITTEN") throw error;
|
|
178
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
179
|
+
routes.failure = failure;
|
|
180
|
+
const followed = (failure instanceof RoutesReadError ? failure.inputs : void 0) ?? routes.engine.last;
|
|
181
|
+
if (followed !== void 0) follow(followed);
|
|
182
|
+
if (routes.engine.last === void 0) {
|
|
183
|
+
const code = unreadRoutesModuleCode(failure.message);
|
|
184
|
+
writeRoutesFile(routes.file, code, routes.root, routes.elsewhere);
|
|
185
|
+
}
|
|
186
|
+
console.error(`[courier] ${failure.message}`);
|
|
187
|
+
}
|
|
188
|
+
routes.configFile = configFileOf(routes.root, routes.config);
|
|
189
|
+
}
|
|
190
|
+
function refreshSoon(routes) {
|
|
191
|
+
clearTimeout(settling.get(routes));
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
settling.delete(routes);
|
|
194
|
+
if (registryOf().get(routes.file) !== routes) return;
|
|
195
|
+
refreshRoutes(routes).catch((error) => {
|
|
196
|
+
console.error(`[courier] ${error instanceof Error ? error.message : String(error)}`);
|
|
197
|
+
});
|
|
198
|
+
}, SETTLE_MS);
|
|
199
|
+
timer.unref();
|
|
200
|
+
settling.set(routes, timer);
|
|
201
|
+
}
|
|
202
|
+
function registryOf() {
|
|
203
|
+
holder[REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
204
|
+
return holder[REGISTRY];
|
|
205
|
+
}
|
|
206
|
+
export { routesFile as t };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What of a loader's context the routes loader uses. webpack and Rspack give
|
|
3
|
+
* all of it; Turbopack runs webpack loaders with a part of it, so what it may
|
|
4
|
+
* lack is optional and used only where it is there.
|
|
5
|
+
*/
|
|
6
|
+
interface RoutesLoaderContext {
|
|
7
|
+
/** The module the loader runs on, absolute. */
|
|
8
|
+
readonly resourcePath: string;
|
|
9
|
+
/** `"production"` in a build, `"development"` under a dev server; Turbopack names none. */
|
|
10
|
+
readonly mode?: string | undefined;
|
|
11
|
+
/** The options the rule hands the loader. */
|
|
12
|
+
getOptions(): unknown;
|
|
13
|
+
/** Whether the loader's result may be kept for the same inputs: never for the routes. */
|
|
14
|
+
cacheable(flag?: boolean): void;
|
|
15
|
+
/** Mark the loader asynchronous; the callback ends it with an error or the module's code. */
|
|
16
|
+
async(): (error: Error | null, content?: string) => void;
|
|
17
|
+
/** Build the module again when this file changes. */
|
|
18
|
+
addDependency(file: string): void;
|
|
19
|
+
/** Build the module again when an entry of this folder is added or removed. */
|
|
20
|
+
addContextDependency?(folder: string): void;
|
|
21
|
+
/** Build the module again once this path, absent now, appears. */
|
|
22
|
+
addMissingDependency?(path: string): void;
|
|
23
|
+
/** Report an error while the module still builds: what a dev server shows over the page. */
|
|
24
|
+
emitError?(error: Error): void;
|
|
25
|
+
}
|
|
26
|
+
export { RoutesLoaderContext as t };
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { n as COURIER_PHASE, r as DEFINITIONS_PHASE, t as CONFIG_READ_LIMIT_MS } from "./read-limits-DUuUaEQM.js";
|
|
2
|
+
import { t as packageFile } from "./package-file-CVAKY_Nu.js";
|
|
3
|
+
import { t as RoutesReadError } from "./routes-read-error-DQhKIE2F.js";
|
|
4
|
+
import { n as isRecord, t as errnoCode } from "./errno-code-CNx_E80R.js";
|
|
5
|
+
import { t as workerMessages } from "./worker-messages-BT2vvNQJ.js";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
import { fork } from "node:child_process";
|
|
8
|
+
import { readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
// src/engine/supervise-child.ts
|
|
11
|
+
/** How long a process is given to end once let go, before it is killed. */
|
|
12
|
+
const GRACE_MS = 2e3;
|
|
13
|
+
const running = /* @__PURE__ */ new Set();
|
|
14
|
+
let exitWatched = false;
|
|
15
|
+
/**
|
|
16
|
+
* Watch over a reading's process for as long as it runs. Let go, it no
|
|
17
|
+
* longer holds this process open: the channel to it closes, which ends it,
|
|
18
|
+
* one that never answered is asked to end, and one still running after a
|
|
19
|
+
* grace period is killed — a config may ignore SIGTERM, or block the
|
|
20
|
+
* process's loop. One still running when this process exits is killed then.
|
|
21
|
+
*
|
|
22
|
+
* @param child - The process, just started
|
|
23
|
+
* @returns How to let it go, saying whether it answered
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const release = superviseChild(fork(worker));
|
|
27
|
+
* release(false); // gave up waiting for it
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
function superviseChild(child) {
|
|
31
|
+
watchExit();
|
|
32
|
+
running.add(child);
|
|
33
|
+
child.once("exit", () => running.delete(child));
|
|
34
|
+
return (answered) => {
|
|
35
|
+
if (!isRunning(child)) return;
|
|
36
|
+
if (child.connected) child.disconnect();
|
|
37
|
+
child.unref();
|
|
38
|
+
if (!answered) child.kill("SIGTERM");
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
if (isRunning(child)) child.kill("SIGKILL");
|
|
41
|
+
}, GRACE_MS).unref();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function watchExit() {
|
|
45
|
+
if (exitWatched) return;
|
|
46
|
+
exitWatched = true;
|
|
47
|
+
process.on("exit", () => {
|
|
48
|
+
for (const child of running) child.kill("SIGKILL");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function isRunning(child) {
|
|
52
|
+
return child.exitCode === null && child.signalCode === null;
|
|
53
|
+
}
|
|
54
|
+
// src/engine/evaluate-in-child.ts
|
|
55
|
+
/** The package this module ships in, by the name its manifest gives. */
|
|
56
|
+
const PACKAGE_NAME = "@noverax/courier-build";
|
|
57
|
+
/**
|
|
58
|
+
* Read a project's routes in a process of its own, started for this reading
|
|
59
|
+
* alone: the config runs there, never in the bundler's process, so a module
|
|
60
|
+
* it reaches is read as it is now, and nothing it leaves behind lingers.
|
|
61
|
+
*
|
|
62
|
+
* @param request - Where the project is, and which env files it reads
|
|
63
|
+
* @param limitMs - How long the reading may take
|
|
64
|
+
* @returns The routes, and what they depended on
|
|
65
|
+
* @throws RoutesReadError for a config that would not load, and
|
|
66
|
+
* `ROUTES_NOT_READ` when the process ran out of time or ended unanswered
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const snapshot = await evaluateInChild({ root: "/app", envFiles: [] });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
function evaluateInChild(request, limitMs = CONFIG_READ_LIMIT_MS) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const unread = (message) => new RoutesReadError("ROUTES_NOT_READ", message, {
|
|
75
|
+
root: request.root,
|
|
76
|
+
configFile: request.configFile
|
|
77
|
+
});
|
|
78
|
+
const worker = packageFile("lib/engine/worker.js");
|
|
79
|
+
if (worker === void 0) {
|
|
80
|
+
reject(unread(`${PACKAGE_NAME} cannot find the folder it is installed in: install it again`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const child = fork(worker, [], {
|
|
84
|
+
execArgv: [],
|
|
85
|
+
env: {
|
|
86
|
+
...process.env,
|
|
87
|
+
[COURIER_PHASE]: DEFINITIONS_PHASE
|
|
88
|
+
},
|
|
89
|
+
stdio: [
|
|
90
|
+
"ignore",
|
|
91
|
+
"inherit",
|
|
92
|
+
"inherit",
|
|
93
|
+
"ipc"
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
const settle = settleOnce(superviseChild(child), resolve, reject);
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
settle(unread(`The config was still being read after ${limitMs / 1e3} s, and was given up`));
|
|
99
|
+
}, limitMs);
|
|
100
|
+
settle.onSettled(() => clearTimeout(timer));
|
|
101
|
+
ask(child, request, settle, unread);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function ask(child, request, settle, unread) {
|
|
105
|
+
child.on("message", (message) => {
|
|
106
|
+
if (workerMessages.isReply(message)) settle(outcomeOf$1(message.reply), true);
|
|
107
|
+
});
|
|
108
|
+
child.once("error", (error) => settle(unread(`The process that reads the config did not start: ${error.message}`)));
|
|
109
|
+
child.once("close", (code, signal) => settle(unread(`The process that reads the config ended (${signal ?? code}) before it answered`)));
|
|
110
|
+
child.once("spawn", () => child.send(workerMessages.request(request)));
|
|
111
|
+
}
|
|
112
|
+
function outcomeOf$1(reply) {
|
|
113
|
+
if (reply.ok) return reply.snapshot;
|
|
114
|
+
return new RoutesReadError(reply.code, reply.message, reply.context, {
|
|
115
|
+
stack: reply.stack,
|
|
116
|
+
inputs: reply.inputs
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function settleOnce(release, resolve, reject) {
|
|
120
|
+
let settled = false;
|
|
121
|
+
const after = [];
|
|
122
|
+
const settle = (outcome, answered = false) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
for (const then of after) then();
|
|
126
|
+
release(answered);
|
|
127
|
+
if (outcome instanceof RoutesReadError) reject(outcome);
|
|
128
|
+
else resolve(outcome);
|
|
129
|
+
};
|
|
130
|
+
return Object.assign(settle, { onSettled: (then) => after.push(then) });
|
|
131
|
+
}
|
|
132
|
+
// src/engine/fingerprint.ts
|
|
133
|
+
/** What no file and no variable can digest to: a fingerprint nothing matches. */
|
|
134
|
+
const UNSETTLED = "unsettled";
|
|
135
|
+
/**
|
|
136
|
+
* How far before a reading began a file system that keeps whole seconds or
|
|
137
|
+
* coarser (HFS+, FAT, some network ones) may date a change made after it:
|
|
138
|
+
* FAT counts in two-second steps.
|
|
139
|
+
*/
|
|
140
|
+
const COARSE_CLOCK_MS = 2e3;
|
|
141
|
+
/**
|
|
142
|
+
* How far before a reading began a file system that keeps finer times may
|
|
143
|
+
* date a change made after it: Linux and Windows date a change by a clock
|
|
144
|
+
* that moves once a tick, every 1 to 10 milliseconds on Linux and about 16
|
|
145
|
+
* on Windows, while the reading's start is taken from a precise one.
|
|
146
|
+
*/
|
|
147
|
+
const TICK_MS = 100;
|
|
148
|
+
/**
|
|
149
|
+
* A digest of what a reading of the routes depended on, as it is now: the
|
|
150
|
+
* size and the time of the last change of every file it read, whether a
|
|
151
|
+
* path it looked at is there and is a folder, the entries of every folder
|
|
152
|
+
* it listed, and the value of every variable it looked at. Equal digests
|
|
153
|
+
* mean the same routes.
|
|
154
|
+
*
|
|
155
|
+
* A file changed after the reading began may have been read before or after
|
|
156
|
+
* the change, so the digest is then {@link UNSETTLED}, which nothing equals,
|
|
157
|
+
* and the next reading reads the routes again. The system may date such a
|
|
158
|
+
* change before the reading began: by up to a tick of the clock it dates
|
|
159
|
+
* changes by, and by up to two seconds where it keeps whole milliseconds or
|
|
160
|
+
* coarser. A change that close to the start counts as made after it.
|
|
161
|
+
*
|
|
162
|
+
* @param inputs - What the reading depended on
|
|
163
|
+
* @param since - When the reading began, in milliseconds since the epoch; omitted, every change counts as before it
|
|
164
|
+
* @returns The digest
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* fingerprintOf(snapshot) === fingerprintOf(snapshot); // true while nothing it read changed
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
function fingerprintOf(inputs, since) {
|
|
171
|
+
const hash = createHash("sha256").update(inputs.configFile ?? "\0none");
|
|
172
|
+
const parts = [...inputs.files.map((file) => filePart(file, since)), ...(inputs.listed ?? []).map((folder) => folderPart(folder, since))];
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
if (part === "unsettled") return UNSETTLED;
|
|
175
|
+
hash.update(part);
|
|
176
|
+
}
|
|
177
|
+
for (const [name, value] of variablesOf(inputs.env)) hash.update(`\n${name}=${value ?? "\0unset"}`);
|
|
178
|
+
return hash.digest("hex");
|
|
179
|
+
}
|
|
180
|
+
function filePart(file, since) {
|
|
181
|
+
const stats = statOf(file);
|
|
182
|
+
if (typeof stats === "string") return `\n${file}\0${stats}`;
|
|
183
|
+
if (stats.isDirectory()) return `\n${file}\0folder`;
|
|
184
|
+
if (since !== void 0 && changedSince(stats.mtimeMs, since)) return UNSETTLED;
|
|
185
|
+
return `\n${file}\0${stats.size}\0${stats.mtimeMs}`;
|
|
186
|
+
}
|
|
187
|
+
function folderPart(folder, since) {
|
|
188
|
+
const stats = statOf(folder);
|
|
189
|
+
if (since !== void 0 && typeof stats !== "string" && changedSince(stats.mtimeMs, since)) return UNSETTLED;
|
|
190
|
+
return `\n${folder}/\0${entriesOf(folder)}`;
|
|
191
|
+
}
|
|
192
|
+
function changedSince(mtimeMs, since) {
|
|
193
|
+
return mtimeMs >= since - (mtimeMs % 1 === 0 ? COARSE_CLOCK_MS : TICK_MS);
|
|
194
|
+
}
|
|
195
|
+
function statOf(path) {
|
|
196
|
+
try {
|
|
197
|
+
return statSync(path, { throwIfNoEntry: false }) ?? "absent";
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return errnoCode(error) ?? "unreadable";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function entriesOf(folder) {
|
|
203
|
+
try {
|
|
204
|
+
return readdirSync(folder).sort().join("\0");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return `\0${errnoCode(error) ?? "unreadable"}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function variablesOf(names) {
|
|
210
|
+
return (names === "*" ? Object.keys(process.env).sort() : names).map((name) => [name, process.env[name]]);
|
|
211
|
+
}
|
|
212
|
+
// src/engine/routes-readings.ts
|
|
213
|
+
/**
|
|
214
|
+
* Make the readings of a project's routes one at a time. Callers that ask
|
|
215
|
+
* while one runs share it, unless something it read changed while it ran:
|
|
216
|
+
* a caller that asked after it began then gets the next reading, which
|
|
217
|
+
* begins after the change, so an edit saved mid-reading is never served
|
|
218
|
+
* stale. A failure is not kept: the next caller reads again.
|
|
219
|
+
*
|
|
220
|
+
* @param read - One reading
|
|
221
|
+
* @returns The readings
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const readings = createReadings(() => evaluateInChild(request));
|
|
225
|
+
* const outcome = await readings.next();
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
function createReadings(read) {
|
|
229
|
+
const state = {
|
|
230
|
+
begun: 0,
|
|
231
|
+
running: false,
|
|
232
|
+
waiting: []
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
next() {
|
|
236
|
+
const kept = state.kept;
|
|
237
|
+
if (kept !== void 0 && isCurrent(kept)) return Promise.resolve({
|
|
238
|
+
ok: true,
|
|
239
|
+
snapshot: kept.snapshot
|
|
240
|
+
});
|
|
241
|
+
return new Promise((settle) => {
|
|
242
|
+
state.waiting.push({
|
|
243
|
+
seen: state.begun,
|
|
244
|
+
settle
|
|
245
|
+
});
|
|
246
|
+
if (!state.running) run(state, read);
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
get kept() {
|
|
250
|
+
return state.kept;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function run(state, read) {
|
|
255
|
+
state.running = true;
|
|
256
|
+
try {
|
|
257
|
+
while (state.waiting.length > 0) await readOnce(state, read);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const stranded = state.waiting;
|
|
260
|
+
state.waiting = [];
|
|
261
|
+
for (const waiter of stranded) waiter.settle({
|
|
262
|
+
ok: false,
|
|
263
|
+
error
|
|
264
|
+
});
|
|
265
|
+
} finally {
|
|
266
|
+
state.running = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function readOnce(state, read) {
|
|
270
|
+
state.begun += 1;
|
|
271
|
+
const reading = state.begun;
|
|
272
|
+
const since = Date.now();
|
|
273
|
+
const outcome = await outcomeOf(read);
|
|
274
|
+
const inputs = inputsOf(outcome);
|
|
275
|
+
const fingerprint = inputs === void 0 ? UNSETTLED : fingerprintOf(inputs, since);
|
|
276
|
+
if (outcome.ok) state.kept = {
|
|
277
|
+
snapshot: outcome.snapshot,
|
|
278
|
+
fingerprint
|
|
279
|
+
};
|
|
280
|
+
const settled = fingerprint !== "unsettled" || inputs === void 0;
|
|
281
|
+
const served = state.waiting.filter((waiter) => settled || waiter.seen < reading);
|
|
282
|
+
state.waiting = state.waiting.filter((waiter) => !served.includes(waiter));
|
|
283
|
+
for (const waiter of served) waiter.settle(outcome);
|
|
284
|
+
}
|
|
285
|
+
function isCurrent(kept) {
|
|
286
|
+
return kept.fingerprint !== "unsettled" && fingerprintOf(kept.snapshot) === kept.fingerprint;
|
|
287
|
+
}
|
|
288
|
+
async function outcomeOf(read) {
|
|
289
|
+
try {
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
snapshot: await read()
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function inputsOf(outcome) {
|
|
302
|
+
if (outcome.ok) return outcome.snapshot;
|
|
303
|
+
return outcome.error instanceof RoutesReadError ? outcome.error.inputs : void 0;
|
|
304
|
+
}
|
|
305
|
+
// src/engine/routes-engine.ts
|
|
306
|
+
/**
|
|
307
|
+
* Create the engine that reads a project's routes for a bundler plugin.
|
|
308
|
+
*
|
|
309
|
+
* @param options - Where the project and its config are
|
|
310
|
+
* @param evaluate - How one reading runs: in a process of its own
|
|
311
|
+
* @returns The engine
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* const engine = createRoutesEngine({ root: process.cwd(), envFiles: [".env.local", ".env"] });
|
|
315
|
+
* const { routes } = await engine.read();
|
|
316
|
+
* ```
|
|
317
|
+
*/
|
|
318
|
+
function createRoutesEngine(options, evaluate = evaluateInChild) {
|
|
319
|
+
const request = requestOf(options);
|
|
320
|
+
const readings = createReadings(() => evaluate({
|
|
321
|
+
...request,
|
|
322
|
+
env: options.envValues?.()
|
|
323
|
+
}));
|
|
324
|
+
return {
|
|
325
|
+
async read() {
|
|
326
|
+
const outcome = await readings.next();
|
|
327
|
+
if (outcome.ok) return outcome.snapshot;
|
|
328
|
+
throw outcome.error;
|
|
329
|
+
},
|
|
330
|
+
get last() {
|
|
331
|
+
return readings.kept?.snapshot;
|
|
332
|
+
},
|
|
333
|
+
fingerprint() {
|
|
334
|
+
const kept = readings.kept;
|
|
335
|
+
return kept === void 0 || kept.fingerprint === "unsettled" ? void 0 : fingerprintOf(kept.snapshot);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function requestOf(options) {
|
|
340
|
+
const root = resolve(options.root);
|
|
341
|
+
const fromRoot = (path) => isAbsolute(path) ? path : resolve(root, path);
|
|
342
|
+
return {
|
|
343
|
+
root,
|
|
344
|
+
configFile: options.config === void 0 ? void 0 : fromRoot(options.config),
|
|
345
|
+
envFiles: (options.envFiles ?? []).map(fromRoot)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// src/engine/routes-module-code.ts
|
|
349
|
+
/**
|
|
350
|
+
* The module a page's bundle takes in place of `@noverax/courier/routes`:
|
|
351
|
+
* the routes of every source of the config, as data alone, written as an
|
|
352
|
+
* object literal that keeps every name its own — `__proto__` too, which a
|
|
353
|
+
* literal would otherwise take for the object's prototype.
|
|
354
|
+
*
|
|
355
|
+
* @param snapshot - The routes as the config gave them
|
|
356
|
+
* @returns The module's code
|
|
357
|
+
* @example
|
|
358
|
+
* ```ts
|
|
359
|
+
* routesModuleCode(snapshot); // 'export const routes = {"shop":{"source":"shop","endpoints":{…}}};\n'
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
function routesModuleCode(snapshot) {
|
|
363
|
+
return `export const routes = ${literalOf(snapshot.routes)};\n`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The same module, as CommonJS, for a host that requires it and transforms
|
|
367
|
+
* nothing it takes: Jest, outside its ES module mode. It exports `routes`
|
|
368
|
+
* as an ES module would, so an import compiled to a `require` takes it by
|
|
369
|
+
* name.
|
|
370
|
+
*
|
|
371
|
+
* @param snapshot - The routes as the config gave them
|
|
372
|
+
* @returns The module's code
|
|
373
|
+
* @example
|
|
374
|
+
* ```ts
|
|
375
|
+
* routesCommonJsCode(snapshot); // '"use strict";\nObject.defineProperty(exports, "__esModule", …);\nexports.routes = {…};\n'
|
|
376
|
+
* ```
|
|
377
|
+
*/
|
|
378
|
+
function routesCommonJsCode(snapshot) {
|
|
379
|
+
return `"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });\nexports.routes = ${literalOf(snapshot.routes)};\n`;
|
|
380
|
+
}
|
|
381
|
+
function literalOf(value) {
|
|
382
|
+
if (Array.isArray(value)) return `[${value.map(literalOf).join(",")}]`;
|
|
383
|
+
if (!isRecord(value)) return JSON.stringify(value) ?? "null";
|
|
384
|
+
return `{${Object.entries(value).filter(([, member]) => member !== void 0).map(([key, member]) => `${keyOf(key)}:${literalOf(member)}`).join(",")}}`;
|
|
385
|
+
}
|
|
386
|
+
function keyOf(key) {
|
|
387
|
+
return key === "__proto__" ? "[\"__proto__\"]" : JSON.stringify(key);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The module a page's bundle takes when the routes were never read: one
|
|
391
|
+
* that fails where it is imported, saying why, as a build fails with it.
|
|
392
|
+
* It still exports `routes`, so a module that imports it links, and the
|
|
393
|
+
* reason is the error the page shows.
|
|
394
|
+
*
|
|
395
|
+
* @param message - Why the routes were not read
|
|
396
|
+
* @returns The module's code
|
|
397
|
+
* @example
|
|
398
|
+
* ```ts
|
|
399
|
+
* unreadRoutesModuleCode("courier.config.ts did not load: …");
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
function unreadRoutesModuleCode(message) {
|
|
403
|
+
return `throw new Error(${JSON.stringify(`[courier] ${message}`)});\nexport const routes = {};\n`;
|
|
404
|
+
}
|
|
405
|
+
export { createRoutesEngine as i, routesModuleCode as n, unreadRoutesModuleCode as r, routesCommonJsCode as t };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// src/util/routes-module-id.ts
|
|
2
|
+
/** What a page imports its routes by: the module the package ships, which the plugin answers. */
|
|
3
|
+
const ROUTES_SPECIFIER = "@noverax/courier/routes";
|
|
4
|
+
/**
|
|
5
|
+
* The module the plugin answers it with. A virtual id, so no environment
|
|
6
|
+
* takes it for an installed file to load as it is, and a server's bundle
|
|
7
|
+
* builds it in as the page's does.
|
|
8
|
+
*/
|
|
9
|
+
const ROUTES_MODULE_ID = "\0@noverax/courier/routes";
|
|
10
|
+
/** Exactly the specifier: a path that merely begins with it is another module. */
|
|
11
|
+
const ROUTES_PATTERN = /^@noverax\/courier\/routes$/;
|
|
12
|
+
export { ROUTES_PATTERN as n, ROUTES_SPECIFIER as r, ROUTES_MODULE_ID as t };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CourierError } from "@noverax/courier";
|
|
2
|
+
// src/engine/routes-read-error.ts
|
|
3
|
+
/**
|
|
4
|
+
* A project's routes could not be read from its config. A build fails with
|
|
5
|
+
* it; a dev server keeps the routes it read last and reports it.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* throw new RoutesReadError("CONFIG_NOT_FOUND", "No courier.config.* in /app or above it", { root: "/app" });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
var RoutesReadError = class extends CourierError {
|
|
13
|
+
/** What the reading had read when it failed, when it answered at all. */
|
|
14
|
+
inputs;
|
|
15
|
+
/**
|
|
16
|
+
* @param code - Why the routes were not read
|
|
17
|
+
* @param message - What went wrong, and what to do about it
|
|
18
|
+
* @param context - The project's root, and the config file once found
|
|
19
|
+
* @param details - The stack of what the config threw, and what the reading had read
|
|
20
|
+
*/
|
|
21
|
+
constructor(code, message, context, { stack, inputs } = {}) {
|
|
22
|
+
super(message, {
|
|
23
|
+
code,
|
|
24
|
+
context
|
|
25
|
+
});
|
|
26
|
+
if (stack !== void 0) this.stack = stack;
|
|
27
|
+
this.inputs = inputs;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export { RoutesReadError as t };
|