@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,108 @@
1
+ /**
2
+ * The boot-time doctor — one verification pass, after providers have booted,
3
+ * that turns latent wiring mistakes into loud, named startup failures instead of
4
+ * mid-request surprises.
5
+ *
6
+ * Its first (and cheapest) job is the provider `provides` contract: every token
7
+ * a provider names in `static provides` must actually be wired, and — in real
8
+ * runtimes — resolvable. Eager-resolving those singletons at boot also closes
9
+ * the facade temporal-coupling trap: a facade's synchronous `makeSync` needs its
10
+ * singleton pre-resolved, and resolving them centrally here means an individual
11
+ * provider does not need its own `onBooted` pre-resolve step.
12
+ */
13
+ import type { Container } from "../container/Container.ts";
14
+ import type { ContainerBindings } from "../container/types.ts";
15
+ import type { ServiceProvider } from "../provider/ServiceProvider.ts";
16
+ import { ZerotalError } from "../errors/ZerotalError.ts";
17
+
18
+ /** A single wiring problem found by {@link runBootDoctor}. */
19
+ export interface BootCheckFailure {
20
+ /** The container token that failed its check. */
21
+ token: string;
22
+ /** The provider class that declared the token in `static provides`. */
23
+ provider: string;
24
+ /** Why it failed — either "not bound", or the construction error message. */
25
+ reason: string;
26
+ }
27
+
28
+ /**
29
+ * Thrown when the boot-time doctor finds one or more wiring problems. Lists every
30
+ * culprit by token and declaring provider, so a startup failure names exactly
31
+ * what to fix.
32
+ *
33
+ * @category Errors
34
+ */
35
+ export class BootCheckError extends ZerotalError {
36
+ constructor(public readonly failures: BootCheckFailure[]) {
37
+ super(BootCheckError._format(failures), "E_BOOT_CHECK_FAILED", 500, { failures });
38
+ }
39
+
40
+ private static _format(failures: BootCheckFailure[]): string {
41
+ const lines = failures.map((f) => ` • "${f.token}" (declared by ${f.provider}): ${f.reason}`);
42
+ const plural = failures.length === 1 ? "" : "s";
43
+ return `[Zerotal] Boot-time doctor found ${failures.length} wiring problem${plural}:\n${lines.join("\n")}`;
44
+ }
45
+ }
46
+
47
+ /** The static shape the doctor reads off a provider class. */
48
+ type ProviderStatics = { provides?: readonly (keyof ContainerBindings)[]; name: string };
49
+
50
+ /**
51
+ * Verify the application's wiring before it starts serving.
52
+ *
53
+ * For every token a provider declares in `static provides`:
54
+ * - **always** verify the token is bound in the container — a token declared but
55
+ * never registered is a wiring mistake, and this check constructs nothing, so
56
+ * it is safe to run in every environment (including tests);
57
+ * - when `eagerResolve` is set, additionally resolve each **non-deferred**
58
+ * singleton now, so a construction error surfaces here as a named boot failure
59
+ * rather than on the first request that touches its facade.
60
+ *
61
+ * Deferred bindings are verified as bound but never eager-resolved — resolving
62
+ * one would boot its provider and defeat the deferral.
63
+ *
64
+ * @param providers - The application's active provider instances.
65
+ * @param container - The application container the providers registered into.
66
+ * @param options - `eagerResolve` turns on construction of non-deferred
67
+ * singletons (real runtimes); leave it off in tests/REPL to avoid side effects.
68
+ * @throws {BootCheckError} listing every culprit when one or more checks fail.
69
+ */
70
+ export async function runBootDoctor(
71
+ providers: readonly ServiceProvider[],
72
+ container: Container,
73
+ options: { eagerResolve: boolean },
74
+ ): Promise<void> {
75
+ const failures: BootCheckFailure[] = [];
76
+
77
+ for (const provider of providers) {
78
+ const ctor = provider.constructor as unknown as ProviderStatics;
79
+ const provides = ctor.provides;
80
+ if (!provides || provides.length === 0) continue;
81
+
82
+ for (const token of provides) {
83
+ if (!container.bound(token)) {
84
+ failures.push({
85
+ token: String(token),
86
+ provider: ctor.name,
87
+ reason:
88
+ "declared in `static provides` but no binding was registered (bind it in onRegister)",
89
+ });
90
+ continue;
91
+ }
92
+
93
+ if (options.eagerResolve && !container.isDeferred(token)) {
94
+ try {
95
+ await container.make(token);
96
+ } catch (error) {
97
+ failures.push({
98
+ token: String(token),
99
+ provider: ctor.name,
100
+ reason: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ if (failures.length > 0) throw new BootCheckError(failures);
108
+ }
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Renders the framework's error pages as standalone HTML: a polished status
3
+ * page for ordinary HTTP errors, and a rich development page with the parsed
4
+ * stack trace, source context, and request details.
5
+ */
6
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
7
+ import { devSurfacesEnabled } from "../support/env.ts";
8
+
9
+ // ── HTTP error page (4xx / production 5xx) ────────────────────────────────────
10
+
11
+ const STATUS_META: Record<number, { title: string; hint: string; color: string; action?: string }> =
12
+ {
13
+ 400: {
14
+ title: "Bad Request",
15
+ color: "#f59e0b",
16
+ hint: "The server could not understand the request due to invalid syntax.",
17
+ },
18
+ 401: {
19
+ title: "Not Authenticated",
20
+ color: "#f59e0b",
21
+ hint: "You must be logged in to access this resource.",
22
+ action: "/login",
23
+ },
24
+ 403: {
25
+ title: "Forbidden",
26
+ color: "#ef4444",
27
+ hint: "You don't have permission to access this resource.",
28
+ },
29
+ 404: {
30
+ title: "Not Found",
31
+ color: "#6366f1",
32
+ hint: "The requested URL was not found on this server. Check for typos or try the home page.",
33
+ },
34
+ 405: {
35
+ title: "Method Not Allowed",
36
+ color: "#f59e0b",
37
+ hint: "The HTTP method used is not supported for this route.",
38
+ },
39
+ 408: {
40
+ title: "Request Timeout",
41
+ color: "#f59e0b",
42
+ hint: "The server timed out waiting for the request.",
43
+ },
44
+ 409: {
45
+ title: "Conflict",
46
+ color: "#f59e0b",
47
+ hint: "The request conflicts with the current state of the resource.",
48
+ },
49
+ 419: {
50
+ title: "Page Expired",
51
+ color: "#f59e0b",
52
+ hint: "Your session has expired. Reload the page and try again.",
53
+ },
54
+ 422: {
55
+ title: "Unprocessable Content",
56
+ color: "#f59e0b",
57
+ hint: "The submitted data failed validation.",
58
+ },
59
+ 429: {
60
+ title: "Too Many Requests",
61
+ color: "#ef4444",
62
+ hint: "You have sent too many requests. Please slow down.",
63
+ },
64
+ 500: {
65
+ title: "Server Error",
66
+ color: "#dc2626",
67
+ hint: "Something went wrong on our end. Check the server logs for details.",
68
+ },
69
+ 503: {
70
+ title: "Service Unavailable",
71
+ color: "#dc2626",
72
+ hint: "The server is temporarily unavailable. Try again in a moment.",
73
+ },
74
+ };
75
+
76
+ /**
77
+ * Render a styled HTML page for an HTTP status (4xx, or 5xx in production).
78
+ *
79
+ * @param code - Machine-readable error code shown as a badge, when present.
80
+ */
81
+ export function renderHttpErrorPage(
82
+ status: number,
83
+ message: string,
84
+ code?: string,
85
+ ctx?: HttpContext,
86
+ ): Response {
87
+ const meta = STATUS_META[status] ?? { title: "Error", color: "#6b7280", hint: "" };
88
+ const method = ctx?.request.method ?? "";
89
+ const url = ctx ? ctx.url.pathname + (ctx.url.search || "") : "";
90
+ // The same predicate the stack-trace gate uses, so the footer never claims
91
+ // "development" on a page that withheld its stack for being production.
92
+ const isProd = !devSurfacesEnabled();
93
+
94
+ const html = `<!DOCTYPE html>
95
+ <html lang="en">
96
+ <head>
97
+ <meta charset="UTF-8"/>
98
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
99
+ <title>${status} — ${esc(meta.title)}</title>
100
+ <style>
101
+ *{box-sizing:border-box;margin:0;padding:0}
102
+ html,body{height:100%;font-family:system-ui,-apple-system,sans-serif;background:#f8f9fc;color:#1e293b}
103
+ body{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:24px}
104
+ .card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:52px 48px;max-width:560px;width:100%;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.06)}
105
+ .status{font-size:96px;font-weight:800;line-height:1;color:${meta.color};opacity:.18;letter-spacing:-.04em;margin-bottom:-8px}
106
+ .title{font-size:28px;font-weight:700;color:#1e293b;margin-bottom:12px}
107
+ .message{font-size:15px;color:#64748b;line-height:1.6;margin-bottom:8px}
108
+ .hint{font-size:14px;color:#94a3b8;line-height:1.6;margin-bottom:28px}
109
+ .url-pill{display:inline-flex;align-items:center;gap:8px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:8px 16px;font-family:monospace;font-size:13px;color:#475569;margin-bottom:28px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
110
+ .method{font-weight:700;color:${meta.color}}
111
+ .actions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
112
+ .btn{padding:9px 20px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;border:1px solid}
113
+ .btn-primary{background:${meta.color};color:#fff;border-color:${meta.color}}
114
+ .btn-ghost{background:#fff;color:#374151;border-color:#e2e8f0}
115
+ .code-badge{display:inline-block;background:#f1f5f9;border-radius:4px;padding:2px 8px;font-size:11px;font-family:monospace;color:#94a3b8;margin-top:20px}
116
+ .footer{margin-top:28px;font-size:12px;color:#cbd5e1}
117
+ ${
118
+ !isProd
119
+ ? `.debug{margin-top:28px;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;text-align:left}
120
+ .debug h3{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#94a3b8;margin-bottom:8px}
121
+ .debug p{font-size:12px;color:#64748b;font-family:monospace}`
122
+ : ""
123
+ }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <div class="card">
128
+ <div class="status">${status}</div>
129
+ <h1 class="title">${esc(meta.title)}</h1>
130
+ ${message && message !== meta.title ? `<p class="message">${esc(message)}</p>` : ""}
131
+ <p class="hint">${esc(meta.hint)}</p>
132
+ ${url ? `<div class="url-pill"><span class="method">${esc(method)}</span><span>${esc(url)}</span></div><br/>` : ""}
133
+ <div class="actions">
134
+ <a href="javascript:history.back()" class="btn btn-ghost">← Go back</a>
135
+ <a href="/" class="btn btn-ghost">Home</a>
136
+ ${meta.action ? `<a href="${meta.action}" class="btn btn-primary">${meta.action === "/login" ? "Log in" : "Go"}</a>` : ""}
137
+ </div>
138
+ ${code ? `<div class="code-badge">${esc(code)}</div>` : ""}
139
+ ${!isProd && status >= 500 ? `<div class="debug"><h3>Debug info</h3><p>Check the server console for the full stack trace.</p></div>` : ""}
140
+ <div class="footer">Zerotal Framework${!isProd ? " · development" : ""}</div>
141
+ </div>
142
+ </body>
143
+ </html>`;
144
+
145
+ return new Response(html, {
146
+ status,
147
+ headers: { "Content-Type": "text/html; charset=utf-8" },
148
+ });
149
+ }
150
+
151
+ // ── Stack frame parser ────────────────────────────────────────────────────────
152
+
153
+ interface Frame {
154
+ fn: string;
155
+ file: string;
156
+ line: number;
157
+ col: number;
158
+ isApp: boolean;
159
+ }
160
+
161
+ function parseStack(stack: string): Frame[] {
162
+ return stack
163
+ .split("\n")
164
+ .slice(1)
165
+ .flatMap((raw) => {
166
+ const line = raw.trim();
167
+ // "at fn (file:line:col)" or "at file:line:col"
168
+ const m =
169
+ line.match(/^at (.+?) \((.+):(\d+):(\d+)\)$/) ?? line.match(/^at ()(.+):(\d+):(\d+)$/);
170
+ if (!m) return [];
171
+ const file = (m[2] ?? "").replace(/^file:\/\/\//, "");
172
+ return [
173
+ {
174
+ fn: m[1]?.trim() || "<anonymous>",
175
+ file,
176
+ line: Number(m[3]),
177
+ col: Number(m[4]),
178
+ isApp:
179
+ !file.includes("node_modules") &&
180
+ !file.startsWith("internal:") &&
181
+ !file.startsWith("bun:") &&
182
+ !file.startsWith("node:") &&
183
+ file !== "",
184
+ },
185
+ ];
186
+ });
187
+ }
188
+
189
+ // ── Code context reader ───────────────────────────────────────────────────────
190
+
191
+ interface CodeLine {
192
+ num: number;
193
+ code: string;
194
+ active: boolean;
195
+ }
196
+
197
+ async function readContext(file: string, errorLine: number, radius = 8): Promise<CodeLine[]> {
198
+ try {
199
+ const text = await Bun.file(file).text();
200
+ const lines = text.split("\n");
201
+ const start = Math.max(0, errorLine - radius - 1);
202
+ const end = Math.min(lines.length, errorLine + radius);
203
+ return lines.slice(start, end).map((code, i) => ({
204
+ num: start + i + 1,
205
+ code,
206
+ active: start + i + 1 === errorLine,
207
+ }));
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ // ── HTML helpers ──────────────────────────────────────────────────────────────
214
+
215
+ function esc(s: string): string {
216
+ return s
217
+ .replace(/&/g, "&amp;")
218
+ .replace(/</g, "&lt;")
219
+ .replace(/>/g, "&gt;")
220
+ .replace(/"/g, "&quot;");
221
+ }
222
+
223
+ function shortPath(full: string): string {
224
+ // Show path relative to packages/ or apps/ root if possible
225
+ const idx = full.replace(/\\/g, "/").lastIndexOf("/packages/");
226
+ if (idx >= 0) return full.slice(idx + 1);
227
+ const idx2 = full.replace(/\\/g, "/").lastIndexOf("/apps/");
228
+ if (idx2 >= 0) return full.slice(idx2 + 1);
229
+ return full;
230
+ }
231
+
232
+ // ── Main render ───────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Render the interactive development error page for an unhandled exception,
236
+ * with its parsed stack trace, source context, and request details. Never
237
+ * served in production.
238
+ */
239
+ export async function renderDevErrorPage(err: unknown, ctx?: HttpContext): Promise<Response> {
240
+ const error = err instanceof Error ? err : new Error(String(err));
241
+ const errClass = error.constructor?.name || "Error";
242
+ const message = error.message || "(no message)";
243
+ const code = (error as { code?: string }).code;
244
+ const frames = parseStack(error.stack ?? "");
245
+
246
+ // Read code context for the first N frames that have readable source files
247
+ const ctxFrames = await Promise.all(
248
+ frames.slice(0, 12).map(async (f) => ({
249
+ ...f,
250
+ context: await readContext(f.file, f.line),
251
+ })),
252
+ );
253
+
254
+ const method = ctx?.request.method ?? "";
255
+ const url = ctx ? ctx.url.pathname + ctx.url.search : "";
256
+ const headers = ctx
257
+ ? [...ctx.request.headers.entries()]
258
+ .filter(([k]) => !["cookie"].includes(k))
259
+ .map(([k, v]) => ({ k, v }))
260
+ : [];
261
+
262
+ const appFrames = ctxFrames.filter((f) => f.isApp);
263
+ const vendorFrames = ctxFrames.filter((f) => !f.isApp);
264
+ const firstApp = appFrames[0] ?? ctxFrames[0];
265
+
266
+ const html = `<!DOCTYPE html>
267
+ <html lang="en">
268
+ <head>
269
+ <meta charset="UTF-8" />
270
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
271
+ <title>${esc(errClass)}: ${esc(message.slice(0, 80))}</title>
272
+ <style>
273
+ *{box-sizing:border-box;margin:0;padding:0}
274
+ body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;background:#f8f8f9;color:#1a1a2e;min-height:100vh}
275
+
276
+ /* ── Header ── */
277
+ .header{background:#1e1e2e;padding:28px 32px;border-bottom:3px solid #e05252}
278
+ .err-badge{display:inline-block;background:#e05252;color:#fff;font-size:11px;font-weight:700;letter-spacing:.06em;padding:3px 10px;border-radius:4px;text-transform:uppercase;margin-bottom:12px}
279
+ .err-msg{color:#f8f8f8;font-size:22px;font-weight:600;line-height:1.4;word-break:break-word;max-width:900px}
280
+ .err-meta{margin-top:12px;display:flex;gap:16px;flex-wrap:wrap}
281
+ .err-meta span{font-size:12px;color:#888;display:flex;align-items:center;gap:5px}
282
+ .badge{background:#2a2a3e;color:#aaa;font-family:monospace;font-size:11px;padding:2px 7px;border-radius:3px}
283
+ .badge.code{color:#f9c84a}
284
+ .method{color:#6ee7b7;font-weight:700;font-family:monospace}
285
+ .path{color:#93c5fd;font-family:monospace}
286
+
287
+ /* ── Layout ── */
288
+ .body{display:grid;grid-template-columns:320px 1fr;min-height:calc(100vh - 200px)}
289
+
290
+ /* ── Frame list ── */
291
+ .frames{background:#16161e;overflow-y:auto;border-right:1px solid #2a2a3e}
292
+ .frames-section{padding:8px 0}
293
+ .frames-label{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#555;padding:8px 16px 4px}
294
+ .frame{padding:10px 16px;cursor:pointer;border-left:3px solid transparent;transition:background .1s}
295
+ .frame:hover{background:#1e1e2e}
296
+ .frame.active{background:#1e2a3a;border-left-color:#6366f1}
297
+ .frame.app .fn{color:#e2e8f0;font-weight:500}
298
+ .frame.vendor .fn{color:#555}
299
+ .frame .fn{font-size:13px;font-family:monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
300
+ .frame .loc{font-size:11px;color:#444;margin-top:2px;font-family:monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
301
+ .frame.app .loc{color:#6366f1}
302
+
303
+ /* ── Code panel ── */
304
+ .code-panel{background:#fff;display:flex;flex-direction:column}
305
+ .code-header{padding:12px 20px;background:#f1f5f9;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:8px}
306
+ .code-file{font-family:monospace;font-size:13px;color:#334155;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
307
+ .code-line-num{font-size:11px;background:#6366f1;color:#fff;padding:2px 8px;border-radius:4px;flex-shrink:0}
308
+ .code-body{overflow:auto;flex:1}
309
+ table.code{border-collapse:collapse;width:100%;font-family:'Fira Code',monospace;font-size:13px;line-height:1.7}
310
+ table.code tr td{padding:1px 0;white-space:pre}
311
+ table.code tr.active{background:#fef3c7}
312
+ table.code tr.active td{color:#92400e}
313
+ table.code tr.active .ln{background:#fbbf24;color:#78350f}
314
+ .ln{display:inline-block;min-width:48px;padding:0 12px 0 8px;text-align:right;color:#94a3b8;user-select:none;border-right:1px solid #e2e8f0;margin-right:12px;font-size:12px}
315
+ table.code tr.active .ln{border-right-color:#fbbf24}
316
+ .code-empty{padding:40px;color:#94a3b8;text-align:center;font-size:14px}
317
+
318
+ /* ── Request panel ── */
319
+ .request{border-top:1px solid #e2e8f0;background:#fff}
320
+ .tabs{display:flex;border-bottom:1px solid #e2e8f0}
321
+ .tab{padding:10px 20px;font-size:13px;font-weight:500;color:#64748b;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
322
+ .tab.active{color:#6366f1;border-bottom-color:#6366f1}
323
+ .tab-body{padding:16px 20px;display:none}
324
+ .tab-body.active{display:block}
325
+ table.info{border-collapse:collapse;width:100%;font-size:13px}
326
+ table.info td{padding:5px 12px;vertical-align:top;border-bottom:1px solid #f1f5f9}
327
+ table.info td:first-child{width:200px;color:#64748b;font-weight:500;white-space:nowrap}
328
+ table.info td:last-child{font-family:monospace;color:#1e293b;word-break:break-all}
329
+
330
+ /* ── Footer ── */
331
+ .footer{padding:12px 20px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:11px;color:#94a3b8;display:flex;gap:16px}
332
+ </style>
333
+ </head>
334
+ <body>
335
+
336
+ <!-- Header -->
337
+ <div class="header">
338
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
339
+ <div style="flex:1;min-width:0">
340
+ <div class="err-badge">${esc(errClass)}</div>
341
+ <div class="err-msg">${esc(message)}</div>
342
+ <div class="err-meta">
343
+ ${code ? `<span><span class="badge code">${esc(code)}</span></span>` : ""}
344
+ ${method ? `<span><span class="badge"><span class="method">${esc(method)}</span> <span class="path">${esc(url)}</span></span></span>` : ""}
345
+ <span><span class="badge">${new Date().toLocaleTimeString()}</span></span>
346
+ </div>
347
+ </div>
348
+ <button id="copyBtn" onclick="copyForAI()" title="Copy error as Markdown for AI assistants"
349
+ style="flex-shrink:0;margin-top:4px;padding:7px 14px;background:#2a2a3e;border:1px solid #3a3a5e;border-radius:6px;color:#aaa;font-size:12px;cursor:pointer;white-space:nowrap;transition:all .15s">
350
+ Copy for AI
351
+ </button>
352
+ </div>
353
+ </div>
354
+
355
+ <!-- Body: frames + code -->
356
+ <div class="body">
357
+
358
+ <!-- Frame list -->
359
+ <div class="frames" id="frameList">
360
+ ${
361
+ appFrames.length
362
+ ? `
363
+ <div class="frames-section">
364
+ <div class="frames-label">Application</div>
365
+ ${appFrames
366
+ .map(
367
+ (f) => `
368
+ <div class="frame app ${f === firstApp ? "active" : ""}" onclick="showFrame(${ctxFrames.indexOf(f)})" id="fr-${ctxFrames.indexOf(f)}">
369
+ <div class="fn">${esc(f.fn)}</div>
370
+ <div class="loc">${esc(shortPath(f.file))}:${f.line}</div>
371
+ </div>`,
372
+ )
373
+ .join("")}
374
+ </div>`
375
+ : ""
376
+ }
377
+ ${
378
+ vendorFrames.length
379
+ ? `
380
+ <div class="frames-section">
381
+ <div class="frames-label">Framework / Vendor</div>
382
+ ${vendorFrames
383
+ .map(
384
+ (f) => `
385
+ <div class="frame vendor" onclick="showFrame(${ctxFrames.indexOf(f)})" id="fr-${ctxFrames.indexOf(f)}">
386
+ <div class="fn">${esc(f.fn)}</div>
387
+ <div class="loc">${esc(shortPath(f.file))}:${f.line}</div>
388
+ </div>`,
389
+ )
390
+ .join("")}
391
+ </div>`
392
+ : ""
393
+ }
394
+ </div>
395
+
396
+ <!-- Code panel -->
397
+ <div class="code-panel" id="codePanel">
398
+ ${ctxFrames
399
+ .map(
400
+ (f, i) => `
401
+ <div id="ctx-${i}" style="display:${f === firstApp ? "flex" : "none"};flex-direction:column;height:100%">
402
+ <div class="code-header">
403
+ <div class="code-file">${esc(shortPath(f.file))}</div>
404
+ <div class="code-line-num">line ${f.line}</div>
405
+ </div>
406
+ <div class="code-body">
407
+ ${
408
+ f.context.length
409
+ ? `
410
+ <table class="code">
411
+ ${f.context
412
+ .map(
413
+ (l) => `
414
+ <tr class="${l.active ? "active" : ""}">
415
+ <td><span class="ln">${l.active ? "►" : ""} ${l.num}</span>${esc(l.code)}</td>
416
+ </tr>`,
417
+ )
418
+ .join("")}
419
+ </table>`
420
+ : `<div class="code-empty">Source not available</div>`
421
+ }
422
+ </div>
423
+ </div>`,
424
+ )
425
+ .join("")}
426
+ </div>
427
+
428
+ </div>
429
+
430
+ <!-- Request panel -->
431
+ <div class="request">
432
+ <div class="tabs">
433
+ <div class="tab active" onclick="showTab('req')">Request</div>
434
+ <div class="tab" onclick="showTab('hdr')">Headers</div>
435
+ <div class="tab" onclick="showTab('stack')">Raw Stack</div>
436
+ </div>
437
+ <div class="tab-body active" id="tab-req">
438
+ <table class="info">
439
+ <tr><td>Method</td><td>${esc(method || "—")}</td></tr>
440
+ <tr><td>URL</td><td>${esc(url || "—")}</td></tr>
441
+ ${ctx ? `<tr><td>Full URL</td><td>${esc(ctx.url.href)}</td></tr>` : ""}
442
+ </table>
443
+ </div>
444
+ <div class="tab-body" id="tab-hdr">
445
+ <table class="info">
446
+ ${headers.map(({ k, v }) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join("") || '<tr><td colspan="2" style="color:#94a3b8">No headers</td></tr>'}
447
+ </table>
448
+ </div>
449
+ <div class="tab-body" id="tab-stack">
450
+ <pre style="font-size:12px;color:#475569;line-height:1.7;overflow:auto;white-space:pre-wrap;word-break:break-all">${esc(error.stack ?? "(no stack)")}</pre>
451
+ </div>
452
+ </div>
453
+
454
+ <div class="footer">
455
+ <span>Zerotal Framework</span>
456
+ <span>Development error page — not shown in production</span>
457
+ </div>
458
+
459
+ <script>
460
+ const ctxData = ${JSON.stringify(ctxFrames.map((f) => f.file + ":" + f.line))};
461
+
462
+ const _errorMd = ${JSON.stringify({
463
+ errClass,
464
+ message,
465
+ code: code ?? null,
466
+ method,
467
+ url,
468
+ stack: error.stack ?? "",
469
+ frames: ctxFrames.map((f) => ({
470
+ fn: f.fn,
471
+ file: shortPath(f.file),
472
+ line: f.line,
473
+ isApp: f.isApp,
474
+ context: f.context.map((l) => ({ num: l.num, code: l.code, active: l.active })),
475
+ })),
476
+ })};
477
+
478
+ function copyForAI() {
479
+ const d = _errorMd;
480
+ let md = '## ' + d.errClass + '\\n\\n';
481
+ md += d.message + '\\n\\n';
482
+ if (d.code) md += '**Code:** \`' + d.code + '\` \\n';
483
+ if (d.method) md += '**Request:** \`' + d.method + ' ' + d.url + '\` \\n';
484
+ md += '\\n';
485
+
486
+ // First app frame with code context
487
+ const appFrame = d.frames.find(f => f.isApp && f.context.length > 0);
488
+ if (appFrame) {
489
+ md += '### Code Context (' + appFrame.file + ':' + appFrame.line + ')\\n\\n';
490
+ md += '\`\`\`\\n';
491
+ appFrame.context.forEach(l => {
492
+ md += (l.active ? '► ' : ' ') + String(l.num).padStart(4) + ' ' + l.code + '\\n';
493
+ });
494
+ md += '\`\`\`\\n\\n';
495
+ }
496
+
497
+ // All app frames
498
+ const appFrames = d.frames.filter(f => f.isApp);
499
+ if (appFrames.length) {
500
+ md += '### Application Stack\\n\\n';
501
+ appFrames.forEach(f => { md += '- \`' + f.fn + '\` — ' + f.file + ':' + f.line + '\\n'; });
502
+ md += '\\n';
503
+ }
504
+
505
+ md += '### Full Stack Trace\\n\\n\`\`\`\\n' + d.stack + '\\n\`\`\`\\n';
506
+
507
+ const btn = document.getElementById('copyBtn');
508
+
509
+ function _ok() {
510
+ if (btn) { btn.textContent = 'Copied!'; btn.style.borderColor = '#6ee7b7'; btn.style.color = '#6ee7b7'; }
511
+ setTimeout(function() {
512
+ if (btn) { btn.textContent = 'Copy for AI'; btn.style.borderColor = '#3a3a5e'; btn.style.color = '#aaa'; }
513
+ }, 2000);
514
+ }
515
+ function _fail() {
516
+ if (btn) { btn.textContent = 'Copy failed'; }
517
+ }
518
+
519
+ // Chromium bug workaround (issues.chromium.org/issues/414348233):
520
+ // navigator.clipboard.writeText can silently fail in non-HTTPS / dev contexts.
521
+ // Fallback: create an off-screen textarea, select its content, execCommand('copy').
522
+ function _fallback() {
523
+ var el = document.createElement('textarea');
524
+ el.value = md;
525
+ el.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';
526
+ document.body.appendChild(el);
527
+ el.focus();
528
+ el.select();
529
+ el.setSelectionRange(0, 0x7fffffff);
530
+ var ok = false;
531
+ try { ok = document.execCommand('copy'); } catch(e) {}
532
+ document.body.removeChild(el);
533
+ ok ? _ok() : _fail();
534
+ }
535
+
536
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
537
+ navigator.clipboard.writeText(md).then(_ok).catch(_fallback);
538
+ } else {
539
+ _fallback();
540
+ }
541
+ }
542
+
543
+ function showFrame(idx) {
544
+ document.querySelectorAll('.frame').forEach(el => el.classList.remove('active'));
545
+ const fr = document.getElementById('fr-' + idx);
546
+ if (fr) fr.classList.add('active');
547
+ document.querySelectorAll('[id^="ctx-"]').forEach(el => el.style.display = 'none');
548
+ const ctx = document.getElementById('ctx-' + idx);
549
+ if (ctx) { ctx.style.display = 'flex'; ctx.style.flexDirection = 'column'; ctx.style.height = '100%'; }
550
+ }
551
+
552
+ function showTab(name) {
553
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
554
+ document.querySelectorAll('.tab-body').forEach(el => el.classList.remove('active'));
555
+ document.querySelectorAll('.tab[onclick*="' + name + '"]').forEach(el => el.classList.add('active'));
556
+ const tb = document.getElementById('tab-' + name);
557
+ if (tb) tb.classList.add('active');
558
+ }
559
+ </script>
560
+ </body>
561
+ </html>`;
562
+
563
+ return new Response(html, {
564
+ status: 500,
565
+ headers: { "Content-Type": "text/html; charset=utf-8" },
566
+ });
567
+ }