@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/src/config/validation.ts
CHANGED
|
@@ -29,7 +29,11 @@ export interface ConfigIssue {
|
|
|
29
29
|
message: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Context handed to a config validator.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
33
37
|
export interface ConfigValidationContext {
|
|
34
38
|
/** The namespace being validated (e.g. `"session"`). */
|
|
35
39
|
namespace: string;
|
|
@@ -52,7 +56,11 @@ export type ConfigValidator = (
|
|
|
52
56
|
ctx: ConfigValidationContext,
|
|
53
57
|
) => ConfigIssue[] | void;
|
|
54
58
|
|
|
55
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* A validator paired with the namespace it guards, as held on the application.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
56
64
|
export interface RegisteredConfigValidator {
|
|
57
65
|
namespace: string;
|
|
58
66
|
validate: ConfigValidator;
|
|
@@ -12,7 +12,11 @@ import type { Application } from "../application/Application.ts";
|
|
|
12
12
|
import type { AppEnvironment } from "../provider/ServiceProvider.ts";
|
|
13
13
|
import { frameworkLog } from "../logger/frameworkLog.ts";
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The context a concern receives while registering discovered modules.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
16
20
|
export interface ConcernContext {
|
|
17
21
|
app: Application;
|
|
18
22
|
env: AppEnvironment;
|
|
@@ -41,7 +45,7 @@ export interface RunConventionsOptions {
|
|
|
41
45
|
root: string;
|
|
42
46
|
env: AppEnvironment;
|
|
43
47
|
/** Per-concern directory overrides (config `discovery.paths`). */
|
|
44
|
-
paths?: Record<string, string
|
|
48
|
+
paths?: Record<string, string> | undefined;
|
|
45
49
|
ctx: ConcernContext;
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -101,7 +105,10 @@ export async function runConventions(
|
|
|
101
105
|
): Promise<void> {
|
|
102
106
|
const orderedConcerns = [...concerns].sort((first, second) => first.order - second.order);
|
|
103
107
|
for (const concern of orderedConcerns) {
|
|
104
|
-
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
|
+
}
|
|
105
112
|
|
|
106
113
|
if (concern.dir && concern.register) {
|
|
107
114
|
const relativeDirectory = options.paths?.[concern.name] ?? concern.dir;
|
|
@@ -126,3 +133,37 @@ export async function runConventions(
|
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
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
|
@@ -40,6 +40,8 @@ function _scanRoots(cwd: string): string[] {
|
|
|
40
40
|
*
|
|
41
41
|
* Returns an empty array when `bun-plugin-tailwind` is not installed.
|
|
42
42
|
* The caller falls back to a `bunx @tailwindcss/cli` subprocess.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
43
45
|
*/
|
|
44
46
|
export async function detectCssPlugins(cwd: string): Promise<BunPlugin[]> {
|
|
45
47
|
try {
|
|
@@ -60,6 +62,8 @@ export async function detectCssPlugins(cwd: string): Promise<BunPlugin[]> {
|
|
|
60
62
|
* @param input Absolute path to the CSS source (e.g. `${cwd}/resources/css/app.css`)
|
|
61
63
|
* @param outdir Absolute path to the output directory (e.g. `${cwd}/public/css`)
|
|
62
64
|
* @param minify Whether to minify the output (true in production)
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
63
67
|
*/
|
|
64
68
|
export async function buildCssBundle(
|
|
65
69
|
input: string,
|
|
@@ -155,6 +159,8 @@ export async function buildCssBundle(
|
|
|
155
159
|
* @param input Absolute path to the JS/TS entry (e.g. `${cwd}/resources/js/app.js`)
|
|
156
160
|
* @param outdir Absolute path to the output directory (e.g. `${cwd}/public/js`)
|
|
157
161
|
* @param minify Whether to minify the output (true in production)
|
|
162
|
+
*
|
|
163
|
+
* @internal
|
|
158
164
|
*/
|
|
159
165
|
export async function buildJsBundle(
|
|
160
166
|
input: string,
|
|
@@ -193,20 +199,24 @@ function _basename(filePath: string): string {
|
|
|
193
199
|
return filePath.split("/").at(-1) ?? filePath;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* The resolved `app.assets` config block (see {@link AppAssetsConfig}).
|
|
204
|
+
*
|
|
205
|
+
* @internal
|
|
206
|
+
*/
|
|
197
207
|
export interface AssetBuildConfig {
|
|
198
208
|
entrypoint: string | string[];
|
|
199
209
|
outDir: string;
|
|
200
210
|
prefix: string;
|
|
201
211
|
minify: boolean;
|
|
202
212
|
/** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
|
|
203
|
-
loader?: Record<string, string
|
|
213
|
+
loader?: Record<string, string> | undefined;
|
|
204
214
|
/**
|
|
205
215
|
* Delete everything in `outDir` this build did not write, rather than only what
|
|
206
216
|
* the last build on this machine recorded. See {@link cleanBuildOutput} — it
|
|
207
217
|
* refuses a directory that holds more than build output.
|
|
208
218
|
*/
|
|
209
|
-
clean?: boolean;
|
|
219
|
+
clean?: boolean | undefined;
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
/**
|
package/src/dev/DevBuildHook.ts
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* server cheerfully reported "rebuilding… ✓ ready" on every change.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Outcome of a frontend build: whether it succeeded and any collected logs.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
16
20
|
export interface BuildResult {
|
|
17
21
|
success: boolean;
|
|
18
22
|
logs?: unknown[];
|
|
@@ -25,7 +29,11 @@ export interface BuildResult {
|
|
|
25
29
|
skipped?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* A frontend build routine that resolves once the build finishes.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
29
37
|
export type BuildHookFn = () => Promise<BuildResult>;
|
|
30
38
|
|
|
31
39
|
const _hooks = new Map<string, BuildHookFn>();
|
package/src/dev/DevDeck.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface Deck {
|
|
|
51
51
|
stop(): void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** @internal */
|
|
54
55
|
export interface DeckOptions {
|
|
55
56
|
writer: OutputWriter;
|
|
56
57
|
/** Restart the named process — bound to `r`. */
|
|
@@ -58,10 +59,10 @@ export interface DeckOptions {
|
|
|
58
59
|
/** The user asked to quit — bound to `q`. */
|
|
59
60
|
onQuit: () => void;
|
|
60
61
|
/** Force a renderer. Defaults to tabs on a TTY, stream otherwise. */
|
|
61
|
-
mode?: "tabs" | "stream";
|
|
62
|
+
mode?: "tabs" | "stream" | undefined;
|
|
62
63
|
/** Overridable for tests. Defaults to `process.stdout`/`process.stdin`. */
|
|
63
|
-
stdout?: DeckStdout;
|
|
64
|
-
stdin?: DeckStdin;
|
|
64
|
+
stdout?: DeckStdout | undefined;
|
|
65
|
+
stdin?: DeckStdin | undefined;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** The bits of `process.stdout` the deck reads. */
|
|
@@ -90,6 +91,8 @@ export interface DeckStdin {
|
|
|
90
91
|
* read keys from. A process with a TTY on stdout but not stdin (some CI
|
|
91
92
|
* runners, `zt dev < /dev/null`) would render a tab bar nobody could ever
|
|
92
93
|
* switch, so it gets stream instead.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
93
96
|
*/
|
|
94
97
|
export function createDeck(options: DeckOptions): Deck {
|
|
95
98
|
const stdout = options.stdout ?? process.stdout;
|
|
@@ -108,6 +111,8 @@ export function createDeck(options: DeckOptions): Deck {
|
|
|
108
111
|
* Colour is applied only when stdout is a terminal. Piped to a file this emits
|
|
109
112
|
* nothing but text, which is the point — a log with escape codes in it is a log
|
|
110
113
|
* you have to clean before you can read it.
|
|
114
|
+
*
|
|
115
|
+
* @internal
|
|
111
116
|
*/
|
|
112
117
|
export class StreamDeck implements Deck {
|
|
113
118
|
private _width = 0;
|
|
@@ -181,6 +186,7 @@ interface Card {
|
|
|
181
186
|
scroll: number;
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
/** @internal */
|
|
184
190
|
export class TabsDeck implements Deck {
|
|
185
191
|
private readonly _cards: Card[] = [];
|
|
186
192
|
private readonly _index = new Map<string, Card>();
|
|
@@ -14,6 +14,8 @@ import { ZEROTAL_VERSION, installedCoreVersion } from "../support/version.ts";
|
|
|
14
14
|
* All optional, and all defaulting to the console. Without them the orchestrator
|
|
15
15
|
* behaves exactly as it did before the deck existed — which is what keeps the
|
|
16
16
|
* plain `bun --watch` path and the existing tests honest.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
17
19
|
*/
|
|
18
20
|
export interface DevOrchestratorHooks {
|
|
19
21
|
/**
|
package/src/dev/DevProcess.ts
CHANGED
|
@@ -69,13 +69,21 @@ export interface DevProcessDefinition {
|
|
|
69
69
|
color?: DevProcessColor;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The colours a deck tab can take. Named, not ANSI codes, so the deck owns the rendering.
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
73
77
|
export type DevProcessColor = "cyan" | "magenta" | "yellow" | "green" | "blue" | "red";
|
|
74
78
|
|
|
75
79
|
/** Assigned in order to processes that did not pick a colour. */
|
|
76
80
|
const _PALETTE: DevProcessColor[] = ["cyan", "magenta", "yellow", "green", "blue", "red"];
|
|
77
81
|
|
|
78
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* A definition with every default filled in and its argv settled.
|
|
84
|
+
*
|
|
85
|
+
* @internal
|
|
86
|
+
*/
|
|
79
87
|
export interface ResolvedDevProcess {
|
|
80
88
|
name: string;
|
|
81
89
|
label: string;
|
|
@@ -95,12 +103,16 @@ export interface ResolvedDevProcess {
|
|
|
95
103
|
registrant: string;
|
|
96
104
|
}
|
|
97
105
|
|
|
98
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* The `app.dev` config block.
|
|
108
|
+
*
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
99
111
|
export interface DevConfigShape {
|
|
100
112
|
/** App-level processes, registered after every provider's. */
|
|
101
|
-
processes?: DevProcessDefinition[];
|
|
113
|
+
processes?: DevProcessDefinition[] | undefined;
|
|
102
114
|
/** Names to drop, whoever registered them. */
|
|
103
|
-
disable?: string[];
|
|
115
|
+
disable?: string[] | undefined;
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/** The bits of `Application` this module needs — kept structural to avoid a cycle. */
|
|
@@ -123,6 +135,8 @@ interface ConfigReader {
|
|
|
123
135
|
* @param app A booted application — providers must have run, since `enabled`
|
|
124
136
|
* and a `command` thunk are allowed to read config.
|
|
125
137
|
* @param config The config manager, read for `app.dev`.
|
|
138
|
+
*
|
|
139
|
+
* @internal
|
|
126
140
|
*/
|
|
127
141
|
export async function collectDevProcesses(
|
|
128
142
|
app: ProviderHost,
|
|
@@ -19,7 +19,11 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
|
19
19
|
import { BaseMiddleware } from "../middleware/BaseMiddleware.ts";
|
|
20
20
|
import { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Produces an HTML fragment to inject before `</body>` for the given request.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
23
27
|
export type DevHtmlSnippet = (ctx: HttpContext) => string;
|
|
24
28
|
|
|
25
29
|
interface RegisteredSnippet {
|
|
@@ -39,6 +43,8 @@ const _snippets: RegisteredSnippet[] = [];
|
|
|
39
43
|
* registerDevHtmlSnippet("devtools", (ctx) =>
|
|
40
44
|
* ctx.url.pathname.startsWith("/__zerotal") ? "" : `<script src="/__zerotal/devtools/client.js"></script>`,
|
|
41
45
|
* );
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
42
48
|
*/
|
|
43
49
|
export function registerDevHtmlSnippet(name: string, fn: DevHtmlSnippet): void {
|
|
44
50
|
if (_snippets.some((s) => s.name === name)) return;
|
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,
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ResolvedDevProcess } from "./DevProcess.ts";
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* What the supervisor needs back from whatever it spawned.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
20
24
|
export interface DevChild {
|
|
21
25
|
readonly stdout: ReadableStream<Uint8Array> | null;
|
|
22
26
|
readonly stderr: ReadableStream<Uint8Array> | null;
|
|
@@ -24,16 +28,28 @@ export interface DevChild {
|
|
|
24
28
|
kill(signal?: number | NodeJS.Signals): void;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* How the supervisor starts a child. Injected so tests need no real processes.
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
28
36
|
export type DevSpawnFn = (
|
|
29
37
|
argv: string[],
|
|
30
38
|
options: { cwd: string; env: Record<string, string | undefined> },
|
|
31
39
|
) => DevChild;
|
|
32
40
|
|
|
33
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Where a process is in its life.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
34
46
|
export type DevProcessState = "starting" | "running" | "restarting" | "exited" | "parked";
|
|
35
47
|
|
|
36
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* A process as the deck sees it.
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
37
53
|
export interface DevProcessStatus {
|
|
38
54
|
name: string;
|
|
39
55
|
label: string;
|
|
@@ -53,19 +69,20 @@ export interface DevSupervisorHooks {
|
|
|
53
69
|
onState?: (status: DevProcessStatus) => void;
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
/** @internal */
|
|
56
73
|
export interface DevSupervisorOptions extends DevSupervisorHooks {
|
|
57
74
|
cwd: string;
|
|
58
|
-
env?: Record<string, string | undefined
|
|
75
|
+
env?: Record<string, string | undefined> | undefined;
|
|
59
76
|
/** Defaults to `Bun.spawn` with output piped and **stdin ignored**. */
|
|
60
|
-
spawn?: DevSpawnFn;
|
|
77
|
+
spawn?: DevSpawnFn | undefined;
|
|
61
78
|
/**
|
|
62
79
|
* Backoff before each retry, in ms. Overridable for the same reason `spawn`
|
|
63
80
|
* is: a test of the restart policy should assert the policy, not spend three
|
|
64
81
|
* seconds proving that `setTimeout` works.
|
|
65
82
|
*/
|
|
66
|
-
backoffMs?: number[];
|
|
83
|
+
backoffMs?: number[] | undefined;
|
|
67
84
|
/** How long a run must last to be considered healthy. See {@link HEALTHY_AFTER_MS}. */
|
|
68
|
-
healthyAfterMs?: number;
|
|
85
|
+
healthyAfterMs?: number | undefined;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
/** Give up after this many consecutive failed starts. */
|
|
@@ -92,6 +109,7 @@ interface Entry {
|
|
|
92
109
|
healthyTimer?: ReturnType<typeof setTimeout> | undefined;
|
|
93
110
|
}
|
|
94
111
|
|
|
112
|
+
/** @internal */
|
|
95
113
|
export class DevSupervisor {
|
|
96
114
|
private readonly _entries = new Map<string, Entry>();
|
|
97
115
|
private readonly _cwd: string;
|
package/src/dev/bootBuild.ts
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
24
24
|
import { isProdLike } from "../support/env.ts";
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* What to do about a boot-time asset build, and why.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
27
31
|
export interface BootBuildDecision {
|
|
28
32
|
/** Whether to run the build. */
|
|
29
33
|
build: boolean;
|
|
@@ -37,6 +41,8 @@ export interface BootBuildDecision {
|
|
|
37
41
|
* Tested by writing rather than by reading a permission bit: the thing that makes
|
|
38
42
|
* `public/` unwritable in the failure this guards against is `ProtectSystem=strict`, a
|
|
39
43
|
* mount-level restriction that a mode check does not see.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
40
46
|
*/
|
|
41
47
|
export async function isWritableDir(dir: string): Promise<boolean> {
|
|
42
48
|
const probe = `${dir}/.zerotal-write-probe-${process.pid}`;
|
|
@@ -68,6 +74,8 @@ export async function isWritableDir(dir: string): Promise<boolean> {
|
|
|
68
74
|
* if (!decision.build) console.info(decision.reason);
|
|
69
75
|
* else await build();
|
|
70
76
|
* ```
|
|
77
|
+
*
|
|
78
|
+
* @internal
|
|
71
79
|
*/
|
|
72
80
|
export async function bootBuildDecision(
|
|
73
81
|
outDirs: string[],
|
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/reloadClient.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* Exported as a string because it is injected inline: `DevReloadMiddleware`
|
|
20
20
|
* rewrites HTML responses with it for every view layer, and Inertia bakes it
|
|
21
21
|
* into its cached template so streamed pages need no body rewrite.
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
22
24
|
*/
|
|
23
25
|
export const DEV_RELOAD_CLIENT: string =
|
|
24
26
|
`<script>(function(){` +
|
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;
|
|
@@ -39,7 +39,11 @@ export interface StartDevModeOptions {
|
|
|
39
39
|
deckMode?: "tabs" | "stream" | undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The server's card. Named so `--only=server` and `--without=server` read naturally.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
43
47
|
export const SERVER_PROCESS_NAME = "server";
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -47,6 +51,8 @@ export const SERVER_PROCESS_NAME = "server";
|
|
|
47
51
|
*
|
|
48
52
|
* Never returns: the orchestrator parks on an unresolved promise and the exit
|
|
49
53
|
* happens in its signal handlers, after the deck has restored the terminal.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
50
56
|
*/
|
|
51
57
|
export async function startDevMode(options: StartDevModeOptions): Promise<void> {
|
|
52
58
|
const supervised = options.processes.filter((entry) => entry.name !== SERVER_PROCESS_NAME);
|
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
|
17
17
|
import { unroutedRoutesWarning } from "../support/unroutedRoutes.ts";
|
|
18
18
|
import { isWritableDir } from "../dev/bootBuild.ts";
|
|
19
19
|
import { isProdLike } from "../support/env.ts";
|
|
20
|
+
import { throttlesKeyedOnSocket } from "./throttleIdentity.ts";
|
|
20
21
|
|
|
21
22
|
/** One finding: ok is silent health, warn is worth reading, fail is broken now. */
|
|
22
23
|
export interface DoctorCheckResult {
|
|
@@ -365,12 +366,53 @@ const secureHeadersCheck: DoctorCheck = {
|
|
|
365
366
|
},
|
|
366
367
|
};
|
|
367
368
|
|
|
368
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* A rate limiter behind a proxy that was never told about the proxy keys every
|
|
371
|
+
* visitor to the same bucket, because the socket address is the proxy's. It then
|
|
372
|
+
* does the opposite of its job: one attacker spends the allowance for everybody.
|
|
373
|
+
*
|
|
374
|
+
* A warning rather than a failure, because the framework cannot see the deployment.
|
|
375
|
+
* An app served directly, with no proxy in front of it, is correctly configured
|
|
376
|
+
* exactly as it stands — and that app should not be blocked from deploying by a
|
|
377
|
+
* guess. What is worth saying is that the combination is almost always wrong, and
|
|
378
|
+
* that nothing else will ever tell you.
|
|
379
|
+
*/
|
|
380
|
+
const throttleIdentityCheck: DoctorCheck = {
|
|
381
|
+
id: "throttle-trusted-proxies",
|
|
382
|
+
label: "Rate-limit identity",
|
|
383
|
+
run(app) {
|
|
384
|
+
const exposed = throttlesKeyedOnSocket(app);
|
|
385
|
+
if (exposed.length === 0) return ok("every registered throttle can identify a client.");
|
|
386
|
+
if (!_isProductionEnv(app)) {
|
|
387
|
+
return ok(`${exposed.length} throttle(s) key on the socket address — fine locally.`);
|
|
388
|
+
}
|
|
389
|
+
const where = exposed
|
|
390
|
+
.slice(0, 3)
|
|
391
|
+
.map((t) => t.where)
|
|
392
|
+
.join(", ");
|
|
393
|
+
return warn(
|
|
394
|
+
`${exposed.length} throttle(s) (${where}${exposed.length > 3 ? ", …" : ""}) key on the ` +
|
|
395
|
+
`socket address and no trustedProxies is set. Behind a reverse proxy that address is ` +
|
|
396
|
+
`the proxy's for every request, so all visitors share one bucket and one of them can ` +
|
|
397
|
+
`lock out the rest.`,
|
|
398
|
+
"If a proxy fronts this app, set trustedProxies to how many — `ThrottleMiddleware.with({ " +
|
|
399
|
+
"maxAttempts: 5, trustedProxies: 1 })`, or `app.throttle.trustedProxies` for all of " +
|
|
400
|
+
"them. If nothing fronts it, this is already correct.",
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The core checks every app gets.
|
|
407
|
+
*
|
|
408
|
+
* @internal
|
|
409
|
+
*/
|
|
369
410
|
export const builtinDoctorChecks: DoctorCheck[] = [
|
|
370
411
|
appKeyCheck,
|
|
371
412
|
allowedOriginsCheck,
|
|
372
413
|
corsWildcardCheck,
|
|
373
414
|
secureHeadersCheck,
|
|
415
|
+
throttleIdentityCheck,
|
|
374
416
|
bootAssetWriteCheck,
|
|
375
417
|
syncVsMigrationsCheck,
|
|
376
418
|
unroutedRoutesCheck,
|