@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,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global response helpers usable from controller handlers — `redirect()`,
|
|
3
|
+
* `back()`, `json()`, `view()`, `html()`, `markdown()`, `file()`, and `abort()`
|
|
4
|
+
* — plus the fluent `ResponseBuilder` / `MarkdownBuilder` they return.
|
|
5
|
+
*/
|
|
6
|
+
import { RequestContext } from "../context/RequestContext.ts";
|
|
7
|
+
import { HttpError, NotFoundError } from "../errors/HttpError.ts";
|
|
8
|
+
import { route } from "../router/Router.ts";
|
|
9
|
+
import { safeRedirectPath } from "../pipeline/HttpContext.ts";
|
|
10
|
+
import { DEFAULT_MD_OPTIONS, type BunMarkdownOptions } from "../helpers/markdown.ts";
|
|
11
|
+
import type { ZerotalError } from "../errors/ZerotalError.ts";
|
|
12
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
13
|
+
|
|
14
|
+
/** Markup a view renders to — a string or anything stringifiable (e.g. JSX `SafeHtml`). */
|
|
15
|
+
type ViewMarkup = string | { toString(): string };
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- supertype of every view component the public overloads accept.
|
|
17
|
+
type AnyViewComponent = (ctx: HttpContext<any>, props: any) => ViewMarkup | Promise<ViewMarkup>;
|
|
18
|
+
|
|
19
|
+
function _ctx(): HttpContext {
|
|
20
|
+
const ctx = RequestContext.tryGet();
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"[Zerotal] Response helpers (back, redirect, json, etc.) must be called inside an active HTTP request.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── ResponseBuilder ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fluent builder returned by the redirect helpers for attaching flash data
|
|
33
|
+
* (`.withErrors()`, `.withSuccess()`, …) to the response.
|
|
34
|
+
*
|
|
35
|
+
* It implements `PromiseLike<void>` so that `return back().withErrors(...)`
|
|
36
|
+
* compiles cleanly in handlers typed as `async (ctx): Promise<void>`: TypeScript
|
|
37
|
+
* unwraps `PromiseLike<void>` in async context, making the builder void-compatible.
|
|
38
|
+
*/
|
|
39
|
+
export class ResponseBuilder implements PromiseLike<void> {
|
|
40
|
+
readonly #ctx: HttpContext;
|
|
41
|
+
|
|
42
|
+
constructor(ctx: HttpContext) {
|
|
43
|
+
this.#ctx = ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Flash an arbitrary key/value onto the next request's session. */
|
|
47
|
+
with(key: string, value: unknown): this {
|
|
48
|
+
this.#ctx.flash(key, value);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Flash a map of field errors under the `errors` key. */
|
|
53
|
+
withErrors(errors: Record<string, string>): this {
|
|
54
|
+
return this.with("errors", errors);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Flash a success message under the `success` key. */
|
|
58
|
+
withSuccess(message: string): this {
|
|
59
|
+
return this.with("success", message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Flash an error message under the `error` key. */
|
|
63
|
+
withError(message: string): this {
|
|
64
|
+
return this.with("error", message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Flash a warning message under the `warning` key. */
|
|
68
|
+
withWarning(message: string): this {
|
|
69
|
+
return this.with("warning", message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Flash an info message under the `info` key. */
|
|
73
|
+
withInfo(message: string): this {
|
|
74
|
+
return this.with("info", message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
then<TResult1 = void, TResult2 = never>(
|
|
78
|
+
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
|
|
79
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
80
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
81
|
+
return Promise.resolve<void>(undefined).then(onfulfilled, onrejected);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── RedirectBuilder ─────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returned by the no-argument `redirect()` form to pick a destination fluently. Each method
|
|
89
|
+
* sets the redirect target and returns a {@link ResponseBuilder} so flash data can be chained:
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* redirect("/login", 302).withError("Please log in to continue.");
|
|
93
|
+
* redirect().back().withErrors({ email: "Invalid email or password." });
|
|
94
|
+
* redirect().intended("/").withInfo("Please log in to continue.");
|
|
95
|
+
* redirect().to("profile", { username: "alice" }).withSuccess("Profile updated.");
|
|
96
|
+
*/
|
|
97
|
+
export class RedirectBuilder {
|
|
98
|
+
readonly #ctx: HttpContext;
|
|
99
|
+
|
|
100
|
+
constructor(ctx: HttpContext) {
|
|
101
|
+
this.#ctx = ctx;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Redirect to an absolute or relative URL. */
|
|
105
|
+
away(url: string, status: 301 | 302 | 303 | 307 | 308 = 302): ResponseBuilder {
|
|
106
|
+
this.#ctx.redirect(url, status);
|
|
107
|
+
return new ResponseBuilder(this.#ctx);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Redirect to a named route, resolving its URL from the route name and params. */
|
|
111
|
+
to(
|
|
112
|
+
name: string,
|
|
113
|
+
params: Record<string, string | number> = {},
|
|
114
|
+
status: 301 | 302 | 303 | 307 | 308 = 302,
|
|
115
|
+
): ResponseBuilder {
|
|
116
|
+
this.#ctx.redirect(route(name, params), status);
|
|
117
|
+
return new ResponseBuilder(this.#ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Redirect to the previous URL (the `Referer`). */
|
|
121
|
+
back(status: 301 | 302 | 303 | 307 | 308 = 302): ResponseBuilder {
|
|
122
|
+
this.#ctx.back(status);
|
|
123
|
+
return new ResponseBuilder(this.#ctx);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Redirect to the URL stored in session under `intended_url` (set by RequireAuth when
|
|
128
|
+
* intercepting an unauthenticated request), then clear it. Falls back to `fallback` when
|
|
129
|
+
* none is stored, or when the stored URL is cross-origin (open-redirect guard).
|
|
130
|
+
*/
|
|
131
|
+
intended(fallback = "/", status: 301 | 302 | 303 | 307 | 308 = 302): ResponseBuilder {
|
|
132
|
+
const session = (
|
|
133
|
+
this.#ctx as unknown as {
|
|
134
|
+
session?: { get<T>(k: string): T | undefined; forget(k: string): void };
|
|
135
|
+
}
|
|
136
|
+
).session;
|
|
137
|
+
const stored = session?.get<string>("intended_url");
|
|
138
|
+
if (stored) session?.forget("intended_url");
|
|
139
|
+
this.#ctx.redirect(safeRedirectPath(stored, this.#ctx.url.origin) ?? fallback, status);
|
|
140
|
+
return new ResponseBuilder(this.#ctx);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return type for controller handler methods.
|
|
146
|
+
*
|
|
147
|
+
* `void` covers the direct helpers (`json()`, `view()`, `ctx.json()`, etc.).
|
|
148
|
+
* `ResponseBuilder` covers fluent redirect helpers (`back()`, `redirect()`, etc.)
|
|
149
|
+
* `MarkdownBuilder` covers `markdown().withLayout(...)` chains.
|
|
150
|
+
* Both builder types implement `PromiseLike<void>` so they are valid in
|
|
151
|
+
* `async (): Promise<void>` handlers.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* async store(ctx: HttpContext): Promise<ControllerResponse> {
|
|
155
|
+
* return back().withErrors({ title: 'Required' });
|
|
156
|
+
* }
|
|
157
|
+
*/
|
|
158
|
+
export type ControllerResponse = void | ResponseBuilder | MarkdownBuilder;
|
|
159
|
+
|
|
160
|
+
// ── MarkdownBuilder ────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Builder returned by `markdown()` that enables optional layout chaining, e.g.
|
|
164
|
+
* `return markdown(content, { title }).withLayout(Layout, { title })`.
|
|
165
|
+
*
|
|
166
|
+
* Calling `.withLayout()` re-renders the markdown inside the provided layout
|
|
167
|
+
* function, overwriting the plain markdown response already set by `markdown()`.
|
|
168
|
+
* Implements `PromiseLike<void>` following the same pattern as `ResponseBuilder`.
|
|
169
|
+
*/
|
|
170
|
+
export class MarkdownBuilder implements PromiseLike<void> {
|
|
171
|
+
readonly #content: string;
|
|
172
|
+
readonly #options: BunMarkdownOptions & { title?: string };
|
|
173
|
+
readonly #status: number;
|
|
174
|
+
|
|
175
|
+
constructor(content: string, options?: BunMarkdownOptions & { title?: string }, status = 200) {
|
|
176
|
+
this.#content = content;
|
|
177
|
+
this.#options = options ?? {};
|
|
178
|
+
this.#status = status;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Re-render the markdown content inside a custom layout function.
|
|
183
|
+
*
|
|
184
|
+
* `layoutFn` receives `{ ...props, content: <rendered-html> }` and must return
|
|
185
|
+
* a complete HTML string. This call REPLACES the plain markdown response
|
|
186
|
+
* that was set when `markdown()` was first called.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* import { Layout } from "./_layout";
|
|
190
|
+
*
|
|
191
|
+
* export function GET() {
|
|
192
|
+
* const title = "Welcome to Zerotal!";
|
|
193
|
+
* return markdown(CONTENT, { title }).withLayout(Layout, { title });
|
|
194
|
+
* }
|
|
195
|
+
*/
|
|
196
|
+
withLayout<P extends { content: string }>(
|
|
197
|
+
layoutFn: (props: P) => string,
|
|
198
|
+
props: Omit<P, "content">,
|
|
199
|
+
): void {
|
|
200
|
+
const { title: _title, ...mdOptions } = this.#options;
|
|
201
|
+
const body = Bun.markdown.html(this.#content, {
|
|
202
|
+
...DEFAULT_MD_OPTIONS,
|
|
203
|
+
...(mdOptions as BunMarkdownOptions),
|
|
204
|
+
});
|
|
205
|
+
const ctx = RequestContext.tryGet();
|
|
206
|
+
if (!ctx) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"[Zerotal] MarkdownBuilder.withLayout() must be called inside an active HTTP request.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
ctx.html(layoutFn({ ...props, content: body } as P), this.#status);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
then<TResult1 = void, TResult2 = never>(
|
|
215
|
+
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
|
|
216
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
217
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
218
|
+
return Promise.resolve<void>(undefined).then(onfulfilled, onrejected);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── redirect() ────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Single entry point for every redirect. Pass a URL for a direct redirect, or call with no
|
|
226
|
+
* arguments to pick the destination fluently via {@link RedirectBuilder}. All forms return a
|
|
227
|
+
* {@link ResponseBuilder} so flash data chains naturally.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* redirect("/login", 302).withError("Please log in to continue.");
|
|
231
|
+
* redirect().back().withErrors({ email: "Invalid email or password." });
|
|
232
|
+
* redirect().intended("/").withInfo("Please log in to continue.");
|
|
233
|
+
* redirect().to("profile", { username: "alice" }).withSuccess("Profile updated.");
|
|
234
|
+
*
|
|
235
|
+
* @throws {Error} when called outside an active HTTP request.
|
|
236
|
+
*/
|
|
237
|
+
export function redirect(url: string, status?: 301 | 302 | 303 | 307 | 308): ResponseBuilder;
|
|
238
|
+
export function redirect(): RedirectBuilder;
|
|
239
|
+
export function redirect(
|
|
240
|
+
url?: string,
|
|
241
|
+
status: 301 | 302 | 303 | 307 | 308 = 302,
|
|
242
|
+
): ResponseBuilder | RedirectBuilder {
|
|
243
|
+
const ctx = _ctx();
|
|
244
|
+
if (url === undefined) return new RedirectBuilder(ctx);
|
|
245
|
+
ctx.redirect(url, status);
|
|
246
|
+
return new ResponseBuilder(ctx);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Redirect to a named route.
|
|
251
|
+
* @param name - The route name to redirect to.
|
|
252
|
+
* @param params - Optional route parameters to interpolate into the route path.
|
|
253
|
+
* @param status - Optional HTTP status code (default: 302).
|
|
254
|
+
* @returns
|
|
255
|
+
*/
|
|
256
|
+
export function redirectTo(
|
|
257
|
+
name: string,
|
|
258
|
+
params: Record<string, string | number> = {},
|
|
259
|
+
status: 301 | 302 | 303 | 307 | 308 = 302,
|
|
260
|
+
): ResponseBuilder {
|
|
261
|
+
return redirect().to(name, params, status);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── json() ───────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Set a JSON response body on the current request.
|
|
268
|
+
*
|
|
269
|
+
* @param data - Any JSON-serializable value.
|
|
270
|
+
* @param status - HTTP status code (default: 200).
|
|
271
|
+
* @throws {Error} when called outside an active HTTP request.
|
|
272
|
+
* @example
|
|
273
|
+
* json({ user: { id: 1, name: 'Alice' } }); // 200
|
|
274
|
+
* json({ message: 'Created' }, 201);
|
|
275
|
+
*/
|
|
276
|
+
export function json(data: unknown, status = 200): void {
|
|
277
|
+
_ctx().json(data, status);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── view() ───────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Render a view as the current response. Accepts either pre-rendered markup, or
|
|
284
|
+
* a view component plus its props — in which case the component receives the
|
|
285
|
+
* active request's `HttpContext` (route params and model bindings live on
|
|
286
|
+
* `ctx.params`) and the `props` you pass as a second argument.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* // component + props — the request HttpContext is passed automatically:
|
|
290
|
+
* view(Welcome, { title: "Welcome to Zerotal" });
|
|
291
|
+
*
|
|
292
|
+
* // pre-rendered markup:
|
|
293
|
+
* view(<Welcome title="Hi" />);
|
|
294
|
+
*/
|
|
295
|
+
export function view(markup: string | { toString(): string }, status?: number): void;
|
|
296
|
+
export function view<P extends Record<string, unknown> = Record<string, never>>(
|
|
297
|
+
component: (ctx: HttpContext, props: P) => ViewMarkup | Promise<ViewMarkup>,
|
|
298
|
+
props?: P,
|
|
299
|
+
status?: number,
|
|
300
|
+
): void | Promise<void>;
|
|
301
|
+
export function view(
|
|
302
|
+
markupOrComponent: ViewMarkup | AnyViewComponent,
|
|
303
|
+
propsOrStatus?: Record<string, unknown> | number,
|
|
304
|
+
status = 200,
|
|
305
|
+
): void | Promise<void> {
|
|
306
|
+
// Delegate to HttpContext.view, which carries the same dual overload.
|
|
307
|
+
return (_ctx().view as (...args: unknown[]) => void | Promise<void>)(
|
|
308
|
+
markupOrComponent,
|
|
309
|
+
propsOrStatus,
|
|
310
|
+
status,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── html() ───────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** Set a raw HTML response body on the current request. */
|
|
317
|
+
export function html(markup: string | { toString(): string }, status = 200): void {
|
|
318
|
+
_ctx().html(markup, status);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── markdown() ───────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Render markdown content and set it as the current response.
|
|
325
|
+
*
|
|
326
|
+
* Returns a `MarkdownBuilder` that can optionally chain `.withLayout(layoutFn, props)`
|
|
327
|
+
* to re-render the content inside a custom layout. When `.withLayout()` is called it
|
|
328
|
+
* replaces the plain markdown response set here with the layout-wrapped version.
|
|
329
|
+
*
|
|
330
|
+
* @example — plain (backward-compatible)
|
|
331
|
+
* markdown(content);
|
|
332
|
+
*
|
|
333
|
+
* @example — with custom layout
|
|
334
|
+
* return markdown(content, { title }).withLayout(Layout, { title });
|
|
335
|
+
*/
|
|
336
|
+
export function markdown(
|
|
337
|
+
content: string,
|
|
338
|
+
options?: BunMarkdownOptions & { title?: string },
|
|
339
|
+
status = 200,
|
|
340
|
+
): MarkdownBuilder {
|
|
341
|
+
// Render immediately so plain usage (no .withLayout) works without returning.
|
|
342
|
+
_ctx().markdown(content, options, status);
|
|
343
|
+
return new MarkdownBuilder(content, options, status);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── file() ───────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Stream a file from disk as the response. Defaults to `attachment` disposition
|
|
350
|
+
* (a browser download) with the file's basename; override either via `options`.
|
|
351
|
+
*
|
|
352
|
+
* @param path - Absolute or working-directory-relative path to the file.
|
|
353
|
+
* @param options - Optional `filename` (download name) and `disposition`
|
|
354
|
+
* (`"attachment"` to download, `"inline"` to display in the browser).
|
|
355
|
+
* @throws {NotFoundError} If no file exists at `path`.
|
|
356
|
+
* @example
|
|
357
|
+
* await file('storage/invoices/2026-07.pdf'); // download as "2026-07.pdf"
|
|
358
|
+
* await file('storage/report.pdf', { filename: 'Q3-Report.pdf' }); // download, renamed
|
|
359
|
+
* await file('storage/preview.pdf', { disposition: 'inline' }); // view in-browser
|
|
360
|
+
*/
|
|
361
|
+
export async function file(
|
|
362
|
+
path: string,
|
|
363
|
+
options?: { filename?: string; disposition?: "attachment" | "inline" },
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
const ctx = _ctx();
|
|
366
|
+
const bunFile = Bun.file(path);
|
|
367
|
+
|
|
368
|
+
if (!(await bunFile.exists())) throw new NotFoundError(`File not found: ${path}`);
|
|
369
|
+
|
|
370
|
+
const name = options?.filename ?? path.replace(/\\/g, "/").split("/").pop() ?? "download";
|
|
371
|
+
const disposition = options?.disposition ?? "attachment";
|
|
372
|
+
|
|
373
|
+
// Bun's FileRef is a Blob-compatible body but its TS type predates the W3C Blob interface — cast needed.
|
|
374
|
+
const blob = bunFile as unknown as Blob;
|
|
375
|
+
|
|
376
|
+
ctx.response = new Response(blob, {
|
|
377
|
+
headers: {
|
|
378
|
+
"Content-Disposition": `${disposition}; filename="${name}"`,
|
|
379
|
+
"Content-Type": blob.type || "application/octet-stream",
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── abort() ──────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Throw an HTTP error to end the request. Accepts a message (defaults to 500),
|
|
388
|
+
* an explicit status plus message, or a `ZerotalError` subclass to instantiate.
|
|
389
|
+
*
|
|
390
|
+
* @throws {HttpError} (or the given `ZerotalError` subclass) — always; the return
|
|
391
|
+
* type is `never`.
|
|
392
|
+
* @example
|
|
393
|
+
* abort('Something went wrong'); // 500
|
|
394
|
+
* abort(403, 'Forbidden'); // explicit status
|
|
395
|
+
* abort(NotFoundError); // throw a specific error class
|
|
396
|
+
*/
|
|
397
|
+
export function abort(message: string): never;
|
|
398
|
+
export function abort(status: number, message: string): never;
|
|
399
|
+
export function abort(ErrorClass: new () => ZerotalError): never;
|
|
400
|
+
export function abort(
|
|
401
|
+
statusOrMessageOrError: string | number | (new () => ZerotalError),
|
|
402
|
+
message?: string,
|
|
403
|
+
): never {
|
|
404
|
+
if (typeof statusOrMessageOrError === "function") {
|
|
405
|
+
throw new (statusOrMessageOrError as new () => ZerotalError)();
|
|
406
|
+
}
|
|
407
|
+
if (typeof statusOrMessageOrError === "string") {
|
|
408
|
+
throw new HttpError(statusOrMessageOrError, 500);
|
|
409
|
+
}
|
|
410
|
+
throw new HttpError(message ?? "Aborted", statusOrMessageOrError);
|
|
411
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Str` string-manipulation utility — a collection of case converters,
|
|
3
|
+
* substring helpers, and a `macro()` hook for registering project-specific
|
|
4
|
+
* string methods at runtime.
|
|
5
|
+
*/
|
|
6
|
+
export const Str = {
|
|
7
|
+
/** 'hello-world' | 'hello_world' | 'hello world' → 'helloWorld' */
|
|
8
|
+
camelCase(value: string): string {
|
|
9
|
+
return value.toLowerCase().replace(/[-_\s]+([a-z])/g, (_, char: string) => char.toUpperCase());
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
/** 'helloWorld' → 'hello_world' */
|
|
13
|
+
snakeCase(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
16
|
+
.replace(/([a-z\d])([A-Z])/g, "$1_$2")
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
/** 'helloWorld' | 'hello world' → 'hello-world' */
|
|
21
|
+
slugify(value: string): string {
|
|
22
|
+
return (
|
|
23
|
+
value
|
|
24
|
+
.replace(/([A-Z])/g, " $1")
|
|
25
|
+
.normalize("NFD")
|
|
26
|
+
// Strip combining diacritical marks (U+0300–U+036F) so accented letters slug cleanly.
|
|
27
|
+
.replace(/[̀-ͯ]/g, "")
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
31
|
+
.replace(/[\s_]+/g, "-")
|
|
32
|
+
.replace(/-+/g, "-")
|
|
33
|
+
.replace(/^-|-$/g, "")
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** 'hello world' → 'Hello World' */
|
|
38
|
+
titleCase(value: string): string {
|
|
39
|
+
return value.replace(/\b[a-z]/g, (char) => char.toUpperCase());
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** 'hello-world' | 'helloWorld' → 'HelloWorld' */
|
|
43
|
+
pascalCase(value: string): string {
|
|
44
|
+
// Collapse separators without lowercasing so existing camelCase humps survive.
|
|
45
|
+
const collapsed = value.replace(/[-_\s]+([a-zA-Z])/g, (_, char: string) => char.toUpperCase());
|
|
46
|
+
return collapsed.charAt(0).toUpperCase() + collapsed.slice(1);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Upper-case the first character only. 'hello world' → 'Hello world' */
|
|
50
|
+
capitalize(value: string): string {
|
|
51
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Lower-case the first character only. 'BlogPost' → 'blogPost' */
|
|
55
|
+
lcfirst(value: string): string {
|
|
56
|
+
return value.charAt(0).toLowerCase() + value.slice(1);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Truncate to maxLength, appending suffix if truncated.
|
|
61
|
+
* truncate('hello world', 7) → 'hell...'
|
|
62
|
+
*/
|
|
63
|
+
truncate(value: string, maxLength: number, suffix = "..."): string {
|
|
64
|
+
if (value.length <= maxLength) return value;
|
|
65
|
+
return value.slice(0, Math.max(0, maxLength - suffix.length)) + suffix;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Returns true if the value contains only alphanumeric characters (non-empty). */
|
|
69
|
+
isAlphanumeric(value: string): boolean {
|
|
70
|
+
return /^[a-zA-Z0-9]+$/.test(value);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/** Pad left: padLeft('42', 5, '0') → '00042' */
|
|
74
|
+
padLeft(value: string, length: number, char = " "): string {
|
|
75
|
+
return value.padStart(length, char);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/** 'helloWorld' | 'hello world' → 'hello-world' */
|
|
79
|
+
kebab(value: string): string {
|
|
80
|
+
return value
|
|
81
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
82
|
+
.replace(/([a-z\d])([A-Z])/g, "$1-$2")
|
|
83
|
+
.replace(/[\s_]+/g, "-")
|
|
84
|
+
.toLowerCase();
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/** Collapse all consecutive whitespace (including newlines) into a single space and trim. */
|
|
88
|
+
squish(value: string): string {
|
|
89
|
+
return value.replace(/\s+/g, " ").trim();
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ensure the string ends with `cap`.
|
|
94
|
+
* If it already ends with `cap`, returns as-is.
|
|
95
|
+
* finish('https://example.com', '/') → 'https://example.com/'
|
|
96
|
+
*/
|
|
97
|
+
finish(value: string, cap: string): string {
|
|
98
|
+
return value.endsWith(cap) ? value : value + cap;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure the string starts with `prefix`.
|
|
103
|
+
* If it already starts with `prefix`, returns as-is.
|
|
104
|
+
* start('path/to/file', '/') → '/path/to/file'
|
|
105
|
+
*/
|
|
106
|
+
start(value: string, prefix: string): string {
|
|
107
|
+
return value.startsWith(prefix) ? value : prefix + value;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** Return everything after the first occurrence of `needle`, or the full string if not found. */
|
|
111
|
+
after(value: string, needle: string): string {
|
|
112
|
+
const index = value.indexOf(needle);
|
|
113
|
+
return index === -1 ? value : value.slice(index + needle.length);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/** Return everything before the first occurrence of `needle`, or the full string if not found. */
|
|
117
|
+
before(value: string, needle: string): string {
|
|
118
|
+
const index = value.indexOf(needle);
|
|
119
|
+
return index === -1 ? value : value.slice(0, index);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** Return everything after the LAST occurrence of `needle`. */
|
|
123
|
+
afterLast(value: string, needle: string): string {
|
|
124
|
+
const index = value.lastIndexOf(needle);
|
|
125
|
+
return index === -1 ? value : value.slice(index + needle.length);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/** Return everything before the LAST occurrence of `needle`. */
|
|
129
|
+
beforeLast(value: string, needle: string): string {
|
|
130
|
+
const index = value.lastIndexOf(needle);
|
|
131
|
+
return index === -1 ? value : value.slice(0, index);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/** Return true if `value` contains `substring` (case-sensitive). */
|
|
135
|
+
contains(value: string, substring: string): boolean {
|
|
136
|
+
return value.includes(substring);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate a cryptographically random alphanumeric string of the given length.
|
|
141
|
+
* Uses `crypto.getRandomValues` — no external dependencies.
|
|
142
|
+
*/
|
|
143
|
+
random(length = 32): string {
|
|
144
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
145
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
146
|
+
return Array.from(bytes, (byte) => chars[byte % chars.length]).join("");
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Replace the first occurrence of `search` with `replace`.
|
|
151
|
+
* replaceFirst('the cat sat on the mat', 'the', 'a') → 'a cat sat on the mat'
|
|
152
|
+
*/
|
|
153
|
+
replaceFirst(value: string, search: string, replace: string): string {
|
|
154
|
+
const index = value.indexOf(search);
|
|
155
|
+
return index === -1
|
|
156
|
+
? value
|
|
157
|
+
: value.slice(0, index) + replace + value.slice(index + search.length);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Replace the last occurrence of `search` with `replace`.
|
|
162
|
+
*/
|
|
163
|
+
replaceLast(value: string, search: string, replace: string): string {
|
|
164
|
+
const index = value.lastIndexOf(search);
|
|
165
|
+
return index === -1
|
|
166
|
+
? value
|
|
167
|
+
: value.slice(0, index) + replace + value.slice(index + search.length);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/** Reverse a string: reverse('hello') → 'olleh' */
|
|
171
|
+
reverse(value: string): string {
|
|
172
|
+
return [...value].reverse().join("");
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Limit the string to `maxWords` words, appending `suffix` if truncated.
|
|
177
|
+
* words('One two three four', 2) → 'One two...'
|
|
178
|
+
*/
|
|
179
|
+
words(value: string, maxWords: number, suffix = "..."): string {
|
|
180
|
+
const parts = value.trim().split(/\s+/);
|
|
181
|
+
if (parts.length <= maxWords) return value;
|
|
182
|
+
return parts.slice(0, maxWords).join(" ") + suffix;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Register a macro (custom method) on the Str object.
|
|
187
|
+
*
|
|
188
|
+
* The function is assigned directly to `Str`, so you call it as `Str.myMacro(...)`.
|
|
189
|
+
* Add a module augmentation to your project for full TypeScript type safety:
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* // In AppServiceProvider.onBooted():
|
|
193
|
+
* Str.macro('readableSize', (bytes: number) =>
|
|
194
|
+
* bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`
|
|
195
|
+
* );
|
|
196
|
+
*
|
|
197
|
+
* // In types.d.ts (for type safety):
|
|
198
|
+
* declare module '@zerotal/core' {
|
|
199
|
+
* interface StrMacros { readableSize(bytes: number): string; }
|
|
200
|
+
* }
|
|
201
|
+
*
|
|
202
|
+
* // In a controller:
|
|
203
|
+
* (Str as typeof Str & StrMacros).readableSize(2048); // '2.0 KB'
|
|
204
|
+
*/
|
|
205
|
+
macro(name: string, fn: (...args: unknown[]) => unknown): void {
|
|
206
|
+
(Str as Record<string, unknown>)[name] = fn;
|
|
207
|
+
},
|
|
208
|
+
};
|