@zerotal/core 1.4.0 β 1.5.1
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 +370 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +24 -4
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +99 -3
- package/src/support/unroutedRoutes.ts +37 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
/**
|
|
1
|
+
ο»Ώ/**
|
|
2
2
|
* The `serve` command, which starts the HTTP server. Also drives dev mode: the
|
|
3
3
|
* file-watching orchestrator (process 1) and the managed dev worker (process 2).
|
|
4
4
|
*/
|
|
5
5
|
import { Command } from "../Command.ts";
|
|
6
|
-
import {
|
|
6
|
+
import type { FlagDef } from "../Command.ts";
|
|
7
7
|
import { hasDevBuildHooks, runDevBuildHooks } from "../../dev/DevBuildHook.ts";
|
|
8
8
|
import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
|
|
9
|
+
import { bootBuildDecision } from "../../dev/bootBuild.ts";
|
|
10
|
+
import { collectDevProcesses, type ResolvedDevProcess } from "../../dev/DevProcess.ts";
|
|
11
|
+
import { startDevMode, SERVER_PROCESS_NAME } from "../../dev/startDevMode.ts";
|
|
9
12
|
import type { ConfigManager } from "../../config/ConfigManager.ts";
|
|
10
13
|
import type { Application } from "../../application/Application.ts";
|
|
11
14
|
import * as DevWsServer from "../../dev/DevWsServer.ts";
|
|
@@ -34,36 +37,36 @@ export class ServeCommand extends Command {
|
|
|
34
37
|
static description = "Start the HTTP server";
|
|
35
38
|
static needsApp = true;
|
|
36
39
|
|
|
37
|
-
static flags = [
|
|
40
|
+
static flags: FlagDef[] = [
|
|
38
41
|
{
|
|
39
42
|
name: "port",
|
|
40
43
|
short: "p",
|
|
41
|
-
type: "number"
|
|
44
|
+
type: "number",
|
|
42
45
|
description: "Port to listen on",
|
|
43
46
|
default: 3000,
|
|
44
47
|
},
|
|
45
48
|
{
|
|
46
49
|
name: "dev",
|
|
47
|
-
type: "boolean"
|
|
50
|
+
type: "boolean",
|
|
48
51
|
description: "Start in dev mode with file watching, auto-rebuild, and browser reload",
|
|
49
52
|
default: false,
|
|
50
53
|
},
|
|
51
54
|
{
|
|
52
55
|
name: "force",
|
|
53
|
-
type: "boolean"
|
|
56
|
+
type: "boolean",
|
|
54
57
|
description: "If the port is busy, stop the process holding it",
|
|
55
58
|
default: false,
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
61
|
name: "auto-port",
|
|
59
|
-
type: "boolean"
|
|
62
|
+
type: "boolean",
|
|
60
63
|
description: "If the port is busy, start on the next free port",
|
|
61
64
|
default: false,
|
|
62
65
|
},
|
|
63
66
|
// Internal flag β spawned by DevOrchestrator; not shown in help
|
|
64
67
|
{
|
|
65
68
|
name: "dev-worker",
|
|
66
|
-
type: "boolean"
|
|
69
|
+
type: "boolean",
|
|
67
70
|
description: "Internal: run as the managed server process under DevOrchestrator",
|
|
68
71
|
default: false,
|
|
69
72
|
},
|
|
@@ -72,49 +75,23 @@ export class ServeCommand extends Command {
|
|
|
72
75
|
async run(): Promise<void> {
|
|
73
76
|
const isDev = this.flags["dev"] as boolean;
|
|
74
77
|
const isWorker = this.flags["dev-worker"] as boolean;
|
|
75
|
-
const port = await this._resolvePort(this.flags["port"] as number, isWorker);
|
|
76
|
-
const assets = this._assetsConfig();
|
|
77
78
|
|
|
78
79
|
// ββ DEV ORCHESTRATOR (Process 1) ββββββββββββββββββββββββββββββββββββββββ
|
|
79
80
|
// Spawns and manages the server child process, watches files, drives builds.
|
|
81
|
+
//
|
|
82
|
+
// Handled before the port is resolved, because in dev mode the port is not
|
|
83
|
+
// always needed: `--list` starts nothing, and `--only=queue` starts no
|
|
84
|
+
// server. Resolving first turns either into a prompt about killing whatever
|
|
85
|
+
// holds port 3000 β or a hard failure with no TTY β over a port that was
|
|
86
|
+
// never going to be bound.
|
|
80
87
|
if (isDev) {
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
// Every view package (Inertia/Flow) that registered a build routine gets
|
|
84
|
-
// run on each change; otherwise synthesise one from `app.assets` so
|
|
85
|
-
// configured assets rebuild on change.
|
|
86
|
-
let buildHook: (() => Promise<{ success: boolean; logs?: unknown[] }>) | undefined =
|
|
87
|
-
hasDevBuildHooks() ? runDevBuildHooks : undefined;
|
|
88
|
-
if (!buildHook && assets) {
|
|
89
|
-
buildHook = (): Promise<{ success: boolean; logs?: unknown[] }> =>
|
|
90
|
-
buildConfiguredAssets(assets, process.cwd());
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!buildHook) {
|
|
94
|
-
// No Inertia (or no frontend build configured). Fall back to simple
|
|
95
|
-
// bun --watch restart so the developer still gets auto-reload.
|
|
96
|
-
console.warn(" [zerotal:dev] no build hook registered β falling back to bun --watch");
|
|
97
|
-
const subprocess = Bun.spawn(
|
|
98
|
-
["bun", "--watch", Bun.main, "serve", "--port", String(port)],
|
|
99
|
-
{
|
|
100
|
-
stdin: "inherit",
|
|
101
|
-
stdout: "inherit",
|
|
102
|
-
stderr: "inherit",
|
|
103
|
-
// The port is already settled here, and each --watch restart races
|
|
104
|
-
// the socket the previous run is still letting go of. Without this
|
|
105
|
-
// the child would re-prompt on every save.
|
|
106
|
-
env: { ...Bun.env, [PORT_RESOLVED_ENV_VAR]: "1" },
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
await subprocess.exited;
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const orchestrator = new DevOrchestrator(port, process.cwd(), buildHook);
|
|
114
|
-
await orchestrator.start();
|
|
88
|
+
await this._runDevMode();
|
|
115
89
|
return;
|
|
116
90
|
}
|
|
117
91
|
|
|
92
|
+
const port = await this._resolvePort(this.flags["port"] as number, isWorker);
|
|
93
|
+
const assets = this._assetsConfig();
|
|
94
|
+
|
|
118
95
|
// ββ DEV WORKER (Process 2) ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
119
96
|
// Normal server start, but:
|
|
120
97
|
// 1. Registers GET /__dev/events for browser SSE reload.
|
|
@@ -122,6 +99,13 @@ export class ServeCommand extends Command {
|
|
|
122
99
|
if (isWorker) {
|
|
123
100
|
const app = this.app as import("../../application/Application.ts").Application;
|
|
124
101
|
|
|
102
|
+
// Refresh types/routes.generated.ts from the routes this boot registered.
|
|
103
|
+
// The worker is the right place for it: the orchestrator restarts the
|
|
104
|
+
// worker on every route-file change, so this runs exactly when the route
|
|
105
|
+
// table can have changed, and it reads a booted router rather than
|
|
106
|
+
// re-deriving names from filenames.
|
|
107
|
+
await this._writeRouteTypes();
|
|
108
|
+
|
|
125
109
|
// Enable the /__dev/ws HMR WebSocket endpoint.
|
|
126
110
|
app.enableDevWs();
|
|
127
111
|
|
|
@@ -147,6 +131,180 @@ export class ServeCommand extends Command {
|
|
|
147
131
|
await new Promise<never>(() => {});
|
|
148
132
|
}
|
|
149
133
|
|
|
134
|
+
// ββ Dev mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Everything `--dev` does, shared verbatim with `bun zt dev`.
|
|
138
|
+
*
|
|
139
|
+
* `protected` rather than private because {@link DevCommand} is this command
|
|
140
|
+
* with a richer flag set β keeping one body is what stops the two from
|
|
141
|
+
* disagreeing about what dev mode is.
|
|
142
|
+
*/
|
|
143
|
+
protected async _runDevMode(): Promise<void> {
|
|
144
|
+
const processes = await this._devProcesses();
|
|
145
|
+
|
|
146
|
+
if (this.flags["list"]) {
|
|
147
|
+
this._listDevProcesses(processes);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Item C: the cache is keyed on file mtime and size, which a same-size edit
|
|
152
|
+
// that preserves mtime slips past. This is the escape hatch for that, and
|
|
153
|
+
// for the "I don't believe the cache" moment every cache eventually causes.
|
|
154
|
+
if (this.flags["force-build"]) {
|
|
155
|
+
(Bun.env as Record<string, string>)["ZT_NO_BUILD_CACHE"] = "1";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const wantsServer = processes.some((entry) => entry.name === SERVER_PROCESS_NAME);
|
|
159
|
+
const assets = this._assetsConfig();
|
|
160
|
+
|
|
161
|
+
if (!wantsServer) {
|
|
162
|
+
// `--only=queue`, say. No port to bind and no assets to build β just the
|
|
163
|
+
// supervisor and the deck, so a busy port is nobody's problem.
|
|
164
|
+
await startDevMode({
|
|
165
|
+
port: 0,
|
|
166
|
+
cwd: process.cwd(),
|
|
167
|
+
processes,
|
|
168
|
+
writer: this._writer,
|
|
169
|
+
deckMode: this.flags["stream"] ? "stream" : undefined,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const port = await this._resolvePort(this.flags["port"] as number, false);
|
|
175
|
+
this._announce("dev", port);
|
|
176
|
+
|
|
177
|
+
// Every view package (Inertia/Flow) that registered a build routine gets
|
|
178
|
+
// run on each change; otherwise synthesise one from `app.assets` so
|
|
179
|
+
// configured assets rebuild on change.
|
|
180
|
+
let buildHook: (() => Promise<{ success: boolean; logs?: unknown[] }>) | undefined =
|
|
181
|
+
hasDevBuildHooks() ? runDevBuildHooks : undefined;
|
|
182
|
+
if (!buildHook && assets) {
|
|
183
|
+
buildHook = (): Promise<{ success: boolean; logs?: unknown[] }> =>
|
|
184
|
+
buildConfiguredAssets(assets, process.cwd());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!buildHook) {
|
|
188
|
+
// No Inertia (or no frontend build configured). Fall back to simple
|
|
189
|
+
// bun --watch restart so the developer still gets auto-reload.
|
|
190
|
+
//
|
|
191
|
+
// Deliberately keeps the terminal to itself: `bun --watch` needs stdin,
|
|
192
|
+
// and a deck drawn over a process we are not supervising would show tabs
|
|
193
|
+
// that no key could restart.
|
|
194
|
+
this.warn(" [zerotal:dev] no build hook registered β falling back to bun --watch");
|
|
195
|
+
if (processes.some((entry) => entry.name !== SERVER_PROCESS_NAME)) {
|
|
196
|
+
this.dim(" [zerotal:dev] dev processes are not supervised on this path.");
|
|
197
|
+
}
|
|
198
|
+
const subprocess = Bun.spawn(["bun", "--watch", Bun.main, "serve", "--port", String(port)], {
|
|
199
|
+
stdin: "inherit",
|
|
200
|
+
stdout: "inherit",
|
|
201
|
+
stderr: "inherit",
|
|
202
|
+
// The port is already settled here, and each --watch restart races
|
|
203
|
+
// the socket the previous run is still letting go of. Without this
|
|
204
|
+
// the child would re-prompt on every save.
|
|
205
|
+
env: { ...Bun.env, [PORT_RESOLVED_ENV_VAR]: "1" },
|
|
206
|
+
});
|
|
207
|
+
await subprocess.exited;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await startDevMode({
|
|
212
|
+
port,
|
|
213
|
+
cwd: process.cwd(),
|
|
214
|
+
build: buildHook,
|
|
215
|
+
processes,
|
|
216
|
+
writer: this._writer,
|
|
217
|
+
deckMode: this.flags["stream"] ? "stream" : undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What dev mode should run, after `--only` / `--without`.
|
|
223
|
+
*
|
|
224
|
+
* The server is prepended as an ordinary entry so the two filters need no
|
|
225
|
+
* special case for it β `--only=queue` really does mean "just the queue", and
|
|
226
|
+
* says so by leaving the server out of the list rather than by ignoring you.
|
|
227
|
+
*/
|
|
228
|
+
protected async _devProcesses(): Promise<ResolvedDevProcess[]> {
|
|
229
|
+
const app = this.app as Application;
|
|
230
|
+
const server: ResolvedDevProcess = {
|
|
231
|
+
name: SERVER_PROCESS_NAME,
|
|
232
|
+
label: SERVER_PROCESS_NAME,
|
|
233
|
+
color: "green",
|
|
234
|
+
restart: "on-failure",
|
|
235
|
+
after: "none",
|
|
236
|
+
registrant: "@zerotal/core",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let contributed: ResolvedDevProcess[] = [];
|
|
240
|
+
try {
|
|
241
|
+
contributed = await collectDevProcesses(app, this._config());
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// A badly-formed definition names its registrant. Reporting and carrying
|
|
244
|
+
// on beats refusing to start the server over another package's typo.
|
|
245
|
+
this.error(` ${error instanceof Error ? error.message : String(error)}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const only = _names(this.flags["only"]);
|
|
249
|
+
const without = _names(this.flags["without"]);
|
|
250
|
+
|
|
251
|
+
return [server, ...contributed].filter((entry) => {
|
|
252
|
+
if (only.length > 0 && !only.includes(entry.name)) return false;
|
|
253
|
+
return !without.includes(entry.name);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `--list`: what would run, and who asked for it. */
|
|
258
|
+
private _listDevProcesses(processes: ResolvedDevProcess[]): void {
|
|
259
|
+
this.section("Dev processes");
|
|
260
|
+
if (processes.length === 0) {
|
|
261
|
+
this.dim(" Nothing to run β every process was filtered out.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const entry of processes) {
|
|
265
|
+
const command = entry.run
|
|
266
|
+
? "(in-process)"
|
|
267
|
+
: (entry.argv?.join(" ") ?? "(managed by the orchestrator)");
|
|
268
|
+
this.line(` ${entry.name}`);
|
|
269
|
+
this.table(
|
|
270
|
+
[
|
|
271
|
+
["command", command],
|
|
272
|
+
["registered by", entry.registrant],
|
|
273
|
+
["restart", entry.restart],
|
|
274
|
+
["after", entry.after],
|
|
275
|
+
],
|
|
276
|
+
4,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Refresh the generated route-name map from this boot's router.
|
|
283
|
+
*
|
|
284
|
+
* Best-effort by design: a read-only project directory (a container mount, a
|
|
285
|
+
* sandbox) must not take the dev server down over a types file, and the app
|
|
286
|
+
* still runs perfectly well without it β only the compile-time check is lost.
|
|
287
|
+
*/
|
|
288
|
+
private async _writeRouteTypes(): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const { writeRouteTypes } = await import("../../router/routeTypes.ts");
|
|
291
|
+
const { Router } = await import("../../router/Router.ts");
|
|
292
|
+
const result = await writeRouteTypes(Router.namedRoutes);
|
|
293
|
+
if (result.changed) this.dim(` [zerotal:dev] wrote ${result.path} (${result.count} routes)`);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.dim(` [zerotal:dev] could not write route types: ${error}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The config manager, or undefined when the app has none bound. */
|
|
300
|
+
private _config(): { get<T>(key: string, fallback?: T): T | undefined } | undefined {
|
|
301
|
+
try {
|
|
302
|
+
return (this.app as Application).container.makeSync("config") as ConfigManager;
|
|
303
|
+
} catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
150
308
|
// ββ Private ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
151
309
|
|
|
152
310
|
/**
|
|
@@ -282,8 +440,29 @@ export class ServeCommand extends Command {
|
|
|
282
440
|
}
|
|
283
441
|
}
|
|
284
442
|
|
|
443
|
+
/** The resolved `app.env`, or undefined when the config store is unavailable. */
|
|
444
|
+
private _appEnv(): string | undefined {
|
|
445
|
+
try {
|
|
446
|
+
const config = (this.app as Application).container.makeSync("config") as ConfigManager;
|
|
447
|
+
return config.get("app.env") as string | undefined;
|
|
448
|
+
} catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
285
453
|
/** Bundle the configured assets, reporting any build failure (non-fatal β the server still starts). */
|
|
286
454
|
private async _buildAssets(assets: AssetBuildConfig): Promise<void> {
|
|
455
|
+
// A production deployment that built its assets ahead of time and locked the tree
|
|
456
|
+
// down is doing the right thing; rebuilding at boot would only fail. See bootBuild.ts.
|
|
457
|
+
const decision = await bootBuildDecision(
|
|
458
|
+
[`${process.cwd()}/${assets.outDir}`],
|
|
459
|
+
(this._appEnv() ?? Bun.env["APP_ENV"]) as string | undefined,
|
|
460
|
+
);
|
|
461
|
+
if (!decision.build) {
|
|
462
|
+
this.info(decision.reason ?? "Skipping the boot-time asset build.");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
287
466
|
const entries = Array.isArray(assets.entrypoint)
|
|
288
467
|
? assets.entrypoint.join(", ")
|
|
289
468
|
: assets.entrypoint;
|
|
@@ -314,6 +493,15 @@ export class ServeCommand extends Command {
|
|
|
314
493
|
}
|
|
315
494
|
}
|
|
316
495
|
|
|
496
|
+
/** Split a `--only` / `--without` value into names. Absent means "no filter". */
|
|
497
|
+
function _names(flag: unknown): string[] {
|
|
498
|
+
if (typeof flag !== "string") return [];
|
|
499
|
+
return flag
|
|
500
|
+
.split(",")
|
|
501
|
+
.map((name) => name.trim())
|
|
502
|
+
.filter(Boolean);
|
|
503
|
+
}
|
|
504
|
+
|
|
317
505
|
/** `" by bun.exe (pid 1234)"`, or `""` when the holder could not be identified. */
|
|
318
506
|
function _describe(owner: PortOwner | undefined): string {
|
|
319
507
|
if (!owner) return "";
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @packageDocumentation
|
|
26
26
|
*/
|
|
27
27
|
export { ServeCommand } from "./ServeCommand.ts";
|
|
28
|
+
export { DevCommand } from "./DevCommand.ts";
|
|
28
29
|
export { ReplCommand } from "./ReplCommand.ts";
|
|
29
30
|
export { StartCommand } from "./StartCommand.ts";
|
|
30
31
|
export { WorkerCommand } from "./WorkerCommand.ts";
|
|
@@ -47,7 +48,11 @@ export { MakeResourceCommand } from "./MakeResourceCommand.ts";
|
|
|
47
48
|
export { MakeTestCommand } from "./MakeTestCommand.ts";
|
|
48
49
|
export { TestCommand } from "./TestCommand.ts";
|
|
49
50
|
export { RouteListCommand } from "./RouteListCommand.ts";
|
|
51
|
+
export { RouteTypesCommand } from "./RouteTypesCommand.ts";
|
|
52
|
+
export { DoctorCommand } from "./DoctorCommand.ts";
|
|
53
|
+
export { DeployCommand, makeDeployCommand } from "./DeployCommand.ts";
|
|
50
54
|
export { MakeProviderCommand } from "./MakeProviderCommand.ts";
|
|
51
55
|
export { CssBuildCommand } from "./CssBuildCommand.ts";
|
|
56
|
+
export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
|
|
52
57
|
export { LintPackagesCommand } from "./LintPackagesCommand.ts";
|
|
53
58
|
export { MakePackageCommand } from "./MakePackageCommand.ts";
|
|
@@ -4,16 +4,8 @@
|
|
|
4
4
|
// DO NOT MODIFY THIS FILE.
|
|
5
5
|
// This file is managed by the Zerotal framework.
|
|
6
6
|
//
|
|
7
|
-
// To add custom commands, create classes in app/commands/
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// override async onBooted(): Promise<void> {
|
|
11
|
-
// if (this.app._env === 'console') {
|
|
12
|
-
// const runner = this.app.container.makeSync('commands');
|
|
13
|
-
// const { MyCommand } = await import('../commands/MyCommand.ts');
|
|
14
|
-
// runner.register(MyCommand);
|
|
15
|
-
// }
|
|
16
|
-
// }
|
|
7
|
+
// To add custom commands, create classes in app/commands/ β
|
|
8
|
+
// they are discovered automatically (`bun zerotal.ts make:command MyCommand`).
|
|
17
9
|
//
|
|
18
10
|
// Help: bun zerotal.ts list
|
|
19
11
|
// bun zerotal.ts help <command>
|
package/src/config/AppConfig.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* defaults, conventions), with sensible defaults applied over caller overrides.
|
|
5
5
|
*/
|
|
6
6
|
import { deepMerge } from "../support/deepMerge.ts";
|
|
7
|
+
import { isProdLike } from "../support/env.ts";
|
|
7
8
|
import type { HealthConfigShape } from "../health/Health.ts";
|
|
9
|
+
import type { DevConfigShape } from "../dev/DevProcess.ts";
|
|
8
10
|
|
|
9
11
|
/** TLS certificate and key paths that enable HTTPS when provided. */
|
|
10
12
|
export interface AppTlsConfig {
|
|
@@ -30,6 +32,7 @@ export interface ConventionsConfig {
|
|
|
30
32
|
jobs: string;
|
|
31
33
|
schedules: string;
|
|
32
34
|
validators: string;
|
|
35
|
+
commands: string;
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -45,9 +48,32 @@ export interface AppThrottleConfig {
|
|
|
45
48
|
windowSeconds: number;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* App-level security-header defaults (consumed by `SecureHeadersMiddleware`).
|
|
53
|
+
*
|
|
54
|
+
* The middleware reads this whole block and layers it over its own defaults, so
|
|
55
|
+
* every one of its options has always worked here β but only `frameOptions` was
|
|
56
|
+
* declared, which made the rest a type error to write down. `secure` is the one
|
|
57
|
+
* that matters: HSTS is emitted only when it is true, so an app that could not
|
|
58
|
+
* name it in config had no supported way to turn HSTS on.
|
|
59
|
+
*/
|
|
49
60
|
export interface AppSecureHeadersConfig {
|
|
50
61
|
frameOptions: "DENY" | "SAMEORIGIN";
|
|
62
|
+
/**
|
|
63
|
+
* Enable HSTS (and the `Secure` flag on the XSRF-TOKEN cookie). Defaults to
|
|
64
|
+
* `false` β turn it on for any deployment served over HTTPS.
|
|
65
|
+
*/
|
|
66
|
+
secure?: boolean;
|
|
67
|
+
/** `Strict-Transport-Security` max-age in seconds. Defaults to one year. */
|
|
68
|
+
hstsMaxAge?: number;
|
|
69
|
+
/** Include `includeSubDomains` in the HSTS header. Defaults to `true`. */
|
|
70
|
+
hstsIncludeSubDomains?: boolean;
|
|
71
|
+
/** Set the HSTS `preload` directive. Only with the domain registered. Defaults to `false`. */
|
|
72
|
+
hstsPreload?: boolean;
|
|
73
|
+
/** `Content-Security-Policy` value. Omitted by default. */
|
|
74
|
+
contentSecurityPolicy?: string;
|
|
75
|
+
/** `Referrer-Policy` value. Defaults to `strict-origin-when-cross-origin`. */
|
|
76
|
+
referrerPolicy?: string;
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/**
|
|
@@ -121,6 +147,7 @@ const DEFAULT_CONVENTION_PATHS: ConventionsConfig["paths"] = {
|
|
|
121
147
|
jobs: "app/jobs",
|
|
122
148
|
schedules: "app/schedules",
|
|
123
149
|
validators: "app/validators",
|
|
150
|
+
commands: "app/commands",
|
|
124
151
|
};
|
|
125
152
|
|
|
126
153
|
// ββ Full shape (resolved β what the config store holds after AppConfig()) ββββ
|
|
@@ -180,6 +207,26 @@ export interface AppConfigShape {
|
|
|
180
207
|
*/
|
|
181
208
|
health: boolean | HealthConfigShape;
|
|
182
209
|
|
|
210
|
+
// ββ Transport origins βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
211
|
+
/**
|
|
212
|
+
* Origins accepted for credentialed endpoints that bypass the middleware pipeline:
|
|
213
|
+
* WebSocket upgrades and raw routes, of which Flow's `/__flow/http` action fallback
|
|
214
|
+
* is one. Compared exactly β no wildcards, no suffix matching.
|
|
215
|
+
*
|
|
216
|
+
* The app's own origin is always accepted, but "own" means
|
|
217
|
+
* `new URL(request.url).origin` β behind a reverse proxy that is the loopback
|
|
218
|
+
* address it was bound to (`http://127.0.0.1:3000`), never the public URL the
|
|
219
|
+
* browser truthfully sends. So the origin of {@link AppConfigShape.url} is always
|
|
220
|
+
* merged into this list: without it a proxied app renders every page correctly and
|
|
221
|
+
* refuses every browser-initiated action with a 403, which is a far quieter failure
|
|
222
|
+
* than a 500 and passes any health check that reads a status code.
|
|
223
|
+
*
|
|
224
|
+
* Add entries only for a *different* host that legitimately drives this app β an SPA
|
|
225
|
+
* on `app.example.com` calling `api.example.com`. Unlike every other array in this
|
|
226
|
+
* config, what you pass is added to the URL's origin rather than replacing it.
|
|
227
|
+
*/
|
|
228
|
+
allowedOrigins: string[];
|
|
229
|
+
|
|
183
230
|
// ββ Middleware defaults βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
184
231
|
/** CORS defaults applied by `CorsMiddleware` when registered without explicit options. */
|
|
185
232
|
cors: AppCorsConfig;
|
|
@@ -192,11 +239,60 @@ export interface AppConfigShape {
|
|
|
192
239
|
/** Front-end asset bundling, built on `serve`. Omitted means no asset build. */
|
|
193
240
|
assets?: AppAssetsConfig;
|
|
194
241
|
|
|
242
|
+
// ββ Dev mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
243
|
+
/**
|
|
244
|
+
* Extra processes for `bun zt dev`, and names to drop.
|
|
245
|
+
*
|
|
246
|
+
* App entries are registered after every provider's, so reusing a provider's
|
|
247
|
+
* name replaces it rather than adding a second tab β which is how an app swaps
|
|
248
|
+
* out, say, the queue worker for its own. `disable` removes by name whoever
|
|
249
|
+
* registered it.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```ts
|
|
253
|
+
* dev: {
|
|
254
|
+
* processes: [{ name: "stripe", command: ["stripe", "listen", "--forward-to", "localhost:3000"] }],
|
|
255
|
+
* disable: ["queue"],
|
|
256
|
+
* }
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
dev?: DevConfigShape;
|
|
260
|
+
|
|
195
261
|
// ββ Conventions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
196
262
|
/** Auto-discovery settings (enabled + per-concern paths). */
|
|
197
263
|
conventions: ConventionsConfig;
|
|
198
264
|
}
|
|
199
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Reduce a list of URLs or origins to the deduplicated origins an `Origin` header can be
|
|
268
|
+
* compared against.
|
|
269
|
+
*
|
|
270
|
+
* Full URLs are accepted because the two things that feed this list β `app.url` and an
|
|
271
|
+
* `ALLOWED_ORIGINS` env var someone pasted a browser address into β usually carry a path
|
|
272
|
+
* or a trailing slash, and `https://app.com/` never equals the `https://app.com` a browser
|
|
273
|
+
* sends. Entries that do not parse are kept verbatim rather than dropped: an unmatchable
|
|
274
|
+
* entry is inert, whereas silently discarding one hides the typo that caused it. `bun zt
|
|
275
|
+
* doctor` reports those.
|
|
276
|
+
*/
|
|
277
|
+
function _normaliseOrigins(entries: string[]): string[] {
|
|
278
|
+
const out: string[] = [];
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (typeof entry !== "string") continue;
|
|
281
|
+
const trimmed = entry.trim();
|
|
282
|
+
if (trimmed === "") continue;
|
|
283
|
+
let origin: string;
|
|
284
|
+
try {
|
|
285
|
+
origin = new URL(trimmed).origin;
|
|
286
|
+
// `new URL("mailto:a@b").origin` is "null" β parsed, but not an origin.
|
|
287
|
+
if (origin === "null") origin = trimmed;
|
|
288
|
+
} catch {
|
|
289
|
+
origin = trimmed;
|
|
290
|
+
}
|
|
291
|
+
if (!out.includes(origin)) out.push(origin);
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
200
296
|
// ββ AppConfig factory βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
201
297
|
|
|
202
298
|
/**
|
|
@@ -232,6 +328,7 @@ export function AppConfig(options: {
|
|
|
232
328
|
tls?: AppTlsConfig;
|
|
233
329
|
maxRequestBodySize?: number;
|
|
234
330
|
health?: boolean | HealthConfigShape;
|
|
331
|
+
allowedOrigins?: string[];
|
|
235
332
|
cors?: Partial<AppCorsConfig>;
|
|
236
333
|
throttle?: Partial<AppThrottleConfig>;
|
|
237
334
|
secureHeaders?: Partial<AppSecureHeadersConfig>;
|
|
@@ -242,6 +339,7 @@ export function AppConfig(options: {
|
|
|
242
339
|
minify?: boolean;
|
|
243
340
|
loader?: Record<string, AssetLoaderKind>;
|
|
244
341
|
};
|
|
342
|
+
dev?: DevConfigShape;
|
|
245
343
|
conventions?: { enabled?: boolean; paths?: Partial<ConventionsConfig["paths"]> };
|
|
246
344
|
}): AppConfigShape {
|
|
247
345
|
// Resolve env-derived defaults, then deep-merge the caller's overrides so partial nested
|
|
@@ -258,6 +356,7 @@ export function AppConfig(options: {
|
|
|
258
356
|
http3: false,
|
|
259
357
|
maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE,
|
|
260
358
|
health: false,
|
|
359
|
+
allowedOrigins: [],
|
|
261
360
|
cors: DEFAULT_CORS,
|
|
262
361
|
throttle: DEFAULT_THROTTLE,
|
|
263
362
|
secureHeaders: DEFAULT_SECURE_HEADERS,
|
|
@@ -265,10 +364,18 @@ export function AppConfig(options: {
|
|
|
265
364
|
};
|
|
266
365
|
const resolved = deepMerge(defaults, options as Partial<AppConfigShape>);
|
|
267
366
|
|
|
367
|
+
// `allowedOrigins` always contains the origin of `url`, and is the one array here that
|
|
368
|
+
// unions rather than replaces. An app naming a second origin does not mean "and stop
|
|
369
|
+
// trusting my own public URL", and the cost of getting that wrong is asymmetric: the
|
|
370
|
+
// app keeps rendering and every action 403s, with nothing in the logs to say why.
|
|
371
|
+
resolved.allowedOrigins = _normaliseOrigins([resolved.url, ...resolved.allowedOrigins]);
|
|
372
|
+
|
|
268
373
|
// Normalise the optional `assets` block: fill outDir/prefix/minify defaults
|
|
269
374
|
// only when the app actually declares an asset entrypoint.
|
|
270
375
|
if (options.assets) {
|
|
271
|
-
|
|
376
|
+
// `isProdLike`, so `staging` minifies too. This runs at config-load time, where
|
|
377
|
+
// `resolved.env` is still the deployment name rather than the runtime mode.
|
|
378
|
+
const isProduction = isProdLike(resolved.env);
|
|
272
379
|
resolved.assets = {
|
|
273
380
|
entrypoint: options.assets.entrypoint,
|
|
274
381
|
outDir: options.assets.outDir ?? "public",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy targets β the environments this app is released to, and the pipeline each
|
|
3
|
+
* one runs.
|
|
4
|
+
*
|
|
5
|
+
* Every target gets its own command: declare `production` and `staging` and you get
|
|
6
|
+
* `bun zt deploy:production` and `bun zt deploy:staging`. The name is the whole
|
|
7
|
+
* point of the split β it is checked against the deployment this process was
|
|
8
|
+
* actually started as, so running the production pipeline against a staging box
|
|
9
|
+
* stops on the first line rather than migrating the wrong database.
|
|
10
|
+
*
|
|
11
|
+
* The file is optional. Without it, `production` and `staging` exist with the
|
|
12
|
+
* default pipeline, which is what most apps want.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* // config/deploy.ts
|
|
17
|
+
* import { DeployConfig } from "zerotal/config";
|
|
18
|
+
*
|
|
19
|
+
* export default DeployConfig({
|
|
20
|
+
* targets: {
|
|
21
|
+
* production: { url: "https://example.com" },
|
|
22
|
+
* staging: { url: "https://staging.example.com" },
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The release steps, in order. Each names a `zt` command; one that is not
|
|
30
|
+
* registered is skipped, so an app without Inertia simply has no `inertia:build`.
|
|
31
|
+
*
|
|
32
|
+
* Ordered so that everything able to refuse runs before anything that mutates:
|
|
33
|
+
* the build cannot corrupt a database and the migration cannot half-apply if the
|
|
34
|
+
* preflight has already stopped the deploy.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_DEPLOY_STEPS: readonly string[] = ["assets:build", "inertia:build", "migrate"];
|
|
37
|
+
|
|
38
|
+
/** One environment this app is released to. */
|
|
39
|
+
export interface DeployTarget {
|
|
40
|
+
/**
|
|
41
|
+
* The app's public URL in this environment. Used by `--probe` to run a real
|
|
42
|
+
* WebSocket handshake against the deployed site, the way a browser would.
|
|
43
|
+
*/
|
|
44
|
+
url?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Override the release steps for this target. Defaults to
|
|
47
|
+
* {@link DEFAULT_DEPLOY_STEPS}. Names a `zt` command per entry.
|
|
48
|
+
*/
|
|
49
|
+
steps?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `deploy` config namespace. */
|
|
53
|
+
export interface DeployConfigShape {
|
|
54
|
+
targets: Record<string, DeployTarget>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The targets assumed when an app ships no `config/deploy.ts`. */
|
|
58
|
+
export const DEFAULT_DEPLOY_TARGETS: Record<string, DeployTarget> = {
|
|
59
|
+
production: {},
|
|
60
|
+
staging: {},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the `deploy` config block, filling in defaults.
|
|
65
|
+
*
|
|
66
|
+
* @param options - The targets this app releases to.
|
|
67
|
+
*/
|
|
68
|
+
export function DeployConfig(options: Partial<DeployConfigShape> = {}): DeployConfigShape {
|
|
69
|
+
const targets = options.targets ?? DEFAULT_DEPLOY_TARGETS;
|
|
70
|
+
return { targets };
|
|
71
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type {
|
|
|
36
36
|
AppAssetsConfig,
|
|
37
37
|
AssetLoaderKind,
|
|
38
38
|
} from "./AppConfig.ts";
|
|
39
|
+
export { DeployConfig, DEFAULT_DEPLOY_STEPS, DEFAULT_DEPLOY_TARGETS } from "./DeployConfig.ts";
|
|
40
|
+
export type { DeployConfigShape, DeployTarget } from "./DeployConfig.ts";
|
|
39
41
|
export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
|
|
40
42
|
export { ConfigValidationError } from "./validation.ts";
|
|
41
43
|
export type {
|
package/src/config/registry.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { HealthConfigShape } from "../health/Health.ts";
|
|
|
21
21
|
*/
|
|
22
22
|
export interface ConfigRegistry {
|
|
23
23
|
app: AppConfigShape;
|
|
24
|
+
deploy: import("./DeployConfig.ts").DeployConfigShape;
|
|
24
25
|
health: HealthConfigShape;
|
|
25
26
|
lock: import("../lock/config.ts").LockConfigShape;
|
|
26
27
|
logging: import("../logger/config.ts").LoggingConfigShape;
|