@rivi-software/workers-bundle-check 0.0.0-stage → 0.1.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/dist/check.js ADDED
@@ -0,0 +1,233 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { findServedSourceMaps, readAssets } from "./assets.js";
4
+ import { describeRule, findForbidden, findStaleRules, validateRules, } from "./forbidden.js";
5
+ import { describePattern, isPackageName } from "./modules.js";
6
+ import { allows, describeAllowed, findSecrets, SECRET_PATTERNS, validateAllowedSecrets, validateSecretPatterns, } from "./secrets.js";
7
+ import { decodeMappings, display, readSourceMap, realPath, renderedSources, sourceAt, } from "./source-map.js";
8
+ /** Thrown by {@link checkWorkerBundle} when it finds problems: they are in `report.problems`. */
9
+ export class WorkerBundleCheckError extends Error {
10
+ report;
11
+ constructor(report) {
12
+ const count = report.problems.length;
13
+ super([
14
+ `The Worker bundle check found ${count} problem${count === 1 ? "" : "s"}:`,
15
+ ...report.problems.map((problem) => ` ${problem.message}`),
16
+ ].join("\n"));
17
+ this.name = "WorkerBundleCheckError";
18
+ this.report = report;
19
+ }
20
+ }
21
+ /**
22
+ * Checks a Worker's build: no forbidden module or marker in the Worker, no source map among the
23
+ * assets Wrangler would serve, and no value shaped like a secret in either. Resolves to the
24
+ * report when it finds nothing; rejects with a {@link WorkerBundleCheckError} that lists the
25
+ * problems otherwise. Run it after the build.
26
+ */
27
+ export async function checkWorkerBundle(options) {
28
+ const report = await inspect(options);
29
+ if (report.problems.length)
30
+ throw new WorkerBundleCheckError(report);
31
+ return report;
32
+ }
33
+ /** The Worker's files that can carry a secret: scripts, markup and data. */
34
+ const TEXT = /\.(?:[cm]?js|json|html?|css|txt|xml|svg|webmanifest)$/i;
35
+ const SCRIPT = /\.[cm]?js$/i;
36
+ /**
37
+ * Served files the secret check passes over: source maps, which are a problem of their own, and
38
+ * binary formats. It reads any other served file, such as a stray `.env`, and passes over one
39
+ * that holds a NUL byte near its start.
40
+ */
41
+ const NOT_TEXT = /\.(?:map|png|jpe?g|gif|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|mp[34]|m4a|aac|og[ag]|wav|flac|webm|mov|avi|wasm|zip|gz|br|zst|tar|pdf|bin)$/i;
42
+ async function inspect(options) {
43
+ const given = path.resolve(options.cwd ?? ".");
44
+ const cwd = realPath(given) ?? given;
45
+ const serverDir = path.resolve(cwd, options.serverDir);
46
+ const assetsDir = options.assetsDir === undefined ? undefined : path.resolve(cwd, options.assetsDir);
47
+ const rules = options.forbidden ?? [];
48
+ const allowed = options.allowedSecrets ?? [];
49
+ const patterns = [...SECRET_PATTERNS, ...(options.secretPatterns ?? [])];
50
+ validateRules(rules);
51
+ validateAllowedSecrets(allowed);
52
+ validateSecretPatterns(options.secretPatterns ?? []);
53
+ const show = (file) => display(file, cwd);
54
+ const problems = [];
55
+ const texts = new Map();
56
+ const text = (file) => {
57
+ let found = texts.get(file);
58
+ if (!found)
59
+ texts.set(file, (found = readFile(file, "utf8")));
60
+ return found;
61
+ };
62
+ /**
63
+ * A file with its modules, when it is a script with a source map. A Worker's map must be
64
+ * readable; a served asset's only names where a secret comes from, so an unreadable one is
65
+ * passed over.
66
+ */
67
+ const read = async (file, asset = false) => {
68
+ const code = await text(file);
69
+ const sourceMap = SCRIPT.test(file)
70
+ ? await readSourceMap(file, code, cwd).catch((error) => {
71
+ if (asset)
72
+ return undefined;
73
+ throw error;
74
+ })
75
+ : undefined;
76
+ if (!sourceMap)
77
+ return { file, path: show(file), code };
78
+ const mappings = decodeMappings(sourceMap.map.mappings);
79
+ const modules = new Map();
80
+ for (const [source, bytes] of renderedSources(code, mappings)) {
81
+ const name = sourceMap.modules[source];
82
+ if (name)
83
+ modules.set(name, (modules.get(name) ?? 0) + bytes);
84
+ }
85
+ return { file, path: show(file), code, sourceMap, mappings, modules };
86
+ };
87
+ // The Worker: every file in its build output, but the assets when they are inside it.
88
+ const entries = await readdir(serverDir, { recursive: true, withFileTypes: true }).catch((error) => {
89
+ if (error.code === "ENOENT")
90
+ throw new Error(`There is no Worker build at ${show(serverDir)}: run the build first.`);
91
+ throw error;
92
+ });
93
+ const inAssets = (file) => assetsDir !== undefined && !path.relative(assetsDir, file).startsWith("..");
94
+ const serverFiles = entries
95
+ .filter((entry) => entry.isFile())
96
+ .map((entry) => path.join(entry.parentPath, entry.name))
97
+ .filter((file) => !inAssets(file))
98
+ .sort();
99
+ const workerFiles = await Promise.all(serverFiles.filter((file) => SCRIPT.test(file)).map((file) => read(file)));
100
+ if (!workerFiles.length)
101
+ throw new Error(`${show(serverDir)} holds no JavaScript. Pass the directory the Worker's build writes its modules to, such as dist/server.`);
102
+ // Forbidden modules and markers.
103
+ if (rules.some((rule) => rule.module !== undefined))
104
+ for (const file of workerFiles)
105
+ if (!file.modules)
106
+ problems.push({
107
+ kind: "missing-source-map",
108
+ file: file.path,
109
+ message: `${file.path} has no source map, so the rules cannot see the modules in it. Build the Worker with source maps.`,
110
+ });
111
+ for (const finding of findForbidden(workerFiles, rules)) {
112
+ if (finding.kind === "forbidden-marker")
113
+ problems.push({
114
+ ...finding,
115
+ message: `${finding.file} contains the marker ${describePattern(finding.rule.marker)}, which is forbidden: ${finding.rule.reason}.`,
116
+ });
117
+ else {
118
+ const [largest] = finding.modules;
119
+ const count = finding.modules.length;
120
+ const total = finding.modules.reduce((sum, module) => sum + module.bytes, 0);
121
+ problems.push({
122
+ ...finding,
123
+ message: count === 1
124
+ ? `${finding.file} holds ${largest.path} (${kB(largest.bytes)}), which ${describePattern(finding.rule.module)} forbids: ${finding.rule.reason}.`
125
+ : `${finding.file} holds ${count} modules (${kB(total)}) that ${describePattern(finding.rule.module)} forbids: ${finding.rule.reason}. The largest is ${largest.path} (${kB(largest.bytes)}).`,
126
+ });
127
+ }
128
+ }
129
+ const skip = [serverDir, ...(assetsDir === undefined ? [] : [assetsDir])];
130
+ for (const { rule, part } of await findStaleRules(workerFiles, rules, cwd, skip))
131
+ problems.push({
132
+ kind: "stale-rule",
133
+ rule,
134
+ part,
135
+ message: `The rule against ${describeRule(rule)} guards nothing: ${staleReason(rule, part)}. Update the rule or remove it.`,
136
+ });
137
+ // The assets Wrangler would serve.
138
+ const assets = assetsDir === undefined
139
+ ? undefined
140
+ : await readAssets(assetsDir).catch((error) => {
141
+ if (error.code === "ENOENT")
142
+ throw new Error(`There are no assets at ${show(assetsDir)}: run the build first.`);
143
+ throw error;
144
+ });
145
+ const assetsIgnore = assetsDir === undefined ? "" : show(path.join(assetsDir, ".assetsignore"));
146
+ for (const { kind, file } of await findServedSourceMaps(assets?.served ?? [], text))
147
+ problems.push(kind === "served-source-map"
148
+ ? {
149
+ kind,
150
+ file: show(file),
151
+ message: `${show(file)} is a source map Wrangler would serve: leave it out with ${assetsIgnore}, or delete it before the deploy.`,
152
+ }
153
+ : {
154
+ kind,
155
+ file: show(file),
156
+ message: `${show(file)} would be served with its source map inline. Build it with external or hidden source maps.`,
157
+ });
158
+ // Values shaped like secrets, in the Worker and in what the assets serve.
159
+ const plain = async (file) => ({
160
+ file,
161
+ path: show(file),
162
+ code: await text(file),
163
+ });
164
+ const scanned = [
165
+ ...workerFiles,
166
+ ...(await Promise.all(serverFiles.filter((file) => TEXT.test(file) && !SCRIPT.test(file)).map(plain))),
167
+ ...(await Promise.all((assets?.served ?? [])
168
+ .filter(({ file }) => !NOT_TEXT.test(file))
169
+ .map(({ file }) => plain(file)))).filter(({ code }) => !code.slice(0, 8000).includes("\0")),
170
+ ];
171
+ const used = new Set();
172
+ let allowedCount = 0;
173
+ for (const scannedFile of scanned) {
174
+ const findings = findSecrets(scannedFile.code, patterns);
175
+ if (!findings.length)
176
+ continue;
177
+ // A served script's map is read only to name where a value comes from.
178
+ const file = workerFiles.includes(scannedFile)
179
+ ? scannedFile
180
+ : await read(scannedFile.file, true);
181
+ for (const finding of findings) {
182
+ const source = file.mappings && file.sourceMap
183
+ ? sourceAt(file.mappings, finding.line - 1, finding.column)
184
+ : -1;
185
+ const module = source >= 0 ? file.sourceMap.modules[source] : undefined;
186
+ const covering = allowed.filter((entry) => allows(entry, finding, module));
187
+ if (covering.length) {
188
+ for (const entry of covering)
189
+ used.add(entry);
190
+ allowedCount++;
191
+ continue;
192
+ }
193
+ problems.push({
194
+ kind: "secret",
195
+ file: file.path,
196
+ name: finding.name,
197
+ fingerprint: finding.fingerprint,
198
+ line: finding.line,
199
+ ...(module === undefined ? {} : { module }),
200
+ message: `${file.path}:${finding.line} holds a value shaped like a ${finding.name}${module === undefined ? "" : `, from ${module}`} (fingerprint ${finding.fingerprint}).`,
201
+ });
202
+ }
203
+ }
204
+ for (const entry of allowed)
205
+ if (!used.has(entry))
206
+ problems.push({
207
+ kind: "stale-allowed-secret",
208
+ allowed: entry,
209
+ message: `The allowed secret with ${describeAllowed(entry)} (${entry.reason}) matches no finding. Remove it.`,
210
+ });
211
+ return {
212
+ serverDir: show(serverDir),
213
+ workerFiles: workerFiles.map((file) => file.path),
214
+ modules: new Set(workerFiles.flatMap((file) => [...(file.modules?.keys() ?? [])])).size,
215
+ ...(assetsDir === undefined ? {} : { assetsDir: show(assetsDir) }),
216
+ servedAssets: assets?.served.length ?? 0,
217
+ ignoredAssets: assets?.ignored.length ?? 0,
218
+ allowedSecrets: allowedCount,
219
+ problems,
220
+ };
221
+ }
222
+ function staleReason(rule, part) {
223
+ if (part === "module")
224
+ return isPackageName(rule.module)
225
+ ? `no package ${describePattern(rule.module)} is installed`
226
+ : `no file matches ${describePattern(rule.module)}`;
227
+ return rule.module === undefined
228
+ ? `no file of the project, outside node_modules, contains ${describePattern(rule.marker)}`
229
+ : `no file that ${describePattern(rule.module)} matches contains ${describePattern(rule.marker)}`;
230
+ }
231
+ function kB(bytes) {
232
+ return bytes < 1000 ? `${bytes} B` : `${(bytes / 1000).toFixed(1)} kB`;
233
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./run-cli.js";
3
+ /** `workers-bundle-check [--config <file>] [--server <dir>] [--assets <dir>]` (./run-cli.ts). */
4
+ process.exitCode = await runCli(process.argv.slice(2).filter((arg) => arg !== "--"));
@@ -0,0 +1,65 @@
1
+ import { type ModulePattern } from "./modules.js";
2
+ /**
3
+ * What the Worker must not carry, and why. A rule names a module, a marker, or both; the Worker
4
+ * breaks it when it holds either.
5
+ */
6
+ export type ForbiddenRule = {
7
+ /** The modules the Worker must not hold: a package name, a glob or a regular expression. */
8
+ module?: ModulePattern;
9
+ /**
10
+ * A string or regular expression the Worker's code must not contain, such as a string only
11
+ * dev code has. It finds code that no source map names.
12
+ */
13
+ marker?: string | RegExp;
14
+ /** Why, for the report: `test-only`, `import single icons`. */
15
+ reason: string;
16
+ };
17
+ /** A built JavaScript file of the Worker. */
18
+ export type WorkerFile = {
19
+ /** Its path, as reports show it. */
20
+ path: string;
21
+ code: string;
22
+ /** The bytes of code each module renders in it, by module path; undefined without a map. */
23
+ modules?: ReadonlyMap<string, number>;
24
+ };
25
+ /** A module the Worker holds, and the bytes of code it renders there. */
26
+ export type BundledModule = {
27
+ path: string;
28
+ bytes: number;
29
+ };
30
+ /** A forbidden rule the Worker breaks, in one of its files. */
31
+ export type ForbiddenFinding = {
32
+ kind: "forbidden-module";
33
+ rule: ForbiddenRule;
34
+ file: string;
35
+ modules: BundledModule[];
36
+ } | {
37
+ kind: "forbidden-marker";
38
+ rule: ForbiddenRule;
39
+ file: string;
40
+ };
41
+ /** The part of a rule that matches nothing the project has. */
42
+ export type StaleRule = {
43
+ rule: ForbiddenRule;
44
+ part: "module" | "marker";
45
+ };
46
+ /** Throws when a rule names neither a module nor a marker, or has no reason. */
47
+ export declare function validateRules(rules: readonly ForbiddenRule[]): void;
48
+ /** Whether `code` contains the marker. */
49
+ export declare function hasMarker(code: string, marker: string | RegExp): boolean;
50
+ /** Each rule the Worker's files break, per file. */
51
+ export declare function findForbidden(files: readonly WorkerFile[], rules: readonly ForbiddenRule[]): ForbiddenFinding[];
52
+ /**
53
+ * The rule parts that match nothing: a guard that matches nothing guards nothing, so the list
54
+ * stays current. The Worker should hold none of what the rules name, so a rule is checked
55
+ * against the project instead (see {@link projectFiles}):
56
+ * - a module pattern, against the project's files: a package name needs the package installed,
57
+ * and a glob or regular expression needs a file it matches
58
+ * - a marker, against the text of the files its rule's module pattern matches; without one,
59
+ * against the project's own files under `cwd`, outside `node_modules`
60
+ *
61
+ * A part the Worker matches is never stale.
62
+ */
63
+ export declare function findStaleRules(files: readonly WorkerFile[], rules: readonly ForbiddenRule[], cwd: string, skip: readonly string[]): Promise<StaleRule[]>;
64
+ /** A rule as reports name it: its reason and what it forbids. */
65
+ export declare function describeRule(rule: ForbiddenRule): string;
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { describePattern, isPackageName, moduleMatcher, projectFiles, stateless, } from "./modules.js";
5
+ /** Throws when a rule names neither a module nor a marker, or has no reason. */
6
+ export function validateRules(rules) {
7
+ for (const rule of rules) {
8
+ if (rule.module === undefined && rule.marker === undefined)
9
+ throw new TypeError(`A forbidden rule names neither a module nor a marker: ${show(rule)}.`);
10
+ if (!rule.reason?.trim())
11
+ throw new TypeError(`A forbidden rule has no reason: ${show(rule)}.`);
12
+ }
13
+ }
14
+ function show(rule) {
15
+ return JSON.stringify(rule, (_, value) => value instanceof RegExp ? String(value) : value);
16
+ }
17
+ /** Whether `code` contains the marker. */
18
+ export function hasMarker(code, marker) {
19
+ return typeof marker === "string" ? code.includes(marker) : stateless(marker).test(code);
20
+ }
21
+ /** Each rule the Worker's files break, per file. */
22
+ export function findForbidden(files, rules) {
23
+ const findings = [];
24
+ for (const rule of rules) {
25
+ const matches = rule.module === undefined ? undefined : moduleMatcher(rule.module);
26
+ for (const file of files) {
27
+ const modules = matches
28
+ ? [...(file.modules ?? [])]
29
+ .filter(([modulePath]) => matches(modulePath))
30
+ .map(([modulePath, bytes]) => ({ path: modulePath, bytes }))
31
+ .sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path))
32
+ : [];
33
+ if (modules.length)
34
+ findings.push({ kind: "forbidden-module", rule, file: file.path, modules });
35
+ if (rule.marker !== undefined && hasMarker(file.code, rule.marker))
36
+ findings.push({ kind: "forbidden-marker", rule, file: file.path });
37
+ }
38
+ }
39
+ return findings;
40
+ }
41
+ /** Files read for a marker: code and the text formats that carry it. */
42
+ const TEXT = /\.(?:[cm]?[jt]sx?|json|vue|svelte|astro|html?)$/i;
43
+ /**
44
+ * The rule parts that match nothing: a guard that matches nothing guards nothing, so the list
45
+ * stays current. The Worker should hold none of what the rules name, so a rule is checked
46
+ * against the project instead (see {@link projectFiles}):
47
+ * - a module pattern, against the project's files: a package name needs the package installed,
48
+ * and a glob or regular expression needs a file it matches
49
+ * - a marker, against the text of the files its rule's module pattern matches; without one,
50
+ * against the project's own files under `cwd`, outside `node_modules`
51
+ *
52
+ * A part the Worker matches is never stale.
53
+ */
54
+ export async function findStaleRules(files, rules, cwd, skip) {
55
+ const pending = [];
56
+ for (const rule of rules) {
57
+ const matches = rule.module === undefined ? undefined : moduleMatcher(rule.module);
58
+ const inWorker = matches !== undefined &&
59
+ files.some((file) => [...(file.modules?.keys() ?? [])].some(matches));
60
+ const module = rule.module !== undefined &&
61
+ !inWorker &&
62
+ !(typeof rule.module === "string" &&
63
+ isPackageName(rule.module) &&
64
+ installed(rule.module, cwd));
65
+ const marker = rule.marker !== undefined && !files.some((file) => hasMarker(file.code, rule.marker));
66
+ if (module || marker)
67
+ pending.push({ rule, matches, module, marker });
68
+ }
69
+ const open = () => pending.some((entry) => entry.module || entry.marker);
70
+ if (open())
71
+ for await (const { path: modulePath, file } of projectFiles(cwd, skip)) {
72
+ let text;
73
+ const read = () => (text ??= TEXT.test(file)
74
+ ? readFile(file, "utf8").catch(() => undefined)
75
+ : Promise.resolve(undefined));
76
+ for (const entry of pending) {
77
+ const named = entry.matches?.(modulePath);
78
+ if (entry.module && named)
79
+ entry.module = false;
80
+ if (!entry.marker)
81
+ continue;
82
+ const own = !entry.matches &&
83
+ !modulePath.startsWith("../") &&
84
+ !/(?:^|\/)node_modules\//.test(modulePath);
85
+ if (!named && !own)
86
+ continue;
87
+ const code = await read();
88
+ if (code !== undefined && hasMarker(code, entry.rule.marker))
89
+ entry.marker = false;
90
+ }
91
+ if (!open())
92
+ break;
93
+ }
94
+ return pending.flatMap(({ rule, module, marker }) => [
95
+ ...(module ? [{ rule, part: "module" }] : []),
96
+ ...(marker ? [{ rule, part: "marker" }] : []),
97
+ ]);
98
+ }
99
+ /** Whether a package is installed where Node would find it from `cwd`, or in pnpm's store. */
100
+ function installed(name, cwd) {
101
+ for (let dir = cwd;; dir = path.dirname(dir)) {
102
+ for (const base of ["node_modules", "node_modules/.pnpm/node_modules"])
103
+ if (existsSync(path.join(dir, base, name, "package.json")))
104
+ return true;
105
+ if (dir === path.dirname(dir))
106
+ return false;
107
+ }
108
+ }
109
+ /** A rule as reports name it: its reason and what it forbids. */
110
+ export function describeRule(rule) {
111
+ const parts = [
112
+ rule.module === undefined ? undefined : `module ${describePattern(rule.module)}`,
113
+ rule.marker === undefined ? undefined : `marker ${describePattern(rule.marker)}`,
114
+ ].filter(Boolean);
115
+ return `${parts.join(" and ")} (${rule.reason})`;
116
+ }
@@ -0,0 +1,4 @@
1
+ export { checkWorkerBundle, WorkerBundleCheckError, type WorkerBundleCheckOptions, type WorkerBundleProblem, type WorkerBundleReport, } from "./check.js";
2
+ export { type BundledModule, type ForbiddenRule } from "./forbidden.js";
3
+ export { type ModulePattern } from "./modules.js";
4
+ export { type AllowedSecret, SECRET_PATTERNS, type SecretPattern } from "./secrets.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { checkWorkerBundle, WorkerBundleCheckError, } from "./check.js";
2
+ export { SECRET_PATTERNS } from "./secrets.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which modules a rule names:
3
+ * - a package name, such as `msw` or `@faker-js/faker`: every module in that package, wherever it
4
+ * is installed
5
+ * - a glob, such as `src/mocks/**` or `**\/icons/all.js`: the module paths it matches
6
+ * - a regular expression: the module paths it matches
7
+ *
8
+ * A module path is relative to the check's `cwd`, with `/` between segments. The leading `../` of
9
+ * a module outside `cwd` is left out before matching, so `node_modules/…` in a monorepo's root
10
+ * matches as `node_modules/…`.
11
+ */
12
+ export type ModulePattern = string | RegExp;
13
+ /** Whether a module pattern is a package name. */
14
+ export declare function isPackageName(pattern: ModulePattern): boolean;
15
+ /** A test for module paths: whether `pattern` names the module. */
16
+ export declare function moduleMatcher(pattern: ModulePattern): (modulePath: string) => boolean;
17
+ /** A copy of a regular expression without the `g` and `y` flags, whose `test` keeps state. */
18
+ export declare function stateless(regExp: RegExp): RegExp;
19
+ /** A pattern or marker as reports show it. */
20
+ export declare function describePattern(pattern: string | RegExp): string;
21
+ /** A file of the project: its module path and its absolute real path. */
22
+ export type ProjectFile = {
23
+ path: string;
24
+ file: string;
25
+ };
26
+ /**
27
+ * Every file the project has, for the check of stale rules: the files under `cwd`, and those in
28
+ * the `node_modules` of each directory above it, where a monorepo installs its packages. It
29
+ * follows symlinked directories, each real directory once, and names each file by its real path,
30
+ * as bundlers do. It skips `.git` and the directories in `skip`, the build's output.
31
+ */
32
+ export declare function projectFiles(cwd: string, skip: readonly string[]): AsyncGenerator<ProjectFile>;
@@ -0,0 +1,83 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Minimatch } from "minimatch";
4
+ import { posixRelative, realPath } from "./source-map.js";
5
+ /** npm's rules for a package name, which never holds a glob character. */
6
+ const PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
7
+ /** Whether a module pattern is a package name. */
8
+ export function isPackageName(pattern) {
9
+ return typeof pattern === "string" && PACKAGE_NAME.test(pattern);
10
+ }
11
+ /** A test for module paths: whether `pattern` names the module. */
12
+ export function moduleMatcher(pattern) {
13
+ if (pattern instanceof RegExp) {
14
+ const regExp = stateless(pattern);
15
+ return (modulePath) => regExp.test(matchable(modulePath));
16
+ }
17
+ if (isPackageName(pattern)) {
18
+ const segment = `/node_modules/${pattern}/`;
19
+ return (modulePath) => `/${matchable(modulePath)}`.includes(segment);
20
+ }
21
+ const glob = new Minimatch(pattern.replace(/^\.\//, ""), { dot: true });
22
+ return (modulePath) => glob.match(matchable(modulePath));
23
+ }
24
+ /** A module path without the `../` that leads outside `cwd`. */
25
+ function matchable(modulePath) {
26
+ return modulePath.replace(/^(?:\.\.\/)+/, "");
27
+ }
28
+ /** A copy of a regular expression without the `g` and `y` flags, whose `test` keeps state. */
29
+ export function stateless(regExp) {
30
+ return regExp.global || regExp.sticky
31
+ ? new RegExp(regExp.source, regExp.flags.replace(/[gy]/g, ""))
32
+ : regExp;
33
+ }
34
+ /** A pattern or marker as reports show it. */
35
+ export function describePattern(pattern) {
36
+ return typeof pattern === "string" ? JSON.stringify(pattern) : String(pattern);
37
+ }
38
+ /**
39
+ * Every file the project has, for the check of stale rules: the files under `cwd`, and those in
40
+ * the `node_modules` of each directory above it, where a monorepo installs its packages. It
41
+ * follows symlinked directories, each real directory once, and names each file by its real path,
42
+ * as bundlers do. It skips `.git` and the directories in `skip`, the build's output.
43
+ */
44
+ export async function* projectFiles(cwd, skip) {
45
+ const skipped = new Set(skip.map((dir) => realPath(dir) ?? dir));
46
+ const seen = new Set();
47
+ const queue = [];
48
+ const visit = (dir) => {
49
+ if (dir && !seen.has(dir) && !skipped.has(dir)) {
50
+ seen.add(dir);
51
+ queue.push(dir);
52
+ }
53
+ };
54
+ visit(realPath(cwd));
55
+ for (let dir = path.dirname(cwd);; dir = path.dirname(dir)) {
56
+ visit(realPath(path.join(dir, "node_modules")));
57
+ if (dir === path.dirname(dir))
58
+ break;
59
+ }
60
+ for (let index = 0; index < queue.length; index++) {
61
+ const dir = queue[index];
62
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
63
+ for (const entry of entries) {
64
+ if (entry.name === ".git")
65
+ continue;
66
+ const file = path.join(dir, entry.name);
67
+ if (entry.isDirectory())
68
+ visit(file);
69
+ else if (entry.isFile())
70
+ yield { path: posixRelative(cwd, file), file };
71
+ else if (entry.isSymbolicLink()) {
72
+ const target = await stat(file).catch(() => undefined);
73
+ const resolved = target && realPath(file);
74
+ if (!resolved)
75
+ continue;
76
+ if (target.isDirectory())
77
+ visit(resolved);
78
+ else if (target.isFile())
79
+ yield { path: posixRelative(cwd, resolved), file: resolved };
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,12 @@
1
+ /** The configuration files the command looks for in the current directory, in this order. */
2
+ export declare const CONFIG_FILES: string[];
3
+ export declare const USAGE = "Usage: workers-bundle-check [--config <file>] [--server <dir>] [--assets <dir>]\n\nChecks a Worker's build: no forbidden module or marker in the Worker, no source\nmap among the assets Wrangler would serve, and no value shaped like a secret.\nRun it after the build.\n\n --config A module whose default export holds the options. Default: the\n first of workers-bundle-check.config.ts, .mts, .js, .mjs and .json\n that exists. A JSON file holds no regular expressions.\n --server The Worker's build output, such as dist/server (the option serverDir)\n --assets Its static assets directory, such as dist/client (the option assetsDir)\n\nPaths are relative to the current directory.";
4
+ type Context = {
5
+ /** The directory paths are relative to. */
6
+ cwd: string;
7
+ log: (line: string) => void;
8
+ error: (line: string) => void;
9
+ };
10
+ /** Runs `workers-bundle-check` with `args` and returns its exit code. */
11
+ export declare function runCli(args: string[], context?: Context): Promise<number>;
12
+ export {};
@@ -0,0 +1,100 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { parseArgs } from "node:util";
6
+ import { checkWorkerBundle, } from "./check.js";
7
+ /** The configuration files the command looks for in the current directory, in this order. */
8
+ export const CONFIG_FILES = [
9
+ "workers-bundle-check.config.ts",
10
+ "workers-bundle-check.config.mts",
11
+ "workers-bundle-check.config.js",
12
+ "workers-bundle-check.config.mjs",
13
+ "workers-bundle-check.config.json",
14
+ ];
15
+ export const USAGE = `Usage: workers-bundle-check [--config <file>] [--server <dir>] [--assets <dir>]
16
+
17
+ Checks a Worker's build: no forbidden module or marker in the Worker, no source
18
+ map among the assets Wrangler would serve, and no value shaped like a secret.
19
+ Run it after the build.
20
+
21
+ --config A module whose default export holds the options. Default: the
22
+ first of workers-bundle-check.config.ts, .mts, .js, .mjs and .json
23
+ that exists. A JSON file holds no regular expressions.
24
+ --server The Worker's build output, such as dist/server (the option serverDir)
25
+ --assets Its static assets directory, such as dist/client (the option assetsDir)
26
+
27
+ Paths are relative to the current directory.`;
28
+ /** Runs `workers-bundle-check` with `args` and returns its exit code. */
29
+ export async function runCli(args, context = { cwd: process.cwd(), log: console.log, error: console.error }) {
30
+ let values;
31
+ try {
32
+ ({ values } = parseArgs({
33
+ args,
34
+ strict: true,
35
+ options: {
36
+ config: { type: "string" },
37
+ server: { type: "string" },
38
+ assets: { type: "string" },
39
+ help: { type: "boolean", short: "h" },
40
+ },
41
+ }));
42
+ }
43
+ catch (error) {
44
+ context.error(`${error instanceof Error ? error.message : error}\n\n${USAGE}`);
45
+ return 2;
46
+ }
47
+ if (values.help) {
48
+ context.log(USAGE);
49
+ return 0;
50
+ }
51
+ try {
52
+ const found = values.config
53
+ ? path.resolve(context.cwd, values.config)
54
+ : CONFIG_FILES.map((file) => path.join(context.cwd, file)).find((file) => existsSync(file));
55
+ if (values.config && !existsSync(found)) {
56
+ context.error(`There is no configuration file at ${values.config}.`);
57
+ return 2;
58
+ }
59
+ const config = found ? await loadConfig(found) : {};
60
+ const serverDir = values.server ?? config.serverDir;
61
+ if (!serverDir) {
62
+ context.error(`Name the Worker's build output: --server <dir>, or serverDir in ${found ? path.relative(context.cwd, found) : CONFIG_FILES[0]}.\n\n${USAGE}`);
63
+ return 2;
64
+ }
65
+ const report = await checkWorkerBundle({
66
+ ...config,
67
+ cwd: path.resolve(context.cwd, config.cwd ?? "."),
68
+ serverDir,
69
+ assetsDir: values.assets ?? config.assetsDir,
70
+ });
71
+ for (const line of summary(report, config))
72
+ context.log(line);
73
+ return 0;
74
+ }
75
+ catch (error) {
76
+ context.error(error instanceof Error ? error.message : String(error));
77
+ return 1;
78
+ }
79
+ }
80
+ /** The options a configuration file's default export holds. */
81
+ async function loadConfig(file) {
82
+ const config = file.endsWith(".json")
83
+ ? JSON.parse(await readFile(file, "utf8"))
84
+ : (await import(pathToFileURL(file).href)).default;
85
+ if (!config || typeof config !== "object" || Array.isArray(config))
86
+ throw new Error(`${file} must export the options as its default export: { serverDir, … }.`);
87
+ return config;
88
+ }
89
+ /** What a passing check read, line by line. */
90
+ function summary(report, config) {
91
+ const rules = config.forbidden?.length ?? 0;
92
+ const files = report.workerFiles.length;
93
+ const lines = [
94
+ `${report.serverDir}: ${files} file${files === 1 ? "" : "s"} and ${report.modules} module${report.modules === 1 ? "" : "s"}, none that ${rules} forbidden rule${rules === 1 ? "" : "s"} name.`,
95
+ ];
96
+ if (report.assetsDir !== undefined)
97
+ lines.push(`${report.assetsDir}: ${report.servedAssets} file${report.servedAssets === 1 ? "" : "s"} served and ${report.ignoredAssets} left out, no source map among them.`);
98
+ lines.push(`No value shaped like a secret${report.allowedSecrets ? `, but the ${report.allowedSecrets} allowed` : ""}.`);
99
+ return lines;
100
+ }