@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
package/src/http/Http.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Http` facade — a fluent, static entry point for making outgoing HTTP
|
|
3
|
+
* requests, plus the test helpers (`fake`, `assertSent`, …) for intercepting
|
|
4
|
+
* and asserting on them.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
PendingRequest,
|
|
8
|
+
_installFakes,
|
|
9
|
+
_clearFakes,
|
|
10
|
+
_getRecorded,
|
|
11
|
+
type FakeStub,
|
|
12
|
+
} from "./HttpClient.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fluent facade for outgoing (server-to-server) HTTP requests. Static verb
|
|
16
|
+
* methods ({@link Http.get}, {@link Http.post}, …) return a {@link PendingRequest}
|
|
17
|
+
* that can be awaited directly or chained (`.json()`, `.text()`). Prefix a request
|
|
18
|
+
* with configuration ({@link Http.withHeaders}, {@link Http.withToken},
|
|
19
|
+
* {@link Http.timeout}, {@link Http.retry}) to apply it before the verb.
|
|
20
|
+
*
|
|
21
|
+
* The `fake`/`assert*` helpers intercept requests in tests so no real network
|
|
22
|
+
* traffic occurs.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* // GET with a custom header, retry, and timeout
|
|
27
|
+
* const users = await Http
|
|
28
|
+
* .withHeaders({ "X-Trace": "abc" })
|
|
29
|
+
* .retry(3, 200) // up to 3 attempts, 200ms apart
|
|
30
|
+
* .timeout(5000) // abort after 5s
|
|
31
|
+
* .get("https://api.example.com/users")
|
|
32
|
+
* .json();
|
|
33
|
+
*
|
|
34
|
+
* // POST a JSON body with a bearer token
|
|
35
|
+
* const created = await Http
|
|
36
|
+
* .withToken("secret")
|
|
37
|
+
* .post("https://api.example.com/users", { name: "Alice" })
|
|
38
|
+
* .json();
|
|
39
|
+
*
|
|
40
|
+
* // Testing — intercept requests
|
|
41
|
+
* Http.fake([{ url: "https://api.example.com/*", body: { ok: true } }]);
|
|
42
|
+
* await Http.get("https://api.example.com/users");
|
|
43
|
+
* Http.assertSent((req) => req.url.includes("/users"));
|
|
44
|
+
* Http.resetFakes();
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export class Http {
|
|
48
|
+
// ── Instance builder (for chaining global options before the method) ─────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Start a request with the given headers applied.
|
|
52
|
+
* Returns a builder whose `get`/`post`/etc. methods issue the actual request.
|
|
53
|
+
* @category Configuration
|
|
54
|
+
*/
|
|
55
|
+
static withHeaders(headers: Record<string, string>): _Builder {
|
|
56
|
+
return new _Builder().withHeaders(headers);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set a Bearer token on the next request.
|
|
61
|
+
* @category Configuration
|
|
62
|
+
*/
|
|
63
|
+
static withToken(token: string): _Builder {
|
|
64
|
+
return new _Builder().withToken(token);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Set HTTP Basic Auth credentials.
|
|
69
|
+
* @category Configuration
|
|
70
|
+
*/
|
|
71
|
+
static withBasicAuth(username: string, password: string): _Builder {
|
|
72
|
+
return new _Builder().withBasicAuth(username, password);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set the request timeout in milliseconds.
|
|
77
|
+
* @category Retries & resilience
|
|
78
|
+
*/
|
|
79
|
+
static timeout(milliseconds: number): _Builder {
|
|
80
|
+
return new _Builder().timeout(milliseconds);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Retry up to `times` times with optional delay.
|
|
85
|
+
* @category Retries & resilience
|
|
86
|
+
*/
|
|
87
|
+
static retry(times: number, delay?: number): _Builder {
|
|
88
|
+
return new _Builder().retry(times, delay);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Verb shortcuts ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Begin a GET request to `url`.
|
|
95
|
+
* @category Requests
|
|
96
|
+
*/
|
|
97
|
+
static get(url: string): PendingRequest {
|
|
98
|
+
return new PendingRequest("GET", url);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Begin a POST request to `url`, attaching `data` as a JSON body when given.
|
|
103
|
+
* @category Requests
|
|
104
|
+
*/
|
|
105
|
+
static post(url: string, data?: unknown): PendingRequest {
|
|
106
|
+
const request = new PendingRequest("POST", url);
|
|
107
|
+
if (data !== undefined) request.withJson(data);
|
|
108
|
+
return request;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Begin a PUT request to `url`, attaching `data` as a JSON body when given.
|
|
113
|
+
* @category Requests
|
|
114
|
+
*/
|
|
115
|
+
static put(url: string, data?: unknown): PendingRequest {
|
|
116
|
+
const request = new PendingRequest("PUT", url);
|
|
117
|
+
if (data !== undefined) request.withJson(data);
|
|
118
|
+
return request;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Begin a PATCH request to `url`, attaching `data` as a JSON body when given.
|
|
123
|
+
* @category Requests
|
|
124
|
+
*/
|
|
125
|
+
static patch(url: string, data?: unknown): PendingRequest {
|
|
126
|
+
const request = new PendingRequest("PATCH", url);
|
|
127
|
+
if (data !== undefined) request.withJson(data);
|
|
128
|
+
return request;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Begin a DELETE request to `url`.
|
|
133
|
+
* @category Requests
|
|
134
|
+
*/
|
|
135
|
+
static delete(url: string): PendingRequest {
|
|
136
|
+
return new PendingRequest("DELETE", url);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Begin a HEAD request to `url`.
|
|
141
|
+
* @category Requests
|
|
142
|
+
*/
|
|
143
|
+
static head(url: string): PendingRequest {
|
|
144
|
+
return new PendingRequest("HEAD", url);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Fake / testing ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Intercept all outgoing HTTP requests with the given stubs.
|
|
151
|
+
* No real HTTP requests will be made while fakes are active.
|
|
152
|
+
*
|
|
153
|
+
* @param stubs Array of URL-response pairs. Use `'*'` to match any URL.
|
|
154
|
+
* @category Testing
|
|
155
|
+
* @example
|
|
156
|
+
* Http.fake([
|
|
157
|
+
* { url: 'https://api.example.com/users', body: [{ id: 1 }] },
|
|
158
|
+
* { url: '*', status: 503 },
|
|
159
|
+
* ]);
|
|
160
|
+
*/
|
|
161
|
+
static fake(stubs: FakeStub[] = [{ url: "*" }]): void {
|
|
162
|
+
_installFakes(stubs);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remove all fake stubs and restore real HTTP behaviour.
|
|
167
|
+
* @category Testing
|
|
168
|
+
*/
|
|
169
|
+
static resetFakes(): void {
|
|
170
|
+
_clearFakes();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Return all requests recorded since `Http.fake()` was called.
|
|
175
|
+
* @category Testing
|
|
176
|
+
*/
|
|
177
|
+
static recorded(): Array<{ method: string; url: string; options: RequestInit }> {
|
|
178
|
+
return _getRecorded();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Assert that at least one recorded request matches the predicate.
|
|
183
|
+
* @category Testing
|
|
184
|
+
* @throws {Error} when no recorded request matches.
|
|
185
|
+
*/
|
|
186
|
+
static assertSent(predicate: (req: { method: string; url: string }) => boolean): void {
|
|
187
|
+
const recorded = _getRecorded();
|
|
188
|
+
const match = recorded.some(predicate);
|
|
189
|
+
if (!match) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Http.assertSent: no recorded request matched the predicate.\n` +
|
|
192
|
+
`Recorded: ${JSON.stringify(
|
|
193
|
+
recorded.map((entry) => `${entry.method} ${entry.url}`),
|
|
194
|
+
null,
|
|
195
|
+
2,
|
|
196
|
+
)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Assert that no recorded request matches the predicate.
|
|
203
|
+
* @category Testing
|
|
204
|
+
* @throws {Error} when a recorded request matches.
|
|
205
|
+
*/
|
|
206
|
+
static assertNotSent(predicate: (req: { method: string; url: string }) => boolean): void {
|
|
207
|
+
const recorded = _getRecorded();
|
|
208
|
+
const match = recorded.some(predicate);
|
|
209
|
+
if (match) {
|
|
210
|
+
const found = recorded.filter(predicate).map((entry) => `${entry.method} ${entry.url}`);
|
|
211
|
+
throw new Error(`Http.assertNotSent: unexpected request(s) matched: ${found.join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Assert that exactly `count` requests were recorded.
|
|
217
|
+
* @category Testing
|
|
218
|
+
* @throws {Error} when the recorded count differs from `count`.
|
|
219
|
+
*/
|
|
220
|
+
static assertSentCount(count: number): void {
|
|
221
|
+
const actual = _getRecorded().length;
|
|
222
|
+
if (actual !== count) {
|
|
223
|
+
throw new Error(`Http.assertSentCount: expected ${count}, got ${actual}.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Assert that no requests were recorded.
|
|
229
|
+
* @category Testing
|
|
230
|
+
* @throws {Error} when any request was recorded.
|
|
231
|
+
*/
|
|
232
|
+
static assertNothingSent(): void {
|
|
233
|
+
Http.assertSentCount(0);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Internal pre-configured builder ──────────────────────────────────────────
|
|
238
|
+
// Http.withToken(...).get(url) pattern
|
|
239
|
+
|
|
240
|
+
class _Builder {
|
|
241
|
+
private _headers: Record<string, string> = {};
|
|
242
|
+
private _timeoutMs: number | undefined = undefined;
|
|
243
|
+
private _retries: number = 0;
|
|
244
|
+
private _retryDelay: number = 100;
|
|
245
|
+
|
|
246
|
+
withHeaders(headers: Record<string, string>): this {
|
|
247
|
+
Object.assign(this._headers, headers);
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
withToken(token: string): this {
|
|
251
|
+
this._headers["Authorization"] = `Bearer ${token}`;
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
withBasicAuth(username: string, password: string): this {
|
|
255
|
+
this._headers["Authorization"] = `Basic ${btoa(`${username}:${password}`)}`;
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
timeout(milliseconds: number): this {
|
|
259
|
+
this._timeoutMs = milliseconds;
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
retry(times: number, delay = 100): this {
|
|
263
|
+
this._retries = times;
|
|
264
|
+
this._retryDelay = delay;
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private _build(
|
|
269
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD",
|
|
270
|
+
url: string,
|
|
271
|
+
data?: unknown,
|
|
272
|
+
): PendingRequest {
|
|
273
|
+
const request = new PendingRequest(method, url).withHeaders(this._headers);
|
|
274
|
+
if (this._timeoutMs !== undefined) request.timeout(this._timeoutMs);
|
|
275
|
+
if (this._retries > 0) request.retry(this._retries, this._retryDelay);
|
|
276
|
+
if (data !== undefined) request.withJson(data);
|
|
277
|
+
return request;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
get(url: string): PendingRequest {
|
|
281
|
+
return this._build("GET", url);
|
|
282
|
+
}
|
|
283
|
+
post(url: string, data?: unknown): PendingRequest {
|
|
284
|
+
return this._build("POST", url, data);
|
|
285
|
+
}
|
|
286
|
+
put(url: string, data?: unknown): PendingRequest {
|
|
287
|
+
return this._build("PUT", url, data);
|
|
288
|
+
}
|
|
289
|
+
patch(url: string, data?: unknown): PendingRequest {
|
|
290
|
+
return this._build("PATCH", url, data);
|
|
291
|
+
}
|
|
292
|
+
delete(url: string): PendingRequest {
|
|
293
|
+
return this._build("DELETE", url);
|
|
294
|
+
}
|
|
295
|
+
head(url: string): PendingRequest {
|
|
296
|
+
return this._build("HEAD", url);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP client internals behind the `Http` facade: the fluent
|
|
3
|
+
* `PendingRequest` builder, the `HttpClientResponse` wrapper, and the
|
|
4
|
+
* request-interception machinery that powers `Http.fake()` in tests.
|
|
5
|
+
*/
|
|
6
|
+
import { ZerotalError } from "../errors/ZerotalError.ts";
|
|
7
|
+
import { FrameworkEvents, OutgoingRequestCompleted } from "../events/FrameworkEvents.ts";
|
|
8
|
+
|
|
9
|
+
/** An HTTP request method. */
|
|
10
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
11
|
+
|
|
12
|
+
/** A fake response definition matched by URL while `Http.fake()` is active. */
|
|
13
|
+
export interface FakeStub {
|
|
14
|
+
/** URL to match — exact string or glob-style `*` wildcard. */
|
|
15
|
+
url: string;
|
|
16
|
+
status?: number;
|
|
17
|
+
/** Response body. If object/array, serialized as JSON and Content-Type set automatically. */
|
|
18
|
+
body?: unknown;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _fakeStubs: FakeStub[] | null = null;
|
|
23
|
+
const _recorded: Array<{ method: HttpMethod; url: string; options: RequestInit }> = [];
|
|
24
|
+
|
|
25
|
+
/** @internal Install fake stubs; while active, no real HTTP requests are made. */
|
|
26
|
+
export function _installFakes(stubs: FakeStub[]): void {
|
|
27
|
+
_fakeStubs = stubs;
|
|
28
|
+
_recorded.length = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @internal Remove all fakes, restoring real HTTP behaviour. */
|
|
32
|
+
export function _clearFakes(): void {
|
|
33
|
+
_fakeStubs = null;
|
|
34
|
+
_recorded.length = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @internal Return a copy of every request made while fakes are active. */
|
|
38
|
+
export function _getRecorded(): typeof _recorded {
|
|
39
|
+
return [..._recorded];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _matchUrl(pattern: string, url: string): boolean {
|
|
43
|
+
if (pattern === "*") return true;
|
|
44
|
+
if (!pattern.includes("*")) return pattern === url;
|
|
45
|
+
const regex = new RegExp(
|
|
46
|
+
"^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
|
|
47
|
+
);
|
|
48
|
+
return regex.test(url);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _buildFakeResponse(stub: FakeStub): Response {
|
|
52
|
+
const status = stub.status ?? 200;
|
|
53
|
+
const headers = new Headers(stub.headers ?? {});
|
|
54
|
+
|
|
55
|
+
if (stub.body === undefined) {
|
|
56
|
+
return new Response(null, { status, headers });
|
|
57
|
+
}
|
|
58
|
+
if (typeof stub.body === "string") {
|
|
59
|
+
if (!headers.has("Content-Type")) headers.set("Content-Type", "text/plain");
|
|
60
|
+
return new Response(stub.body, { status, headers });
|
|
61
|
+
}
|
|
62
|
+
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
63
|
+
return new Response(JSON.stringify(stub.body), { status, headers });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── HttpClientResponse ────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** Wraps a fetch Response with convenient accessors. */
|
|
69
|
+
export class HttpClientResponse {
|
|
70
|
+
constructor(private readonly _res: Response) {}
|
|
71
|
+
|
|
72
|
+
get status(): number {
|
|
73
|
+
return this._res.status;
|
|
74
|
+
}
|
|
75
|
+
get ok(): boolean {
|
|
76
|
+
return this._res.ok;
|
|
77
|
+
}
|
|
78
|
+
get headers(): Headers {
|
|
79
|
+
return this._res.headers;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
json<T = unknown>(): Promise<T> {
|
|
83
|
+
return this._res.json() as Promise<T>;
|
|
84
|
+
}
|
|
85
|
+
text(): Promise<string> {
|
|
86
|
+
return this._res.text();
|
|
87
|
+
}
|
|
88
|
+
blob(): Promise<Blob> {
|
|
89
|
+
return this._res.blob();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return this response, or throw when the status is >= 400.
|
|
94
|
+
* @throws {HttpClientError} when the underlying response is not `ok`.
|
|
95
|
+
*/
|
|
96
|
+
throw(): this {
|
|
97
|
+
if (!this._res.ok) {
|
|
98
|
+
throw new HttpClientError(`HTTP ${this._res.status}`, this._res.status, this);
|
|
99
|
+
}
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Thrown by `HttpClientResponse.throw()` when a response has a 4xx/5xx status. */
|
|
105
|
+
export class HttpClientError extends ZerotalError {
|
|
106
|
+
constructor(
|
|
107
|
+
message: string,
|
|
108
|
+
status: number,
|
|
109
|
+
readonly response: HttpClientResponse,
|
|
110
|
+
) {
|
|
111
|
+
super(message, "E_HTTP_CLIENT", status);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── PendingRequest ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fluent HTTP request builder.
|
|
119
|
+
*
|
|
120
|
+
* Can be awaited directly (`await Http.get(url)`) or chained for shorthand
|
|
121
|
+
* (`await Http.post(url, data).json()`).
|
|
122
|
+
*/
|
|
123
|
+
export class PendingRequest implements PromiseLike<HttpClientResponse> {
|
|
124
|
+
private _headers: Record<string, string> = {};
|
|
125
|
+
private _timeout: number | undefined = undefined;
|
|
126
|
+
private _retries: number = 0;
|
|
127
|
+
private _retryDelay: number = 100;
|
|
128
|
+
private _body: BodyInit | undefined = undefined;
|
|
129
|
+
private _bodyType: "json" | "form" | "raw" = "json";
|
|
130
|
+
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly _method: HttpMethod,
|
|
133
|
+
private readonly _url: string,
|
|
134
|
+
) {}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Merge additional headers into the request.
|
|
138
|
+
* @category Configuration
|
|
139
|
+
*/
|
|
140
|
+
withHeaders(headers: Record<string, string>): this {
|
|
141
|
+
Object.assign(this._headers, headers);
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Set a Bearer token in the Authorization header.
|
|
147
|
+
* @category Configuration
|
|
148
|
+
*/
|
|
149
|
+
withToken(token: string): this {
|
|
150
|
+
this._headers["Authorization"] = `Bearer ${token}`;
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Set HTTP Basic Auth credentials.
|
|
156
|
+
* @category Configuration
|
|
157
|
+
*/
|
|
158
|
+
withBasicAuth(username: string, password: string): this {
|
|
159
|
+
const encoded = btoa(`${username}:${password}`);
|
|
160
|
+
this._headers["Authorization"] = `Basic ${encoded}`;
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Set an Accept: application/json header.
|
|
166
|
+
* @category Configuration
|
|
167
|
+
*/
|
|
168
|
+
acceptJson(): this {
|
|
169
|
+
this._headers["Accept"] = "application/json";
|
|
170
|
+
return this;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Abort the request after the given number of milliseconds.
|
|
175
|
+
* @category Retries & resilience
|
|
176
|
+
*/
|
|
177
|
+
timeout(ms: number): this {
|
|
178
|
+
this._timeout = ms;
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Retry failed requests up to `times` times, with optional delay in ms.
|
|
184
|
+
* @category Retries & resilience
|
|
185
|
+
*/
|
|
186
|
+
retry(times: number, delay = 100): this {
|
|
187
|
+
this._retries = times;
|
|
188
|
+
this._retryDelay = delay;
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Attach a JSON body. Used internally — prefer passing data to Http.post().
|
|
194
|
+
* @category Configuration
|
|
195
|
+
*/
|
|
196
|
+
withJson(data: unknown): this {
|
|
197
|
+
this._body = JSON.stringify(data);
|
|
198
|
+
this._bodyType = "json";
|
|
199
|
+
this._headers["Content-Type"] = "application/json";
|
|
200
|
+
return this;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Attach a FormData body.
|
|
205
|
+
* @category Configuration
|
|
206
|
+
*/
|
|
207
|
+
withFormData(data: FormData): this {
|
|
208
|
+
this._body = data;
|
|
209
|
+
this._bodyType = "form";
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Execute the request and return an HttpClientResponse.
|
|
215
|
+
* @category Responses
|
|
216
|
+
* @throws the last error encountered after all retry attempts are exhausted
|
|
217
|
+
* (e.g. a network failure or `AbortSignal.timeout` abort).
|
|
218
|
+
* @throws {Error} when `Http.fake()` is active but no stub matches the URL.
|
|
219
|
+
*/
|
|
220
|
+
async send(): Promise<HttpClientResponse> {
|
|
221
|
+
const options: RequestInit = {
|
|
222
|
+
method: this._method,
|
|
223
|
+
headers: this._headers,
|
|
224
|
+
};
|
|
225
|
+
if (this._body !== undefined) options.body = this._body;
|
|
226
|
+
|
|
227
|
+
if (_fakeStubs !== null) {
|
|
228
|
+
_recorded.push({ method: this._method, url: this._url, options });
|
|
229
|
+
const stub = _fakeStubs.find((candidate) => _matchUrl(candidate.url, this._url));
|
|
230
|
+
if (stub) return new HttpClientResponse(_buildFakeResponse(stub));
|
|
231
|
+
throw new Error(`[Http.fake] No stub matched: ${this._method} ${this._url}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const startedAt = performance.now();
|
|
235
|
+
let lastError: unknown;
|
|
236
|
+
for (let attempt = 0; attempt <= this._retries; attempt++) {
|
|
237
|
+
try {
|
|
238
|
+
const signal = this._timeout ? AbortSignal.timeout(this._timeout) : undefined;
|
|
239
|
+
const response = await fetch(this._url, signal ? { ...options, signal } : options);
|
|
240
|
+
this._emitCompleted(response.status, response.ok, performance.now() - startedAt);
|
|
241
|
+
return new HttpClientResponse(response);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
lastError = error;
|
|
244
|
+
if (attempt < this._retries) {
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, this._retryDelay));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this._emitCompleted(0, false, performance.now() - startedAt);
|
|
250
|
+
throw lastError;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Announce a finished outgoing request on the framework event bus (best-effort). */
|
|
254
|
+
private _emitCompleted(status: number, ok: boolean, ms: number): void {
|
|
255
|
+
let host = this._url;
|
|
256
|
+
try {
|
|
257
|
+
host = new URL(this._url).host;
|
|
258
|
+
} catch {
|
|
259
|
+
/* non-absolute URL — keep the raw value */
|
|
260
|
+
}
|
|
261
|
+
FrameworkEvents.emit(
|
|
262
|
+
new OutgoingRequestCompleted(host, this._method, this._url, status, Math.round(ms), ok),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Shorthand: send and deserialize JSON response body.
|
|
268
|
+
* @category Responses
|
|
269
|
+
*/
|
|
270
|
+
json<T = unknown>(): Promise<T> {
|
|
271
|
+
return this.send().then((response) => response.json<T>());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Shorthand: send and return the text body.
|
|
276
|
+
* @category Responses
|
|
277
|
+
*/
|
|
278
|
+
text(): Promise<string> {
|
|
279
|
+
return this.send().then((response) => response.text());
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Allow `await Http.get(url)` without calling .send() explicitly.
|
|
283
|
+
then<TResult1 = HttpClientResponse, TResult2 = never>(
|
|
284
|
+
onfulfilled?: ((value: HttpClientResponse) => TResult1 | PromiseLike<TResult1>) | null,
|
|
285
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
286
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
287
|
+
return this.send().then(onfulfilled, onrejected);
|
|
288
|
+
}
|
|
289
|
+
}
|