@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-build helpers for dev mode: detecting the app's Tailwind plugin and
|
|
3
|
+
* bundling CSS and JavaScript entry points with Bun's native bundler (falling
|
|
4
|
+
* back to the Tailwind CLI when the plugin is absent).
|
|
5
|
+
*/
|
|
6
|
+
import type { BunPlugin } from "bun";
|
|
7
|
+
import { pruneBuildOutput } from "./BuildOutput.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect and load `bun-plugin-tailwind` from the app's own node_modules.
|
|
11
|
+
*
|
|
12
|
+
* Bun ships first-class CSS support and `bun-plugin-tailwind` handles
|
|
13
|
+
* Tailwind v4 natively — no PostCSS or @tailwindcss/postcss needed.
|
|
14
|
+
* Apps only need:
|
|
15
|
+
*
|
|
16
|
+
* bun add -d bun-plugin-tailwind
|
|
17
|
+
*
|
|
18
|
+
* Packages are resolved from the project CWD (not from @zerotal/core) so the
|
|
19
|
+
* plugin only needs to be in the app's own node_modules.
|
|
20
|
+
*
|
|
21
|
+
* Returns an empty array when `bun-plugin-tailwind` is not installed.
|
|
22
|
+
* The caller falls back to a `bunx @tailwindcss/cli` subprocess.
|
|
23
|
+
*/
|
|
24
|
+
export async function detectCssPlugins(cwd: string): Promise<BunPlugin[]> {
|
|
25
|
+
try {
|
|
26
|
+
const pluginPath = Bun.resolveSync("bun-plugin-tailwind", cwd);
|
|
27
|
+
const module = (await import(pluginPath)) as { default: BunPlugin };
|
|
28
|
+
return [module.default];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a CSS entry point using the Tailwind PostCSS plugin.
|
|
36
|
+
*
|
|
37
|
+
* Used by PulseProvider and ViewProvider to register a dev build hook
|
|
38
|
+
* for CSS-only apps (no JS bundle required).
|
|
39
|
+
*
|
|
40
|
+
* @param input Absolute path to the CSS source (e.g. `${cwd}/resources/css/app.css`)
|
|
41
|
+
* @param outdir Absolute path to the output directory (e.g. `${cwd}/public/css`)
|
|
42
|
+
* @param minify Whether to minify the output (true in production)
|
|
43
|
+
*/
|
|
44
|
+
export async function buildCssBundle(
|
|
45
|
+
input: string,
|
|
46
|
+
outdir: string,
|
|
47
|
+
minify = false,
|
|
48
|
+
): Promise<{ success: boolean; logs: unknown[] }> {
|
|
49
|
+
const cwd = process.cwd();
|
|
50
|
+
const plugins = await detectCssPlugins(cwd);
|
|
51
|
+
|
|
52
|
+
if (plugins.length > 0) {
|
|
53
|
+
// bun-plugin-tailwind is available — use Bun's native CSS bundler
|
|
54
|
+
return Bun.build({
|
|
55
|
+
entrypoints: [input],
|
|
56
|
+
outdir,
|
|
57
|
+
target: "browser",
|
|
58
|
+
minify,
|
|
59
|
+
plugins,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fallback: run the Tailwind CLI as a subprocess
|
|
64
|
+
// Works with just `tailwindcss` installed (no postcss needed).
|
|
65
|
+
try {
|
|
66
|
+
const outputFile = `${outdir}/${_basename(input)}`;
|
|
67
|
+
const commandArgs = [
|
|
68
|
+
"bun",
|
|
69
|
+
"x",
|
|
70
|
+
"--bun",
|
|
71
|
+
"@tailwindcss/cli",
|
|
72
|
+
"-i",
|
|
73
|
+
input,
|
|
74
|
+
"-o",
|
|
75
|
+
outputFile,
|
|
76
|
+
...(minify ? ["--minify"] : []),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const subprocess = Bun.spawn(commandArgs, {
|
|
80
|
+
cwd,
|
|
81
|
+
stdout: "pipe",
|
|
82
|
+
stderr: "pipe",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const [exitCode, stderr] = await Promise.all([
|
|
86
|
+
subprocess.exited,
|
|
87
|
+
new Response(subprocess.stderr).text(),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
success: exitCode === 0,
|
|
92
|
+
logs: exitCode !== 0 ? [stderr] : [],
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { success: false, logs: [error] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Bundle a JavaScript entry point for the browser using Bun's native bundler.
|
|
101
|
+
*
|
|
102
|
+
* Used by FlowProvider to bundle `resources/js/app.js` → `public/js/app.js`.
|
|
103
|
+
* Workspace package imports (e.g. `@zerotal/devtools/client`) are resolved
|
|
104
|
+
* from the app's own node_modules and tree-shaken into the output.
|
|
105
|
+
*
|
|
106
|
+
* @param input Absolute path to the JS/TS entry (e.g. `${cwd}/resources/js/app.js`)
|
|
107
|
+
* @param outdir Absolute path to the output directory (e.g. `${cwd}/public/js`)
|
|
108
|
+
* @param minify Whether to minify the output (true in production)
|
|
109
|
+
*/
|
|
110
|
+
export async function buildJsBundle(
|
|
111
|
+
input: string,
|
|
112
|
+
outdir: string,
|
|
113
|
+
minify = false,
|
|
114
|
+
): Promise<{ success: boolean; logs: unknown[] }> {
|
|
115
|
+
try {
|
|
116
|
+
return await Bun.build({
|
|
117
|
+
entrypoints: [input],
|
|
118
|
+
outdir,
|
|
119
|
+
target: "browser",
|
|
120
|
+
format: "esm",
|
|
121
|
+
minify,
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { success: false, logs: [error] };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _basename(filePath: string): string {
|
|
129
|
+
return filePath.split("/").at(-1) ?? filePath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The resolved `app.assets` config block (see {@link AppAssetsConfig}). */
|
|
133
|
+
export interface AssetBuildConfig {
|
|
134
|
+
entrypoint: string | string[];
|
|
135
|
+
outDir: string;
|
|
136
|
+
prefix: string;
|
|
137
|
+
minify: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bundle the app's configured asset entrypoint(s) with Bun's native bundler.
|
|
142
|
+
*
|
|
143
|
+
* Entry paths and `outDir` are resolved relative to `cwd`. A JS/TS entry that
|
|
144
|
+
* imports CSS emits a sibling stylesheet; Tailwind v4 is handled automatically
|
|
145
|
+
* when `bun-plugin-tailwind` is installed in the app. Called by `serve` (once)
|
|
146
|
+
* and by the dev build hook (on change) — see {@link buildConfiguredAssets}.
|
|
147
|
+
*
|
|
148
|
+
* Mirrors the Inertia build pipeline: `splitting` emits shared chunks from
|
|
149
|
+
* dynamic imports (per-page code-splitting), and sourcemaps are emitted in dev
|
|
150
|
+
* (external) but omitted in production. Entry filenames stay stable across
|
|
151
|
+
* builds — cache-busting is handled at reference time by `asset()` appending
|
|
152
|
+
* `?v=` in dev, not by hashed output names — while split chunks are named after
|
|
153
|
+
* their content and so change on every rebuild. The chunks the build replaces
|
|
154
|
+
* are swept up afterwards; see {@link pruneBuildOutput}.
|
|
155
|
+
*/
|
|
156
|
+
export async function buildConfiguredAssets(
|
|
157
|
+
assets: AssetBuildConfig,
|
|
158
|
+
cwd: string,
|
|
159
|
+
): Promise<{ success: boolean; logs: unknown[] }> {
|
|
160
|
+
const entries = Array.isArray(assets.entrypoint) ? assets.entrypoint : [assets.entrypoint];
|
|
161
|
+
const entrypoints = entries.map((entry) => `${cwd}/${entry}`);
|
|
162
|
+
const outdir = `${cwd}/${assets.outDir}`;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const plugins = await detectCssPlugins(cwd);
|
|
166
|
+
const result = await Bun.build({
|
|
167
|
+
entrypoints,
|
|
168
|
+
outdir,
|
|
169
|
+
target: "browser",
|
|
170
|
+
format: "esm",
|
|
171
|
+
// Per-page chunks from dynamic imports — same as `inertia:build`.
|
|
172
|
+
splitting: true,
|
|
173
|
+
// Sourcemaps for dev debugging; none in production (minified) builds.
|
|
174
|
+
sourcemap: assets.minify ? "none" : "external",
|
|
175
|
+
minify: assets.minify,
|
|
176
|
+
plugins,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (result.success) await pruneBuildOutput(outdir, result.outputs);
|
|
180
|
+
return result;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { success: false, logs: [error] };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry of frontend build routines used in dev mode. Lets view packages
|
|
3
|
+
* (`@zerotal/inertia`, `@zerotal/flow`) hand their build routine to the core
|
|
4
|
+
* DevOrchestrator without core importing them, avoiding a circular dependency.
|
|
5
|
+
*
|
|
6
|
+
* The registry holds one entry per package rather than a single routine,
|
|
7
|
+
* because an app can run more than one view layer at a time — an Inertia app
|
|
8
|
+
* that installs `@zerotal/monitor` pulls in Flow for the monitor panel, and
|
|
9
|
+
* both have a bundle to build. With a single slot the provider that booted last
|
|
10
|
+
* would silently replace the other's build, and the displaced package's assets
|
|
11
|
+
* would sit frozen at whatever the last manual build left on disk while the dev
|
|
12
|
+
* server cheerfully reported "rebuilding… ✓ ready" on every change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Outcome of a frontend build: whether it succeeded and any collected logs. */
|
|
16
|
+
export interface BuildResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
logs?: unknown[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A frontend build routine that resolves once the build finishes. */
|
|
22
|
+
export type BuildHookFn = () => Promise<BuildResult>;
|
|
23
|
+
|
|
24
|
+
const _hooks = new Map<string, BuildHookFn>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @internal Exported for view packages to wire their dev build into core.
|
|
28
|
+
*
|
|
29
|
+
* Register a frontend build routine for dev mode under a package name.
|
|
30
|
+
* Registering the same name twice replaces the earlier routine, so a provider
|
|
31
|
+
* may safely register on every boot.
|
|
32
|
+
*
|
|
33
|
+
* Called from provider boot (e.g. `InertiaProvider.onBooted()`) so the core
|
|
34
|
+
* DevOrchestrator can trigger a build without `@zerotal/core` importing the
|
|
35
|
+
* view package (which would create a circular dependency).
|
|
36
|
+
*/
|
|
37
|
+
export function registerDevBuildHook(name: string, fn: BuildHookFn): void {
|
|
38
|
+
_hooks.set(name, fn);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @internal Whether any package has a dev build routine registered. */
|
|
42
|
+
export function hasDevBuildHooks(): boolean {
|
|
43
|
+
return _hooks.size > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @internal Forget every registered routine (tests only). */
|
|
47
|
+
export function _resetDevBuildHooks(): void {
|
|
48
|
+
_hooks.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @internal Run every registered build routine and merge the outcomes.
|
|
53
|
+
*
|
|
54
|
+
* Routines run concurrently — they write to separate output directories — and
|
|
55
|
+
* one failing (or throwing) neither hides the others nor stops them. Logs are
|
|
56
|
+
* tagged with the package name so a failure points at the build that produced it.
|
|
57
|
+
*/
|
|
58
|
+
export async function runDevBuildHooks(): Promise<BuildResult> {
|
|
59
|
+
const results = await Promise.all(
|
|
60
|
+
Array.from(_hooks, async ([name, fn]): Promise<BuildResult> => {
|
|
61
|
+
try {
|
|
62
|
+
const result = await fn();
|
|
63
|
+
return { success: result.success, logs: (result.logs ?? []).map((l) => `[${name}] ${l}`) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { success: false, logs: [`[${name}] ${error}`] };
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
success: results.every((result) => result.success),
|
|
72
|
+
logs: results.flatMap((result) => result.logs ?? []),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The supervisor process for dev mode: it builds assets, spawns the Zerotal
|
|
3
|
+
* server as a child, watches the filesystem, and restarts or rebuilds on change.
|
|
4
|
+
*/
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
import type { BuildHookFn } from "./DevBuildHook.ts";
|
|
7
|
+
import { DEV_WORKER_ENV_VAR } from "../support/env.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dev Orchestrator — Process 1 of the two-process dev mode.
|
|
11
|
+
*
|
|
12
|
+
* Responsibilities:
|
|
13
|
+
* - Runs an initial pages-manifest sync + asset build before the server starts.
|
|
14
|
+
* - Spawns the Zerotal server (Process 2) as a child process with stdin piped.
|
|
15
|
+
* - Watches the filesystem and:
|
|
16
|
+
* • Backend change → debounced server restart (150 ms)
|
|
17
|
+
* • Frontend change → debounced asset rebuild + browser reload signal (80 ms)
|
|
18
|
+
* - Writes `reload\n` to the child's stdin after each successful rebuild so the
|
|
19
|
+
* server can forward the signal to connected SSE clients.
|
|
20
|
+
*
|
|
21
|
+
* Process 2 is started with --dev-worker so it:
|
|
22
|
+
* - Serves GET /__dev/events (SSE reload endpoint)
|
|
23
|
+
* - Reads stdin and calls DevReloadServer.broadcast('reload') on each `reload` line
|
|
24
|
+
*/
|
|
25
|
+
export class DevOrchestrator {
|
|
26
|
+
// Paths whose changes trigger a full server restart
|
|
27
|
+
private static readonly BACKEND = [
|
|
28
|
+
"app/",
|
|
29
|
+
"routes/",
|
|
30
|
+
"bootstrap/",
|
|
31
|
+
"config/",
|
|
32
|
+
"packages/",
|
|
33
|
+
"resources/app.html",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Paths whose changes trigger a frontend rebuild only
|
|
37
|
+
private static readonly FRONTEND = ["resources/pages/", "resources/js/", "resources/css/"];
|
|
38
|
+
|
|
39
|
+
// Always-ignored output / tool directories
|
|
40
|
+
private static readonly IGNORE = ["public/", ".zerotal/", "node_modules/", ".git/"];
|
|
41
|
+
|
|
42
|
+
// pages.generated.ts is written BY the build — ignore its own change event
|
|
43
|
+
private static readonly IGNORE_FILES = new Set(["resources/js/pages.generated.ts"]);
|
|
44
|
+
|
|
45
|
+
private _child: ReturnType<typeof Bun.spawn> | null = null;
|
|
46
|
+
private _restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
private _buildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
/** Per-build asset-version token — bumped on each build, busts `asset()` `?v=` URLs. */
|
|
49
|
+
private _assetVersion = Date.now().toString(36);
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly _port: number,
|
|
53
|
+
private readonly _cwd: string,
|
|
54
|
+
private readonly _build: BuildHookFn,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
async start(): Promise<void> {
|
|
58
|
+
console.log(" [zerotal:dev] ⚙ building assets...");
|
|
59
|
+
const buildSucceeded = await this._runBuild();
|
|
60
|
+
if (!buildSucceeded) {
|
|
61
|
+
console.warn(" [zerotal:dev] ⚠ initial build failed — starting server anyway");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._spawnServer();
|
|
65
|
+
this._watch();
|
|
66
|
+
|
|
67
|
+
// Park the process — cleanup happens in signal handlers registered by _watch()
|
|
68
|
+
await new Promise<never>(() => {});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Server management ──────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
private _spawnServer(): void {
|
|
74
|
+
this._child = Bun.spawn(
|
|
75
|
+
["bun", Bun.main, "serve", "--port", String(this._port), "--dev-worker"],
|
|
76
|
+
{
|
|
77
|
+
stdin: "pipe",
|
|
78
|
+
stdout: "inherit",
|
|
79
|
+
stderr: "inherit",
|
|
80
|
+
cwd: this._cwd,
|
|
81
|
+
env: {
|
|
82
|
+
...Bun.env,
|
|
83
|
+
APP_ENV: "web",
|
|
84
|
+
// Mark the worker as developer-supervised. APP_ENV above is the
|
|
85
|
+
// runtime mode, not a deployment name, so it cannot carry this —
|
|
86
|
+
// without the flag the worker looks production-like to every
|
|
87
|
+
// dev-surface gate and `serve --dev` renders bare 500s with no stack.
|
|
88
|
+
[DEV_WORKER_ENV_VAR]: "1",
|
|
89
|
+
ZT_ASSET_VERSION: this._assetVersion,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
this._child.exited.then((code) => {
|
|
95
|
+
// Ignore expected exits (restart in progress or clean shutdown)
|
|
96
|
+
if (this._child && this._restartTimer === null && code !== 0) {
|
|
97
|
+
console.log(` [zerotal:dev] server exited with code ${code}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private _scheduleRestart(): void {
|
|
103
|
+
if (this._restartTimer) clearTimeout(this._restartTimer);
|
|
104
|
+
this._restartTimer = setTimeout(async () => {
|
|
105
|
+
this._restartTimer = null;
|
|
106
|
+
console.log(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
|
|
107
|
+
|
|
108
|
+
// Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
|
|
109
|
+
// controllers returning markup) contain Tailwind classes the stylesheet scans via
|
|
110
|
+
// `@source`, so a backend edit can introduce new classes. Without this, a new utility
|
|
111
|
+
// used in a page wouldn't appear until an unrelated `resources/` file changed. The
|
|
112
|
+
// fresh `_assetVersion` (bumped by _runBuild) is passed to the respawned worker, so the
|
|
113
|
+
// browser refetches the updated CSS.
|
|
114
|
+
await this._runBuild();
|
|
115
|
+
|
|
116
|
+
const previousChild = this._child;
|
|
117
|
+
this._child = null;
|
|
118
|
+
|
|
119
|
+
if (previousChild) {
|
|
120
|
+
previousChild.kill("SIGTERM");
|
|
121
|
+
const forceKill = setTimeout(() => previousChild.kill("SIGKILL"), 1_500);
|
|
122
|
+
await previousChild.exited;
|
|
123
|
+
clearTimeout(forceKill);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this._spawnServer();
|
|
127
|
+
}, 150);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Build management ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
private _scheduleBuild(path: string): void {
|
|
133
|
+
if (this._buildTimer) clearTimeout(this._buildTimer);
|
|
134
|
+
this._buildTimer = setTimeout(async () => {
|
|
135
|
+
this._buildTimer = null;
|
|
136
|
+
const label = path.startsWith("resources/pages/") ? "page" : "asset";
|
|
137
|
+
console.log(` [zerotal:dev] ⚙ ${label} changed — rebuilding...`);
|
|
138
|
+
|
|
139
|
+
const buildSucceeded = await this._runBuild();
|
|
140
|
+
if (buildSucceeded) {
|
|
141
|
+
console.log(" [zerotal:dev] ✓ ready — reloading browser");
|
|
142
|
+
this._signalReload();
|
|
143
|
+
}
|
|
144
|
+
}, 80);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async _runBuild(): Promise<boolean> {
|
|
148
|
+
try {
|
|
149
|
+
const result = await this._build();
|
|
150
|
+
if (!result.success) {
|
|
151
|
+
console.error(" [zerotal:dev] ✗ build failed:");
|
|
152
|
+
for (const entry of result.logs ?? []) {
|
|
153
|
+
console.error(" ", String(entry));
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// Fresh token so the next `asset()` URL changes and the browser refetches.
|
|
158
|
+
this._assetVersion = Date.now().toString(36);
|
|
159
|
+
return true;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error(" [zerotal:dev] ✗ build error:", error);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private _signalReload(): void {
|
|
167
|
+
try {
|
|
168
|
+
// When spawned with `stdin: "pipe"` this is a FileSink (not a numeric fd).
|
|
169
|
+
// Carry the fresh asset-version token so the worker can update `asset()`
|
|
170
|
+
// URLs before broadcasting the browser reload.
|
|
171
|
+
const sink = this._child?.stdin as import("bun").FileSink | undefined;
|
|
172
|
+
sink?.write(`reload:${this._assetVersion}\n`);
|
|
173
|
+
sink?.flush?.();
|
|
174
|
+
} catch {
|
|
175
|
+
// Child may be in mid-restart — ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── File watcher ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
private _watch(): void {
|
|
182
|
+
const watcher = watch(this._cwd, { recursive: true }, (_event, filename) => {
|
|
183
|
+
if (!filename) return;
|
|
184
|
+
|
|
185
|
+
// Normalise to forward slashes (Windows reports backslashes).
|
|
186
|
+
const relativePath = filename.replace(/\\/g, "/");
|
|
187
|
+
|
|
188
|
+
// Skip hidden dirs, ignored dirs, and generated files.
|
|
189
|
+
if (relativePath.startsWith(".")) return;
|
|
190
|
+
if (DevOrchestrator.IGNORE.some((prefix) => relativePath.startsWith(prefix))) return;
|
|
191
|
+
if (DevOrchestrator.IGNORE_FILES.has(relativePath)) return;
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
DevOrchestrator.BACKEND.some(
|
|
195
|
+
(prefix) => relativePath.startsWith(prefix) || relativePath === prefix.replace(/\/$/, ""),
|
|
196
|
+
)
|
|
197
|
+
) {
|
|
198
|
+
this._scheduleRestart();
|
|
199
|
+
} else if (DevOrchestrator.FRONTEND.some((prefix) => relativePath.startsWith(prefix))) {
|
|
200
|
+
this._scheduleBuild(relativePath);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
watcher.close();
|
|
206
|
+
this._child?.kill("SIGTERM");
|
|
207
|
+
process.exit(0);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
process.on("SIGTERM", cleanup);
|
|
211
|
+
process.on("SIGINT", cleanup);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only HTML response injector.
|
|
3
|
+
*
|
|
4
|
+
* Registered globally by `Application.enableDevWs()` (i.e. only under
|
|
5
|
+
* `serve --dev-worker`), it rewrites every `text/html` response to inject:
|
|
6
|
+
*
|
|
7
|
+
* 1. A tiny live-reload client that connects to the `/__dev/ws` WebSocket and
|
|
8
|
+
* reloads the page whenever the DevOrchestrator rebuilds assets.
|
|
9
|
+
* 2. Any snippets registered via {@link registerDevHtmlSnippet} — this is how
|
|
10
|
+
* `@zerotal/devtools` auto-injects its in-page panel.
|
|
11
|
+
*
|
|
12
|
+
* This generalises what Inertia previously did in its own HTML template, so the
|
|
13
|
+
* reload client now works for *any* view layer (flow, flow-ui, JSX, plain HTML)
|
|
14
|
+
* with zero app wiring. Pages that already embed a `/__dev/ws` client (e.g. an
|
|
15
|
+
* Inertia template) are detected and not double-injected.
|
|
16
|
+
*/
|
|
17
|
+
import type { NextFn } from "../pipeline/types.ts";
|
|
18
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
19
|
+
import { BaseMiddleware } from "../middleware/BaseMiddleware.ts";
|
|
20
|
+
import { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
21
|
+
|
|
22
|
+
/** Produces an HTML fragment to inject before `</body>` for the given request. */
|
|
23
|
+
export type DevHtmlSnippet = (ctx: HttpContext) => string;
|
|
24
|
+
|
|
25
|
+
interface RegisteredSnippet {
|
|
26
|
+
name: string;
|
|
27
|
+
fn: DevHtmlSnippet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const _snippets: RegisteredSnippet[] = [];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register an HTML snippet to inject into dev HTML responses (before `</body>`).
|
|
34
|
+
* Idempotent by `name`, so providers can register on every boot safely. Return
|
|
35
|
+
* an empty string from `fn` to skip injection for a given request (e.g. to avoid
|
|
36
|
+
* injecting into your own tool's pages).
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* registerDevHtmlSnippet("devtools", (ctx) =>
|
|
40
|
+
* ctx.url.pathname.startsWith("/__zerotal") ? "" : `<script src="/__zerotal/devtools/client.js"></script>`,
|
|
41
|
+
* );
|
|
42
|
+
*/
|
|
43
|
+
export function registerDevHtmlSnippet(name: string, fn: DevHtmlSnippet): void {
|
|
44
|
+
if (_snippets.some((s) => s.name === name)) return;
|
|
45
|
+
_snippets.push({ name, fn });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal — reset the registry (tests only). */
|
|
49
|
+
export function _resetDevHtmlSnippets(): void {
|
|
50
|
+
_snippets.length = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The live-reload client is only meaningful when the /__dev/ws endpoint exists
|
|
54
|
+
// (i.e. under `serve --dev-worker`). Snippets, by contrast, inject in any dev
|
|
55
|
+
// mode — so devtools can share this middleware even under a plain `serve`.
|
|
56
|
+
let _reloadClientActive = false;
|
|
57
|
+
|
|
58
|
+
/** Enable injection of the live-reload client. Called by `Application.enableDevWs()`. */
|
|
59
|
+
export function setDevReloadClientActive(active: boolean): void {
|
|
60
|
+
_reloadClientActive = active;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class DevReloadMiddleware extends BaseMiddleware {
|
|
64
|
+
protected options: {} = {};
|
|
65
|
+
|
|
66
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
67
|
+
const res = await next();
|
|
68
|
+
if (!(res instanceof Response)) return res;
|
|
69
|
+
|
|
70
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
71
|
+
if (!contentType.includes("text/html")) return res;
|
|
72
|
+
|
|
73
|
+
let html: string;
|
|
74
|
+
try {
|
|
75
|
+
html = await res.clone().text();
|
|
76
|
+
} catch {
|
|
77
|
+
return res; // unreadable / streaming body — leave it alone
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let injection = "";
|
|
81
|
+
// Inject the reload client only under dev-worker, and not when the page
|
|
82
|
+
// already embeds one (e.g. an Inertia template).
|
|
83
|
+
if (_reloadClientActive && !html.includes("/__dev/ws")) injection += DEV_RELOAD_CLIENT;
|
|
84
|
+
for (const snippet of _snippets) {
|
|
85
|
+
try {
|
|
86
|
+
injection += snippet.fn(http);
|
|
87
|
+
} catch {
|
|
88
|
+
/* a broken snippet must never break the page */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!injection) return res;
|
|
92
|
+
|
|
93
|
+
const body = html.includes("</body>")
|
|
94
|
+
? html.replace("</body>", `${injection}\n</body>`)
|
|
95
|
+
: html + injection;
|
|
96
|
+
|
|
97
|
+
const headers = new Headers(res.headers);
|
|
98
|
+
headers.delete("content-length"); // body changed — let the runtime recompute
|
|
99
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages the set of active SSE connections for the dev auto-reload feature.
|
|
3
|
+
*
|
|
4
|
+
* Used by ServeCommand (--dev-worker mode):
|
|
5
|
+
* - handleSSE() — returns a streaming Response for GET /__dev/events
|
|
6
|
+
* - broadcast(msg) — pushes a message to every connected browser tab
|
|
7
|
+
*
|
|
8
|
+
* The Application registers the /__dev/events route before starting only when
|
|
9
|
+
* withDevReload() is called. Zero code runs in production.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const _encoder = new TextEncoder();
|
|
13
|
+
|
|
14
|
+
// Set of live stream controllers — one per connected browser tab.
|
|
15
|
+
const _clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
16
|
+
|
|
17
|
+
let _pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
18
|
+
|
|
19
|
+
function _startPing(): void {
|
|
20
|
+
if (_pingTimer) return;
|
|
21
|
+
_pingTimer = setInterval(() => broadcast("ping"), 25_000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _stopPing(): void {
|
|
25
|
+
if (_pingTimer) {
|
|
26
|
+
clearInterval(_pingTimer);
|
|
27
|
+
_pingTimer = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return a Server-Sent Events streaming Response.
|
|
33
|
+
* Pass this as the handler for GET /__dev/events.
|
|
34
|
+
*/
|
|
35
|
+
export function handleSSE(): Response {
|
|
36
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
37
|
+
|
|
38
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
39
|
+
start(controller) {
|
|
40
|
+
streamController = controller;
|
|
41
|
+
_clients.add(controller);
|
|
42
|
+
_startPing();
|
|
43
|
+
// Handshake event — confirms the connection is live.
|
|
44
|
+
controller.enqueue(_encoder.encode("data: connected\n\n"));
|
|
45
|
+
},
|
|
46
|
+
// `cancel(reason)` receives the cancellation reason, not the controller.
|
|
47
|
+
// Use the captured controller reference so we remove the correct entry.
|
|
48
|
+
cancel() {
|
|
49
|
+
if (streamController) {
|
|
50
|
+
_clients.delete(streamController);
|
|
51
|
+
streamController = null;
|
|
52
|
+
}
|
|
53
|
+
if (_clients.size === 0) _stopPing();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return new Response(stream, {
|
|
58
|
+
headers: {
|
|
59
|
+
"Content-Type": "text/event-stream",
|
|
60
|
+
"Cache-Control": "no-cache, no-store",
|
|
61
|
+
Connection: "keep-alive",
|
|
62
|
+
"X-Accel-Buffering": "no", // disable nginx buffering for SSE
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Push a message to all connected browser tabs.
|
|
69
|
+
* Stale controllers (closed tabs) are silently pruned.
|
|
70
|
+
*/
|
|
71
|
+
export function broadcast(message: string): void {
|
|
72
|
+
const payload = _encoder.encode(`data: ${message}\n\n`);
|
|
73
|
+
const staleControllers: ReadableStreamDefaultController<Uint8Array>[] = [];
|
|
74
|
+
|
|
75
|
+
for (const controller of _clients) {
|
|
76
|
+
try {
|
|
77
|
+
controller.enqueue(payload);
|
|
78
|
+
} catch {
|
|
79
|
+
staleControllers.push(controller);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const controller of staleControllers) _clients.delete(controller);
|
|
84
|
+
if (_clients.size === 0) _stopPing();
|
|
85
|
+
}
|