@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,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
|
+
}
|