@zerotal/core 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +370 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +24 -4
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +99 -3
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -13,8 +13,12 @@
13
13
  * suppressed. Accepts `"production"`, `"prod"`, and `"staging"`,
14
14
  * case-insensitively.
15
15
  *
16
+ * Pass {@link deployEnv}, not `Bun.env["APP_ENV"]` — after `setAppEnv()` the latter
17
+ * holds a runtime mode, and `isProdLike("web")` is `false` for every deployment.
18
+ *
19
+ * @internal
16
20
  * @example
17
- * if (!isProdLike(Bun.env["APP_ENV"] ?? "")) return renderDevErrorPage(error);
21
+ * if (!isProdLike(deployEnv())) return renderDevErrorPage(error);
18
22
  */
19
23
  export function isProdLike(env: string): boolean {
20
24
  const normalized = env.trim().toLowerCase();
@@ -43,6 +47,59 @@ export function isDevSurfaceAllowed(env: string): boolean {
43
47
  */
44
48
  export const DEV_WORKER_ENV_VAR = "ZT_DEV";
45
49
 
50
+ /**
51
+ * Where `setAppEnv()` parks the deployment name before overwriting `APP_ENV`.
52
+ * @internal
53
+ */
54
+ export const DEPLOY_ENV_VAR = "ZT_APP_ENV";
55
+
56
+ /**
57
+ * The values of `APP_ENV` that name a runtime *mode* rather than a deployment.
58
+ * `setAppEnv()` writes these; {@link deployEnv} recognises them to know whether
59
+ * `APP_ENV` still holds the deployment name.
60
+ *
61
+ * @internal
62
+ */
63
+ export const RUNTIME_MODES: ReadonlySet<string> = new Set([
64
+ "web",
65
+ "worker",
66
+ "console",
67
+ "test",
68
+ "testing",
69
+ "repl",
70
+ ]);
71
+
72
+ /**
73
+ * The deployment name this process was started with — `production`, `staging`,
74
+ * `local`, whatever the operator set — as opposed to the runtime *mode*.
75
+ *
76
+ * `APP_ENV` carries both meanings, and the second one destroys the first:
77
+ * `setAppEnv()` overwrites it with `web` / `console` / `worker` before the app
78
+ * boots, so a gate that asks `isProdLike(Bun.env["APP_ENV"])` after startup is
79
+ * asking whether `"web"` is production and always getting no. That was not
80
+ * theoretical — it silently disabled the weak-`APP_KEY` refusal and left the
81
+ * ORM's N+1 detector wrapping every query in production.
82
+ *
83
+ * `setAppEnv()` now preserves the original value, and this reads it back. Prefer
84
+ * it to `Bun.env["APP_ENV"]` for **any** production decision. Config is an
85
+ * equally correct source where it is available (`config("app.env")`), but this
86
+ * works before config is loaded and in processes that have none.
87
+ *
88
+ * @internal
89
+ */
90
+ export function deployEnv(): string {
91
+ const current = Bun.env["APP_ENV"] ?? "";
92
+ // If `APP_ENV` still holds a deployment name, it has not been overwritten yet —
93
+ // or something set it deliberately since — so it is the freshest answer. Only
94
+ // once it holds a runtime mode is the preserved copy the better one.
95
+ //
96
+ // Preferring the preserved copy unconditionally made it sticky for the life of
97
+ // the process: anything that set `APP_ENV` afterwards was ignored, which is
98
+ // wrong in itself and which leaked between test files sharing one process.
99
+ if (current && !RUNTIME_MODES.has(current.toLowerCase())) return current;
100
+ return Bun.env[DEPLOY_ENV_VAR] ?? current;
101
+ }
102
+
46
103
  /**
47
104
  * Whether *this process* may expose dev-only surfaces — the stack-trace error
48
105
  * page, the trace inspector, an open monitor panel.
@@ -56,7 +113,9 @@ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
56
113
  * construction. This is the case that carries dev mode, because `APP_ENV`
57
114
  * cannot: `setAppEnv()` overwrites it with a *runtime mode* (`web`,
58
115
  * `worker`, `console`) before the app boots, so by the time any gate reads
59
- * it, whatever deployment name the developer configured is gone.
116
+ * `APP_ENV` it holds the mode rather than the deployment name. (The name
117
+ * itself is not lost — `setAppEnv()` parks it, and {@link deployEnv} reads
118
+ * it back. What is lost is the ability to learn it from `APP_ENV`.)
60
119
  *
61
120
  * - `APP_ENV` still names an explicitly non-production environment. This
62
121
  * covers processes started outside the CLI — the test harness, and any
@@ -67,5 +126,42 @@ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
67
126
  */
68
127
  export function devSurfacesEnabled(): boolean {
69
128
  if (Bun.env[DEV_WORKER_ENV_VAR] === "1") return true;
70
- return isDevSurfaceAllowed(Bun.env["APP_ENV"] ?? "");
129
+ // `deployEnv()`, not `Bun.env["APP_ENV"]` by the time anything asks, `setAppEnv()`
130
+ // has replaced that with the runtime mode, and `isDevSurfaceAllowed("web")` is false.
131
+ // An app with `APP_ENV=development` in its `.env` was therefore getting production
132
+ // error pages from a plain `zt serve`.
133
+ return isDevSurfaceAllowed(deployEnv());
134
+ }
135
+
136
+ /**
137
+ * Whether a `DevOrchestrator` owns asset builds for this process.
138
+ *
139
+ * View providers build their bundles once at boot so assets are ready before
140
+ * the first request. Under `serve --dev` that is redundant three times over:
141
+ * the orchestrator process boots the app (registering hooks, and building), then
142
+ * runs the hooks itself, then spawns a worker that boots the app and builds
143
+ * again. Every backend save paid for two of those.
144
+ *
145
+ * Both processes are recognised, for different reasons:
146
+ *
147
+ * - The supervised worker carries {@link DEV_WORKER_ENV_VAR}. The orchestrator
148
+ * has already built and pruned before spawning it, so its assets are on disk
149
+ * before it binds a port.
150
+ * - The orchestrator itself is recognised from `argv`, not an environment
151
+ * variable, because providers boot *before* `ServeCommand.run()` gets to set
152
+ * one — the flag would always arrive too late to be read.
153
+ *
154
+ * Anything else — a plain `serve`, a queue worker, a test — is unsupervised and
155
+ * still builds at boot.
156
+ *
157
+ * @param env Environment to read; defaults to the process environment.
158
+ * @param argv Command line to read; defaults to the process command line.
159
+ * @internal
160
+ */
161
+ export function isDevOrchestrated(
162
+ env: Record<string, string | undefined> = Bun.env,
163
+ argv: readonly string[] = Bun.argv,
164
+ ): boolean {
165
+ if (env[DEV_WORKER_ENV_VAR] === "1") return true;
166
+ return argv.includes("serve") && argv.includes("--dev");
71
167
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Unrouted `routes/` directory check.
3
+ *
4
+ * A conventional `routes/index.ts` full of `Router.get(...)` calls does nothing
5
+ * until a `routing()` group imports it — the file typechecks, the calls would
6
+ * execute cleanly, and every path in it 404s in a way that is indistinguishable
7
+ * from a typo'd URL. The scaffold wires only `fileBasedRouting()`, so "I made a
8
+ * routes file" and "it needs registering" are otherwise never connected.
9
+ */
10
+ import { readdirSync } from "node:fs";
11
+ import { resolve, sep } from "node:path";
12
+
13
+ /**
14
+ * A warning message when `<root>/routes` holds route files that no `routing()`
15
+ * group loads, or `null` when the directory is absent, empty, or covered.
16
+ */
17
+ export function unroutedRoutesWarning(root: string, routedFiles: string[]): string | null {
18
+ const dir = resolve(root, "routes");
19
+ let files: string[];
20
+ try {
21
+ files = readdirSync(dir).filter((f) => /\.(ts|js)$/.test(f) && !/\.test\.(ts|js)$/.test(f));
22
+ } catch {
23
+ return null; // no routes/ directory — nothing to warn about
24
+ }
25
+ if (files.length === 0) return null;
26
+ const insideDir = (file: string) => {
27
+ const normalized = resolve(file);
28
+ return normalized === dir || normalized.startsWith(dir + sep);
29
+ };
30
+ if (routedFiles.some(insideDir)) return null;
31
+ const suggestion = files.includes("index.ts") ? "index.ts" : files[0];
32
+ return (
33
+ `routes/ contains ${files.join(", ")} but no routing() group loads it — ` +
34
+ `every route in it will 404. Add .routing("./routes/${suggestion}") to your ` +
35
+ `Application, or remove the directory.`
36
+ );
37
+ }