@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,490 @@
1
+ /**
2
+ * Fluent, immutable URI builder for composing and manipulating URLs as objects.
3
+ *
4
+ * Every mutator returns a NEW `Uri`, so instances are safe to share. Works with both absolute
5
+ * URLs (`https://example.com/p?x=1`) and relative ones (`/dashboard?tab=2`).
6
+ *
7
+ * @example
8
+ * uri("https://example.com/users?page=1")
9
+ * .withQuery({ page: 2, sort: "name" })
10
+ * .withFragment("top")
11
+ * .value(); // "https://example.com/users?page=2&sort=name#top"
12
+ *
13
+ * uri("/login").redirect(); // → ResponseBuilder (302)
14
+ * uri().intended("/dashboard").redirect(); // → redirect to the stored intended URL
15
+ */
16
+ import { RequestContext } from "../context/RequestContext.ts";
17
+ import { route } from "../router/Router.ts";
18
+ import { safeRedirectPath } from "../pipeline/HttpContext.ts";
19
+ import { ResponseBuilder } from "../helpers/response.ts";
20
+ import { config } from "../helpers/config.ts";
21
+
22
+ type QueryValue = string | string[];
23
+
24
+ /**
25
+ * Accepted shape for query parameters passed to the `Uri` query mutators
26
+ * ({@link Uri.withQuery}, {@link Uri.replaceQuery}, …) and the `url()` service.
27
+ * Values are coerced to strings; arrays become repeated (multi-value) parameters.
28
+ */
29
+ export type QueryInput = Record<string, string | number | boolean | string[]>;
30
+
31
+ interface UriParts {
32
+ scheme?: string;
33
+ user?: string;
34
+ password?: string;
35
+ host?: string;
36
+ port?: number;
37
+ path: string;
38
+ query: Record<string, QueryValue>;
39
+ fragment?: string;
40
+ }
41
+
42
+ /** Read-only view over a URI's query string, returned by `Uri.query()`. */
43
+ export interface UriQueryString {
44
+ /** All query parameters as a plain object. */
45
+ all(): Record<string, QueryValue>;
46
+ /** The first value for a key (or undefined). */
47
+ get(key: string): string | undefined;
48
+ /** Every value for a key as an array. */
49
+ getAll(key: string): string[];
50
+ /** Whether a key is present. */
51
+ has(key: string): boolean;
52
+ /** The encoded query string (without the leading `?`). */
53
+ toString(): string;
54
+ }
55
+
56
+ const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
57
+
58
+ function parseQueryString(search: string): Record<string, QueryValue> {
59
+ const out: Record<string, QueryValue> = {};
60
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
61
+ for (const key of new Set(params.keys())) {
62
+ const values = params.getAll(key);
63
+ out[key] = values.length > 1 ? values : (values[0] ?? "");
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function buildQueryString(query: Record<string, QueryValue>): string {
69
+ const params = new URLSearchParams();
70
+ for (const [key, value] of Object.entries(query)) {
71
+ if (Array.isArray(value)) for (const v of value) params.append(key, v);
72
+ else params.append(key, value);
73
+ }
74
+ return params.toString();
75
+ }
76
+
77
+ function normaliseQueryInput(input: QueryInput): Record<string, QueryValue> {
78
+ const out: Record<string, QueryValue> = {};
79
+ for (const [key, value] of Object.entries(input)) {
80
+ out[key] = Array.isArray(value) ? value.map(String) : String(value);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * The configured application base URL (`config('app.url')`, then `APP_URL`), without a trailing
87
+ * slash. Empty string when neither is set. @internal — shared with the `url()` helper.
88
+ */
89
+ export function appBaseUrl(): string {
90
+ let base: string | undefined;
91
+ try {
92
+ base = config("app.url" as never) as string | undefined;
93
+ } catch {
94
+ base = undefined;
95
+ }
96
+ base ??= Bun.env.APP_URL;
97
+ return (base ?? "").replace(/\/+$/, "");
98
+ }
99
+
100
+ function parse(value: string): UriParts {
101
+ if (SCHEME_RE.test(value)) {
102
+ const u = new URL(value);
103
+ return {
104
+ scheme: u.protocol.replace(/:$/, ""),
105
+ user: u.username ? decodeURIComponent(u.username) : undefined,
106
+ password: u.password ? decodeURIComponent(u.password) : undefined,
107
+ host: u.hostname,
108
+ port: u.port ? Number(u.port) : undefined,
109
+ path: u.pathname,
110
+ query: parseQueryString(u.search),
111
+ fragment: u.hash ? u.hash.slice(1) : undefined,
112
+ } as UriParts;
113
+ }
114
+
115
+ // Relative URI — split off #fragment, then ?query, leaving the path.
116
+ let rest = value;
117
+ let fragment: string | undefined;
118
+ const hashIdx = rest.indexOf("#");
119
+ if (hashIdx >= 0) {
120
+ fragment = rest.slice(hashIdx + 1);
121
+ rest = rest.slice(0, hashIdx);
122
+ }
123
+ let query: Record<string, QueryValue> = {};
124
+ const qIdx = rest.indexOf("?");
125
+ if (qIdx >= 0) {
126
+ query = parseQueryString(rest.slice(qIdx));
127
+ rest = rest.slice(0, qIdx);
128
+ }
129
+ return { path: rest, query, fragment } as UriParts;
130
+ }
131
+
132
+ /**
133
+ * Fluent, immutable URI value object. Parse an absolute or relative URL, read its
134
+ * parts, and rewrite scheme/host/path/query/fragment — every mutator returns a
135
+ * NEW `Uri`, so instances are safe to share and reuse.
136
+ *
137
+ * Construct via the {@link uri} helper or the static factories ({@link Uri.of},
138
+ * {@link Uri.to}, {@link Uri.current}, {@link Uri.route}). For app-aware URL
139
+ * generation and signing, see the higher-level {@link Url} / {@link url} service.
140
+ *
141
+ * @example
142
+ * Uri.of("https://example.com/users?page=1")
143
+ * .withQuery({ page: 2, sort: "name" })
144
+ * .withFragment("top")
145
+ * .value(); // "https://example.com/users?page=2&sort=name#top"
146
+ *
147
+ * @throws {Error} from {@link Uri.url} (and, transitively, {@link Uri.current})
148
+ * when called on a relative URI that has no host.
149
+ */
150
+ export class Uri {
151
+ readonly #parts: UriParts;
152
+
153
+ private constructor(parts: UriParts) {
154
+ this.#parts = parts;
155
+ }
156
+
157
+ // ── Factories ──────────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Build a Uri from a string (or clone another Uri).
161
+ * @category Construction
162
+ */
163
+ static of(value: string | Uri): Uri {
164
+ if (value instanceof Uri) return new Uri({ ...value.#parts, query: { ...value.#parts.query } });
165
+ return new Uri(parse(value));
166
+ }
167
+
168
+ /**
169
+ * Build an absolute Uri for `path`, relative to the app URL (`config('app.url')`).
170
+ * @category Construction
171
+ */
172
+ static to(path: string): Uri {
173
+ const base = appBaseUrl();
174
+ const rel = path.startsWith("/") || SCHEME_RE.test(path) ? path : `/${path}`;
175
+ return Uri.of(base && !SCHEME_RE.test(rel) ? `${base}${rel}` : rel);
176
+ }
177
+
178
+ /**
179
+ * The current request's full URL.
180
+ * @category Construction
181
+ * @throws {Error} when called outside an active HTTP request.
182
+ */
183
+ static current(): Uri {
184
+ return Uri.of(RequestContext.get().fullUrl());
185
+ }
186
+
187
+ /**
188
+ * Build a Uri from a named route + params.
189
+ * @category Construction
190
+ */
191
+ static route(name: string, params: Record<string, string | number> = {}): Uri {
192
+ return Uri.of(route(name, params));
193
+ }
194
+
195
+ #clone(patch: Partial<UriParts>): Uri {
196
+ return new Uri({ ...this.#parts, ...patch, query: patch.query ?? { ...this.#parts.query } });
197
+ }
198
+
199
+ // ── Inspectors ─────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * The scheme (e.g. `"https"`), or undefined for a relative URI.
203
+ * @category Components
204
+ */
205
+ scheme(): string | undefined {
206
+ return this.#parts.scheme;
207
+ }
208
+ /**
209
+ * The decoded userinfo username, or undefined.
210
+ * @category Components
211
+ */
212
+ user(): string | undefined {
213
+ return this.#parts.user;
214
+ }
215
+ /**
216
+ * The decoded userinfo password, or undefined.
217
+ * @category Components
218
+ */
219
+ password(): string | undefined {
220
+ return this.#parts.password;
221
+ }
222
+ /**
223
+ * The host (without port), or undefined for a relative URI.
224
+ * @category Components
225
+ */
226
+ host(): string | undefined {
227
+ return this.#parts.host;
228
+ }
229
+ /**
230
+ * The port number, or undefined when none is set.
231
+ * @category Components
232
+ */
233
+ port(): number | undefined {
234
+ return this.#parts.port;
235
+ }
236
+ /**
237
+ * The path component (may be empty).
238
+ * @category Components
239
+ */
240
+ path(): string {
241
+ return this.#parts.path;
242
+ }
243
+ /**
244
+ * The fragment (without the leading `#`), or undefined.
245
+ * @category Components
246
+ */
247
+ fragment(): string | undefined {
248
+ return this.#parts.fragment;
249
+ }
250
+ /**
251
+ * The assembled absolute URL string.
252
+ * @category Output
253
+ * @throws {Error} when the URI is relative (has no host).
254
+ */
255
+ url(): string {
256
+ const p = this.#parts;
257
+ if (!p.host) throw new Error("Cannot call Uri.url() on a relative URI");
258
+ let out = `${p.scheme ?? "http"}://`;
259
+ if (p.user) {
260
+ out += encodeURIComponent(p.user);
261
+ if (p.password) out += `:${encodeURIComponent(p.password)}`;
262
+ out += "@";
263
+ }
264
+ out += p.host;
265
+ if (p.port) out += `:${p.port}`;
266
+ out += p.path || "/";
267
+ const qs = buildQueryString(p.query);
268
+ if (qs) out += `?${qs}`;
269
+ if (p.fragment) out += `#${p.fragment}`;
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * A read-only view over the query string.
275
+ * @category Query
276
+ */
277
+ query(): UriQueryString {
278
+ const q = this.#parts.query;
279
+ return {
280
+ all: () => ({ ...q }),
281
+ get: (key) => {
282
+ const v = q[key];
283
+ return Array.isArray(v) ? v[0] : v;
284
+ },
285
+ getAll: (key) => {
286
+ const v = q[key];
287
+ return v === undefined ? [] : Array.isArray(v) ? [...v] : [v];
288
+ },
289
+ has: (key) => key in q,
290
+ toString: () => buildQueryString(q),
291
+ };
292
+ }
293
+
294
+ // ── Mutators (return a new Uri) ──────────────────────────────────────────────
295
+
296
+ /**
297
+ * Return a copy with the scheme replaced (trailing `://` is stripped).
298
+ * @category Components
299
+ */
300
+ withScheme(scheme: string): Uri {
301
+ return this.#clone({ scheme: scheme.replace(/:?\/?\/?$/, "") });
302
+ }
303
+ /**
304
+ * Return a copy with the userinfo username (and optional password) set.
305
+ * @category Components
306
+ */
307
+ withUser(user: string | undefined, password?: string): Uri {
308
+ return this.#clone({ user, ...(password !== undefined ? { password } : {}) } as UriParts);
309
+ }
310
+ /**
311
+ * Return a copy with the userinfo password set.
312
+ * @category Components
313
+ */
314
+ withPassword(password: string | undefined): Uri {
315
+ return this.#clone({ password } as UriParts);
316
+ }
317
+ /**
318
+ * Return a copy with the host replaced.
319
+ * @category Components
320
+ */
321
+ withHost(host: string): Uri {
322
+ return this.#clone({ host });
323
+ }
324
+ /**
325
+ * Return a copy with the port set (pass undefined to remove it).
326
+ * @category Components
327
+ */
328
+ withPort(port: number | undefined): Uri {
329
+ return this.#clone({ port } as UriParts);
330
+ }
331
+ /**
332
+ * Return a copy with the path replaced; a non-empty path is given a leading slash.
333
+ * @category Components
334
+ */
335
+ withPath(path: string): Uri {
336
+ return this.#clone({ path: path.startsWith("/") || path === "" ? path : `/${path}` });
337
+ }
338
+ /**
339
+ * Return a copy with the fragment set (pass undefined to remove it).
340
+ * @category Components
341
+ */
342
+ withFragment(fragment: string | undefined): Uri {
343
+ return this.#clone({ fragment } as UriParts);
344
+ }
345
+
346
+ /**
347
+ * Merge the given parameters into the query string (overwriting existing keys).
348
+ * @category Query
349
+ */
350
+ withQuery(query: QueryInput): Uri {
351
+ return this.#clone({ query: { ...this.#parts.query, ...normaliseQueryInput(query) } });
352
+ }
353
+
354
+ /**
355
+ * Add parameters only when their key is not already present.
356
+ * @category Query
357
+ */
358
+ withQueryIfMissing(query: QueryInput): Uri {
359
+ const merged = { ...this.#parts.query };
360
+ for (const [key, value] of Object.entries(normaliseQueryInput(query))) {
361
+ if (!(key in merged)) merged[key] = value;
362
+ }
363
+ return this.#clone({ query: merged });
364
+ }
365
+
366
+ /**
367
+ * Replace the entire query string with the given parameters.
368
+ * @category Query
369
+ */
370
+ replaceQuery(query: QueryInput): Uri {
371
+ return this.#clone({ query: normaliseQueryInput(query) });
372
+ }
373
+
374
+ /**
375
+ * Append a value to a key, turning it into a multi-value parameter.
376
+ * @category Query
377
+ */
378
+ pushOntoQuery(key: string, value: string | number): Uri {
379
+ const current = this.#parts.query[key];
380
+ const next: QueryValue =
381
+ current === undefined
382
+ ? String(value)
383
+ : Array.isArray(current)
384
+ ? [...current, String(value)]
385
+ : [current, String(value)];
386
+ return this.#clone({ query: { ...this.#parts.query, [key]: next } });
387
+ }
388
+
389
+ /**
390
+ * Remove one or more keys from the query string.
391
+ * @category Query
392
+ */
393
+ withoutQuery(keys: string[]): Uri {
394
+ const next = { ...this.#parts.query };
395
+ for (const key of keys) delete next[key];
396
+ return this.#clone({ query: next });
397
+ }
398
+
399
+ // ── Zerotal extras ────────────────────────────────────────────────────────────
400
+
401
+ /**
402
+ * Resolve the URL the user was heading to before authentication (the session's
403
+ * `intended_url`, set by RequireAuth), falling back to `fallback`. Cross-origin stored URLs
404
+ * are rejected (open-redirect guard). Returns a fresh Uri pointing at the resolved target.
405
+ *
406
+ * @example
407
+ * return uri().intended("/dashboard").redirect();
408
+ *
409
+ * @category Construction
410
+ */
411
+ intended(fallback = "/"): Uri {
412
+ const ctx = RequestContext.tryGet();
413
+ const session = (
414
+ ctx as unknown as { session?: { get<T>(k: string): T | undefined; forget(k: string): void } }
415
+ )?.session;
416
+ const stored = session?.get<string>("intended_url");
417
+ if (stored) session?.forget("intended_url");
418
+ const origin = ctx?.url.origin ?? "";
419
+ return Uri.of(safeRedirectPath(stored, origin) ?? fallback);
420
+ }
421
+
422
+ /**
423
+ * Issue a redirect to this URI, returning a {@link ResponseBuilder} for flash chaining.
424
+ * @category Output
425
+ * @throws {Error} when called outside an active HTTP request.
426
+ */
427
+ redirect(status: 301 | 302 | 303 | 307 | 308 = 302): ResponseBuilder {
428
+ const ctx = RequestContext.get();
429
+ ctx.redirect(this.value(), status);
430
+ return new ResponseBuilder(ctx);
431
+ }
432
+
433
+ // ── Output ────────────────────────────────────────────────────────────────────
434
+
435
+ /**
436
+ * The assembled URI string. Unlike {@link Uri.url}, this works for both
437
+ * absolute and relative URIs.
438
+ * @category Output
439
+ */
440
+ value(): string {
441
+ const p = this.#parts;
442
+ let out = "";
443
+ if (p.host) {
444
+ out += `${p.scheme ?? "http"}://`;
445
+ if (p.user) {
446
+ out += encodeURIComponent(p.user);
447
+ if (p.password) out += `:${encodeURIComponent(p.password)}`;
448
+ out += "@";
449
+ }
450
+ out += p.host;
451
+ if (p.port) out += `:${p.port}`;
452
+ out += p.path || "/";
453
+ } else {
454
+ out += p.path;
455
+ }
456
+ const qs = buildQueryString(p.query);
457
+ if (qs) out += `?${qs}`;
458
+ if (p.fragment) out += `#${p.fragment}`;
459
+ return out;
460
+ }
461
+
462
+ /**
463
+ * Alias for {@link Uri.value} — enables string coercion and template literals.
464
+ * @category Output
465
+ */
466
+ toString(): string {
467
+ return this.value();
468
+ }
469
+
470
+ /**
471
+ * Serialize to the URI string when passed to `JSON.stringify`.
472
+ * @category Output
473
+ */
474
+ toJSON(): string {
475
+ return this.value();
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Fluent URI helper. Pass a URL string to build from it, or call with no arguments to start
481
+ * from the current request URL.
482
+ *
483
+ * @example
484
+ * uri("https://example.com/p?x=1").withQuery({ x: 2 }).value();
485
+ * uri().withoutQuery(["page"]).value(); // current URL, page param dropped
486
+ * uri().intended("/dashboard").redirect(); // redirect to the stored intended URL
487
+ */
488
+ export function uri(value?: string | Uri): Uri {
489
+ return value === undefined ? Uri.current() : Uri.of(value);
490
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `@zerotal/core/http` module — everything for talking HTTP from the
3
+ * server side and shaping HTTP responses. It groups five concerns:
4
+ *
5
+ * - **Outbound requests** — the {@link Http} facade and its fluent
6
+ * {@link PendingRequest} builder for server-to-server calls, with headers,
7
+ * auth, timeouts, retries, and a `fake()` test harness.
8
+ * - **URL building & signing** — the immutable {@link Uri} value object
9
+ * ({@link uri}) and the app-aware {@link Url} service ({@link url}) for
10
+ * fully-qualified, named-route, and HMAC-signed links.
11
+ * - **Uploads** — {@link UploadedFile} for validating and storing multipart
12
+ * form files.
13
+ * - **API resources** — {@link Resource} and {@link ResourceCollection} for
14
+ * transforming models into JSON response envelopes.
15
+ * - **Content negotiation** — {@link negotiate} / {@link detectChannel} for
16
+ * branching a handler by web / JSON / CLI client.
17
+ *
18
+ * @example
19
+ * import { Http, uri } from '@zerotal/core/http';
20
+ *
21
+ * const target = uri('https://api.example.com/users').withQuery({ page: 2 }).value();
22
+ * const users = await Http.withToken(token).timeout(5000).get(target).json();
23
+ *
24
+ * @packageDocumentation
25
+ */
26
+ export { Uri, uri } from "./Uri.ts";
27
+ export type { UriQueryString, QueryInput } from "./Uri.ts";
28
+ export { url, Url, UrlKeyMissingError } from "./url.ts";
29
+ export type { UrlGenerator } from "./url.ts";
30
+ export { Http } from "./Http.ts";
31
+ export { UploadedFile } from "./UploadedFile.ts";
32
+ export type { StorageDisk, FileValidationOptions } from "./UploadedFile.ts";
33
+ export { HttpClientResponse, HttpClientError, PendingRequest } from "./HttpClient.ts";
34
+ export type { FakeStub } from "./HttpClient.ts";
35
+ export { Resource, ResourceCollection } from "./Resource.ts";
36
+ export type { PaginatedData } from "./Resource.ts";
37
+ export { negotiate } from "./negotiate.ts";
38
+ export type {
39
+ Channel,
40
+ AnsiColor,
41
+ WebContext,
42
+ ApiContext,
43
+ CliContext,
44
+ NegotiateMap,
45
+ } from "./negotiate.ts";
46
+ export { isAllowedOrigin } from "./originGuard.ts";