@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,171 @@
1
+ /**
2
+ * API Resource transformer — converts models to JSON response shapes.
3
+ *
4
+ * Extend this class, implement toArray(), and use it in controllers to
5
+ * separate presentation from persistence. Wraps data in a `{ data: ... }`
6
+ * envelope by default (toggle with `Resource.withoutWrapping()`).
7
+ *
8
+ * @example
9
+ * export class UserResource extends Resource<User> {
10
+ * toArray(): Record<string, unknown> {
11
+ * return {
12
+ * id: this.resource.id,
13
+ * name: this.resource.name,
14
+ * email: this.resource.email,
15
+ * };
16
+ * }
17
+ * }
18
+ *
19
+ * // In a controller:
20
+ * ctx.json(new UserResource(user).toJson()); // { data: { id, name, email } }
21
+ * ctx.json(UserResource.collection(UserResource, users)); // { data: [...] }
22
+ * ctx.response = new UserResource(user).toResponse(); // 200 application/json
23
+ */
24
+
25
+ /**
26
+ * Minimal shape required to build a ResourceCollection.
27
+ * Structurally compatible with PaginateResult<T> from @zerotal/orm so no
28
+ * cross-package import is needed.
29
+ */
30
+ export interface PaginatedData<T> {
31
+ data: T[];
32
+ total: number;
33
+ page: number;
34
+ perPage: number;
35
+ lastPage: number;
36
+ nextPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
37
+ previousPageUrl(baseUrl?: string, query?: Record<string, string>): string | null;
38
+ }
39
+
40
+ // Process-level toggle for the `{ data: ... }` envelope. Intentionally
41
+ // module-global: wrapping is an app-wide presentation choice set once at boot,
42
+ // not per-request state.
43
+ let _wrap = true;
44
+
45
+ export abstract class Resource<T> {
46
+ constructor(protected readonly resource: T) {}
47
+
48
+ /** Override this to define the serialized representation. */
49
+ abstract toArray(): Record<string, unknown>;
50
+
51
+ /**
52
+ * Return the resource as a plain object.
53
+ * Includes the `{ data: ... }` wrapper unless `withoutWrapping()` was called.
54
+ */
55
+ toJson(): Record<string, unknown> {
56
+ const payload = this.toArray();
57
+ return _wrap ? { data: payload } : payload;
58
+ }
59
+
60
+ /**
61
+ * Return a `Response` with the JSON-serialized resource and the given status.
62
+ *
63
+ * @example
64
+ * ctx.response = new UserResource(user).toResponse(201);
65
+ */
66
+ toResponse(status = 200): Response {
67
+ return Response.json(this.toJson(), { status });
68
+ }
69
+
70
+ // ── Static helpers ────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Serialize a collection of models, wrapping the array in `{ data: [...] }`.
74
+ *
75
+ * @example
76
+ * ctx.json(UserResource.collection(UserResource, users));
77
+ */
78
+ static collection<T, R extends Resource<T>>(
79
+ ResourceClass: new (item: T) => R,
80
+ items: T[],
81
+ meta?: Record<string, unknown>,
82
+ ): Record<string, unknown> | Record<string, unknown>[] {
83
+ const data = items.map((item) => new ResourceClass(item).toArray());
84
+ // Flat mode returns the bare array; a meta object always needs an envelope.
85
+ if (!_wrap && !meta) return data;
86
+ const result: Record<string, unknown> = { data };
87
+ if (meta) Object.assign(result, { meta });
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Disable the `{ data: ... }` wrapping for all resources in this request.
93
+ * Call once at the application level if you prefer flat responses.
94
+ */
95
+ static withoutWrapping(): void {
96
+ _wrap = false;
97
+ }
98
+
99
+ /** Re-enable the `{ data: ... }` wrapper (the default). */
100
+ static withWrapping(): void {
101
+ _wrap = true;
102
+ }
103
+
104
+ /** Returns whether wrapping is currently enabled. */
105
+ static wrapping(): boolean {
106
+ return _wrap;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Paginated API resource collection — wraps a PaginateResult into the standard
112
+ * `{ data, meta, links }` envelope.
113
+ *
114
+ * @example
115
+ * // In a controller:
116
+ * const posts = await Post.query().paginate(15, page);
117
+ * ctx.json(ResourceCollection.of(PostResource, posts));
118
+ * // → {
119
+ * // data: [...],
120
+ * // meta: { current_page, last_page, total, per_page, from, to },
121
+ * // links: { first, prev, next, last }
122
+ * // }
123
+ *
124
+ * @example
125
+ * // With a base URL for fully-qualified pagination links:
126
+ * ctx.json(ResourceCollection.of(PostResource, posts, '/api/posts'));
127
+ */
128
+ export class ResourceCollection {
129
+ /**
130
+ * Transform a paginated result set using the given Resource class.
131
+ *
132
+ * @param ResourceClass - The Resource subclass to transform each item.
133
+ * @param paginated - A PaginateResult (or any object matching PaginatedData<T>).
134
+ * @param baseUrl - Optional base URL; used to build `links.*` href strings.
135
+ * @param query - Extra query params merged into every pagination link.
136
+ */
137
+ static of<T, R extends Resource<T>>(
138
+ ResourceClass: new (item: T) => R,
139
+ paginated: PaginatedData<T>,
140
+ baseUrl = "",
141
+ query: Record<string, string> = {},
142
+ ): Record<string, unknown> {
143
+ const data = paginated.data.map((item) => new ResourceClass(item).toArray());
144
+
145
+ const from = paginated.data.length > 0 ? (paginated.page - 1) * paginated.perPage + 1 : null;
146
+ const to =
147
+ paginated.data.length > 0
148
+ ? (paginated.page - 1) * paginated.perPage + paginated.data.length
149
+ : null;
150
+
151
+ const meta = {
152
+ current_page: paginated.page,
153
+ last_page: paginated.lastPage,
154
+ total: paginated.total,
155
+ per_page: paginated.perPage,
156
+ from,
157
+ to,
158
+ };
159
+
160
+ const links = {
161
+ first: baseUrl ? `${baseUrl}?${new URLSearchParams({ ...query, page: "1" })}` : null,
162
+ prev: paginated.previousPageUrl(baseUrl, query),
163
+ next: paginated.nextPageUrl(baseUrl, query),
164
+ last: baseUrl
165
+ ? `${baseUrl}?${new URLSearchParams({ ...query, page: String(paginated.lastPage) })}`
166
+ : null,
167
+ };
168
+
169
+ return { data, meta, links };
170
+ }
171
+ }
@@ -0,0 +1,204 @@
1
+ import { sniffContentType, type SniffedType } from "./sniffContentType.ts";
2
+ import type { StorageDriver } from "../storage/types.ts";
3
+
4
+ /**
5
+ * The part of a storage disk `UploadedFile.store()` needs: somewhere to put
6
+ * bytes, and a URL for what was put.
7
+ *
8
+ * Narrower than {@link StorageDriver} on purpose — `store()` has no business
9
+ * with `delete` or `temporaryUrl` — but it is now a view of the real contract
10
+ * rather than a look-alike, so the two cannot drift.
11
+ */
12
+ export type StorageDisk = Pick<StorageDriver, "url"> & {
13
+ put(path: string, content: Uint8Array, options?: { contentType?: string }): Promise<void>;
14
+ };
15
+
16
+ /**
17
+ * Rules for {@link UploadedFile.isValid}. Omit a field to skip that check; an
18
+ * empty options object only verifies that a non-empty file was received.
19
+ */
20
+ export interface FileValidationOptions {
21
+ /** Maximum allowed size in bytes. */
22
+ maxSize?: number;
23
+ /** Allowed MIME types — e.g. `['image/jpeg', 'image/png', 'application/pdf']`. */
24
+ mimes?: string[];
25
+ }
26
+
27
+ /**
28
+ * Wraps a browser `File` from a multipart form upload and adds Zerotal-native
29
+ * validation and one-line storage.
30
+ *
31
+ * Instances are created by `HttpContext.file()` / `HttpContext.files()`.
32
+ *
33
+ * @example
34
+ * async updateAvatar(ctx: HttpContext): HttpResponse {
35
+ * const avatar = await ctx.file('avatar');
36
+ *
37
+ * if (!avatar?.isValid({ maxSize: 2 * 1024 * 1024, mimes: ['image/jpeg', 'image/png'] })) {
38
+ * return redirect().back().withErrors({ avatar: 'Must be a JPEG or PNG under 2 MB.' });
39
+ * }
40
+ *
41
+ * const path = await avatar.store('avatars', Storage.disk());
42
+ * await ctx.user!.update({ avatarPath: path });
43
+ * return redirect('/profile').withSuccess('Avatar updated!');
44
+ * }
45
+ */
46
+ export class UploadedFile {
47
+ readonly #file: File;
48
+
49
+ constructor(file: File) {
50
+ this.#file = file;
51
+ }
52
+
53
+ /** Original filename as sent by the browser (may be untrusted — sanitise before display). */
54
+ get originalName(): string {
55
+ return this.#file.name;
56
+ }
57
+
58
+ /** MIME type as reported by the browser (e.g. `'image/jpeg'`). */
59
+ get mimeType(): string {
60
+ return this.#file.type;
61
+ }
62
+
63
+ /** File size in bytes. */
64
+ get size(): number {
65
+ return this.#file.size;
66
+ }
67
+
68
+ /**
69
+ * Lowercase extension derived from the original filename, without the dot, stripped to
70
+ * `[a-z0-9]`.
71
+ *
72
+ * The filename is client-supplied, so anything outside that set — a null byte, a slash,
73
+ * a `%00` — is a smuggling attempt rather than an extension. This is what the *client
74
+ * called* the file; {@link store} does not trust it when naming what it writes.
75
+ */
76
+ extension(): string {
77
+ const parts = this.#file.name.split(".");
78
+ if (parts.length < 2) return "";
79
+ return parts[parts.length - 1]!.toLowerCase().replace(/[^a-z0-9]/g, "");
80
+ }
81
+
82
+ /**
83
+ * Returns `true` when the file satisfies all given rules.
84
+ * Pass no options to simply confirm a file was received.
85
+ *
86
+ * @example
87
+ * avatar.isValid({ maxSize: 5 * 1024 * 1024, mimes: ['image/jpeg', 'image/png'] })
88
+ */
89
+ isValid(options: FileValidationOptions = {}): boolean {
90
+ if (this.#file.size === 0) return false;
91
+ if (options.maxSize !== undefined && this.#file.size > options.maxSize) return false;
92
+ if (options.mimes && options.mimes.length > 0) {
93
+ if (!options.mimes.includes(this.#file.type)) return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /** Read the file as raw bytes. */
99
+ async bytes(): Promise<Uint8Array> {
100
+ return new Uint8Array(await this.#file.arrayBuffer());
101
+ }
102
+
103
+ /** Read the file as a UTF-8 string. */
104
+ async text(): Promise<string> {
105
+ return this.#file.text();
106
+ }
107
+
108
+ /**
109
+ * Write the file to a disk and return the stored path.
110
+ *
111
+ * The filename defaults to `<uuid>.<ext>` — predictable, safe, and collision-free.
112
+ * Pass `filename` to override it.
113
+ *
114
+ * **Both the extension and the stored `Content-Type` come from the file's own bytes,
115
+ * not from the client.** The multipart part's `Content-Type` and the filename's suffix
116
+ * are claims the uploader controls: `avatar` sent as `x.html` with `text/html` would
117
+ * otherwise be written as `avatars/<uuid>.html` and served as HTML, which is stored XSS
118
+ * on whatever origin serves the disk. Bytes that match no known format are stored as
119
+ * `application/octet-stream` with a `.bin` extension, which downloads rather than
120
+ * executes. See {@link sniffContentType}.
121
+ *
122
+ * An explicit `filename` is taken at face value — you chose it, so it is yours to get
123
+ * right — but the sniffed content type still applies.
124
+ *
125
+ * @param directory Target directory, e.g. `'avatars'` or `'uploads/docs'`
126
+ * @param disk Any `StorageDisk` — typically `Storage.disk()` or `Storage.disk('s3')`
127
+ * @param filename Optional override; defaults to `<uuid>.<sniffed-ext>`
128
+ * @returns The stored path, e.g. `'avatars/f47ac10b.jpg'`
129
+ *
130
+ * @example
131
+ * const path = await avatar.store('avatars', Storage.disk());
132
+ * const path = await avatar.store('docs', Storage.disk('s3'), 'terms-v2.pdf');
133
+ */
134
+ async store(directory: string, disk: StorageDisk, filename?: string): Promise<string> {
135
+ const bytes = await this.bytes();
136
+ const sniffed = sniffContentType(bytes);
137
+ const name = filename ?? `${crypto.randomUUID()}.${sniffed.extension}`;
138
+ const path = `${directory.replace(/\/+$/, "")}/${name}`;
139
+
140
+ await disk.put(path, bytes, { contentType: sniffed.contentType });
141
+
142
+ return path;
143
+ }
144
+
145
+ /**
146
+ * What the file's own bytes say it is, ignoring both client-supplied claims.
147
+ *
148
+ * Use it to reject an upload whose contents disagree with its declared type — a `.jpg`
149
+ * whose bytes are a ZIP, say — before storing it.
150
+ *
151
+ * @returns The detected content type, canonical extension, and whether detection succeeded.
152
+ * @example
153
+ * const { contentType, recognised } = await avatar.detectType();
154
+ * if (!recognised || !contentType.startsWith('image/')) return badRequest('Not an image.');
155
+ */
156
+ async detectType(): Promise<SniffedType> {
157
+ return sniffContentType(await this.bytes());
158
+ }
159
+
160
+ /**
161
+ * Store the file and immediately return its public URL.
162
+ *
163
+ * @example
164
+ * const url = await avatar.storeAndGetUrl('avatars', Storage.disk('s3'));
165
+ * await user.update({ avatarUrl: url });
166
+ */
167
+ async storeAndGetUrl(directory: string, disk: StorageDisk, filename?: string): Promise<string> {
168
+ return disk.url(await this.store(directory, disk, filename));
169
+ }
170
+
171
+ /**
172
+ * Build an `UploadedFile` from scratch, for unit-testing the code that
173
+ * receives one without going through a multipart request.
174
+ *
175
+ * The contents are arbitrary bytes, so the type this reports is the type you
176
+ * declare — which is the point for a size or extension check, but means
177
+ * {@link detectType} and {@link store} will see unrecognised bytes and fall
178
+ * back to `application/octet-stream`. When the test turns on what the bytes
179
+ * actually are, build a real one with `fakeFile` from `@zerotal/testing` and
180
+ * pass it here.
181
+ *
182
+ * @param name - Filename the "client" sent.
183
+ * @param options.type - MIME type to report from {@link mimeType}.
184
+ * @param options.size - Size in bytes; the file is padded to it.
185
+ * @param options.content - Exact contents, overriding `size`.
186
+ *
187
+ * @example
188
+ * const file = UploadedFile.fake('avatar.png', { type: 'image/png', size: 1024 });
189
+ * expect(file.isValid({ maxSize: 2048, mimes: ['image/png'] })).toBe(true);
190
+ */
191
+ static fake(
192
+ name = "file.txt",
193
+ options: {
194
+ type?: string;
195
+ size?: number;
196
+ content?: string | Uint8Array<ArrayBuffer> | File;
197
+ } = {},
198
+ ): UploadedFile {
199
+ const { type = "application/octet-stream", size = 1024, content } = options;
200
+ if (content instanceof File) return new UploadedFile(content);
201
+ const parts: BlobPart[] = content === undefined ? [new Uint8Array(size)] : [content];
202
+ return new UploadedFile(new File(parts, name, { type }));
203
+ }
204
+ }