@zerotal/core 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/api-surface.md +151 -81
- package/package.json +2 -1
- package/src/application/Application.ts +4 -4
- package/src/build/codemod.ts +1 -1
- package/src/command/CommandRunner.ts +35 -4
- package/src/command/builtin/AssetsPruneCommand.ts +138 -0
- package/src/command/builtin/DeployCommand.ts +19 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/command/startZerotal.ts +51 -8
- package/src/config/AppConfig.ts +10 -10
- package/src/conventions/ConventionLoader.ts +39 -2
- package/src/dev/BuildOutput.ts +58 -6
- package/src/dev/CssPlugins.ts +2 -2
- package/src/dev/DevDeck.ts +3 -3
- package/src/dev/DevProcess.ts +2 -2
- package/src/dev/DevSupervisor.ts +5 -5
- package/src/dev/index.ts +6 -1
- package/src/dev/startDevMode.ts +1 -1
- package/src/doctor/throttleIdentity.ts +6 -2
- package/src/health/Health.ts +5 -5
- package/src/helpers/index.ts +2 -1
- package/src/helpers/markdown.ts +7 -0
- package/src/http/UploadedFile.ts +2 -2
- package/src/index.ts +2 -1
- package/src/lock/LockManager.ts +4 -4
- package/src/logger/LoggerMiddleware.ts +1 -1
- package/src/logger/types.ts +4 -4
- package/src/middleware/CorsMiddleware.ts +6 -6
- package/src/middleware/RateLimiter.ts +105 -22
- package/src/middleware/SecureHeadersMiddleware.ts +8 -8
- package/src/middleware/ThrottleMiddleware.ts +57 -4
- package/src/middleware/WebhookMiddleware.ts +7 -7
- package/src/router/RouteHandler.ts +2 -2
- package/src/router/Router.ts +5 -5
- package/src/security/redactGraph.ts +1 -1
- package/src/shared/format.ts +8 -8
- package/src/storage/StorageFilesMiddleware.ts +2 -2
- package/src/storage/types.ts +10 -10
- package/src/support/cookie.ts +6 -6
- package/src/support/deepMerge.ts +49 -0
- package/src/support/runtime.ts +169 -4
|
@@ -363,6 +363,7 @@ export class CommandRunner {
|
|
|
363
363
|
MakeProviderCommand,
|
|
364
364
|
CssBuildCommand,
|
|
365
365
|
AssetsBuildCommand,
|
|
366
|
+
AssetsPruneCommand,
|
|
366
367
|
LintPackagesCommand,
|
|
367
368
|
MakePackageCommand,
|
|
368
369
|
} = await import("./builtin/index.ts");
|
|
@@ -414,6 +415,7 @@ export class CommandRunner {
|
|
|
414
415
|
TestCommand,
|
|
415
416
|
CssBuildCommand,
|
|
416
417
|
AssetsBuildCommand,
|
|
418
|
+
AssetsPruneCommand,
|
|
417
419
|
LintPackagesCommand,
|
|
418
420
|
MakePackageCommand,
|
|
419
421
|
]);
|
|
@@ -531,8 +533,21 @@ export class CommandRunner {
|
|
|
531
533
|
const startedAt = performance.now();
|
|
532
534
|
try {
|
|
533
535
|
await instance.run();
|
|
534
|
-
|
|
535
|
-
process.
|
|
536
|
+
|
|
537
|
+
// Honour `process.exitCode` if the command set one.
|
|
538
|
+
//
|
|
539
|
+
// This used to be an unconditional `process.exit(0)`, so a command could
|
|
540
|
+
// only fail by throwing — and `process.exitCode = 1` is the idiomatic way
|
|
541
|
+
// to fail a Bun/Node CLI without an exception, which is what most people
|
|
542
|
+
// reach for. It was silently discarded. An app wrote a `release:check`
|
|
543
|
+
// gate that printed six blockers, set the code, and exited 0; the deploy
|
|
544
|
+
// script's `if ! …` read success and would have restarted a broken
|
|
545
|
+
// production deployment. A gate that cannot fail is not a gate, and that
|
|
546
|
+
// one failed *open*, which is the worst direction available.
|
|
547
|
+
const code = process.exitCode ?? 0;
|
|
548
|
+
const ok = Number(code) === 0;
|
|
549
|
+
FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, Number(code), ok));
|
|
550
|
+
process.exit(Number(code));
|
|
536
551
|
} catch (error) {
|
|
537
552
|
FrameworkEvents.emit(
|
|
538
553
|
new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
|
|
@@ -592,16 +607,32 @@ export class CommandRunner {
|
|
|
592
607
|
|
|
593
608
|
const name = (Cmd as { commandName?: string }).commandName ?? commandName;
|
|
594
609
|
const startedAt = performance.now();
|
|
610
|
+
// `process.exitCode` is process-global, and this runs the command *in this
|
|
611
|
+
// process* — so it has to be cleared before and restored after, or a command
|
|
612
|
+
// that sets it poisons the runner and everything called after it. Reading it
|
|
613
|
+
// at all is the point: `zt deploy` gates on these return codes, and a
|
|
614
|
+
// `release:check` that printed its blockers and set the code used to come back
|
|
615
|
+
// as success. The gate failed open, which is the one direction a gate must not.
|
|
616
|
+
//
|
|
617
|
+
// `0`, not `undefined`, for both the clear and the restore: in Bun,
|
|
618
|
+
// `process.exitCode = undefined` is a no-op that leaves the previous value in
|
|
619
|
+
// place, so clearing with it does nothing and one blocked command marks every
|
|
620
|
+
// command after it as failed.
|
|
621
|
+
const outerExitCode = process.exitCode ?? 0;
|
|
622
|
+
process.exitCode = 0;
|
|
595
623
|
try {
|
|
596
624
|
await instance.run();
|
|
597
|
-
|
|
598
|
-
|
|
625
|
+
const code = Number(process.exitCode ?? 0);
|
|
626
|
+
FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, code, code === 0));
|
|
627
|
+
return { code, output: writer.flush() };
|
|
599
628
|
} catch (error) {
|
|
600
629
|
FrameworkEvents.emit(
|
|
601
630
|
new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
|
|
602
631
|
);
|
|
603
632
|
const errorLine = `\x1b[31m✖ ${(error as Error).message}\x1b[0m\n`;
|
|
604
633
|
return { code: 1, output: writer.flush() + errorLine };
|
|
634
|
+
} finally {
|
|
635
|
+
process.exitCode = outerExitCode;
|
|
605
636
|
}
|
|
606
637
|
}
|
|
607
638
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { Command } from "../Command.ts";
|
|
4
|
+
import type { Application } from "../../application/Application.ts";
|
|
5
|
+
import type { ConfigManager } from "../../config/ConfigManager.ts";
|
|
6
|
+
import { _recordedBuildOutput } from "../../dev/BuildOutput.ts";
|
|
7
|
+
import type { AssetBuildConfig } from "../../dev/CssPlugins.ts";
|
|
8
|
+
|
|
9
|
+
/** Bundler-generated code-split chunk, e.g. `chunk-2502z4dn.js` (+ `.map`). */
|
|
10
|
+
const CHUNK_NAME = /^chunk-[a-z0-9]+\.js(\.map)?$/i;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `bun zt assets:prune` — remove the chunks a previous release left behind.
|
|
14
|
+
*
|
|
15
|
+
* `assets:build --clean` cleans the directory it *builds into*, which is the build
|
|
16
|
+
* machine. It does nothing for the common release shape: build here, tar the output,
|
|
17
|
+
* extract it over `public/` there. Extracting a tarball merges — nothing removes a
|
|
18
|
+
* file the new release does not contain — so every deploy adds another set of
|
|
19
|
+
* content-hashed chunks to a directory that only ever grows. One app reached 225
|
|
20
|
+
* chunk files on disk for the 49 its entry point references.
|
|
21
|
+
*
|
|
22
|
+
* Nothing breaks while that happens, which is why it runs for months unnoticed. The
|
|
23
|
+
* accumulation even *masks* a stale-cache bug, because the pruned chunk an old
|
|
24
|
+
* browser asks for is usually still lying there. But it grows without bound on a box
|
|
25
|
+
* with a disk quota, and a release process that cannot say which files belong to the
|
|
26
|
+
* current release is one that cannot clean up after itself.
|
|
27
|
+
*
|
|
28
|
+
* This is that answer. `assets:build` records what it wrote; ship `.zerotal/` with
|
|
29
|
+
* the release and this removes what the record does not claim.
|
|
30
|
+
*
|
|
31
|
+
* ## What it will and will not delete
|
|
32
|
+
*
|
|
33
|
+
* Only two things qualify: a file an *earlier* recorded build wrote that the current
|
|
34
|
+
* one did not, and a file named the way the bundler names a code-split chunk.
|
|
35
|
+
* Everything else is left alone, because an output directory is usually `public/`,
|
|
36
|
+
* where a blanket sweep takes the app's images and favicon with it.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```bash
|
|
40
|
+
* # on the server, after extracting the release and before the restart
|
|
41
|
+
* bun zt assets:prune --dry-run # list what would go
|
|
42
|
+
* bun zt assets:prune
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @category Build & assets
|
|
46
|
+
*/
|
|
47
|
+
export class AssetsPruneCommand extends Command {
|
|
48
|
+
static override commandName = "assets:prune";
|
|
49
|
+
static override description = "Remove asset files left behind by an earlier release";
|
|
50
|
+
static override needsApp = true;
|
|
51
|
+
|
|
52
|
+
static override flags = [
|
|
53
|
+
{
|
|
54
|
+
name: "dir",
|
|
55
|
+
type: "string" as const,
|
|
56
|
+
description: "Output directory to prune (defaults to the configured `app.assets.outDir`)",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "dry-run",
|
|
60
|
+
type: "boolean" as const,
|
|
61
|
+
description: "List what would be removed, and remove none of it",
|
|
62
|
+
default: false,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
async run(): Promise<void> {
|
|
67
|
+
const cwd = process.cwd();
|
|
68
|
+
const dir = (this.flags["dir"] as string | undefined) ?? this._configuredOutDir();
|
|
69
|
+
if (!dir) {
|
|
70
|
+
this.warn(
|
|
71
|
+
"No output directory to prune. Pass --dir, or configure app.assets.outDir in config/app.ts.",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const root = resolve(cwd, dir);
|
|
77
|
+
const recorded = new Set(await _recordedBuildOutput(root, cwd));
|
|
78
|
+
if (recorded.size === 0) {
|
|
79
|
+
// Without a record, "what belongs to this release" has no answer, and the
|
|
80
|
+
// by-name sweep alone would be guessing about a directory it does not own.
|
|
81
|
+
this.warn(
|
|
82
|
+
`No build record for ${dir}. Run \`zt assets:build\` (and ship .zerotal/ with the ` +
|
|
83
|
+
`release) so a prune knows which files are the current one's.`,
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stale: string[] = [];
|
|
89
|
+
for (const entry of await _listEntries(root)) {
|
|
90
|
+
if (recorded.has(entry)) continue;
|
|
91
|
+
if (!CHUNK_NAME.test(entry.split("/").at(-1) ?? "")) continue;
|
|
92
|
+
stale.push(entry);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (stale.length === 0) {
|
|
96
|
+
this.info(`${dir}: nothing to prune — ${recorded.size} file(s) all accounted for.`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this.flags["dry-run"] === true) {
|
|
101
|
+
this.info(`${dir}: ${stale.length} file(s) would be removed:`);
|
|
102
|
+
for (const path of stale.sort()) this.line(` ${path}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let removed = 0;
|
|
107
|
+
for (const path of stale) {
|
|
108
|
+
try {
|
|
109
|
+
await unlink(join(root, path));
|
|
110
|
+
removed++;
|
|
111
|
+
} catch {
|
|
112
|
+
// Already gone, or held open. The next prune tries again, and a file we
|
|
113
|
+
// could not delete is not worth failing a release over.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.info(`${dir}: removed ${removed} stale file(s), kept ${recorded.size}.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The configured asset output directory, or undefined when the app declares none. */
|
|
120
|
+
private _configuredOutDir(): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
const config = (this.app as Application).container.makeSync("config") as ConfigManager;
|
|
123
|
+
return (config.get("app.assets") as AssetBuildConfig | undefined)?.outDir;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every entry under `root`, relative and slash-normalised. Empty when unreadable. */
|
|
131
|
+
async function _listEntries(root: string): Promise<string[]> {
|
|
132
|
+
try {
|
|
133
|
+
const entries = await readdir(root, { recursive: true });
|
|
134
|
+
return entries.map((entry) => relative(".", entry).split("\\").join("/"));
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -51,6 +51,12 @@ export abstract class DeployCommand extends Command {
|
|
|
51
51
|
description: "Print the steps that would run, and run none of them",
|
|
52
52
|
default: false,
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
name: "check",
|
|
56
|
+
type: "boolean" as const,
|
|
57
|
+
description: "Run the preflight gate and stop — build nothing, migrate nothing",
|
|
58
|
+
default: false,
|
|
59
|
+
},
|
|
54
60
|
{
|
|
55
61
|
name: "skip-migrations",
|
|
56
62
|
type: "boolean" as const,
|
|
@@ -77,6 +83,19 @@ export abstract class DeployCommand extends Command {
|
|
|
77
83
|
const steps = this._steps(app, target);
|
|
78
84
|
if (dryRun) return this._printPlan(app, target, steps);
|
|
79
85
|
|
|
86
|
+
// `--check` is the gate on its own, for the moment in a release script where the
|
|
87
|
+
// new code is on disk and the service has not restarted yet. Everything that can
|
|
88
|
+
// refuse has already run by the end of preflight and none of it mutates, so
|
|
89
|
+
// stopping here is a complete answer: exit 0 and restart, exit 1 and keep serving
|
|
90
|
+
// the previous release. A workspace that has lost its banking details or had its
|
|
91
|
+
// mail driver knocked back to `log` never goes live broken.
|
|
92
|
+
if (this.flags["check"] === true) {
|
|
93
|
+
await this._preflight(app, target);
|
|
94
|
+
this.newLine();
|
|
95
|
+
this.info(`${target} passed preflight. Nothing was built, migrated or restarted.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
await this._preflight(app, target);
|
|
81
100
|
await this._runSteps(app, steps);
|
|
82
101
|
await this._verify(app);
|
|
@@ -55,5 +55,6 @@ export { DeployCommand, makeDeployCommand } from "./DeployCommand.ts";
|
|
|
55
55
|
export { MakeProviderCommand } from "./MakeProviderCommand.ts";
|
|
56
56
|
export { CssBuildCommand } from "./CssBuildCommand.ts";
|
|
57
57
|
export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
|
|
58
|
+
export { AssetsPruneCommand } from "./AssetsPruneCommand.ts";
|
|
58
59
|
export { LintPackagesCommand } from "./LintPackagesCommand.ts";
|
|
59
60
|
export { MakePackageCommand } from "./MakePackageCommand.ts";
|
|
@@ -8,17 +8,20 @@ import { CommandRunner } from "./CommandRunner.ts";
|
|
|
8
8
|
import { configLoader } from "../config/ConfigLoader.ts";
|
|
9
9
|
import { setAppEnv } from "../helpers/index.ts";
|
|
10
10
|
import {
|
|
11
|
+
runtimeBelowFloor,
|
|
12
|
+
runtimeBelowFloorMessage,
|
|
11
13
|
runtimeMismatch,
|
|
12
14
|
runtimeMismatchAllowed,
|
|
13
15
|
runtimeMismatchMessage,
|
|
14
16
|
} from "../support/runtime.ts";
|
|
17
|
+
import type { RuntimeMismatch } from "../support/runtime.ts";
|
|
15
18
|
import { RuntimeMismatchError } from "../errors/RuntimeMismatchError.ts";
|
|
16
19
|
import type { Application } from "../application/Application.ts";
|
|
17
20
|
|
|
18
21
|
/** Options for {@link startZerotal}. */
|
|
19
22
|
export interface StartZerotalOptions {
|
|
20
23
|
/** Directory to load config from, resolved against the cwd. Default: `./config`. */
|
|
21
|
-
configDir?: string;
|
|
24
|
+
configDir?: string | undefined;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -66,21 +69,61 @@ export async function startZerotal(
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
* Refuse to run
|
|
72
|
+
* Refuse to run on a Bun the project does not agree with.
|
|
73
|
+
*
|
|
74
|
+
* Two disagreements, both about the same thing — which binary the assertions
|
|
75
|
+
* downstream of here are assertions *about*:
|
|
76
|
+
*
|
|
77
|
+
* - The runtime is below the `engines.bun` the project declares. Every generated
|
|
78
|
+
* app writes that floor and nothing had ever enforced it.
|
|
79
|
+
* - The project installs a different Bun in `node_modules` than the one running.
|
|
80
|
+
* Most projects do not install Bun as a package, so most never see this one.
|
|
70
81
|
*
|
|
71
82
|
* At the top of the entry point rather than in a check somebody remembers to run,
|
|
72
|
-
* because the value is in it being unmissable: the failure
|
|
73
|
-
* that passes about the wrong binary, and nothing downstream of here can notice
|
|
74
|
-
* that. A project with no `node_modules/bun` — most of them — never sees this.
|
|
83
|
+
* because the value is in it being unmissable: the failure both prevent is a suite
|
|
84
|
+
* that passes about the wrong binary, and nothing downstream of here can notice that.
|
|
75
85
|
*
|
|
76
|
-
* @throws When
|
|
77
|
-
*
|
|
86
|
+
* @throws When either check fails, unless `ZT_ALLOW_RUNTIME_MISMATCH` is set, which
|
|
87
|
+
* downgrades it to a warning on stderr.
|
|
78
88
|
* @internal
|
|
79
89
|
*/
|
|
80
90
|
function assertOneRuntime(): void {
|
|
91
|
+
const floor = runtimeBelowFloor();
|
|
92
|
+
if (floor) {
|
|
93
|
+
_refuse(runtimeBelowFloorMessage(floor), {
|
|
94
|
+
running: floor.running,
|
|
95
|
+
installed: floor.required,
|
|
96
|
+
manifest: floor.manifest,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
const mismatch = runtimeMismatch();
|
|
82
101
|
if (!mismatch) return;
|
|
83
|
-
|
|
102
|
+
|
|
103
|
+
// A runtime the project never asked for is a warning, not a refusal. It arrives
|
|
104
|
+
// as a transitive peer — `bun-plugin-tailwind` declares `bun` as a required peer
|
|
105
|
+
// and Bun auto-installs it — and nothing executes the stray copy, so "two
|
|
106
|
+
// runtimes in play" is not what is happening. Refusing over it crash-looped an
|
|
107
|
+
// app behind a 502 twice, and the remedies the refusal prints are the wrong ones
|
|
108
|
+
// for this cause. Loud, because the version skew is still real if anything ever
|
|
109
|
+
// *does* run it.
|
|
110
|
+
if (mismatch.chosen === false) {
|
|
111
|
+
console.warn(`
|
|
112
|
+
⚠ ${runtimeMismatchMessage(mismatch)}
|
|
113
|
+
`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_refuse(runtimeMismatchMessage(mismatch), mismatch);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stop, or warn and continue when the escape hatch is set.
|
|
122
|
+
*
|
|
123
|
+
* @param message - The full explanation, already formatted.
|
|
124
|
+
* @param mismatch - The two versions, carried on the error for a harness to read.
|
|
125
|
+
*/
|
|
126
|
+
function _refuse(message: string, mismatch: RuntimeMismatch): void {
|
|
84
127
|
if (runtimeMismatchAllowed()) {
|
|
85
128
|
console.warn(`\n⚠ ${message}\n`);
|
|
86
129
|
return;
|
package/src/config/AppConfig.ts
CHANGED
|
@@ -63,17 +63,17 @@ export interface AppSecureHeadersConfig {
|
|
|
63
63
|
* Enable HSTS (and the `Secure` flag on the XSRF-TOKEN cookie). Defaults to
|
|
64
64
|
* `false` — turn it on for any deployment served over HTTPS.
|
|
65
65
|
*/
|
|
66
|
-
secure?: boolean;
|
|
66
|
+
secure?: boolean | undefined;
|
|
67
67
|
/** `Strict-Transport-Security` max-age in seconds. Defaults to one year. */
|
|
68
|
-
hstsMaxAge?: number;
|
|
68
|
+
hstsMaxAge?: number | undefined;
|
|
69
69
|
/** Include `includeSubDomains` in the HSTS header. Defaults to `true`. */
|
|
70
|
-
hstsIncludeSubDomains?: boolean;
|
|
70
|
+
hstsIncludeSubDomains?: boolean | undefined;
|
|
71
71
|
/** Set the HSTS `preload` directive. Only with the domain registered. Defaults to `false`. */
|
|
72
|
-
hstsPreload?: boolean;
|
|
72
|
+
hstsPreload?: boolean | undefined;
|
|
73
73
|
/** `Content-Security-Policy` value. Omitted by default. */
|
|
74
|
-
contentSecurityPolicy?: string;
|
|
74
|
+
contentSecurityPolicy?: string | undefined;
|
|
75
75
|
/** `Referrer-Policy` value. Defaults to `strict-origin-when-cross-origin`. */
|
|
76
|
-
referrerPolicy?: string;
|
|
76
|
+
referrerPolicy?: string | undefined;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
@@ -106,7 +106,7 @@ export interface AppAssetsConfig {
|
|
|
106
106
|
* // config/app.ts
|
|
107
107
|
* assets: { entrypoint: 'resources/css/app.css', loader: { '.woff2': 'file', '.woff': 'file' } }
|
|
108
108
|
*/
|
|
109
|
-
loader?: Record<string, AssetLoaderKind
|
|
109
|
+
loader?: Record<string, AssetLoaderKind> | undefined;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
@@ -176,7 +176,7 @@ export interface AppConfigShape {
|
|
|
176
176
|
/** Enable HTTP/3 over QUIC. Requires a valid tls config. Default: false */
|
|
177
177
|
http3: boolean;
|
|
178
178
|
/** TLS certificate + key for HTTPS. Enables HTTPS when set. */
|
|
179
|
-
tls?: AppTlsConfig;
|
|
179
|
+
tls?: AppTlsConfig | undefined;
|
|
180
180
|
/**
|
|
181
181
|
* Largest request body the server will accept, in bytes. Default: 8 MiB.
|
|
182
182
|
*
|
|
@@ -237,7 +237,7 @@ export interface AppConfigShape {
|
|
|
237
237
|
|
|
238
238
|
// ── Assets ────────────────────────────────────────────────────────────────
|
|
239
239
|
/** Front-end asset bundling, built on `serve`. Omitted means no asset build. */
|
|
240
|
-
assets?: AppAssetsConfig;
|
|
240
|
+
assets?: AppAssetsConfig | undefined;
|
|
241
241
|
|
|
242
242
|
// ── Dev mode ──────────────────────────────────────────────────────────────
|
|
243
243
|
/**
|
|
@@ -256,7 +256,7 @@ export interface AppConfigShape {
|
|
|
256
256
|
* }
|
|
257
257
|
* ```
|
|
258
258
|
*/
|
|
259
|
-
dev?: DevConfigShape;
|
|
259
|
+
dev?: DevConfigShape | undefined;
|
|
260
260
|
|
|
261
261
|
// ── Conventions ───────────────────────────────────────────────────────────
|
|
262
262
|
/** Auto-discovery settings (enabled + per-concern paths). */
|
|
@@ -45,7 +45,7 @@ export interface RunConventionsOptions {
|
|
|
45
45
|
root: string;
|
|
46
46
|
env: AppEnvironment;
|
|
47
47
|
/** Per-concern directory overrides (config `discovery.paths`). */
|
|
48
|
-
paths?: Record<string, string
|
|
48
|
+
paths?: Record<string, string> | undefined;
|
|
49
49
|
ctx: ConcernContext;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -105,7 +105,10 @@ export async function runConventions(
|
|
|
105
105
|
): Promise<void> {
|
|
106
106
|
const orderedConcerns = [...concerns].sort((first, second) => first.order - second.order);
|
|
107
107
|
for (const concern of orderedConcerns) {
|
|
108
|
-
if (concern.envs && !concern.envs.includes(options.env))
|
|
108
|
+
if (concern.envs && !concern.envs.includes(options.env)) {
|
|
109
|
+
await _announceSkipped(concern, options);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
109
112
|
|
|
110
113
|
if (concern.dir && concern.register) {
|
|
111
114
|
const relativeDirectory = options.paths?.[concern.name] ?? concern.dir;
|
|
@@ -130,3 +133,37 @@ export async function runConventions(
|
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Say once, at boot, that this process is not running a convention the app has
|
|
139
|
+
* written files for.
|
|
140
|
+
*
|
|
141
|
+
* An env-restricted concern is skipped by *not looking*, which is correct and
|
|
142
|
+
* completely silent: from a web process's point of view nothing is wrong, because
|
|
143
|
+
* from a web process's point of view nothing exists. An app ran for weeks in
|
|
144
|
+
* production with `app/schedules` full and no worker process — no inventory hold
|
|
145
|
+
* released, no payment reminder sent, nothing logged, and it was found by going
|
|
146
|
+
* looking rather than by being told.
|
|
147
|
+
*
|
|
148
|
+
* Only announced when the directory actually holds files: "skipping 0 schedules" on
|
|
149
|
+
* every boot of every app is noise, and noise is how the line that matters gets
|
|
150
|
+
* scrolled past.
|
|
151
|
+
*/
|
|
152
|
+
async function _announceSkipped(
|
|
153
|
+
concern: ConcernDescriptor,
|
|
154
|
+
options: RunConventionsOptions,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
if (!concern.dir || !concern.register) return;
|
|
157
|
+
|
|
158
|
+
const relativeDirectory = options.paths?.[concern.name] ?? concern.dir;
|
|
159
|
+
const files = (await _scanDir(`${options.root}/${relativeDirectory}`)).filter(
|
|
160
|
+
(file) => !_shouldSkip(file),
|
|
161
|
+
);
|
|
162
|
+
if (files.length === 0) return;
|
|
163
|
+
|
|
164
|
+
frameworkLog("app").info(
|
|
165
|
+
`Skipping ${files.length} file(s) in ${relativeDirectory} — the "${concern.name}" convention ` +
|
|
166
|
+
`does not run in env=${options.env} (it runs in: ${(concern.envs ?? []).join(", ")}).`,
|
|
167
|
+
{ convention: concern.name, env: options.env, files: files.length },
|
|
168
|
+
);
|
|
169
|
+
}
|
package/src/dev/BuildOutput.ts
CHANGED
|
@@ -98,15 +98,67 @@ function _relative(root: string, path: string): string {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
101
|
+
* Where a directory's manifest lives.
|
|
102
|
+
*
|
|
103
|
+
* Outside `outdir` — writing it inside would put a file the app then serves (and
|
|
104
|
+
* registers a route for) into the public directory.
|
|
105
|
+
*
|
|
106
|
+
* Named from the output directory's path **relative to the project**, not its
|
|
107
|
+
* absolute one. The hash used to be `Bun.hash(absolutePath)`, which made the
|
|
108
|
+
* manifest belong to a machine rather than to a release: the same project built at
|
|
109
|
+
* `/home/me/app` and unpacked at `/opt/app` produced two different filenames, so a
|
|
110
|
+
* manifest that travelled with a deploy matched nothing at the other end and the
|
|
111
|
+
* server could not say which files belonged to the current release. It also meant
|
|
112
|
+
* moving a checkout silently orphaned the record and stopped pruning.
|
|
113
|
+
*
|
|
114
|
+
* @internal
|
|
103
115
|
*/
|
|
104
|
-
function
|
|
105
|
-
const
|
|
116
|
+
export function _buildManifestPath(root: string, cwd: string = process.cwd()): string {
|
|
117
|
+
const relativeRoot = _relative(resolve(cwd), resolve(root));
|
|
118
|
+
const slug = relativeRoot.replace(/[^a-z0-9]+/gi, "-") || "out";
|
|
106
119
|
// The slug alone can collide (two different roots normalising to the same
|
|
107
120
|
// name); the hash makes each directory's manifest its own.
|
|
108
|
-
const hash = Bun.hash(
|
|
109
|
-
return join(
|
|
121
|
+
const hash = Bun.hash(relativeRoot).toString(36);
|
|
122
|
+
return join(cwd, MANIFEST_DIR, `${slug}-${hash}.json`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Back-compat alias used throughout this module. */
|
|
126
|
+
function _manifestPath(root: string): string {
|
|
127
|
+
return _buildManifestPath(root);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Every file the recorded builds wrote to `outdir`, relative and slash-normalised.
|
|
132
|
+
*
|
|
133
|
+
* The answer to "which assets belong to this release" — the question a deploy has
|
|
134
|
+
* to answer before it can remove anything, and the one that has no answer at all
|
|
135
|
+
* from inside a directory somebody extracted a tarball over.
|
|
136
|
+
*
|
|
137
|
+
* @param outdir - The output directory to describe.
|
|
138
|
+
* @param cwd - Project root the manifest is stored under. Defaults to the working directory.
|
|
139
|
+
* @returns The recorded files, or `[]` when no manifest exists.
|
|
140
|
+
*
|
|
141
|
+
* @internal
|
|
142
|
+
*/
|
|
143
|
+
export async function _recordedBuildOutput(
|
|
144
|
+
outdir: string,
|
|
145
|
+
cwd: string = process.cwd(),
|
|
146
|
+
): Promise<string[]> {
|
|
147
|
+
try {
|
|
148
|
+
const contents = (await Bun.file(_buildManifestPath(outdir, cwd)).json()) as unknown;
|
|
149
|
+
if (Array.isArray(contents)) {
|
|
150
|
+
return contents.filter((entry): entry is string => typeof entry === "string").sort();
|
|
151
|
+
}
|
|
152
|
+
if (!contents || typeof contents !== "object") return [];
|
|
153
|
+
const files = new Set<string>();
|
|
154
|
+
for (const list of Object.values(contents as Record<string, unknown>)) {
|
|
155
|
+
if (!Array.isArray(list)) continue;
|
|
156
|
+
for (const entry of list) if (typeof entry === "string") files.add(entry);
|
|
157
|
+
}
|
|
158
|
+
return [...files].sort();
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
110
162
|
}
|
|
111
163
|
|
|
112
164
|
/**
|
package/src/dev/CssPlugins.ts
CHANGED
|
@@ -210,13 +210,13 @@ export interface AssetBuildConfig {
|
|
|
210
210
|
prefix: string;
|
|
211
211
|
minify: boolean;
|
|
212
212
|
/** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
|
|
213
|
-
loader?: Record<string, string
|
|
213
|
+
loader?: Record<string, string> | undefined;
|
|
214
214
|
/**
|
|
215
215
|
* Delete everything in `outDir` this build did not write, rather than only what
|
|
216
216
|
* the last build on this machine recorded. See {@link cleanBuildOutput} — it
|
|
217
217
|
* refuses a directory that holds more than build output.
|
|
218
218
|
*/
|
|
219
|
-
clean?: boolean;
|
|
219
|
+
clean?: boolean | undefined;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
package/src/dev/DevDeck.ts
CHANGED
|
@@ -59,10 +59,10 @@ export interface DeckOptions {
|
|
|
59
59
|
/** The user asked to quit — bound to `q`. */
|
|
60
60
|
onQuit: () => void;
|
|
61
61
|
/** Force a renderer. Defaults to tabs on a TTY, stream otherwise. */
|
|
62
|
-
mode?: "tabs" | "stream";
|
|
62
|
+
mode?: "tabs" | "stream" | undefined;
|
|
63
63
|
/** Overridable for tests. Defaults to `process.stdout`/`process.stdin`. */
|
|
64
|
-
stdout?: DeckStdout;
|
|
65
|
-
stdin?: DeckStdin;
|
|
64
|
+
stdout?: DeckStdout | undefined;
|
|
65
|
+
stdin?: DeckStdin | undefined;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** The bits of `process.stdout` the deck reads. */
|
package/src/dev/DevProcess.ts
CHANGED
|
@@ -110,9 +110,9 @@ export interface ResolvedDevProcess {
|
|
|
110
110
|
*/
|
|
111
111
|
export interface DevConfigShape {
|
|
112
112
|
/** App-level processes, registered after every provider's. */
|
|
113
|
-
processes?: DevProcessDefinition[];
|
|
113
|
+
processes?: DevProcessDefinition[] | undefined;
|
|
114
114
|
/** Names to drop, whoever registered them. */
|
|
115
|
-
disable?: string[];
|
|
115
|
+
disable?: string[] | undefined;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** The bits of `Application` this module needs — kept structural to avoid a cycle. */
|
package/src/dev/DevSupervisor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
2
|
* Runs the dev processes and keeps them running.
|
|
3
3
|
*
|
|
4
4
|
* The supervisor owns lifetimes, not presentation: it spawns, watches, restarts,
|
|
@@ -72,17 +72,17 @@ export interface DevSupervisorHooks {
|
|
|
72
72
|
/** @internal */
|
|
73
73
|
export interface DevSupervisorOptions extends DevSupervisorHooks {
|
|
74
74
|
cwd: string;
|
|
75
|
-
env?: Record<string, string | undefined
|
|
75
|
+
env?: Record<string, string | undefined> | undefined;
|
|
76
76
|
/** Defaults to `Bun.spawn` with output piped and **stdin ignored**. */
|
|
77
|
-
spawn?: DevSpawnFn;
|
|
77
|
+
spawn?: DevSpawnFn | undefined;
|
|
78
78
|
/**
|
|
79
79
|
* Backoff before each retry, in ms. Overridable for the same reason `spawn`
|
|
80
80
|
* is: a test of the restart policy should assert the policy, not spend three
|
|
81
81
|
* seconds proving that `setTimeout` works.
|
|
82
82
|
*/
|
|
83
|
-
backoffMs?: number[];
|
|
83
|
+
backoffMs?: number[] | undefined;
|
|
84
84
|
/** How long a run must last to be considered healthy. See {@link HEALTHY_AFTER_MS}. */
|
|
85
|
-
healthyAfterMs?: number;
|
|
85
|
+
healthyAfterMs?: number | undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** Give up after this many consecutive failed starts. */
|
package/src/dev/index.ts
CHANGED
|
@@ -33,7 +33,12 @@ export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
|
33
33
|
export { browserEnvDefines } from "./buildEnv.ts";
|
|
34
34
|
export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
|
|
35
35
|
export type { AssetBuildConfig } from "./CssPlugins.ts";
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
pruneBuildOutput,
|
|
38
|
+
cleanBuildOutput,
|
|
39
|
+
_recordedBuildOutput,
|
|
40
|
+
_buildManifestPath,
|
|
41
|
+
} from "./BuildOutput.ts";
|
|
37
42
|
// Lets `serve` and any view provider agree on whether to build at boot, so a hardened
|
|
38
43
|
// production unit with a read-only output directory logs a line instead of restart-looping.
|
|
39
44
|
export { bootBuildDecision, isWritableDir } from "./bootBuild.ts";
|
package/src/dev/startDevMode.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface StartDevModeOptions {
|
|
|
31
31
|
* and nothing to serve it to — `--only=queue` is supervision and a deck, and
|
|
32
32
|
* the orchestrator that would call this never starts.
|
|
33
33
|
*/
|
|
34
|
-
build?: BuildHookFn;
|
|
34
|
+
build?: BuildHookFn | undefined;
|
|
35
35
|
/** Everything the providers and the app contributed, already filtered. */
|
|
36
36
|
processes: ResolvedDevProcess[];
|
|
37
37
|
writer: OutputWriter;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* @module
|
|
25
25
|
*/
|
|
26
26
|
import type { Application } from "../application/Application.ts";
|
|
27
|
-
import { ThrottleMiddleware } from "../middleware/ThrottleMiddleware.ts";
|
|
27
|
+
import { ThrottleMiddleware, _isIpDerived } from "../middleware/ThrottleMiddleware.ts";
|
|
28
28
|
import { Router } from "../router/Router.ts";
|
|
29
29
|
|
|
30
30
|
/** A registered throttle and how it decides who is who. */
|
|
@@ -64,7 +64,11 @@ function readIdentity(cls: unknown, where: string): ThrottleIdentity | null {
|
|
|
64
64
|
name: (cls as { name?: string }).name || "ThrottleMiddleware",
|
|
65
65
|
where,
|
|
66
66
|
trustsProxies: typeof options.trustedProxies === "number" && options.trustedProxies > 0,
|
|
67
|
-
|
|
67
|
+
// A resolver that keys on the client address is not an exemption — it is
|
|
68
|
+
// exactly the case this check exists for. `RateLimiter`'s `.byIp()`,
|
|
69
|
+
// `.byUser()` and `.byApiKey()` all mark themselves, so the named-limiter API
|
|
70
|
+
// stops slipping past the audit that covers the middleware it builds on.
|
|
71
|
+
customKey: typeof options.keyResolver === "function" && !_isIpDerived(options.keyResolver),
|
|
68
72
|
};
|
|
69
73
|
} catch {
|
|
70
74
|
// A middleware that will not construct is a different problem, and not one
|