@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,72 @@
1
+ /**
2
+ * Shared cookie helpers.
3
+ *
4
+ * A single place to build a `Set-Cookie` value and to read a cookie off a
5
+ * request, so session drivers, CSRF, and "remember me" don't each re-implement
6
+ * the attribute assembly and header parsing (the 2026-07 review found the pair
7
+ * copied across four files). Keeping it here means a future change — a
8
+ * `__Host-` prefix, `SameSite=Strict`, a `Partitioned` attribute — happens once.
9
+ */
10
+
11
+ export type SameSite = "Strict" | "Lax" | "None";
12
+
13
+ export interface CookieOptions {
14
+ /** Cookie name. */
15
+ name: string;
16
+ /** Cookie value (caller is responsible for encoding if needed). */
17
+ value: string;
18
+ /** `Path` attribute. Defaults to `/`. */
19
+ path?: string;
20
+ /** `Max-Age` in seconds. Omit for a session cookie. `0` clears the cookie. */
21
+ maxAge?: number;
22
+ /** `SameSite` attribute. Defaults to `Lax`. */
23
+ sameSite?: SameSite;
24
+ /** Add the `HttpOnly` attribute. Defaults to `true` — pass `false` for cookies JS must read (e.g. XSRF-TOKEN). */
25
+ httpOnly?: boolean;
26
+ /** Add the `Secure` attribute. Defaults to `false`. */
27
+ secure?: boolean;
28
+ /** `Domain` attribute. Omitted when absent. */
29
+ domain?: string;
30
+ }
31
+
32
+ /**
33
+ * Build a `Set-Cookie` header value from structured options.
34
+ *
35
+ * @example
36
+ * buildCookie({ name: "session", value, maxAge: 86400, secure: true });
37
+ * // "session=…; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400; Secure"
38
+ */
39
+ export function buildCookie(options: CookieOptions): string {
40
+ const parts = [`${options.name}=${options.value}`, `Path=${options.path ?? "/"}`];
41
+ if (options.domain) parts.push(`Domain=${options.domain}`);
42
+ if (options.httpOnly ?? true) parts.push("HttpOnly");
43
+ parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
44
+ if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
45
+ if (options.secure) parts.push("Secure");
46
+ return parts.join("; ");
47
+ }
48
+
49
+ /**
50
+ * Read a single cookie value off a request by name.
51
+ *
52
+ * Prefers Bun's native `request.cookies` (`Bun.CookieMap`, populated on
53
+ * `Bun.serve`-served requests) and falls back to parsing the `Cookie` header
54
+ * for synthetic requests (e.g. tests) where the native map is absent.
55
+ */
56
+ export function readCookie(request: Request, name: string): string | undefined {
57
+ const cookies = (request as { cookies?: Bun.CookieMap }).cookies;
58
+ if (cookies) return cookies.get(name) ?? undefined;
59
+ return parseCookieHeader(request.headers.get("Cookie") ?? "", name);
60
+ }
61
+
62
+ /** Extract a single cookie value from a raw `Cookie` header string. */
63
+ export function parseCookieHeader(header: string, name: string): string | undefined {
64
+ for (const part of header.split(";")) {
65
+ const eqIdx = part.indexOf("=");
66
+ if (eqIdx === -1) continue;
67
+ if (part.slice(0, eqIdx).trim() === name) {
68
+ return part.slice(eqIdx + 1).trim();
69
+ }
70
+ }
71
+ return undefined;
72
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared cryptographic helpers.
3
+ *
4
+ * These exist so that constant-time comparison and SHA-256 hex digests are
5
+ * implemented exactly once. Auth, session, and crypt code must use these
6
+ * instead of re-implementing `timingSafeEqual` casts or hashing idioms —
7
+ * the 2026-07 code review found six independent copies of the compare and
8
+ * four of the digest, in two different idioms.
9
+ */
10
+ import { timingSafeEqual } from "node:crypto";
11
+
12
+ /**
13
+ * Constant-time string comparison.
14
+ *
15
+ * Returns `false` (rather than throwing) when lengths differ. Length is not
16
+ * secret for the token formats used across the framework (fixed-length
17
+ * hashes, hex digests, OTP codes), so the early return does not leak
18
+ * anything useful.
19
+ *
20
+ * @example
21
+ * if (!safeEqual(candidateHash, storedHash)) throw new InvalidTokenError();
22
+ */
23
+ export function safeEqual(a: string, b: string): boolean {
24
+ const ab = Buffer.from(a);
25
+ const bb = Buffer.from(b);
26
+ if (ab.length !== bb.length) return false;
27
+ return timingSafeEqual(ab as unknown as Uint8Array, bb as unknown as Uint8Array);
28
+ }
29
+
30
+ /**
31
+ * SHA-256 digest of `input`, hex-encoded. Synchronous, via `Bun.CryptoHasher`.
32
+ *
33
+ * This is the canonical helper for token-hash storage (remember tokens,
34
+ * reset tokens, OTPs, PATs, recovery codes): store `sha256Hex(token)`,
35
+ * never the token itself.
36
+ */
37
+ export function sha256Hex(input: string | Uint8Array): string {
38
+ return new Bun.CryptoHasher("sha256").update(input).digest("hex");
39
+ }
40
+
41
+ /**
42
+ * HMAC-SHA256 of `payload` under `key`, hex-encoded. Synchronous, via
43
+ * `Bun.CryptoHasher`.
44
+ *
45
+ * The canonical helper for keyed signing across the framework (signed URLs,
46
+ * snapshot checksums, signed upload paths). Pair it with {@link safeEqual} to
47
+ * verify — never compare digests with `===`. Use {@link sha256Hex} instead when
48
+ * hashing an unkeyed token for storage.
49
+ */
50
+ export function hmacHex(payload: string | Uint8Array, key: string): string {
51
+ return new Bun.CryptoHasher("sha256", key).update(payload).digest("hex");
52
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Recursive object merge (lodash.merge-style): merges `override` onto `base`
3
+ * and returns a brand-new value, used by config factories and middleware option
4
+ * builders. See the notes below for the full merge semantics.
5
+ */
6
+
7
+ // Semantics:
8
+ // • Nested PLAIN objects merge key-by-key, recursively — a partial override of a
9
+ // deep object keeps the base's other keys, at any depth.
10
+ // • Arrays and primitives REPLACE wholesale (see "Arrays" below).
11
+ // • Class instances, Dates, Maps, Sets, RegExps, functions, etc. are treated as
12
+ // atomic values: replaced wholesale and passed through by reference (never
13
+ // merged into and never cloned), so their prototype and identity survive.
14
+ // • `undefined` values in `override` are ignored, so a partial override never
15
+ // blanks out a default.
16
+ // • Neither `base` nor `override` is mutated, and the result shares no mutable
17
+ // plain structure with either: every plain object and array in the result is a
18
+ // fresh copy. This means mutating the merged config can never corrupt the
19
+ // module-level `defaults` object a factory merges onto, nor the options object
20
+ // the caller passed in.
21
+ // • Prototype-polluting keys (`__proto__`, `constructor`, `prototype`) are
22
+ // skipped, so merging untrusted input (env files, parsed JSON) is safe.
23
+ //
24
+ // ── Arrays ────────────────────────────────────────────────────────────────────
25
+ // Arrays are REPLACED as a whole — never concatenated, de-duplicated, or merged
26
+ // element-by-element:
27
+ //
28
+ // deepMerge({ hosts: ["a", "b"] }, { hosts: ["c"] }) → { hosts: ["c"] }
29
+ // // NOT ["a","b","c"], and NOT ["c","b"].
30
+ //
31
+ // This is deliberate: there is no surprise-free universal rule for merging two
32
+ // arrays (append? replace-by-index? union?), so the override is given the final
33
+ // say. Guidance when designing config/middleware option shapes:
34
+ // • If an option is a list users should be able to EXTEND, expose it as a plain
35
+ // array and document that providing it replaces the default outright. Tell
36
+ // users to spread the default themselves, e.g.
37
+ // `SomeConfig({ hosts: [...DEFAULT_HOSTS, "extra"] })`.
38
+ // • If you need a keyed, extensible sub-config, model it as a nested OBJECT keyed
39
+ // by name (like `cache.stores` or `storage.disks`) rather than an array.
40
+ // Objects merge, so a user can add one key without losing the built-ins.
41
+ // • The replacement array is deep-cloned, so mutating the merged result never
42
+ // reaches back into the value the caller passed in.
43
+
44
+ /** Keys that must never be copied across — they can pollute `Object.prototype`. */
45
+ const _UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
46
+
47
+ /**
48
+ * A *plain* object: a `{}`-style record whose prototype is `Object.prototype` or
49
+ * `null`. Class instances, arrays, Dates, Maps, etc. are intentionally excluded so
50
+ * deepMerge treats them as atomic values instead of recursing into them.
51
+ */
52
+ function _isPlainObject(value: unknown): value is Record<string, unknown> {
53
+ if (value === null || typeof value !== "object") return false;
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === Object.prototype || proto === null;
56
+ }
57
+
58
+ /**
59
+ * Deep-clone plain objects and arrays; return everything else (primitives,
60
+ * functions, class instances, Dates, …) by reference. Keeps merged results
61
+ * structurally isolated from their inputs without breaking non-plain values.
62
+ */
63
+ function _clone<T>(value: T): T {
64
+ if (Array.isArray(value)) return value.map((el) => _clone(el)) as unknown as T;
65
+ if (_isPlainObject(value)) {
66
+ const out: Record<string, unknown> = {};
67
+ for (const key in value) {
68
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
69
+ if (_UNSAFE_KEYS.has(key)) continue;
70
+ out[key] = _clone((value as Record<string, unknown>)[key]);
71
+ }
72
+ return out as unknown as T;
73
+ }
74
+ return value;
75
+ }
76
+
77
+ /**
78
+ * Deep-merge `override` onto `base`. Used by config factories (`AppConfig`,
79
+ * `CacheConfig`, …) and `BaseMiddleware.with()` so user overrides change only the
80
+ * keys they specify — at any depth — leaving every other default in place.
81
+ *
82
+ * Nested plain objects merge recursively; arrays, primitives, and class instances
83
+ * replace wholesale; `undefined` overrides are ignored; and the result is a fresh
84
+ * value that shares no mutable plain structure with `base` or `override`. See the
85
+ * file header for full semantics, including the array-replacement rule.
86
+ *
87
+ * @example
88
+ * deepMerge(
89
+ * { cors: { origin: "*", credentials: false }, port: 3000 },
90
+ * { cors: { credentials: true } },
91
+ * );
92
+ * // → { cors: { origin: "*", credentials: true }, port: 3000 }
93
+ *
94
+ * @example
95
+ * // Arrays replace — they are not concatenated:
96
+ * deepMerge({ tags: ["a", "b"] }, { tags: ["c"] });
97
+ * // → { tags: ["c"] }
98
+ */
99
+ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
100
+ const result = _clone(base) as T;
101
+ for (const key in override) {
102
+ if (!Object.prototype.hasOwnProperty.call(override, key)) continue;
103
+ if (_UNSAFE_KEYS.has(key)) continue;
104
+ const overrideValue = override[key];
105
+ if (overrideValue === undefined) continue;
106
+ const baseValue = (result as Record<string, unknown>)[key];
107
+ if (_isPlainObject(overrideValue) && _isPlainObject(baseValue)) {
108
+ (result as Record<string, unknown>)[key] = deepMerge(
109
+ baseValue,
110
+ overrideValue as Partial<typeof baseValue>,
111
+ );
112
+ } else {
113
+ (result as Record<string, unknown>)[key] = _clone(overrideValue);
114
+ }
115
+ }
116
+ return result;
117
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared deployment-environment predicates.
3
+ *
4
+ * These exist so "are we in production?" is answered exactly once. The 2026-07
5
+ * code review found two divergent copies — one accepting only "production",
6
+ * one also accepting "prod" — which meant APP_ENV=prod or staging deployments
7
+ * served full stack traces on 500s from one code path but not the other.
8
+ */
9
+
10
+ /**
11
+ * Whether `env` names a production-like deployment environment — one where
12
+ * debug output (stack traces, dev error pages, open health details) must be
13
+ * suppressed. Accepts `"production"`, `"prod"`, and `"staging"`,
14
+ * case-insensitively.
15
+ *
16
+ * @example
17
+ * if (!isProdLike(Bun.env["APP_ENV"] ?? "")) return renderDevErrorPage(error);
18
+ */
19
+ export function isProdLike(env: string): boolean {
20
+ const normalized = env.trim().toLowerCase();
21
+ return normalized === "production" || normalized === "prod" || normalized === "staging";
22
+ }
23
+
24
+ /**
25
+ * Whether `env` names an environment where dev-only surfaces (the devtools
26
+ * trace inspector, the monitor panel's default open access, dev error pages)
27
+ * may be exposed without authentication.
28
+ *
29
+ * Only *explicitly* non-production environments qualify. Unset or unknown
30
+ * values return `false` — the gate fails closed, so a production deploy that
31
+ * forgets to set `APP_ENV` (or sets `staging`) never accidentally exposes an
32
+ * internal surface. This is the inverse-but-stricter companion to
33
+ * {@link isProdLike}: `isProdLike("")` is `false`, but so is this.
34
+ */
35
+ export function isDevSurfaceAllowed(env: string): boolean {
36
+ const n = env.trim().toLowerCase();
37
+ return n === "development" || n === "dev" || n === "local" || n === "test" || n === "testing";
38
+ }
39
+
40
+ /**
41
+ * Environment variable the dev orchestrator sets on the server it supervises.
42
+ * @internal
43
+ */
44
+ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
45
+
46
+ /**
47
+ * Whether *this process* may expose dev-only surfaces — the stack-trace error
48
+ * page, the trace inspector, an open monitor panel.
49
+ *
50
+ * Two things qualify a process, and both are needed because they answer
51
+ * different questions:
52
+ *
53
+ * - `serve --dev` supervises this process. The orchestrator only ever runs
54
+ * from a developer's terminal (it watches the filesystem and rebundles
55
+ * assets — nothing a deployment does), so its worker is a dev machine by
56
+ * construction. This is the case that carries dev mode, because `APP_ENV`
57
+ * cannot: `setAppEnv()` overwrites it with a *runtime mode* (`web`,
58
+ * `worker`, `console`) before the app boots, so by the time any gate reads
59
+ * it, whatever deployment name the developer configured is gone.
60
+ *
61
+ * - `APP_ENV` still names an explicitly non-production environment. This
62
+ * covers processes started outside the CLI — the test harness, and any
63
+ * embedder that sets `APP_ENV` itself rather than through `setAppEnv()`.
64
+ *
65
+ * Anything else fails closed, so a production deploy that sets no `APP_ENV`
66
+ * exposes nothing.
67
+ */
68
+ export function devSurfacesEnabled(): boolean {
69
+ if (Bun.env[DEV_WORKER_ENV_VAR] === "1") return true;
70
+ return isDevSurfaceAllowed(Bun.env["APP_ENV"] ?? "");
71
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * This machine's address on the local network, so a starting server can print a
3
+ * URL that other devices can actually open.
4
+ *
5
+ * `http://localhost` only ever reaches the machine serving it. Opening the app
6
+ * on a phone means knowing this box's LAN address, and hunting for it with
7
+ * `ipconfig` is the kind of thing a dev server should just tell you.
8
+ */
9
+ import { networkInterfaces } from "node:os";
10
+
11
+ /** The shape read off each interface — narrowed so tests can pass plain objects. */
12
+ type InterfaceInfo = {
13
+ address: string;
14
+ /** `"IPv4"` from Bun and Node's `os` module; some runtimes report the number `4`. */
15
+ family: string | number;
16
+ internal: boolean;
17
+ };
18
+
19
+ /** An interface table, as returned by `os.networkInterfaces()`. */
20
+ type Interfaces = Record<string, readonly InterfaceInfo[] | undefined>;
21
+
22
+ /**
23
+ * Adapters belonging to a virtual machine, container runtime, or VPN rather than
24
+ * to the network the developer's phone is on. They hold real, non-internal IPv4
25
+ * addresses — a Windows box with WSL and Hyper-V reports several — so they are
26
+ * ranked last rather than dropped: on a machine that has nothing else, a
27
+ * reachable address still beats none at all.
28
+ */
29
+ const VIRTUAL_ADAPTER =
30
+ /vethernet|virtualbox|vmware|hyper-?v|wsl|docker|tailscale|zerotier|utun|veth|br-|tun\d|tap\d/i;
31
+
32
+ /**
33
+ * The best guess at this machine's LAN address, or `undefined` when it is on no
34
+ * network at all (an offline laptop, most containers).
35
+ *
36
+ * @example
37
+ * const host = localNetworkAddress();
38
+ * console.log(host ? `http://${host}:3000` : "no network");
39
+ */
40
+ export function localNetworkAddress(): string | undefined {
41
+ return _pickAddress(networkInterfaces());
42
+ }
43
+
44
+ // ── Private ──────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * The choice itself, over an injected interface table.
48
+ *
49
+ * @internal Exported so the ranking can be tested against machines this one is not.
50
+ */
51
+ export function _pickAddress(interfaces: Interfaces): string | undefined {
52
+ let best: { address: string; rank: number } | undefined;
53
+
54
+ for (const [adapter, addresses] of Object.entries(interfaces)) {
55
+ for (const info of addresses ?? []) {
56
+ if (info.internal) continue;
57
+ if (info.family !== "IPv4" && info.family !== 4) continue;
58
+ // 169.254.x.x is the address an adapter gives itself when DHCP never
59
+ // answered: the interface is up, but on no network anyone can reach.
60
+ if (info.address.startsWith("169.254.")) continue;
61
+
62
+ const rank = (VIRTUAL_ADAPTER.test(adapter) ? 10 : 0) + _scopeRank(info.address);
63
+ if (!best || rank < best.rank) best = { address: info.address, rank };
64
+ }
65
+ }
66
+
67
+ return best?.address;
68
+ }
69
+
70
+ /** Lower is better: the ranges a home or office LAN actually hands out come first. */
71
+ function _scopeRank(address: string): number {
72
+ if (address.startsWith("192.168.")) return 0;
73
+ if (address.startsWith("10.")) return 1;
74
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(address)) return 2;
75
+ // 100.64–127.x is carrier-grade NAT space, which is how Tailscale and similar
76
+ // overlays address a machine — reachable, but not from the phone on this Wi-Fi.
77
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return 4;
78
+ return 3;
79
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Port availability, and the OS lookups behind `serve`'s busy-port prompt.
3
+ *
4
+ * `Bun.serve()` throws a bare `EADDRINUSE` when something already holds the
5
+ * port, which tells a developer nothing about *what* holds it or how to get it
6
+ * back. These helpers answer both questions before the server tries to bind.
7
+ */
8
+
9
+ /**
10
+ * Environment variable marking a child process that inherits an already-resolved
11
+ * port from its supervisor. Such a process must never prompt: its stdin belongs
12
+ * to the parent (the dev worker reads reload signals on it), so a prompt there
13
+ * would wait forever on input that never arrives.
14
+ *
15
+ * @internal
16
+ */
17
+ export const PORT_RESOLVED_ENV_VAR = "ZT_PORT_RESOLVED";
18
+
19
+ /** A process holding a port: its pid, and its executable name when discoverable. */
20
+ export type PortOwner = {
21
+ pid: number;
22
+ /** e.g. `bun.exe`, `node`. Undefined when the platform lookup came up empty. */
23
+ name?: string;
24
+ };
25
+
26
+ /**
27
+ * Whether `port` can be bound right now.
28
+ *
29
+ * Binds and immediately releases, which is the only answer that matches what
30
+ * `Bun.serve()` will do — a connect probe would miss a socket bound to a
31
+ * different interface. The default hostname mirrors `Bun.serve()`'s own, so a
32
+ * port this reports as free is one the server can actually take.
33
+ *
34
+ * @example
35
+ * if (!(await isPortAvailable(3000))) console.log("something is already there");
36
+ */
37
+ export async function isPortAvailable(port: number, hostname = "0.0.0.0"): Promise<boolean> {
38
+ try {
39
+ const probe = Bun.listen({ hostname, port, socket: { data(): void {} } });
40
+ probe.stop(true);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Poll until `port` frees up, giving up after `timeoutMs`.
49
+ *
50
+ * Returns `true` as soon as the port is bindable, `false` on timeout. This is
51
+ * the restart case: a server that has just been signalled still holds its
52
+ * socket for a moment after the signal is delivered.
53
+ */
54
+ export async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
55
+ const deadline = Date.now() + timeoutMs;
56
+ for (;;) {
57
+ if (await isPortAvailable(port)) return true;
58
+ if (Date.now() >= deadline) return false;
59
+ await Bun.sleep(100);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The first bindable port at or after `start`, or `undefined` when `attempts`
65
+ * consecutive ports are all taken (or the scan runs past 65535).
66
+ *
67
+ * @example
68
+ * const port = (await findAvailablePort(3001)) ?? 0; // 0 lets the OS choose
69
+ */
70
+ export async function findAvailablePort(start: number, attempts = 20): Promise<number | undefined> {
71
+ for (let port = start; port < start + attempts && port <= 65535; port++) {
72
+ if (await isPortAvailable(port)) return port;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * The process listening on `port`, or `undefined` when it cannot be identified.
79
+ *
80
+ * Identification is best-effort and shells out to the platform's socket table
81
+ * (`netstat` on Windows, `lsof` elsewhere), so it can come up empty on a
82
+ * stripped-down container or for a socket owned by another user. Callers must
83
+ * treat `undefined` as "unknown", not "nothing is listening".
84
+ */
85
+ export async function findPortOwner(port: number): Promise<PortOwner | undefined> {
86
+ const pid =
87
+ process.platform === "win32"
88
+ ? _parseNetstat(await _capture(["netstat", "-ano", "-p", "TCP"]), port)
89
+ : _parseFirstPid(await _capture(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]));
90
+
91
+ if (pid === undefined) return undefined;
92
+ const name = await _processName(pid);
93
+ return name === undefined ? { pid } : { pid, name };
94
+ }
95
+
96
+ /**
97
+ * End the process `pid`, returning whether it is gone afterwards.
98
+ *
99
+ * Elsewhere this is SIGTERM first, escalating to SIGKILL only if the process is
100
+ * still alive a beat later, so a server gets to run its shutdown hooks. Windows
101
+ * has no signals — `taskkill /F` is the only way to end another process, and it
102
+ * is always abrupt.
103
+ */
104
+ export async function stopProcess(pid: number): Promise<boolean> {
105
+ if (process.platform === "win32") {
106
+ try {
107
+ const killer = Bun.spawn(["taskkill", "/PID", String(pid), "/F"], {
108
+ stdout: "ignore",
109
+ stderr: "ignore",
110
+ });
111
+ return (await killer.exited) === 0;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ try {
118
+ process.kill(pid, "SIGTERM");
119
+ } catch {
120
+ // ESRCH (already gone) counts as success; EPERM does not.
121
+ return !_isAlive(pid);
122
+ }
123
+
124
+ for (let waited = 0; waited < 1_500; waited += 50) {
125
+ if (!_isAlive(pid)) return true;
126
+ await Bun.sleep(50);
127
+ }
128
+
129
+ try {
130
+ process.kill(pid, "SIGKILL");
131
+ } catch {
132
+ return !_isAlive(pid);
133
+ }
134
+ await Bun.sleep(100);
135
+ return !_isAlive(pid);
136
+ }
137
+
138
+ // ── Private ──────────────────────────────────────────────────────────────────
139
+
140
+ /** Whether the process still exists. Signal 0 checks without delivering anything. */
141
+ function _isAlive(pid: number): boolean {
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /** Run a command and return its stdout, or "" if it cannot be run at all. */
151
+ async function _capture(command: string[]): Promise<string> {
152
+ try {
153
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
154
+ const output = await new Response(proc.stdout).text();
155
+ await proc.exited;
156
+ return output;
157
+ } catch {
158
+ return "";
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Pull the listening pid for `port` out of `netstat -ano` output, whose rows are
164
+ * `TCP <local> <foreign> <state> <pid>` with the local address written as
165
+ * `0.0.0.0:3000` or `[::]:3000`.
166
+ */
167
+ function _parseNetstat(output: string, port: number): number | undefined {
168
+ for (const line of output.split("\n")) {
169
+ const columns = line.trim().split(/\s+/);
170
+ if (columns.length < 5) continue;
171
+ const [protocol, local, , state, pid] = columns;
172
+ if (!protocol?.toUpperCase().startsWith("TCP")) continue;
173
+ if (state?.toUpperCase() !== "LISTENING") continue;
174
+ if (!local?.endsWith(`:${port}`)) continue;
175
+ const parsed = Number(pid);
176
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ /** First pid from newline-separated output (`lsof -t` prints one per line). */
182
+ function _parseFirstPid(output: string): number | undefined {
183
+ for (const line of output.split("\n")) {
184
+ const parsed = Number(line.trim());
185
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ /** The executable name for `pid`, so the prompt can say more than a number. */
191
+ async function _processName(pid: number): Promise<string | undefined> {
192
+ if (process.platform === "win32") {
193
+ const output = await _capture(["tasklist", "/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"]);
194
+ return /^"([^"]+)"/.exec(output.trim())?.[1];
195
+ }
196
+ return (await _capture(["ps", "-p", String(pid), "-o", "comm="])).trim() || undefined;
197
+ }