@zerotal/core 1.9.0 → 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 +117 -0
- package/api-surface.md +148 -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 +2 -0
- 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 +36 -9
- 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 +91 -1
|
@@ -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,45 @@ 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
|
-
if (
|
|
83
|
-
|
|
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 {
|
|
84
111
|
if (runtimeMismatchAllowed()) {
|
|
85
112
|
console.warn(`\n⚠ ${message}\n`);
|
|
86
113
|
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
|
package/src/health/Health.ts
CHANGED
|
@@ -149,20 +149,20 @@ export const Health = new HealthRegistry();
|
|
|
149
149
|
/** The health config, authored under the `health` key of `config/app.ts`. */
|
|
150
150
|
export interface HealthConfigShape {
|
|
151
151
|
/** Serve the endpoint. Default: on outside production, off in production. */
|
|
152
|
-
enabled?: boolean;
|
|
152
|
+
enabled?: boolean | undefined;
|
|
153
153
|
/** Route path. Default: `/health`. */
|
|
154
|
-
path?: string;
|
|
154
|
+
path?: string | undefined;
|
|
155
155
|
/** Shared secret. Required in production; supplied via `?key=` or `X-Health-Key`. */
|
|
156
|
-
secret?: string;
|
|
156
|
+
secret?: string | undefined;
|
|
157
157
|
/** Include per-check details. Set false for a bare `{ status }` body. Default: true. */
|
|
158
|
-
showDetails?: boolean;
|
|
158
|
+
showDetails?: boolean | undefined;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** The health config after defaults and back-compat have been applied. */
|
|
162
162
|
export interface ResolvedHealthConfig {
|
|
163
163
|
enabled: boolean;
|
|
164
164
|
path: string;
|
|
165
|
-
secret?: string;
|
|
165
|
+
secret?: string | undefined;
|
|
166
166
|
showDetails: boolean;
|
|
167
167
|
}
|
|
168
168
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -298,5 +298,6 @@ export { markdownPage, type BunMarkdownOptions } from "./markdown.ts";
|
|
|
298
298
|
// must not carry private copies.
|
|
299
299
|
export { escapeHtml } from "./html.ts";
|
|
300
300
|
export { pluralize, singularize, tableNameFor } from "../support/str.ts";
|
|
301
|
-
export { deepMerge } from "../support/deepMerge.ts";
|
|
301
|
+
export { deepMerge, definedOnly } from "../support/deepMerge.ts";
|
|
302
|
+
export type { Resolved } from "../support/deepMerge.ts";
|
|
302
303
|
export { parseCookieHeader } from "../support/cookie.ts";
|
package/src/helpers/markdown.ts
CHANGED
|
@@ -49,6 +49,13 @@ ${body}
|
|
|
49
49
|
* Options for `Bun.markdown.html()`. bun-types exposes the options type only inside
|
|
50
50
|
* its `markdown` namespace; this is the named, exported equivalent the framework uses.
|
|
51
51
|
*
|
|
52
|
+
* Its optional properties stay `?: T` rather than `?: T | undefined`, unlike the
|
|
53
|
+
* framework's own option shapes. This one is not an option shape, it is a *mirror* of
|
|
54
|
+
* somebody else's signature, and a mirror that is wider than what it reflects stops
|
|
55
|
+
* being assignable to it — the value built here is handed straight to
|
|
56
|
+
* `Bun.markdown.html()`.
|
|
57
|
+
*
|
|
58
|
+
* @exact-optional-ignore
|
|
52
59
|
* @internal
|
|
53
60
|
*/
|
|
54
61
|
export interface BunMarkdownOptions {
|
package/src/http/UploadedFile.ts
CHANGED
|
@@ -19,9 +19,9 @@ export type StorageDisk = Pick<StorageDriver, "url"> & {
|
|
|
19
19
|
*/
|
|
20
20
|
export interface FileValidationOptions {
|
|
21
21
|
/** Maximum allowed size in bytes. */
|
|
22
|
-
maxSize?: number;
|
|
22
|
+
maxSize?: number | undefined;
|
|
23
23
|
/** Allowed MIME types — e.g. `['image/jpeg', 'image/png', 'application/pdf']`. */
|
|
24
|
-
mimes?: string[];
|
|
24
|
+
mimes?: string[] | undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
package/src/index.ts
CHANGED
|
@@ -162,7 +162,8 @@ export {
|
|
|
162
162
|
export { config } from "./helpers/config.ts";
|
|
163
163
|
export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
|
|
164
164
|
export { deepMerge } from "./support/deepMerge.ts";
|
|
165
|
-
|
|
165
|
+
// `definedOnly` is a helper, not kernel surface — it lives on `@zerotal/core/helpers`.
|
|
166
|
+
export type { DeepPartial, Resolved } from "./support/deepMerge.ts";
|
|
166
167
|
// The type every class-keyed registry uses — a class rather than an instance.
|
|
167
168
|
export type { ClassRef } from "./support/classRef.ts";
|
|
168
169
|
export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
|