@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. 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
+ }