@zerotal/core 1.3.0 → 1.5.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 +351 -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 +23 -1
- 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 +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zt doctor` — static sanity checks against a booted app.
|
|
3
|
+
*
|
|
4
|
+
* Most of the expensive failures in a Zerotal app are silent: a provider that
|
|
5
|
+
* isn't registered fails by doing nothing, `synchronize` and migrations collide
|
|
6
|
+
* on the first command a new user types, a routes file 404s because nothing
|
|
7
|
+
* loads it. Each is statically detectable, and the individual warnings already
|
|
8
|
+
* exist at various points of the boot sequence — this runs them all in one
|
|
9
|
+
* place, on demand, with the fix next to each finding.
|
|
10
|
+
*
|
|
11
|
+
* Packages contribute their own checks via `app.registerDoctorCheck()` (the
|
|
12
|
+
* scheduler's static-config check is the model); the built-ins cover core.
|
|
13
|
+
*/
|
|
14
|
+
import { readdirSync } from "node:fs";
|
|
15
|
+
import type { Application } from "../application/Application.ts";
|
|
16
|
+
import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
17
|
+
import { unroutedRoutesWarning } from "../support/unroutedRoutes.ts";
|
|
18
|
+
import { isWritableDir } from "../dev/bootBuild.ts";
|
|
19
|
+
import { isProdLike } from "../support/env.ts";
|
|
20
|
+
|
|
21
|
+
/** One finding: ok is silent health, warn is worth reading, fail is broken now. */
|
|
22
|
+
export interface DoctorCheckResult {
|
|
23
|
+
status: "ok" | "warn" | "fail";
|
|
24
|
+
message: string;
|
|
25
|
+
/** The command or edit that resolves it, shown under the finding. */
|
|
26
|
+
fix?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A named check. Contribute app-specific ones via `app.registerDoctorCheck()`. */
|
|
30
|
+
export interface DoctorCheck {
|
|
31
|
+
/** Stable kebab-case id (e.g. `"app-key"`). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Human label printed next to the finding. */
|
|
34
|
+
label: string;
|
|
35
|
+
run(app: Application): DoctorCheckResult | Promise<DoctorCheckResult>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A check paired with what it found. */
|
|
39
|
+
export interface DoctorReportEntry {
|
|
40
|
+
check: DoctorCheck;
|
|
41
|
+
result: DoctorCheckResult;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function _config(app: Application, key: string): unknown {
|
|
47
|
+
try {
|
|
48
|
+
const config = app.container.makeSync("config") as { get(k: string): unknown };
|
|
49
|
+
return config.get(key);
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Non-test source files directly inside `<root>/<dir>`, or [] when absent. */
|
|
56
|
+
function _sourceFiles(root: string, dir: string): string[] {
|
|
57
|
+
try {
|
|
58
|
+
return readdirSync(`${root}/${dir}`).filter(
|
|
59
|
+
(f) => /\.(ts|js)$/.test(f) && !/\.test\.(ts|js)$/.test(f),
|
|
60
|
+
);
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ok = (message: string): DoctorCheckResult => ({ status: "ok", message });
|
|
67
|
+
const warn = (message: string, fix?: string): DoctorCheckResult => ({
|
|
68
|
+
status: "warn",
|
|
69
|
+
message,
|
|
70
|
+
...(fix !== undefined ? { fix } : {}),
|
|
71
|
+
});
|
|
72
|
+
const fail = (message: string, fix?: string): DoctorCheckResult => ({
|
|
73
|
+
status: "fail",
|
|
74
|
+
message,
|
|
75
|
+
...(fix !== undefined ? { fix } : {}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Built-in checks ───────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const appKeyCheck: DoctorCheck = {
|
|
81
|
+
id: "app-key",
|
|
82
|
+
label: "APP_KEY",
|
|
83
|
+
run() {
|
|
84
|
+
const key = Bun.env["APP_KEY"];
|
|
85
|
+
if (!key) {
|
|
86
|
+
return warn(
|
|
87
|
+
"APP_KEY is not set. Sessions, signed URLs and encrypted columns all derive from it; " +
|
|
88
|
+
"a production-like APP_ENV refuses to boot without a strong one.",
|
|
89
|
+
"bun zt key:generate",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const weakness = appKeyStrengthWarning(key);
|
|
93
|
+
return weakness ? fail(weakness, "bun zt key:generate") : ok("set and strong");
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const syncVsMigrationsCheck: DoctorCheck = {
|
|
98
|
+
id: "synchronize-vs-migrations",
|
|
99
|
+
label: "Schema source of truth",
|
|
100
|
+
run(app) {
|
|
101
|
+
const synchronize = _config(app, "database.synchronize") === true;
|
|
102
|
+
const migrations = _sourceFiles(process.cwd(), "database/migrations");
|
|
103
|
+
if (synchronize && migrations.length > 0) {
|
|
104
|
+
return fail(
|
|
105
|
+
`database.synchronize is on and ${migrations.length} migration(s) exist. Boot-time ` +
|
|
106
|
+
`sync creates tables from the models first, so the first \`migrate\` fails with ` +
|
|
107
|
+
`"table already exists". The schema needs exactly one source of truth.`,
|
|
108
|
+
"Set synchronize: false in config/database.ts (migrations become the source of truth).",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (synchronize) return ok("synchronize (no migrations present)");
|
|
112
|
+
return ok(migrations.length > 0 ? "migrations" : "no schema management configured");
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The origin guard on pipeline-bypassing endpoints compares the browser's `Origin` against
|
|
118
|
+
* the app's own — which behind a proxy is the loopback address it bound to. Everything the
|
|
119
|
+
* app needs to be reachable is therefore in `app.allowedOrigins`, and getting it wrong is
|
|
120
|
+
* silent: pages render, actions 403.
|
|
121
|
+
*/
|
|
122
|
+
const allowedOriginsCheck: DoctorCheck = {
|
|
123
|
+
id: "allowed-origins",
|
|
124
|
+
label: "Transport origins",
|
|
125
|
+
run(app) {
|
|
126
|
+
const url = _config(app, "app.url");
|
|
127
|
+
const origins = _config(app, "app.allowedOrigins");
|
|
128
|
+
const list = Array.isArray(origins) ? origins.filter((o) => typeof o === "string") : [];
|
|
129
|
+
|
|
130
|
+
if (list.length === 0) {
|
|
131
|
+
return fail(
|
|
132
|
+
"app.allowedOrigins is empty. Behind a reverse proxy the app's own origin is the " +
|
|
133
|
+
"loopback address it bound to, so every browser-initiated WebSocket and " +
|
|
134
|
+
"/__flow/http action will be refused with 403 while pages keep rendering.",
|
|
135
|
+
"Set url in config/app.ts (AppConfig fills allowedOrigins from it).",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Unparseable entries can never equal an `Origin` header, so they are inert — but an
|
|
140
|
+
// inert entry is always a typo, and the typo is usually the one that mattered.
|
|
141
|
+
const unparseable = list.filter((entry) => {
|
|
142
|
+
try {
|
|
143
|
+
return new URL(entry).origin !== entry;
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
if (unparseable.length > 0) {
|
|
149
|
+
return warn(
|
|
150
|
+
`app.allowedOrigins contains ${unparseable.map((e) => `"${e}"`).join(", ")}, which ` +
|
|
151
|
+
`${unparseable.length === 1 ? "is not an origin" : "are not origins"} and can never ` +
|
|
152
|
+
`match an Origin header. An origin is scheme + host + port, with no path or trailing slash.`,
|
|
153
|
+
"Correct the entry in config/app.ts, e.g. https://app.example.com",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A production app still pointing at localhost has a plausible-looking config and an
|
|
158
|
+
// allowedOrigins list that no real browser will ever match.
|
|
159
|
+
if (_isProductionEnv(app) && typeof url === "string" && _isLoopback(url)) {
|
|
160
|
+
return fail(
|
|
161
|
+
`app.url is ${url} in a production environment, so allowedOrigins is a loopback ` +
|
|
162
|
+
`address. Browsers send the public origin, which is not in the list — every ` +
|
|
163
|
+
`credentialed action will be refused.`,
|
|
164
|
+
"Set APP_URL to the public URL the site is served from.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return ok(list.join(", "));
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `serve` builds configured assets at boot, so the output directory has to be writable —
|
|
174
|
+
* or the build has to have happened at deploy time. Either is fine; the failure is the
|
|
175
|
+
* combination nobody chose deliberately.
|
|
176
|
+
*/
|
|
177
|
+
const bootAssetWriteCheck: DoctorCheck = {
|
|
178
|
+
id: "boot-asset-writes",
|
|
179
|
+
label: "Asset output",
|
|
180
|
+
async run(app) {
|
|
181
|
+
const assets = _config(app, "app.assets") as { outDir?: string } | undefined;
|
|
182
|
+
const dirs = [
|
|
183
|
+
...(assets?.outDir ? [assets.outDir] : []),
|
|
184
|
+
// Flow bundles its own CSS/JS into these when the entry points exist.
|
|
185
|
+
...(await _existingFlowAssetDirs()),
|
|
186
|
+
];
|
|
187
|
+
if (dirs.length === 0) return ok("no bundled assets configured");
|
|
188
|
+
|
|
189
|
+
if (!_isProductionEnv(app)) return ok(`${dirs.join(", ")} (built at boot outside production)`);
|
|
190
|
+
|
|
191
|
+
const unwritable: string[] = [];
|
|
192
|
+
for (const dir of dirs) {
|
|
193
|
+
if (!(await isWritableDir(`${process.cwd()}/${dir}`))) unwritable.push(dir);
|
|
194
|
+
}
|
|
195
|
+
if (unwritable.length === 0) return ok(`${dirs.join(", ")} writable`);
|
|
196
|
+
|
|
197
|
+
return ok(
|
|
198
|
+
`${unwritable.join(", ")} is read-only — the boot-time build is skipped and the ` +
|
|
199
|
+
`assets shipped with the release are served. Build them at deploy time.`,
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Directories Flow writes bundles into, listed only when the matching entry point exists. */
|
|
205
|
+
async function _existingFlowAssetDirs(): Promise<string[]> {
|
|
206
|
+
const pairs = [
|
|
207
|
+
["resources/css/app.css", "public/css"],
|
|
208
|
+
["resources/js/app.js", "public/js"],
|
|
209
|
+
] as const;
|
|
210
|
+
const out: string[] = [];
|
|
211
|
+
for (const [entry, dir] of pairs) {
|
|
212
|
+
if (await Bun.file(`${process.cwd()}/${entry}`).exists()) out.push(dir);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _isProductionEnv(app: Application): boolean {
|
|
218
|
+
return isProdLike(String(_config(app, "app.env") ?? ""));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Whether a URL points at this machine — i.e. is not something a browser elsewhere can reach. */
|
|
222
|
+
function _isLoopback(url: string): boolean {
|
|
223
|
+
try {
|
|
224
|
+
const host = new URL(url).hostname;
|
|
225
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0";
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const unroutedRoutesCheck: DoctorCheck = {
|
|
232
|
+
id: "unrouted-routes",
|
|
233
|
+
label: "routes/ directory",
|
|
234
|
+
run(app) {
|
|
235
|
+
const warning = unroutedRoutesWarning(process.cwd(), app.routedFiles);
|
|
236
|
+
return warning ? warn(warning) : ok("absent or routed");
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** A directory-full-of-classes whose consuming provider isn't registered does nothing. */
|
|
241
|
+
function _providerDirCheck(options: {
|
|
242
|
+
id: string;
|
|
243
|
+
label: string;
|
|
244
|
+
dir: (app: Application) => string;
|
|
245
|
+
binding: string;
|
|
246
|
+
provider: string;
|
|
247
|
+
}): DoctorCheck {
|
|
248
|
+
return {
|
|
249
|
+
id: options.id,
|
|
250
|
+
label: options.label,
|
|
251
|
+
run(app) {
|
|
252
|
+
const dir = options.dir(app);
|
|
253
|
+
const files = _sourceFiles(process.cwd(), dir);
|
|
254
|
+
if (files.length === 0) return ok(`no ${dir}/`);
|
|
255
|
+
if (app.container.bound(options.binding as never)) {
|
|
256
|
+
return ok(`${files.length} file(s), provider registered`);
|
|
257
|
+
}
|
|
258
|
+
return warn(
|
|
259
|
+
`${dir}/ holds ${files.join(", ")} but ${options.provider} is not registered — ` +
|
|
260
|
+
`nothing in it will run, and nothing will say so.`,
|
|
261
|
+
`Add ${options.provider} to bootstrap/providers.ts.`,
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _conventionPath(app: Application, key: string, fallback: string): string {
|
|
268
|
+
const paths = _config(app, "app.conventions.paths") as Record<string, string> | undefined;
|
|
269
|
+
return paths?.[key] ?? fallback;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const schedulesProviderCheck = _providerDirCheck({
|
|
273
|
+
id: "schedules-provider",
|
|
274
|
+
label: "app/schedules",
|
|
275
|
+
dir: (app) => _conventionPath(app, "schedules", "app/schedules"),
|
|
276
|
+
binding: "scheduler",
|
|
277
|
+
provider: "SchedulerProvider",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const jobsProviderCheck = _providerDirCheck({
|
|
281
|
+
id: "jobs-provider",
|
|
282
|
+
label: "app/jobs",
|
|
283
|
+
dir: (app) => _conventionPath(app, "jobs", "app/jobs"),
|
|
284
|
+
binding: "queue",
|
|
285
|
+
provider: "QueueProvider",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const storageProviderCheck: DoctorCheck = {
|
|
289
|
+
id: "storage-provider",
|
|
290
|
+
label: "Storage",
|
|
291
|
+
async run(app) {
|
|
292
|
+
const hasConfig = await Bun.file(`${process.cwd()}/config/storage.ts`).exists();
|
|
293
|
+
if (!hasConfig) return ok("no config/storage.ts");
|
|
294
|
+
if (app.container.bound("storage" as never)) return ok("configured and registered");
|
|
295
|
+
return warn(
|
|
296
|
+
"config/storage.ts exists but StorageProvider is not registered — the first " +
|
|
297
|
+
"Storage.disk(...) call will throw, typically behind auth checks on an upload path.",
|
|
298
|
+
"Add StorageProvider to bootstrap/providers.ts.",
|
|
299
|
+
);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `cors.origin: "*"` tells every site on the internet it may read this app's
|
|
305
|
+
* responses from a browser. That is the right default for a public read-only API
|
|
306
|
+
* and wrong for everything else — and it is what every scaffolded app shipped with
|
|
307
|
+
* until 1.5.0, because the templates set it while the framework's own default was
|
|
308
|
+
* the safe empty list.
|
|
309
|
+
*
|
|
310
|
+
* Only a problem once deployed, so it fails on a production-like deployment and
|
|
311
|
+
* stays quiet locally, where `*` is what makes a second dev server work.
|
|
312
|
+
*/
|
|
313
|
+
const corsWildcardCheck: DoctorCheck = {
|
|
314
|
+
id: "cors-wildcard",
|
|
315
|
+
label: "CORS",
|
|
316
|
+
run(app) {
|
|
317
|
+
const origin = _config(app, "app.cors.origin");
|
|
318
|
+
const wildcard = origin === "*" || (Array.isArray(origin) && origin.some((o) => o === "*"));
|
|
319
|
+
if (!wildcard) return ok("no wildcard origin.");
|
|
320
|
+
if (!_isProductionEnv(app)) return ok('origin is "*" — fine outside a deployment.');
|
|
321
|
+
return fail(
|
|
322
|
+
'app.cors.origin is "*", so any website can read this app\'s responses from a ' +
|
|
323
|
+
"visitor's browser.",
|
|
324
|
+
"Set app.cors.origin to the origins that legitimately call this app, in config/app.ts.",
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* HSTS is emitted only when `app.secureHeaders.secure` is true, which defaults to
|
|
331
|
+
* false and has never had production auto-detection. A deployment that never set it
|
|
332
|
+
* serves no `Strict-Transport-Security` at all, so a visitor's first request each
|
|
333
|
+
* time stays downgradeable to plain HTTP.
|
|
334
|
+
*/
|
|
335
|
+
const secureHeadersCheck: DoctorCheck = {
|
|
336
|
+
id: "secure-headers",
|
|
337
|
+
label: "Secure headers",
|
|
338
|
+
run(app) {
|
|
339
|
+
const secure = _config(app, "app.secureHeaders.secure") === true;
|
|
340
|
+
if (secure) return ok("HSTS enabled.");
|
|
341
|
+
if (!_isProductionEnv(app)) return ok("HSTS off — expected outside a deployment.");
|
|
342
|
+
return fail(
|
|
343
|
+
"app.secureHeaders.secure is not true, so no Strict-Transport-Security header is " +
|
|
344
|
+
"sent and the first request of each visit can be downgraded to HTTP.",
|
|
345
|
+
"Set app.secureHeaders.secure: true in config/app.ts once the site is served over HTTPS.",
|
|
346
|
+
);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** The core checks every app gets. */
|
|
351
|
+
export const builtinDoctorChecks: DoctorCheck[] = [
|
|
352
|
+
appKeyCheck,
|
|
353
|
+
allowedOriginsCheck,
|
|
354
|
+
corsWildcardCheck,
|
|
355
|
+
secureHeadersCheck,
|
|
356
|
+
bootAssetWriteCheck,
|
|
357
|
+
syncVsMigrationsCheck,
|
|
358
|
+
unroutedRoutesCheck,
|
|
359
|
+
schedulesProviderCheck,
|
|
360
|
+
jobsProviderCheck,
|
|
361
|
+
storageProviderCheck,
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Run the built-in checks plus everything providers contributed. A check that
|
|
366
|
+
* throws is reported as a failure of that check, never of the doctor.
|
|
367
|
+
*/
|
|
368
|
+
export async function runDoctor(
|
|
369
|
+
app: Application,
|
|
370
|
+
extraChecks: DoctorCheck[] = [],
|
|
371
|
+
): Promise<DoctorReportEntry[]> {
|
|
372
|
+
// Three sources, one list. `registerDoctorCheck()` is imperative and runs in
|
|
373
|
+
// `onRegister()`; `doctorChecks()` is declarative and is asked of a booted
|
|
374
|
+
// provider, which is the shape a package author already knows from
|
|
375
|
+
// `replContext()` and `devProcesses()`. A provider whose method throws
|
|
376
|
+
// contributes nothing rather than failing the doctor for every other package.
|
|
377
|
+
const declared: DoctorCheck[] = [];
|
|
378
|
+
for (const provider of app._activeProviders ?? []) {
|
|
379
|
+
try {
|
|
380
|
+
declared.push(...(provider.doctorChecks?.() ?? []));
|
|
381
|
+
} catch {
|
|
382
|
+
// Deliberately silent: a broken contribution is not a finding about the
|
|
383
|
+
// app being checked, and the doctor is what the user ran.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const checks = [...builtinDoctorChecks, ...app.doctorChecks, ...declared, ...extraChecks];
|
|
388
|
+
const report: DoctorReportEntry[] = [];
|
|
389
|
+
for (const check of checks) {
|
|
390
|
+
let result: DoctorCheckResult;
|
|
391
|
+
try {
|
|
392
|
+
result = await check.run(app);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
result = fail(`check threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
395
|
+
}
|
|
396
|
+
report.push({ check, result });
|
|
397
|
+
}
|
|
398
|
+
return report;
|
|
399
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe a deployed app's WebSocket transport the way a browser would.
|
|
3
|
+
*
|
|
4
|
+
* Every static check in the doctor runs inside the process, and the expensive production
|
|
5
|
+
* failures are precisely the ones that cannot be seen from there: a reverse proxy that
|
|
6
|
+
* gates the transport path, an origin guard comparing against the loopback address the app
|
|
7
|
+
* bound to, a proxy that never forwards the upgrade. All of them leave the app healthy
|
|
8
|
+
* from the inside — the HTML renders, the logs are quiet, a status-code health check
|
|
9
|
+
* passes — while every action in the browser does nothing.
|
|
10
|
+
*
|
|
11
|
+
* So this probe goes the long way round: out through the public URL, back through the
|
|
12
|
+
* proxy. It sends a real handshake with a real `Origin`, and reads the status the server
|
|
13
|
+
* actually returned.
|
|
14
|
+
*
|
|
15
|
+
* ## Why not curl
|
|
16
|
+
*
|
|
17
|
+
* You can do this by hand, with one caveat that costs an hour the first time: curl over
|
|
18
|
+
* TLS negotiates HTTP/2, where `Connection: Upgrade` is meaningless, and the server answers
|
|
19
|
+
* `404` on a route that is working perfectly. Browsers use HTTP/1.1 for WebSockets, so any
|
|
20
|
+
* hand-run check needs `--http1.1`:
|
|
21
|
+
*
|
|
22
|
+
* ```bash
|
|
23
|
+
* curl -s -o /dev/null -w '%{http_code}\n' --http1.1 \
|
|
24
|
+
* -H 'Origin: https://your.app' -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
|
|
25
|
+
* -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
|
26
|
+
* https://your.app/__flow/ws # expect 101
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The outcome of one handshake attempt. */
|
|
31
|
+
export interface TransportProbeResult {
|
|
32
|
+
/** The full URL probed. */
|
|
33
|
+
url: string;
|
|
34
|
+
/** HTTP status returned to the handshake, or null when the request never completed. */
|
|
35
|
+
status: number | null;
|
|
36
|
+
/** Whether the transport is usable from a browser at this origin. */
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** What the status means here, in one line. */
|
|
39
|
+
message: string;
|
|
40
|
+
/** The change that fixes it, when the diagnosis implies one. */
|
|
41
|
+
fix?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A WebSocket handshake is an ordinary HTTP request until the server agrees to switch. */
|
|
45
|
+
function _handshakeHeaders(origin: string): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
Origin: origin,
|
|
48
|
+
Connection: "Upgrade",
|
|
49
|
+
Upgrade: "websocket",
|
|
50
|
+
"Sec-WebSocket-Version": "13",
|
|
51
|
+
// Any base64 of 16 bytes. The server echoes a derivation of it; nothing here checks
|
|
52
|
+
// that, because the status code is the whole signal.
|
|
53
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Turn a handshake status into a diagnosis. */
|
|
58
|
+
function _diagnose(status: number): Omit<TransportProbeResult, "url" | "status"> {
|
|
59
|
+
if (status === 101) {
|
|
60
|
+
return { ok: true, message: "101 Switching Protocols — a browser can open this socket." };
|
|
61
|
+
}
|
|
62
|
+
if (status === 401 || status === 407) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
message:
|
|
66
|
+
`${status} — something in front of the app is asking for credentials. Browsers do ` +
|
|
67
|
+
`not attach basic-auth credentials to a WebSocket handshake, so any auth gate over ` +
|
|
68
|
+
`this path blocks the transport outright.`,
|
|
69
|
+
fix: "Exempt the transport path at the proxy, e.g. Caddy: `@gated not path /__flow/*`.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (status === 403) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
message:
|
|
76
|
+
"403 — the origin guard refused this Origin. Behind a proxy the app's own origin is " +
|
|
77
|
+
"the loopback address it bound to, so the public origin has to be configured.",
|
|
78
|
+
fix: "Set url in config/app.ts to the public URL (AppConfig fills allowedOrigins from it).",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (status === 404) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
message:
|
|
85
|
+
"404 — nothing handled the upgrade. Either the proxy is not forwarding this path, " +
|
|
86
|
+
"or it terminated the request on a protocol where Upgrade has no meaning.",
|
|
87
|
+
fix: "Check the proxy forwards this path to the app, and that it speaks HTTP/1.1 upstream.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (status === 426 || status === 400) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
message:
|
|
94
|
+
`${status} — the upgrade was not negotiated. Usually the request reached the app ` +
|
|
95
|
+
`over a protocol that cannot upgrade (HTTP/2), rather than a fault in the app.`,
|
|
96
|
+
fix: "Ensure the proxy connects to the app over HTTP/1.1 and does not buffer the connection.",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (status >= 500) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
message: `${status} — the app errored handling the handshake. Check its logs.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, message: `${status} — unexpected; a working transport answers 101.` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Attempt a WebSocket handshake against `url`, declaring `origin`, and report what came back.
|
|
110
|
+
*
|
|
111
|
+
* @param url - The absolute ws/http URL to probe. `http`/`https` are used as-is; the
|
|
112
|
+
* handshake is an HTTP request.
|
|
113
|
+
* @param origin - The `Origin` to send — the public origin a browser would send.
|
|
114
|
+
* @param timeoutMs - How long to wait before giving up. Default 10s.
|
|
115
|
+
*/
|
|
116
|
+
export async function probeWebSocket(
|
|
117
|
+
url: string,
|
|
118
|
+
origin: string,
|
|
119
|
+
timeoutMs = 10_000,
|
|
120
|
+
): Promise<TransportProbeResult> {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(url, {
|
|
123
|
+
headers: _handshakeHeaders(origin),
|
|
124
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
});
|
|
127
|
+
// A 3xx here is a redirect the browser would follow for a page but not for a socket.
|
|
128
|
+
if (response.status >= 300 && response.status < 400) {
|
|
129
|
+
const location = response.headers.get("location") ?? "elsewhere";
|
|
130
|
+
return {
|
|
131
|
+
url,
|
|
132
|
+
status: response.status,
|
|
133
|
+
ok: false,
|
|
134
|
+
message: `${response.status} — redirected to ${location}. A WebSocket handshake is not redirected; probe the final URL.`,
|
|
135
|
+
fix: `Probe ${location} instead, or stop redirecting the transport path.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { url, status: response.status, ..._diagnose(response.status) };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
141
|
+
return {
|
|
142
|
+
url,
|
|
143
|
+
status: null,
|
|
144
|
+
ok: false,
|
|
145
|
+
message: `the request never completed: ${reason}`,
|
|
146
|
+
fix: "Check the host resolves, the TLS certificate is valid, and the port is reachable.",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Probe every WebSocket path an app registered, against its public base URL.
|
|
153
|
+
*
|
|
154
|
+
* @param baseUrl - The public URL the app is served from, as a browser would reach it.
|
|
155
|
+
* @param paths - Registered WS paths, from `app.webSocketPaths()`. Catch-all (`"*"`)
|
|
156
|
+
* registrations are skipped: there is no single URL that represents them.
|
|
157
|
+
*/
|
|
158
|
+
export async function probeTransport(
|
|
159
|
+
baseUrl: string,
|
|
160
|
+
paths: string[],
|
|
161
|
+
): Promise<TransportProbeResult[]> {
|
|
162
|
+
const base = new URL(baseUrl);
|
|
163
|
+
const results: TransportProbeResult[] = [];
|
|
164
|
+
for (const path of paths) {
|
|
165
|
+
if (path === "*") continue;
|
|
166
|
+
results.push(await probeWebSocket(new URL(path, base).href, base.origin));
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
package/src/events/Emitter.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* (fire-and-forget), synchronously, or be deferred to a queue.
|
|
5
5
|
*/
|
|
6
6
|
import { CallQueuedListener } from "./CallQueuedListener.ts";
|
|
7
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
7
8
|
|
|
8
9
|
type EventClass<T extends object> = new (...args: unknown[]) => T;
|
|
9
10
|
type ListenerClass<T extends object> = new (...args: unknown[]) => {
|
|
@@ -56,7 +57,7 @@ export interface QueuedListener<T = any> {
|
|
|
56
57
|
* ```
|
|
57
58
|
*/
|
|
58
59
|
export class Emitter {
|
|
59
|
-
private _listeners = new Map<
|
|
60
|
+
private _listeners = new Map<ClassRef, ListenerClass<object>[]>();
|
|
60
61
|
private _listenerByName = new Map<string, ListenerClass<object>>();
|
|
61
62
|
|
|
62
63
|
// Holds the application so the emitter can resolve the queue manager lazily.
|
|
@@ -170,7 +171,7 @@ export class Emitter {
|
|
|
170
171
|
// A snapshot, not the live array: a listener that registers another listener for the
|
|
171
172
|
// same event otherwise extends the array being iterated. One emit ran handle() 100,000
|
|
172
173
|
// times that way.
|
|
173
|
-
const listenerClasses = [...(this._listeners.get(event.constructor as
|
|
174
|
+
const listenerClasses = [...(this._listeners.get(event.constructor as ClassRef) ?? [])];
|
|
174
175
|
|
|
175
176
|
if (listenerClasses.length === 0) return;
|
|
176
177
|
|
|
@@ -229,7 +230,7 @@ export class Emitter {
|
|
|
229
230
|
// A snapshot, not the live array: a listener that registers another listener for the
|
|
230
231
|
// same event otherwise extends the array being iterated. One emit ran handle() 100,000
|
|
231
232
|
// times that way.
|
|
232
|
-
const listenerClasses = [...(this._listeners.get(event.constructor as
|
|
233
|
+
const listenerClasses = [...(this._listeners.get(event.constructor as ClassRef) ?? [])];
|
|
233
234
|
|
|
234
235
|
for (const ListenerClass of listenerClasses) {
|
|
235
236
|
const listener = new ListenerClass();
|
|
@@ -20,6 +20,7 @@ import { currentApp } from "../../application/currentApp.ts";
|
|
|
20
20
|
import type { Container } from "../../container/Container.ts";
|
|
21
21
|
import type { BindingToken, ContainerBindings, Factory } from "../../container/types.ts";
|
|
22
22
|
import { ContainerLockedError } from "../../errors/ContainerErrors.ts";
|
|
23
|
+
import { isProdLike } from "../../support/env.ts";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Return the live container, throwing {@link ContainerLockedError} when the
|
|
@@ -64,9 +65,16 @@ export const App = {
|
|
|
64
65
|
return currentApp().environment;
|
|
65
66
|
},
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Whether the configured `app.env` is a production-like environment —
|
|
70
|
+
* `production`, `prod`, or `staging`.
|
|
71
|
+
*
|
|
72
|
+
* `staging` counts. A staging box is a deployed box: it serves real traffic over
|
|
73
|
+
* a real network with real credentials, and every reason to suppress a stack
|
|
74
|
+
* trace or a debug surface in production applies to it identically.
|
|
75
|
+
*/
|
|
68
76
|
isProduction(): boolean {
|
|
69
|
-
return
|
|
77
|
+
return isProdLike(_appEnv());
|
|
70
78
|
},
|
|
71
79
|
|
|
72
80
|
/** Whether the configured `app.env` is a local/development environment. */
|
package/src/helpers/index.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { ConfigError } from "../errors/ConfigError.ts";
|
|
25
|
+
import { DEPLOY_ENV_VAR } from "../support/env.ts";
|
|
25
26
|
|
|
26
27
|
// ── basePath() ────────────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -54,6 +55,7 @@ export function basePath(...segments: string[]): string {
|
|
|
54
55
|
* | Command | APP_ENV |
|
|
55
56
|
* |----------------------|-----------|
|
|
56
57
|
* | serve / start / s | web |
|
|
58
|
+
* | dev / d | web |
|
|
57
59
|
* | worker / queue:work | worker |
|
|
58
60
|
* | anything else | console |
|
|
59
61
|
*
|
|
@@ -71,9 +73,29 @@ export function setAppEnv(command?: string): void {
|
|
|
71
73
|
const current = Bun.env["APP_ENV"];
|
|
72
74
|
const environment = Bun.env as Record<string, string>;
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
// Preserve the deployment name before it is overwritten. Every branch below
|
|
77
|
+
// replaces `APP_ENV` with a runtime mode, which is why six different gates that
|
|
78
|
+
// asked "is this production?" of `Bun.env["APP_ENV"]` were reading `"web"` and
|
|
79
|
+
// quietly answering no — including the weak-`APP_KEY` refusal and the ORM's
|
|
80
|
+
// N+1 detector. `deployEnv()` reads this back; see {@link DEPLOY_ENV_VAR}.
|
|
81
|
+
//
|
|
82
|
+
// `??=` so the first caller wins: a re-entrant `setAppEnv` (dev mode boots the
|
|
83
|
+
// app twice) must not stamp the runtime mode over the real deployment name.
|
|
84
|
+
if (current && !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
85
|
+
environment[DEPLOY_ENV_VAR] ??= current;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (["serve", "start", "s", "dev", "d"].includes(normalizedCommand)) {
|
|
75
89
|
// Always force web mode for the HTTP server — deployment-env names like
|
|
76
90
|
// "local" or "production" must not leave the app in console mode.
|
|
91
|
+
//
|
|
92
|
+
// `dev` belongs here with `serve`, and the reason is not cosmetic. Dev mode's
|
|
93
|
+
// process 1 boots the app purely to ask its providers what to run, and a
|
|
94
|
+
// provider is only asked if `static environments` includes the env it booted
|
|
95
|
+
// under. Falling through to "console" below would silently drop every
|
|
96
|
+
// web-only provider — no error, no empty tab, just a process that never
|
|
97
|
+
// appears — and would make `zt dev` and `serve --dev` disagree about what
|
|
98
|
+
// dev mode consists of.
|
|
77
99
|
if (!current || !_RUNTIME_MODES.has(current.toLowerCase())) {
|
|
78
100
|
environment["APP_ENV"] = "web";
|
|
79
101
|
}
|