@zerotal/core 1.8.1 → 1.10.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 +204 -0
- package/api-surface.md +192 -424
- package/package.json +3 -1
- package/src/application/Application.ts +10 -4
- package/src/application/currentApp.ts +7 -1
- package/src/build/codemod.ts +1 -1
- package/src/command/CommandRunner.ts +2 -0
- package/src/command/OutputWriter.ts +5 -1
- package/src/command/builtin/AssetsPruneCommand.ts +138 -0
- package/src/command/builtin/DeployCommand.ts +91 -1
- package/src/command/builtin/TestCommand.ts +9 -2
- package/src/command/builtin/index.ts +1 -0
- package/src/command/startZerotal.ts +62 -1
- package/src/config/AppConfig.ts +10 -10
- package/src/config/ConfigLoader.ts +5 -1
- package/src/config/DeployConfig.ts +33 -0
- package/src/config/index.ts +6 -1
- package/src/config/registry.ts +5 -1
- package/src/config/validation.ts +10 -2
- package/src/conventions/ConventionLoader.ts +44 -3
- package/src/dev/BuildOutput.ts +58 -6
- package/src/dev/CssPlugins.ts +13 -3
- package/src/dev/DevBuildHook.ts +10 -2
- package/src/dev/DevDeck.ts +9 -3
- package/src/dev/DevOrchestrator.ts +2 -0
- package/src/dev/DevProcess.ts +19 -5
- package/src/dev/DevReloadMiddleware.ts +7 -1
- package/src/dev/DevSupervisor.ts +27 -9
- package/src/dev/bootBuild.ts +9 -1
- package/src/dev/index.ts +6 -1
- package/src/dev/reloadClient.ts +2 -0
- package/src/dev/startDevMode.ts +8 -2
- package/src/doctor/AppDoctor.ts +43 -1
- package/src/doctor/throttleIdentity.ts +118 -0
- package/src/errors/RuntimeMismatchError.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/events/Emitter.ts +5 -1
- package/src/health/Health.ts +5 -5
- package/src/helpers/html.ts +2 -0
- package/src/helpers/index.ts +2 -1
- package/src/helpers/markdown.ts +14 -1
- package/src/helpers/pageElements.ts +2 -0
- package/src/http/HttpClient.ts +5 -1
- package/src/http/UploadedFile.ts +2 -2
- package/src/http/Uri.ts +5 -1
- package/src/http/negotiate.ts +5 -1
- package/src/http/originGuard.ts +2 -0
- package/src/http/sniffContentType.ts +7 -1
- 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/macros/config.macro.ts +2 -0
- package/src/metrics/HttpMetrics.ts +10 -2
- package/src/middleware/BaseMiddleware.ts +20 -0
- 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/pipeline/ContextRegistry.ts +12 -2
- package/src/pipeline/HttpContext.ts +4 -0
- package/src/pipeline/currentPage.ts +4 -0
- package/src/pipeline/types.ts +2 -0
- package/src/router/FileRouter.ts +20 -2
- package/src/router/Route.ts +3 -0
- package/src/router/RouteHandler.ts +2 -2
- package/src/router/Router.ts +7 -5
- package/src/router/domain.ts +1 -0
- package/src/router/registry.ts +12 -2
- package/src/security/redactGraph.ts +1 -1
- package/src/shared/format.ts +135 -0
- package/src/shared/index.ts +34 -0
- package/src/storage/StorageFilesMiddleware.ts +4 -2
- package/src/storage/types.ts +10 -10
- package/src/support/cookie.ts +13 -7
- package/src/support/deepMerge.ts +49 -0
- package/src/support/runtime.ts +226 -0
- package/src/view/FileRouteResolver.ts +2 -0
- package/src/view/jsx-runtime.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"./logger": "./src/logger/index.ts",
|
|
17
17
|
"./commands": "./src/command/builtin/index.ts",
|
|
18
18
|
"./helpers": "./src/helpers/index.ts",
|
|
19
|
+
"./runtime": "./src/support/runtime.ts",
|
|
20
|
+
"./shared": "./src/shared/index.ts",
|
|
19
21
|
"./facades": "./src/facade/facades/index.ts",
|
|
20
22
|
"./carbon": "./src/datetime/index.ts",
|
|
21
23
|
"./http": "./src/http/index.ts",
|
|
@@ -96,6 +96,8 @@ async function _discoverConfig(dir: string): Promise<Record<string, Record<strin
|
|
|
96
96
|
* "web" → prefix: "", middleware: ["web"]
|
|
97
97
|
* "api" → prefix: "/api", middleware: ["api"]
|
|
98
98
|
* Custom keys must declare both `prefix` and `middleware` explicitly.
|
|
99
|
+
*
|
|
100
|
+
* @internal
|
|
99
101
|
*/
|
|
100
102
|
export type RoutingEntry = string | { file: string; prefix?: string; middleware?: MiddlewareInput };
|
|
101
103
|
|
|
@@ -105,6 +107,8 @@ export type RoutingConfig = Record<string, RoutingEntry>;
|
|
|
105
107
|
/**
|
|
106
108
|
* A single entry in a `fileBasedRouting()` config map.
|
|
107
109
|
* Same semantics as `RoutingEntry` but points to a directory instead of a file.
|
|
110
|
+
*
|
|
111
|
+
* @internal
|
|
108
112
|
*/
|
|
109
113
|
export type FileRoutingEntry =
|
|
110
114
|
string | { dir: string; prefix?: string; middleware?: MiddlewareInput };
|
|
@@ -178,9 +182,9 @@ type PipeClass = new (...args: any[]) => Pipe<HttpContext>;
|
|
|
178
182
|
|
|
179
183
|
/** Options form for `Application.create({ ... })`. */
|
|
180
184
|
export interface CreateOptions {
|
|
181
|
-
providers?: ProviderClass[];
|
|
182
|
-
env?: Environment;
|
|
183
|
-
config?: ConfigLoader | ConfigMap;
|
|
185
|
+
providers?: ProviderClass[] | undefined;
|
|
186
|
+
env?: Environment | undefined;
|
|
187
|
+
config?: ConfigLoader | ConfigMap | undefined;
|
|
184
188
|
/**
|
|
185
189
|
* Drop {@link SecureHeadersMiddleware} from the front of the pipeline.
|
|
186
190
|
*
|
|
@@ -190,7 +194,7 @@ export interface CreateOptions {
|
|
|
190
194
|
* `config('app.secureHeaders')`; turn it off only when a proxy in front of the app is
|
|
191
195
|
* already emitting the same headers.
|
|
192
196
|
*/
|
|
193
|
-
secureHeaders?: false;
|
|
197
|
+
secureHeaders?: false | undefined;
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function _normaliseEnv(raw: string): Environment {
|
|
@@ -271,6 +275,8 @@ export interface WebSocketHandlers {
|
|
|
271
275
|
/**
|
|
272
276
|
* Installs application-scoped state and returns a teardown function that
|
|
273
277
|
* restores the previous state when the application is reset.
|
|
278
|
+
*
|
|
279
|
+
* @internal
|
|
274
280
|
*/
|
|
275
281
|
export type AppScopeInstaller = () => () => void;
|
|
276
282
|
|
|
@@ -51,7 +51,11 @@ export function currentApp(): Application {
|
|
|
51
51
|
return app;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The current application, or `undefined` when none is available. Safe off-app (CLI bootstrap, tests).
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
55
59
|
export function tryCurrentApp(): Application | undefined {
|
|
56
60
|
return _scope.getStore() ?? _default;
|
|
57
61
|
}
|
|
@@ -67,6 +71,8 @@ export function tryCurrentApp(): Application | undefined {
|
|
|
67
71
|
* // Run a migration against a second app's config without disturbing the default.
|
|
68
72
|
* await withApp(secondApp, () => secondApp.container.make("db"));
|
|
69
73
|
* ```
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
70
76
|
*/
|
|
71
77
|
export function withApp<T>(app: Application, fn: () => T): T {
|
|
72
78
|
return _scope.run(app, fn);
|
package/src/build/codemod.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface RegisterProviderOptions {
|
|
|
40
40
|
/** Import specifier, e.g. '../app/providers/FooProvider.ts' or '@zerotal/foo'. */
|
|
41
41
|
importPath: string;
|
|
42
42
|
/** Bootstrap file. Default: 'bootstrap/providers.ts'. */
|
|
43
|
-
bootstrapPath?: string;
|
|
43
|
+
bootstrapPath?: string | undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -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
|
]);
|
|
@@ -13,7 +13,11 @@ export interface OutputWriter {
|
|
|
13
13
|
writeError(message: string): void;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Default writer — output goes to the terminal
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
17
21
|
export class TerminalWriter implements OutputWriter {
|
|
18
22
|
// Explicit constructor so JSC's function-coverage counter attributes
|
|
19
23
|
// the new TerminalWriter() call to this entry.
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,11 @@ import { runDoctor } from "../../doctor/AppDoctor.ts";
|
|
|
26
26
|
import { probeTransport } from "../../doctor/TransportProbe.ts";
|
|
27
27
|
import { runConfigValidators } from "../../config/validation.ts";
|
|
28
28
|
import { deployEnv } from "../../support/env.ts";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
CONVENTIONAL_PREFLIGHT_COMMAND,
|
|
31
|
+
DEFAULT_DEPLOY_STEPS,
|
|
32
|
+
type DeployTarget,
|
|
33
|
+
} from "../../config/DeployConfig.ts";
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* The pipeline. Subclassed per target by {@link makeDeployCommand}, which is what
|
|
@@ -47,6 +51,12 @@ export abstract class DeployCommand extends Command {
|
|
|
47
51
|
description: "Print the steps that would run, and run none of them",
|
|
48
52
|
default: false,
|
|
49
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
|
+
},
|
|
50
60
|
{
|
|
51
61
|
name: "skip-migrations",
|
|
52
62
|
type: "boolean" as const,
|
|
@@ -73,6 +83,19 @@ export abstract class DeployCommand extends Command {
|
|
|
73
83
|
const steps = this._steps(app, target);
|
|
74
84
|
if (dryRun) return this._printPlan(app, target, steps);
|
|
75
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
|
+
|
|
76
99
|
await this._preflight(app, target);
|
|
77
100
|
await this._runSteps(app, steps);
|
|
78
101
|
await this._verify(app);
|
|
@@ -129,6 +152,70 @@ export abstract class DeployCommand extends Command {
|
|
|
129
152
|
this.line(`✓ config is valid for a ${target} deployment`);
|
|
130
153
|
|
|
131
154
|
await this._doctor(app, "preflight");
|
|
155
|
+
await this._appPreflight(app, target);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run the app's own preflight commands — the refusals the framework is not in a
|
|
160
|
+
* position to make.
|
|
161
|
+
*
|
|
162
|
+
* Last inside preflight, so the framework's cheap structural checks have already
|
|
163
|
+
* spoken; still before every step, so nothing has been built or migrated when one
|
|
164
|
+
* of these says no.
|
|
165
|
+
*/
|
|
166
|
+
private async _appPreflight(app: Application, target: string): Promise<void> {
|
|
167
|
+
const commands = this._preflightCommands(app, target);
|
|
168
|
+
if (commands.length === 0) return;
|
|
169
|
+
|
|
170
|
+
const runner = app.container.makeSync("commands") as CommandRunner;
|
|
171
|
+
for (const name of commands) {
|
|
172
|
+
const { code, output } = await runner.callInProcess([name]);
|
|
173
|
+
if (output.trim()) this.line(output.trimEnd());
|
|
174
|
+
if (code !== 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${name} refused this release. Nothing has been built or migrated — ` +
|
|
177
|
+
`fix what it reported and deploy again.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
this.line(`✓ ${name} passed`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The preflight commands for this target: what `config/deploy.ts` declares, or
|
|
186
|
+
* the conventional `release:check` when the app registers one.
|
|
187
|
+
*
|
|
188
|
+
* A declared name that is not registered is an error rather than a skip. That is
|
|
189
|
+
* the opposite of how `steps` treats an absent command, and deliberately so: a
|
|
190
|
+
* missing `inertia:build` means the app has no Inertia, while a missing gate
|
|
191
|
+
* means the gate is not running, which is exactly the state this feature exists
|
|
192
|
+
* to make impossible.
|
|
193
|
+
*/
|
|
194
|
+
private _preflightCommands(app: Application, target: string): string[] {
|
|
195
|
+
let runner: CommandRunner | undefined;
|
|
196
|
+
try {
|
|
197
|
+
runner = app.container.makeSync("commands") as CommandRunner | undefined;
|
|
198
|
+
} catch {
|
|
199
|
+
runner = undefined;
|
|
200
|
+
}
|
|
201
|
+
// Same reasoning as `_steps`: with no registry to ask, nothing can be
|
|
202
|
+
// confirmed present. Returning nothing is the honest answer, and a declared
|
|
203
|
+
// preflight will be checked again by `_appPreflight` when it goes to run one.
|
|
204
|
+
if (!runner?.has) return [];
|
|
205
|
+
|
|
206
|
+
const declared = this._target(app, target)?.preflight;
|
|
207
|
+
if (declared === undefined) {
|
|
208
|
+
return runner.has(CONVENTIONAL_PREFLIGHT_COMMAND) ? [CONVENTIONAL_PREFLIGHT_COMMAND] : [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const missing = declared.filter((name) => !runner.has(name));
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`config/deploy.ts declares preflight command(s) that are not registered: ` +
|
|
215
|
+
`${missing.join(", ")}. A gate that silently does not run is worse than no gate.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return [...declared];
|
|
132
219
|
}
|
|
133
220
|
|
|
134
221
|
// ── Phase 2 — build and migrate ─────────────────────────────────────────────
|
|
@@ -267,6 +354,9 @@ export abstract class DeployCommand extends Command {
|
|
|
267
354
|
`APP_ENV ${actual || "(unset)"}${actual === target ? "" : ` ✗ expected ${target}`}`,
|
|
268
355
|
);
|
|
269
356
|
this.line(`preflight config validators, then ${"doctor"} checks`);
|
|
357
|
+
for (const name of this._preflightCommands(app, target)) {
|
|
358
|
+
this.line(`preflight ${name}`);
|
|
359
|
+
}
|
|
270
360
|
for (const name of steps) {
|
|
271
361
|
const skipped = name === "migrate" && this.flags["skip-migrations"] === true;
|
|
272
362
|
this.line(`step ${name}${skipped ? " (skipped: --skip-migrations)" : ""}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "../Command.ts";
|
|
2
|
+
import { bunBinary } from "../../support/runtime.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* `bun zt test [pattern] [flags]` — runs the test suite in the test environment.
|
|
@@ -121,10 +122,16 @@ export class TestCommand extends Command {
|
|
|
121
122
|
const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
|
|
122
123
|
bunArguments.push(`--timeout=${timeout}`);
|
|
123
124
|
|
|
124
|
-
this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl}`);
|
|
125
|
+
this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl} bun ${Bun.version}`);
|
|
125
126
|
this.dim(`bun ${bunArguments.join(" ")}\n`);
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
// `bunBinary()`, not `"bun"`. The child of a command whose entire job is to run
|
|
129
|
+
// this app's tests must be the runtime this app is served by, and `"bun"` is
|
|
130
|
+
// resolved against PATH — so `node_modules/.bin/bun zt test` used to satisfy every
|
|
131
|
+
// check in the parent process and then hand the suite to whatever the shell had.
|
|
132
|
+
// The parent already refuses to boot on a runtime mismatch; spawning its own
|
|
133
|
+
// binary is what extends that guarantee to the process the assertions run in.
|
|
134
|
+
const subprocess = Bun.spawn([bunBinary(), ...bunArguments], {
|
|
128
135
|
stdout: "inherit",
|
|
129
136
|
stderr: "inherit",
|
|
130
137
|
env: {
|
|
@@ -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";
|
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
import { CommandRunner } from "./CommandRunner.ts";
|
|
8
8
|
import { configLoader } from "../config/ConfigLoader.ts";
|
|
9
9
|
import { setAppEnv } from "../helpers/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
runtimeBelowFloor,
|
|
12
|
+
runtimeBelowFloorMessage,
|
|
13
|
+
runtimeMismatch,
|
|
14
|
+
runtimeMismatchAllowed,
|
|
15
|
+
runtimeMismatchMessage,
|
|
16
|
+
} from "../support/runtime.ts";
|
|
17
|
+
import type { RuntimeMismatch } from "../support/runtime.ts";
|
|
18
|
+
import { RuntimeMismatchError } from "../errors/RuntimeMismatchError.ts";
|
|
10
19
|
import type { Application } from "../application/Application.ts";
|
|
11
20
|
|
|
12
21
|
/** Options for {@link startZerotal}. */
|
|
13
22
|
export interface StartZerotalOptions {
|
|
14
23
|
/** Directory to load config from, resolved against the cwd. Default: `./config`. */
|
|
15
|
-
configDir?: string;
|
|
24
|
+
configDir?: string | undefined;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
/**
|
|
@@ -35,6 +44,11 @@ export async function startZerotal(
|
|
|
35
44
|
loadApp: () => Promise<{ default: Application }>,
|
|
36
45
|
options: StartZerotalOptions = {},
|
|
37
46
|
): Promise<void> {
|
|
47
|
+
// Before anything else, because everything after this is an assertion about a
|
|
48
|
+
// runtime — and if there are two of them, which one made the assertion is the
|
|
49
|
+
// first thing worth knowing.
|
|
50
|
+
assertOneRuntime();
|
|
51
|
+
|
|
38
52
|
// Load + validate config synchronously — safe before the app boots.
|
|
39
53
|
const config = configLoader(options.configDir ?? "./config");
|
|
40
54
|
config.validate();
|
|
@@ -53,3 +67,50 @@ export async function startZerotal(
|
|
|
53
67
|
await runner.boot();
|
|
54
68
|
await runner.run(process.argv.slice(2));
|
|
55
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
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.
|
|
81
|
+
*
|
|
82
|
+
* At the top of the entry point rather than in a check somebody remembers to run,
|
|
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.
|
|
85
|
+
*
|
|
86
|
+
* @throws When either check fails, unless `ZT_ALLOW_RUNTIME_MISMATCH` is set, which
|
|
87
|
+
* downgrades it to a warning on stderr.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
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
|
+
|
|
100
|
+
const mismatch = runtimeMismatch();
|
|
101
|
+
if (mismatch) _refuse(runtimeMismatchMessage(mismatch), mismatch);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop, or warn and continue when the escape hatch is set.
|
|
106
|
+
*
|
|
107
|
+
* @param message - The full explanation, already formatted.
|
|
108
|
+
* @param mismatch - The two versions, carried on the error for a harness to read.
|
|
109
|
+
*/
|
|
110
|
+
function _refuse(message: string, mismatch: RuntimeMismatch): void {
|
|
111
|
+
if (runtimeMismatchAllowed()) {
|
|
112
|
+
console.warn(`\n⚠ ${message}\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
throw new RuntimeMismatchError(message, mismatch);
|
|
116
|
+
}
|
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). */
|
|
@@ -9,7 +9,11 @@ import { frameworkLog } from "../logger/frameworkLog.ts";
|
|
|
9
9
|
|
|
10
10
|
const requireModule = createRequire(import.meta.url);
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Namespace → config object map (filename without extension is the namespace).
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
13
17
|
export type ConfigMap = Record<string, Record<string, unknown>>;
|
|
14
18
|
type Validator = (value: Record<string, unknown>) => void;
|
|
15
19
|
|
|
@@ -35,6 +35,19 @@
|
|
|
35
35
|
*/
|
|
36
36
|
export const DEFAULT_DEPLOY_STEPS: readonly string[] = ["assets:build", "inertia:build", "migrate"];
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The command a deploy runs before anything else, when the app defines one.
|
|
40
|
+
*
|
|
41
|
+
* A convention rather than a required declaration, because the alternative is the
|
|
42
|
+
* failure this exists to prevent: an app writes a preflight command, forgets to
|
|
43
|
+
* wire it into the pipeline, and every refusal that command knows how to make sits
|
|
44
|
+
* behind something nobody is obliged to run. **A gate nothing calls is a comment.**
|
|
45
|
+
*
|
|
46
|
+
* Register a command by this name and the pipeline finds it. Declare `preflight`
|
|
47
|
+
* on a target to run something else, or to run more than one.
|
|
48
|
+
*/
|
|
49
|
+
export const CONVENTIONAL_PREFLIGHT_COMMAND = "release:check";
|
|
50
|
+
|
|
38
51
|
/** One environment this app is released to. */
|
|
39
52
|
export interface DeployTarget {
|
|
40
53
|
/**
|
|
@@ -47,6 +60,26 @@ export interface DeployTarget {
|
|
|
47
60
|
* {@link DEFAULT_DEPLOY_STEPS}. Names a `zt` command per entry.
|
|
48
61
|
*/
|
|
49
62
|
steps?: readonly string[];
|
|
63
|
+
/**
|
|
64
|
+
* App-owned commands that run in the preflight phase — after the framework's
|
|
65
|
+
* own config validators and `doctor`, and before any step that mutates
|
|
66
|
+
* anything. A non-zero exit refuses the release.
|
|
67
|
+
*
|
|
68
|
+
* This is the slot for the checks only the app can make. The framework can tell
|
|
69
|
+
* you the `APP_KEY` is the one from `.env.example`; it cannot tell you this
|
|
70
|
+
* workspace has no cancellation policy, or that the owner account is still on
|
|
71
|
+
* the password the installer issued it. Those refusals are yours, and until
|
|
72
|
+
* there was somewhere to put them they lived in a command nobody ran.
|
|
73
|
+
*
|
|
74
|
+
* Defaults to {@link CONVENTIONAL_PREFLIGHT_COMMAND} when that command is
|
|
75
|
+
* registered, and to nothing when it is not.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* production: { preflight: ["release:check", "assets:verify"] }
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
preflight?: readonly string[];
|
|
50
83
|
}
|
|
51
84
|
|
|
52
85
|
/** The `deploy` config namespace. */
|
package/src/config/index.ts
CHANGED
|
@@ -36,7 +36,12 @@ export type {
|
|
|
36
36
|
AppAssetsConfig,
|
|
37
37
|
AssetLoaderKind,
|
|
38
38
|
} from "./AppConfig.ts";
|
|
39
|
-
export {
|
|
39
|
+
export {
|
|
40
|
+
DeployConfig,
|
|
41
|
+
DEFAULT_DEPLOY_STEPS,
|
|
42
|
+
DEFAULT_DEPLOY_TARGETS,
|
|
43
|
+
CONVENTIONAL_PREFLIGHT_COMMAND,
|
|
44
|
+
} from "./DeployConfig.ts";
|
|
40
45
|
export type { DeployConfigShape, DeployTarget } from "./DeployConfig.ts";
|
|
41
46
|
export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
|
|
42
47
|
export { ConfigValidationError } from "./validation.ts";
|
package/src/config/registry.ts
CHANGED
|
@@ -53,7 +53,11 @@ type ValueAt<T, P extends string> = P extends `${infer Head}.${infer Rest}`
|
|
|
53
53
|
? T[P]
|
|
54
54
|
: unknown;
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Every valid dot-path string across the registered config namespaces.
|
|
58
|
+
*
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
57
61
|
export type ConfigPath = Leaves<ConfigRegistry>;
|
|
58
62
|
|
|
59
63
|
/** The value type stored at a given config dot-path. */
|