@zerotal/core 1.0.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 +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `repl` command: an interactive REPL that boots the application and exposes
|
|
3
|
+
* it (plus provider-contributed bindings) in the evaluation scope.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Command } from "../Command.ts";
|
|
7
|
+
|
|
8
|
+
const isSyntaxError = (error: unknown): boolean =>
|
|
9
|
+
error != null && typeof error === "object" && (error as { name?: string }).name === "SyntaxError";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bun zt repl` — starts an interactive REPL with the bootstrapped app (and
|
|
13
|
+
* provider-contributed bindings) in the evaluation scope.
|
|
14
|
+
*
|
|
15
|
+
* @category Diagnostics
|
|
16
|
+
*/
|
|
17
|
+
export class ReplCommand extends Command {
|
|
18
|
+
static commandName = "repl";
|
|
19
|
+
static description = "Start an interactive REPL with the bootstrapped app in scope";
|
|
20
|
+
static needsApp = true;
|
|
21
|
+
|
|
22
|
+
async run(): Promise<void> {
|
|
23
|
+
const { $ } = await import("bun");
|
|
24
|
+
const app = this.app as import("../../application/Application.ts").Application;
|
|
25
|
+
|
|
26
|
+
// Collect all globals BEFORE createContext — properties added after may not be
|
|
27
|
+
// visible inside the vm in Bun's implementation.
|
|
28
|
+
const sandbox: Record<string, unknown> = { app, $, console, process };
|
|
29
|
+
|
|
30
|
+
// Ask every active provider what it wants available in the REPL.
|
|
31
|
+
// DatabaseProvider returns { DB }, BroadcastProvider could return { Broadcast }, etc.
|
|
32
|
+
for (const provider of app._activeProviders) {
|
|
33
|
+
Object.assign(sandbox, provider.replContext());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { createContext, runInContext } = await import("node:vm");
|
|
37
|
+
const vmContext = createContext(sandbox);
|
|
38
|
+
|
|
39
|
+
const userFacing = ["app", "$", "DB", "Mail", "Cache", "Broadcast"].filter(
|
|
40
|
+
(key) => key in sandbox,
|
|
41
|
+
);
|
|
42
|
+
const contextKeys = userFacing.length
|
|
43
|
+
? userFacing
|
|
44
|
+
: Object.keys(sandbox).filter((key) => !["console", "process"].includes(key));
|
|
45
|
+
console.log("Zerotal REPL – type '.exit' or Ctrl+D to quit");
|
|
46
|
+
console.log(` Context: ${contextKeys.join(", ")}\n`);
|
|
47
|
+
|
|
48
|
+
// ── Readline ──────────────────────────────────────────────────────────────
|
|
49
|
+
const { createInterface } = await import("node:readline");
|
|
50
|
+
const completer = (line: string): [string[], string] => {
|
|
51
|
+
const matches = contextKeys.filter((key) => key.startsWith(line));
|
|
52
|
+
return [matches.length ? matches : contextKeys, line];
|
|
53
|
+
};
|
|
54
|
+
const readline = createInterface({
|
|
55
|
+
input: process.stdin,
|
|
56
|
+
output: process.stdout,
|
|
57
|
+
terminal: true,
|
|
58
|
+
historySize: 1000,
|
|
59
|
+
prompt: "zerotal> ",
|
|
60
|
+
completer,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Persistent history ────────────────────────────────────────────────────
|
|
64
|
+
const homeDir = process.env["USERPROFILE"] ?? process.env["HOME"] ?? process.cwd();
|
|
65
|
+
const historyFile = join(homeDir, ".zerotal_repl_history");
|
|
66
|
+
try {
|
|
67
|
+
const rawHistory = await Bun.file(historyFile).text();
|
|
68
|
+
const lines = rawHistory.split("\n").filter(Boolean).reverse(); // Newest-first for readline.
|
|
69
|
+
(readline as unknown as { history: string[] }).history = lines.slice(0, 1000);
|
|
70
|
+
} catch {
|
|
71
|
+
/* no history file yet */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Evaluator ─────────────────────────────────────────────────────────────
|
|
75
|
+
// Three-stage fallback:
|
|
76
|
+
// ① Sync — handles plain expressions and sync declarations
|
|
77
|
+
// (Bun vm keeps const/let in lexical env across calls)
|
|
78
|
+
// ② Async expression — strips leading const/let/var so the assignment
|
|
79
|
+
// becomes a global-scope write (persists to next line)
|
|
80
|
+
// ③ Async statement — same strip, no return value (complex cases)
|
|
81
|
+
//
|
|
82
|
+
// NOTE: Bun's vm throws an internal error class whose .name is 'SyntaxError'
|
|
83
|
+
// but which is NOT instanceof the global SyntaxError. Use isSyntaxError().
|
|
84
|
+
const evaluate = async (line: string): Promise<void> => {
|
|
85
|
+
let result: unknown;
|
|
86
|
+
let firstError: Error | undefined;
|
|
87
|
+
|
|
88
|
+
// ① Sync
|
|
89
|
+
try {
|
|
90
|
+
result = runInContext(line, vmContext);
|
|
91
|
+
if (result instanceof Promise) result = await result;
|
|
92
|
+
if (result !== undefined)
|
|
93
|
+
process.stdout.write(Bun.inspect(result, { colors: true }) + "\n");
|
|
94
|
+
return;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!isSyntaxError(error)) throw error;
|
|
97
|
+
firstError = error as Error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ② Async expression — strip declaration keyword → global assignment persists
|
|
101
|
+
const asyncLine = line.replace(/^(?:const|let|var)\s+/, "");
|
|
102
|
+
try {
|
|
103
|
+
const promise = runInContext(
|
|
104
|
+
`(async () => { return (${asyncLine}); })()`,
|
|
105
|
+
vmContext,
|
|
106
|
+
) as Promise<unknown>;
|
|
107
|
+
result = await promise;
|
|
108
|
+
if (result !== undefined)
|
|
109
|
+
process.stdout.write(Bun.inspect(result, { colors: true }) + "\n");
|
|
110
|
+
return;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!isSyntaxError(error)) throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ③ Async statement fallback
|
|
116
|
+
try {
|
|
117
|
+
const promise = runInContext(
|
|
118
|
+
`(async () => { ${asyncLine}; })()`,
|
|
119
|
+
vmContext,
|
|
120
|
+
) as Promise<unknown>;
|
|
121
|
+
await promise;
|
|
122
|
+
return;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!isSyntaxError(error)) throw error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw firstError;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── REPL loop ─────────────────────────────────────────────────────────────
|
|
131
|
+
await new Promise<void>((resolve) => {
|
|
132
|
+
readline.on("close", () => {
|
|
133
|
+
const history = (readline as unknown as { history: string[] }).history;
|
|
134
|
+
if (history?.length) {
|
|
135
|
+
Bun.write(historyFile, [...history].reverse().join("\n") + "\n").catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
console.log("");
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
readline.on("SIGINT", () => {
|
|
142
|
+
if (!(readline as unknown as { line: string }).line) {
|
|
143
|
+
readline.close();
|
|
144
|
+
} else {
|
|
145
|
+
process.stdout.write("\n");
|
|
146
|
+
readline.prompt();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
readline.on("line", async (rawLine: string) => {
|
|
151
|
+
const line = rawLine.trim();
|
|
152
|
+
if (!line) {
|
|
153
|
+
readline.prompt();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (line === ".exit" || line === ".quit") {
|
|
157
|
+
readline.close();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await evaluate(line);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
process.stderr.write("\x1b[31m✖ " + message + "\x1b[0m\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
readline.prompt();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
readline.prompt();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Command } from "../Command.ts";
|
|
2
|
+
import { Router } from "../../router/Router.ts";
|
|
3
|
+
|
|
4
|
+
const RESET = "\x1b[0m";
|
|
5
|
+
const DIM = "\x1b[2m";
|
|
6
|
+
const BOLD = "\x1b[1m";
|
|
7
|
+
|
|
8
|
+
const METHOD_COLOR: Record<string, string> = {
|
|
9
|
+
GET: "\x1b[32m", // green
|
|
10
|
+
POST: "\x1b[34m", // blue
|
|
11
|
+
PUT: "\x1b[33m", // yellow
|
|
12
|
+
PATCH: "\x1b[36m", // cyan
|
|
13
|
+
DELETE: "\x1b[31m", // red
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `bun zt route:list` — lists all registered routes.
|
|
18
|
+
*
|
|
19
|
+
* Prints a table of every route registered with the Router, with columns for
|
|
20
|
+
* HTTP method (colour-coded), path, controller, action, and named route key.
|
|
21
|
+
*
|
|
22
|
+
* Requires the app to boot so that providers (e.g. AdminProvider) can register
|
|
23
|
+
* their routes via Router.group() inside onBooting().
|
|
24
|
+
*
|
|
25
|
+
* Flags:
|
|
26
|
+
* --method / -m filter by HTTP verb (case-insensitive)
|
|
27
|
+
* --path / -p filter by path prefix substring
|
|
28
|
+
* --name only show named routes
|
|
29
|
+
* --verbose / -v add a middleware column
|
|
30
|
+
*
|
|
31
|
+
* @category Diagnostics
|
|
32
|
+
*/
|
|
33
|
+
export class RouteListCommand extends Command {
|
|
34
|
+
static commandName = "route:list";
|
|
35
|
+
static description = "List all registered routes";
|
|
36
|
+
static needsApp = true;
|
|
37
|
+
static args = [];
|
|
38
|
+
static flags = [
|
|
39
|
+
{
|
|
40
|
+
name: "method",
|
|
41
|
+
short: "m",
|
|
42
|
+
type: "string" as const,
|
|
43
|
+
description: "Filter by HTTP method (GET, POST, PUT, PATCH, DELETE)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "path",
|
|
47
|
+
short: "p",
|
|
48
|
+
type: "string" as const,
|
|
49
|
+
description: "Filter routes whose path contains this string",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "name",
|
|
53
|
+
short: "n",
|
|
54
|
+
type: "boolean" as const,
|
|
55
|
+
description: "Only show named routes",
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "verbose",
|
|
60
|
+
short: "v",
|
|
61
|
+
type: "boolean" as const,
|
|
62
|
+
description: "Show per-route middleware classes",
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
async run(): Promise<void> {
|
|
68
|
+
const methodFilter = (this.flags["method"] as string | undefined)?.toUpperCase();
|
|
69
|
+
const pathFilter = this.flags["path"] as string | undefined;
|
|
70
|
+
const nameOnly = this.flags["name"] as boolean;
|
|
71
|
+
const verbose = this.flags["verbose"] as boolean;
|
|
72
|
+
|
|
73
|
+
// Build path → name lookup from the named-routes map (name → path)
|
|
74
|
+
const pathToName = new Map<string, string>();
|
|
75
|
+
for (const [name, path] of Router.namedRoutes) {
|
|
76
|
+
pathToName.set(path, name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let rows = Array.from(Router.routes.values());
|
|
80
|
+
|
|
81
|
+
if (methodFilter) {
|
|
82
|
+
rows = rows.filter((route) => route.method === methodFilter);
|
|
83
|
+
}
|
|
84
|
+
if (pathFilter) {
|
|
85
|
+
rows = rows.filter((route) => route.path.includes(pathFilter));
|
|
86
|
+
}
|
|
87
|
+
if (nameOnly) {
|
|
88
|
+
rows = rows.filter((route) => pathToName.has(route.path));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
this.warn("No routes match the given filters.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Sort: path asc, then method asc.
|
|
97
|
+
rows.sort((first, second) => {
|
|
98
|
+
const pathComparison = first.path.localeCompare(second.path);
|
|
99
|
+
return pathComparison !== 0 ? pathComparison : first.method.localeCompare(second.method);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Derive display strings
|
|
103
|
+
type Row = {
|
|
104
|
+
method: string;
|
|
105
|
+
path: string;
|
|
106
|
+
controller: string;
|
|
107
|
+
action: string;
|
|
108
|
+
name: string;
|
|
109
|
+
middleware: string;
|
|
110
|
+
};
|
|
111
|
+
const display: Row[] = rows.map((route) => ({
|
|
112
|
+
method: route.method,
|
|
113
|
+
path: route.path,
|
|
114
|
+
controller: _controllerName(route.controller.name),
|
|
115
|
+
action: route.action,
|
|
116
|
+
name: pathToName.get(route.path) ?? "",
|
|
117
|
+
middleware: route.middleware
|
|
118
|
+
.map((middleware) => middleware.name)
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join(", "),
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
// Column widths.
|
|
124
|
+
const columnWidths = {
|
|
125
|
+
method: Math.max(6, ...display.map((row) => row.method.length)),
|
|
126
|
+
path: Math.max(4, ...display.map((row) => row.path.length)),
|
|
127
|
+
controller: Math.max(10, ...display.map((row) => row.controller.length)),
|
|
128
|
+
action: Math.max(6, ...display.map((row) => row.action.length)),
|
|
129
|
+
name: Math.max(4, ...display.map((row) => row.name.length)),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const total = rows.length;
|
|
133
|
+
const label = total === 1 ? "1 route" : `${total} routes`;
|
|
134
|
+
this._writer.writeLine(`\n${BOLD}Routes${RESET} ${DIM}${label}${RESET}\n`);
|
|
135
|
+
|
|
136
|
+
// Header.
|
|
137
|
+
const header = [
|
|
138
|
+
" ",
|
|
139
|
+
"METHOD".padEnd(columnWidths.method + 2),
|
|
140
|
+
"PATH".padEnd(columnWidths.path + 4),
|
|
141
|
+
"CONTROLLER".padEnd(columnWidths.controller + 4),
|
|
142
|
+
"ACTION".padEnd(columnWidths.action + 4),
|
|
143
|
+
"NAME".padEnd(columnWidths.name + 2),
|
|
144
|
+
...(verbose ? ["MIDDLEWARE"] : []),
|
|
145
|
+
].join("");
|
|
146
|
+
this._writer.writeLine(`${DIM}${header}${RESET}`);
|
|
147
|
+
|
|
148
|
+
const separator =
|
|
149
|
+
" " +
|
|
150
|
+
"─".repeat(
|
|
151
|
+
columnWidths.method +
|
|
152
|
+
columnWidths.path +
|
|
153
|
+
columnWidths.controller +
|
|
154
|
+
columnWidths.action +
|
|
155
|
+
columnWidths.name +
|
|
156
|
+
(verbose ? 30 : 0) +
|
|
157
|
+
18,
|
|
158
|
+
);
|
|
159
|
+
this._writer.writeLine(`${DIM}${separator}${RESET}`);
|
|
160
|
+
|
|
161
|
+
// Rows.
|
|
162
|
+
for (const row of display) {
|
|
163
|
+
const color = METHOD_COLOR[row.method] ?? "";
|
|
164
|
+
const line = [
|
|
165
|
+
" ",
|
|
166
|
+
`${color}${row.method.padEnd(columnWidths.method)}${RESET}`,
|
|
167
|
+
" ",
|
|
168
|
+
row.path.padEnd(columnWidths.path + 2),
|
|
169
|
+
" ",
|
|
170
|
+
`${DIM}${row.controller.padEnd(columnWidths.controller + 2)}${RESET}`,
|
|
171
|
+
" ",
|
|
172
|
+
row.action.padEnd(columnWidths.action + 2),
|
|
173
|
+
" ",
|
|
174
|
+
row.name ? `${DIM}${row.name}${RESET}` : "",
|
|
175
|
+
...(verbose && row.middleware ? [` ${DIM}${row.middleware}${RESET}`] : []),
|
|
176
|
+
].join("");
|
|
177
|
+
this._writer.writeLine(line);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this._writer.writeLine("");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _controllerName(rawName: string): string {
|
|
185
|
+
// File-based route controllers are named "FileRoute<METHOD /path>" — shorten to "file".
|
|
186
|
+
if (rawName.startsWith("FileRoute<")) return "file";
|
|
187
|
+
return rawName;
|
|
188
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `serve` command, which starts the HTTP server. Also drives dev mode: the
|
|
3
|
+
* file-watching orchestrator (process 1) and the managed dev worker (process 2).
|
|
4
|
+
*/
|
|
5
|
+
import { Command } from "../Command.ts";
|
|
6
|
+
import { DevOrchestrator } from "../../dev/DevOrchestrator.ts";
|
|
7
|
+
import { hasDevBuildHooks, runDevBuildHooks } from "../../dev/DevBuildHook.ts";
|
|
8
|
+
import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
|
|
9
|
+
import type { ConfigManager } from "../../config/ConfigManager.ts";
|
|
10
|
+
import type { Application } from "../../application/Application.ts";
|
|
11
|
+
import * as DevWsServer from "../../dev/DevWsServer.ts";
|
|
12
|
+
import { setAssetVersion } from "../../assets/assets.ts";
|
|
13
|
+
import {
|
|
14
|
+
isPortAvailable,
|
|
15
|
+
waitForPort,
|
|
16
|
+
findAvailablePort,
|
|
17
|
+
findPortOwner,
|
|
18
|
+
stopProcess,
|
|
19
|
+
PORT_RESOLVED_ENV_VAR,
|
|
20
|
+
type PortOwner,
|
|
21
|
+
} from "../../support/port.ts";
|
|
22
|
+
import { localNetworkAddress } from "../../support/network.ts";
|
|
23
|
+
import { createInterface } from "node:readline";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `bun zt serve` — starts the HTTP server (and orchestrates dev mode when
|
|
27
|
+
* `--dev` is set). Aliased as `start` and `s`.
|
|
28
|
+
*
|
|
29
|
+
* @category Serving
|
|
30
|
+
*/
|
|
31
|
+
export class ServeCommand extends Command {
|
|
32
|
+
static commandName = "serve";
|
|
33
|
+
static aliases = ["start", "s"];
|
|
34
|
+
static description = "Start the HTTP server";
|
|
35
|
+
static needsApp = true;
|
|
36
|
+
|
|
37
|
+
static flags = [
|
|
38
|
+
{
|
|
39
|
+
name: "port",
|
|
40
|
+
short: "p",
|
|
41
|
+
type: "number" as const,
|
|
42
|
+
description: "Port to listen on",
|
|
43
|
+
default: 3000,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "dev",
|
|
47
|
+
type: "boolean" as const,
|
|
48
|
+
description: "Start in dev mode with file watching, auto-rebuild, and browser reload",
|
|
49
|
+
default: false,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "force",
|
|
53
|
+
type: "boolean" as const,
|
|
54
|
+
description: "If the port is busy, stop the process holding it",
|
|
55
|
+
default: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "auto-port",
|
|
59
|
+
type: "boolean" as const,
|
|
60
|
+
description: "If the port is busy, start on the next free port",
|
|
61
|
+
default: false,
|
|
62
|
+
},
|
|
63
|
+
// Internal flag — spawned by DevOrchestrator; not shown in help
|
|
64
|
+
{
|
|
65
|
+
name: "dev-worker",
|
|
66
|
+
type: "boolean" as const,
|
|
67
|
+
description: "Internal: run as the managed server process under DevOrchestrator",
|
|
68
|
+
default: false,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
async run(): Promise<void> {
|
|
73
|
+
const isDev = this.flags["dev"] as boolean;
|
|
74
|
+
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
|
+
// ── DEV ORCHESTRATOR (Process 1) ────────────────────────────────────────
|
|
79
|
+
// Spawns and manages the server child process, watches files, drives builds.
|
|
80
|
+
if (isDev) {
|
|
81
|
+
this._announce("dev", port);
|
|
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();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── DEV WORKER (Process 2) ──────────────────────────────────────────────
|
|
119
|
+
// Normal server start, but:
|
|
120
|
+
// 1. Registers GET /__dev/events for browser SSE reload.
|
|
121
|
+
// 2. Reads stdin: when it sees "reload", broadcasts to SSE clients.
|
|
122
|
+
if (isWorker) {
|
|
123
|
+
const app = this.app as import("../../application/Application.ts").Application;
|
|
124
|
+
|
|
125
|
+
// Enable the /__dev/ws HMR WebSocket endpoint.
|
|
126
|
+
app.enableDevWs();
|
|
127
|
+
|
|
128
|
+
// Listen on stdin for reload signals from the Orchestrator (Process 1).
|
|
129
|
+
this._listenForReloadSignals();
|
|
130
|
+
|
|
131
|
+
// The application logs "Server listening on …" once it binds; announcing it
|
|
132
|
+
// here as well printed the same fact twice, in two different formats.
|
|
133
|
+
await app.start(port);
|
|
134
|
+
await new Promise<never>(() => {});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── PRODUCTION / normal start ────────────────────────────────────────────
|
|
139
|
+
// Build configured assets once before serving (dev mode builds via the orchestrator).
|
|
140
|
+
if (assets) await this._buildAssets(assets);
|
|
141
|
+
|
|
142
|
+
// Announced after the bind, not before: booting logs its own progress, and a
|
|
143
|
+
// banner printed first ends up buried above it — worse, it would promise a
|
|
144
|
+
// URL that a failed bind never makes good on.
|
|
145
|
+
await (this.app as Application).start(port);
|
|
146
|
+
this._announce("serve", port);
|
|
147
|
+
await new Promise<never>(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The banner a starting server prints: the mode, then one URL per way in.
|
|
154
|
+
*
|
|
155
|
+
* The server binds every interface (`Bun.serve()` defaults to `0.0.0.0`), so
|
|
156
|
+
* the network URL is not an invitation to configure anything — it is the
|
|
157
|
+
* address a phone on the same Wi-Fi can already open, spared the `ipconfig`.
|
|
158
|
+
*/
|
|
159
|
+
private _announce(mode: string, port: number): void {
|
|
160
|
+
const network = localNetworkAddress();
|
|
161
|
+
|
|
162
|
+
this.newLine();
|
|
163
|
+
this.line(` Zerotal › ${mode}`);
|
|
164
|
+
this.newLine();
|
|
165
|
+
this._route("Local", `http://localhost:${port}`);
|
|
166
|
+
this._route(
|
|
167
|
+
"Network",
|
|
168
|
+
network ? `http://${network}:${port}` : "unavailable — this machine is on no network",
|
|
169
|
+
);
|
|
170
|
+
this.newLine();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One `label url` row of the banner: dim label, cyan value. */
|
|
174
|
+
private _route(label: string, value: string): void {
|
|
175
|
+
this._writer.writeLine(` \x1b[2m${label.padEnd(9)}\x1b[0m\x1b[36m${value}\x1b[0m`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Settle which port to actually bind, dealing with a busy one before the
|
|
180
|
+
* server tries and fails.
|
|
181
|
+
*
|
|
182
|
+
* A supervised process — the dev worker, or the `bun --watch` child — is
|
|
183
|
+
* handed a port that was already resolved by its parent and has no terminal
|
|
184
|
+
* of its own, so it waits the port out instead of asking. Everyone else gets
|
|
185
|
+
* the `--force` / `--auto-port` flags, then an interactive menu, then (with no
|
|
186
|
+
* TTY, e.g. CI or a container) a hard error.
|
|
187
|
+
*/
|
|
188
|
+
private async _resolvePort(requested: number, isWorker: boolean): Promise<number> {
|
|
189
|
+
if (await isPortAvailable(requested)) return requested;
|
|
190
|
+
|
|
191
|
+
const supervised = isWorker || Bun.env[PORT_RESOLVED_ENV_VAR] === "1";
|
|
192
|
+
if (supervised) {
|
|
193
|
+
// A restart, not a collision: the server being replaced is still letting
|
|
194
|
+
// go of the socket.
|
|
195
|
+
if (await waitForPort(requested, 3_000)) return requested;
|
|
196
|
+
throw new Error(`Port ${requested} is still in use. Stop what is holding it and retry.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const owner = await findPortOwner(requested);
|
|
200
|
+
const held = `Port ${requested} is already in use${_describe(owner)}.`;
|
|
201
|
+
|
|
202
|
+
if (this.flags["force"]) return this._reclaimPort(requested, owner, held);
|
|
203
|
+
if (this.flags["auto-port"]) return this._nextPort(requested, held);
|
|
204
|
+
|
|
205
|
+
if (!process.stdin.isTTY) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`${held} Free it, or pass --port <n>, --force (stop it), or --auto-port (use the next free port).`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return this._askAboutPort(requested, owner, held);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Offer the two ways out of a busy port. The menu is written by hand rather
|
|
216
|
+
* than with `choice()` because that helper resolves unrecognised input to the
|
|
217
|
+
* first option, and no typo should end up killing a process.
|
|
218
|
+
*/
|
|
219
|
+
private async _askAboutPort(
|
|
220
|
+
requested: number,
|
|
221
|
+
owner: PortOwner | undefined,
|
|
222
|
+
held: string,
|
|
223
|
+
): Promise<number> {
|
|
224
|
+
const next = await findAvailablePort(requested + 1);
|
|
225
|
+
|
|
226
|
+
this.newLine();
|
|
227
|
+
this.warn(` ${held}`);
|
|
228
|
+
this.dim(` [1] Stop it and use port ${requested}`);
|
|
229
|
+
this.dim(next ? ` [2] Use port ${next} instead` : " [2] Use another port (none free nearby)");
|
|
230
|
+
this.dim(" [3] Cancel");
|
|
231
|
+
|
|
232
|
+
const answer = await this.ask(" Choose", next ? "2" : "3");
|
|
233
|
+
this.newLine();
|
|
234
|
+
|
|
235
|
+
if (answer === "1") return this._reclaimPort(requested, owner, held);
|
|
236
|
+
if (answer === "2" && next) return next;
|
|
237
|
+
throw new Error(`${held} Nothing started.`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Stop whatever holds the port, then confirm it actually let go. */
|
|
241
|
+
private async _reclaimPort(
|
|
242
|
+
port: number,
|
|
243
|
+
owner: PortOwner | undefined,
|
|
244
|
+
held: string,
|
|
245
|
+
): Promise<number> {
|
|
246
|
+
if (!owner) {
|
|
247
|
+
throw new Error(`${held} The process holding it could not be identified — free it manually.`);
|
|
248
|
+
}
|
|
249
|
+
// Never turn "free the port" into "kill the CLI asking about it".
|
|
250
|
+
if (owner.pid === process.pid) {
|
|
251
|
+
throw new Error(`${held} This process is holding it.`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.info(` Stopping ${owner.name ?? "process"} (pid ${owner.pid})...`);
|
|
255
|
+
const stopped = await stopProcess(owner.pid);
|
|
256
|
+
|
|
257
|
+
// Even a successful kill leaves the socket closing, so wait for the port
|
|
258
|
+
// itself rather than trusting the exit.
|
|
259
|
+
if (!stopped || !(await waitForPort(port, 3_000))) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Could not free port ${port} (pid ${owner.pid}). Stop it manually, or pass --port <n>.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return port;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The next free port after a busy one, for `--auto-port`. */
|
|
268
|
+
private async _nextPort(requested: number, held: string): Promise<number> {
|
|
269
|
+
const next = await findAvailablePort(requested + 1);
|
|
270
|
+
if (next === undefined) throw new Error(`${held} No free port found nearby.`);
|
|
271
|
+
this.warn(` ${held} Using port ${next}.`);
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Read the resolved `app.assets` config block, or undefined when not configured. */
|
|
276
|
+
private _assetsConfig(): AssetBuildConfig | undefined {
|
|
277
|
+
try {
|
|
278
|
+
const config = (this.app as Application).container.makeSync("config") as ConfigManager;
|
|
279
|
+
return config.get("app.assets") as AssetBuildConfig | undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Bundle the configured assets, reporting any build failure (non-fatal — the server still starts). */
|
|
286
|
+
private async _buildAssets(assets: AssetBuildConfig): Promise<void> {
|
|
287
|
+
const entries = Array.isArray(assets.entrypoint)
|
|
288
|
+
? assets.entrypoint.join(", ")
|
|
289
|
+
: assets.entrypoint;
|
|
290
|
+
this.info(`Building assets: ${entries} → ${assets.outDir}/`);
|
|
291
|
+
const result = await buildConfiguredAssets(assets, process.cwd());
|
|
292
|
+
if (!result.success) {
|
|
293
|
+
this.error("Asset build failed:");
|
|
294
|
+
for (const log of result.logs ?? []) console.error(log);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private _listenForReloadSignals(): void {
|
|
299
|
+
const readline = createInterface({ input: process.stdin, terminal: false });
|
|
300
|
+
|
|
301
|
+
readline.on("line", (line: string) => {
|
|
302
|
+
const trimmed = line.trim();
|
|
303
|
+
// The orchestrator sends `reload` or `reload:<assetVersion>`. Update the
|
|
304
|
+
// asset version (so `asset()` emits a fresh ?v=) before broadcasting.
|
|
305
|
+
if (trimmed === "reload" || trimmed.startsWith("reload:")) {
|
|
306
|
+
const version = trimmed.slice("reload:".length);
|
|
307
|
+
if (version) setAssetVersion(version);
|
|
308
|
+
DevWsServer.broadcast("reload");
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// If stdin closes (the orchestrator died), exit cleanly.
|
|
313
|
+
readline.on("close", () => process.exit(0));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `" by bun.exe (pid 1234)"`, or `""` when the holder could not be identified. */
|
|
318
|
+
function _describe(owner: PortOwner | undefined): string {
|
|
319
|
+
if (!owner) return "";
|
|
320
|
+
return owner.name ? ` by ${owner.name} (pid ${owner.pid})` : ` by pid ${owner.pid}`;
|
|
321
|
+
}
|