@shivaedev/test-story 0.11.1 → 0.13.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 +12 -0
- package/README.md +27 -3
- package/dist/server/fakeHttpService.d.ts +11 -0
- package/dist/server/fakeHttpService.d.ts.map +1 -1
- package/dist/server/fakeHttpService.js +8 -1
- package/dist/server/fakeHttpService.js.map +1 -1
- package/dist/server/forwardingProxy.d.ts +3 -1
- package/dist/server/forwardingProxy.d.ts.map +1 -1
- package/dist/server/forwardingProxy.js +9 -1
- package/dist/server/forwardingProxy.js.map +1 -1
- package/dist/server/internal/fakeHttpControls.d.ts +11 -5
- package/dist/server/internal/fakeHttpControls.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpControls.js +56 -34
- package/dist/server/internal/fakeHttpControls.js.map +1 -1
- package/dist/server/internal/fakeHttpGates.d.ts +10 -0
- package/dist/server/internal/fakeHttpGates.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpGates.js +38 -0
- package/dist/server/internal/fakeHttpGates.js.map +1 -0
- package/dist/server/internal/fakeHttpReceive.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpReceive.js +12 -13
- package/dist/server/internal/fakeHttpReceive.js.map +1 -1
- package/dist/server/internal/fakeHttpSend.d.ts +8 -0
- package/dist/server/internal/fakeHttpSend.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpSend.js +51 -0
- package/dist/server/internal/fakeHttpSend.js.map +1 -0
- package/package.json +2 -2
- package/src/server/fakeHttpService.ts +12 -1
- package/src/server/forwardingProxy.ts +13 -1
- package/src/server/internal/fakeHttpControls.ts +63 -35
- package/src/server/internal/fakeHttpGates.ts +39 -0
- package/src/server/internal/fakeHttpReceive.ts +12 -14
- package/src/server/internal/fakeHttpSend.ts +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `ForwardingProxyOptions.localAddress` selects the proxy's source socket address and rejects unavailable addresses during acquisition. `ForwardingProxyOptions.forwardedHops` appends visitor and edge hops after the client's `x-forwarded-for` chain instead of the caller address; fixed `headers` still override the generated chain.
|
|
8
|
+
|
|
9
|
+
## 0.12.0 - 2026-10-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Fake HTTP services expose `stall`, `replyOnce`, `releaseOnArrival` and `fault` controls for body stalls, raw byte and iterable chunk replies, automatic hold release on route arrival, and faults injected into arrived requests or replies in flight. `FakeHttpReply.chunks` accepts string or byte iterables, and the exported `FakeHttpFault` describes status, pre-header drop and mid-body reset faults. Controls validate route names, narrate through the story and clean up with its scope.
|
|
14
|
+
|
|
3
15
|
## 0.11.1 - 2026-10-18
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -241,6 +241,8 @@ Use `forwardingProxy` from `@shivaedev/test-story/forwardingProxy.ts` in the kit
|
|
|
241
241
|
import { forwardingProxy } from "@shivaedev/test-story/forwardingProxy.ts";
|
|
242
242
|
|
|
243
243
|
const proxy = yield* forwardingProxy(upstream, {
|
|
244
|
+
localAddress: "::1",
|
|
245
|
+
forwardedHops: ["203.0.113.9", "2001:db8::1"],
|
|
244
246
|
headers: {
|
|
245
247
|
host: "internal.test",
|
|
246
248
|
"x-forwarded-host": "app.example",
|
|
@@ -249,7 +251,9 @@ const proxy = yield* forwardingProxy(upstream, {
|
|
|
249
251
|
});
|
|
250
252
|
```
|
|
251
253
|
|
|
252
|
-
Point story requests at `proxy.url`. The proxy listens on IPv4 and IPv6 loopback on the same allocated port, forwards the request's method, path, query, body and headers to `upstream`, and returns the host's status, headers and body. It appends the connecting caller's address to `x-forwarded-for`, preserving any existing chain. The optional `headers` map overrides incoming and generated headers, including `x-forwarded-for` when the story needs a fixed chain.
|
|
254
|
+
Point story requests at `proxy.url`. The proxy listens on IPv4 and IPv6 loopback on the same allocated port, forwards the request's method, path, query, body and headers to `upstream`, and returns the host's status, headers and body. It appends the connecting caller's address to `x-forwarded-for`, preserving any existing chain. Set `forwardedHops` to append those addresses in order instead of the caller, so a client chain of `spoofed` with hops `visitor` and `edge` becomes `spoofed, visitor, edge`. The optional `headers` map overrides incoming and generated headers, including `x-forwarded-for` when the story needs a fixed chain.
|
|
255
|
+
|
|
256
|
+
Set `localAddress` to choose the source socket address of the proxy's connection to the host. The upstream must accept that address family; for a hostname, the proxy resolves the matching family. Use `127.0.0.1` or `::1` for portable loopback stories. An unavailable local address fails acquisition with a message naming it. The proxy returns 502 if it cannot connect to the host. Both listeners close with the story's Scope.
|
|
253
257
|
|
|
254
258
|
#### Reading OTLP exports
|
|
255
259
|
|
|
@@ -270,7 +274,7 @@ For timeout stories, return `undefined` from the `status` callback to accept an
|
|
|
270
274
|
|
|
271
275
|
#### Faking an outbound HTTP API
|
|
272
276
|
|
|
273
|
-
Use `fakeHttpService` from `@shivaedev/test-story/fakeHttpService.ts` in a kit's scoped `create` hook. Like the OTLP receiver, it listens on an allocated loopback port and closes with the scope. Routes have names for controls, a method (default `GET`), and a path whose `:segments` become decoded `request.params`. A reply receives the request and returns a JSON `body
|
|
277
|
+
Use `fakeHttpService` from `@shivaedev/test-story/fakeHttpService.ts` in a kit's scoped `create` hook. Like the OTLP receiver, it listens on an allocated loopback port and closes with the scope. Routes have names for controls, a method (default `GET`), and a path whose `:segments` become decoded `request.params`. A reply receives the request and returns a JSON `body` (or a `Uint8Array`/Buffer sent as raw bytes), optional `status` (default 200), and optional response `headers`.
|
|
274
278
|
|
|
275
279
|
```ts
|
|
276
280
|
import { fakeHttpService } from "@shivaedev/test-story/fakeHttpService.ts";
|
|
@@ -300,6 +304,26 @@ const user = yield* Fiber.join(pending);
|
|
|
300
304
|
|
|
301
305
|
`application.loadUser` is the application's real client call. `failOnce` affects the next authorized matching request to that named route, then the route answers normally. `hold` pauses that route until `release`; `waiting` completes when a request reaches the hold, so a story needs no polling to know when to release it. Other routes still answer. Effects that wait for HTTP should use a live-clock kit.
|
|
302
306
|
|
|
307
|
+
Use the controls through kit verbs after acquiring the service. `replyOnce(route, reply)` scripts the next authorized reply, including raw bytes or `chunks`, a synchronous or asynchronous iterable of strings and byte arrays. Set `headers["content-type"]` to the story's media type; the default is JSON. Chunks are written in iteration order as they become available, so an async iterable can wait between pieces.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
yield* api.replyOnce("download", {
|
|
311
|
+
body: Uint8Array.from([0, 255, 128]),
|
|
312
|
+
headers: { "content-type": "application/octet-stream" },
|
|
313
|
+
});
|
|
314
|
+
const stalled = yield* api.stall("download");
|
|
315
|
+
const pending = yield* Effect.forkChild(application.download());
|
|
316
|
+
yield* stalled.waiting;
|
|
317
|
+
yield* stalled.release;
|
|
318
|
+
const bytes = yield* Fiber.join(pending);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`stall(route)` sends the reply's status and headers, then pauses its body. Its `waiting` effect completes at that pause. Its `release` lets the body continue; a client that gives up also stops waiting on the server. Like a hold, a stall remains installed for matching requests until released. Holds pause before headers. `releaseOnArrival(heldRoute, arrivingRoute)` releases the current hold when an authorized request reaches the named route; script it before that arrival. Rejected requests do not trigger release.
|
|
322
|
+
|
|
323
|
+
`fault(route, fault)` acts on an arrived request in the required phase. `{ kind: "status", reply: { status: 503, body: { error: "unavailable" } } }` replaces a held reply before headers. `{ kind: "drop" }` drops a connection before headers. `{ kind: "reset" }` terminates a reply after headers, including after body chunks have arrived. Wait for the hold, stall or client observation before injecting the fault. A fault fails with guidance when no request is in the required phase; it does not queue a failure for a future call. `failOnce` continues to queue a status reply for the next request.
|
|
324
|
+
|
|
325
|
+
All controls validate route names and narrate through the service's story sink. Closing the scope closes active connections and releases holds, stalls and arrival scripts.
|
|
326
|
+
|
|
303
327
|
A `pages` route accepts a one-based `?page=` query and returns `{ items, next }`, with `next: null` on the last page. No query means page 1; a page past the end returns an empty list; a non-positive or non-integer page gets 400. `api.requests` reads a snapshot of the request log, including rejected and held requests, with `method`, `path` (including query), raw `body` Buffer, `headers`, `params` and `query`.
|
|
304
328
|
|
|
305
329
|
### 2. Once per feature: traits and verbs
|
|
@@ -577,7 +601,7 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
|
|
|
577
601
|
| `domNavigate.ts` | `domNavigate(url?)` |
|
|
578
602
|
| `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
|
|
579
603
|
| `domEnvironment.config.ts` | Default Vitest environment preserving Node host globals and happy-dom document transport |
|
|
580
|
-
| `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpHold`, `FakeHttpServiceOptions` |
|
|
604
|
+
| `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpFault`, `FakeHttpHold`, `FakeHttpServiceOptions` |
|
|
581
605
|
| `forwardingProxy.ts` | `forwardingProxy(upstream, options?)`, `ForwardingProxyOptions` |
|
|
582
606
|
| `otlpReceiver.ts` | `otlpReceiver(options?)`, `OtlpSpan`, `OtlpExport`, `OtlpReceiverOptions` |
|
|
583
607
|
|
|
@@ -10,9 +10,16 @@ export interface FakeHttpRequest {
|
|
|
10
10
|
}
|
|
11
11
|
export interface FakeHttpReply {
|
|
12
12
|
readonly body?: unknown;
|
|
13
|
+
readonly chunks?: Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>;
|
|
13
14
|
readonly headers?: Readonly<Record<string, string>>;
|
|
14
15
|
readonly status?: number;
|
|
15
16
|
}
|
|
17
|
+
export type FakeHttpFault = {
|
|
18
|
+
readonly kind: "status";
|
|
19
|
+
readonly reply: FakeHttpReply;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: "drop" | "reset";
|
|
22
|
+
};
|
|
16
23
|
export type FakeHttpRoute = {
|
|
17
24
|
readonly method?: string;
|
|
18
25
|
readonly path: string;
|
|
@@ -34,9 +41,13 @@ export interface FakeHttpHold {
|
|
|
34
41
|
}
|
|
35
42
|
export declare const fakeHttpService: (options: FakeHttpServiceOptions) => Effect.Effect<{
|
|
36
43
|
failOnce: (route: string, reply: FakeHttpReply) => Effect.Effect<void, never, never>;
|
|
44
|
+
fault: (route: string, fault: FakeHttpFault) => Effect.Effect<void, never, never>;
|
|
37
45
|
hold: (route: string) => Effect.Effect<FakeHttpHold, never, never>;
|
|
38
46
|
narrate: (sink: (line: string) => void) => void;
|
|
47
|
+
releaseOnArrival: (route: string, arrival: string) => Effect.Effect<void, never, never>;
|
|
48
|
+
replyOnce: (route: string, reply: FakeHttpReply) => Effect.Effect<void, never, never>;
|
|
39
49
|
readonly requests: readonly FakeHttpRequest[];
|
|
50
|
+
stall: (route: string) => Effect.Effect<FakeHttpHold, never, never>;
|
|
40
51
|
url: string;
|
|
41
52
|
}, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
|
|
42
53
|
//# sourceMappingURL=fakeHttpService.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpService.d.ts","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB,GAAG,CACD;IAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,CAC7E,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC;IACvD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACtC;AAED,eAAO,MAAM,eAAe;sBAgBR,MAAM,SAAS,aAAa;
|
|
1
|
+
{"version":3,"file":"fakeHttpService.d.ts","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,UAAU,CAAC,GAAG,aAAa,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC;IACrF,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAC;AAE7H,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB,GAAG,CACD;IAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,CAC7E,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC;IACvD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACtC;AAED,eAAO,MAAM,eAAe;sBAgBR,MAAM,SAAS,aAAa;mBAC/B,MAAM,SAAS,aAAa;kBAC7B,MAAM;oBACJ,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;8BAGZ,MAAM,WAAW,MAAM;uBAK9B,MAAM,SAAS,aAAa;uBAC/B,SAAS,eAAe,EAAE;mBAG3B,MAAM;;qEAGrB,CAAC"}
|
|
@@ -19,14 +19,21 @@ export const fakeHttpService = Effect.fnUntraced(function* (options) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
return {
|
|
22
|
-
failOnce: (route, reply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.
|
|
22
|
+
failOnce: (route, reply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.queue(route, reply))),
|
|
23
|
+
fault: (route, fault) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.fault(route, fault))),
|
|
23
24
|
hold: (route) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.hold(route))),
|
|
24
25
|
narrate: (sink) => {
|
|
25
26
|
narrate = sink;
|
|
26
27
|
},
|
|
28
|
+
releaseOnArrival: (route, arrival) => Effect.sync(() => {
|
|
29
|
+
checkRoute(route);
|
|
30
|
+
checkRoute(arrival);
|
|
31
|
+
}).pipe(Effect.andThen(controls.releaseOnArrival(route, arrival))),
|
|
32
|
+
replyOnce: (route, reply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.queue(route, reply))),
|
|
27
33
|
get requests() {
|
|
28
34
|
return requests.slice();
|
|
29
35
|
},
|
|
36
|
+
stall: (route) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.stall(route))),
|
|
30
37
|
url,
|
|
31
38
|
};
|
|
32
39
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpService.js","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAA4B,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"fakeHttpService.js","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
export interface ForwardingProxyOptions {
|
|
3
|
+
readonly forwardedHops?: readonly string[];
|
|
3
4
|
readonly headers?: Readonly<Record<string, string>>;
|
|
5
|
+
readonly localAddress?: string;
|
|
4
6
|
}
|
|
5
7
|
export declare const forwardingProxy: (upstream: string, options?: ForwardingProxyOptions | undefined) => Effect.Effect<{
|
|
6
8
|
url: string;
|
|
7
|
-
},
|
|
9
|
+
}, Error, import("effect/Scope").Scope>;
|
|
8
10
|
//# sourceMappingURL=forwardingProxy.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forwardingProxy.d.ts","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"forwardingProxy.d.ts","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,eAAO,MAAM,eAAe;;uCAuC1B,CAAC"}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { createServer, request } from "node:http";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
import { Effect } from "effect";
|
|
3
4
|
import { loopbackServer } from "#internal/loopback.ts";
|
|
4
5
|
export const forwardingProxy = Effect.fnUntraced(function* (upstream, options = {}) {
|
|
6
|
+
if (options.localAddress !== undefined) {
|
|
7
|
+
yield* Effect.scoped(loopbackServer(createServer(), { host: options.localAddress })).pipe(Effect.mapError((cause) => new Error(`Cannot acquire forwarding proxy with local address ${options.localAddress}`, { cause })));
|
|
8
|
+
}
|
|
5
9
|
const forward = (incoming, response) => {
|
|
6
10
|
const outgoing = request(`${upstream}${incoming.url}`, {
|
|
11
|
+
family: options.localAddress === undefined ? undefined : isIP(options.localAddress),
|
|
7
12
|
headers: {
|
|
8
13
|
...incoming.headers,
|
|
9
|
-
"x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress]
|
|
14
|
+
"x-forwarded-for": [incoming.headers["x-forwarded-for"], ...(options.forwardedHops ?? [incoming.socket.remoteAddress])]
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.join(", "),
|
|
10
17
|
...options.headers,
|
|
11
18
|
},
|
|
19
|
+
localAddress: options.localAddress,
|
|
12
20
|
method: incoming.method,
|
|
13
21
|
}, (result) => {
|
|
14
22
|
response.writeHead(result.statusCode ?? 502, result.headers);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forwardingProxy.js","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAwB,OAAO,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"forwardingProxy.js","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAwB,OAAO,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAQvD,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,QAAgB,EAAE,OAAO,GAA2B,EAAE;IACjH,IAAI,OAAO,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;QACxC,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,CACxF,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,sDAAsD,OAAO,CAAC,YAAY,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,CAC9H,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE;QACvD,MAAM,QAAQ,GAAG,OAAO,CACvB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG,EAAE,EAC5B;YACC,MAAM,EAAE,OAAO,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC;YACnF,OAAO,EAAE;gBACR,GAAG,QAAQ,CAAC,OAAO;gBACnB,iBAAiB,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,aAAa,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC;qBACrH,MAAM,CAAC,OAAO,CAAC;qBACf,IAAI,CAAC,IAAI,CAAC;gBACZ,GAAG,OAAO,CAAC,OAAO;aAClB;YACD,YAAY,EAAE,OAAO,CAAC,YAAY;YAClC,MAAM,EAAE,QAAQ,CAAC,MAAM;SACvB,EACD,CAAC,MAAM,EAAE,EAAE;YACV,QAAQ,CAAC,SAAS,CAAC,MAAM,CAAC,UAAU,IAAI,GAAG,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;YAC7D,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACvB,CAAC,CACD,CAAC;QACF,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YAC9B,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;gBAC1B,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACP,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;YAC/B,CAAC;QACF,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/C,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzB,CAAC,CAAC;IACF,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;IACzD,KAAK,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/F,OAAO,EAAE,GAAG,EAAE,CAAC;AAChB,CAAC,CAAC,CAAC"}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
1
2
|
import { Effect } from "effect";
|
|
2
|
-
import type {
|
|
3
|
+
import type { FakeHttpFault, FakeHttpReply } from "#fakeHttpService.ts";
|
|
3
4
|
export declare function fakeHttpControls(tell: (line: string) => void): {
|
|
5
|
+
arrive: (route: string, response: ServerResponse) => void;
|
|
4
6
|
close: () => void;
|
|
5
|
-
|
|
6
|
-
hold: (route: string) => Effect.Effect<FakeHttpHold>;
|
|
7
|
-
pause: (route: string) => Promise<
|
|
8
|
-
|
|
7
|
+
fault: (route: string, fault: FakeHttpFault) => Effect.Effect<void, never, never>;
|
|
8
|
+
hold: (route: string) => Effect.Effect<import("#fakeHttpService.ts").FakeHttpHold, never, never>;
|
|
9
|
+
pause: (route: string, response: ServerResponse) => Promise<boolean>;
|
|
10
|
+
queue: (route: string, reply: FakeHttpReply) => Effect.Effect<void, never, never>;
|
|
11
|
+
releaseOnArrival: (route: string, arrival: string) => Effect.Effect<void, never, never>;
|
|
12
|
+
stall: (route: string) => Effect.Effect<import("#fakeHttpService.ts").FakeHttpHold, never, never>;
|
|
13
|
+
stallBody: (route: string) => (() => Promise<void>) | undefined;
|
|
14
|
+
takeReply: (route: string) => FakeHttpReply | undefined;
|
|
9
15
|
};
|
|
10
16
|
//# sourceMappingURL=fakeHttpControls.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpControls.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"fakeHttpControls.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;IAO3D,MAAM,UAAU,MAAM,YAAY,cAAc;IAQhD,KAAK;IASL,KAAK,UAAU,MAAM,SAAS,aAAa;IAkB3C,IAAI,UAAU,MAAM;IACpB,KAAK,UAAgB,MAAM,YAAY,cAAc;IAIrD,KAAK,UAAU,MAAM,SAAS,aAAa;IAO3C,gBAAgB,UAAU,MAAM,WAAW,MAAM;IAWjD,KAAK,UAAU,MAAM;IACrB,SAAS,UAAU,MAAM;IAIzB,SAAS,UAAU,MAAM;EAE1B"}
|
|
@@ -1,48 +1,70 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { fakeHttpGates } from "#internal/fakeHttpGates.ts";
|
|
3
|
+
import { fakeHttpConnection, fakeHttpSend } from "#internal/fakeHttpSend.ts";
|
|
2
4
|
export function fakeHttpControls(tell) {
|
|
3
|
-
const
|
|
4
|
-
const holds =
|
|
5
|
+
const replies = new Map();
|
|
6
|
+
const holds = fakeHttpGates(tell, "hold");
|
|
7
|
+
const stalls = fakeHttpGates(tell, "stall");
|
|
8
|
+
const arrivals = new Map();
|
|
9
|
+
const active = new Map();
|
|
5
10
|
return {
|
|
11
|
+
arrive: (route, response) => {
|
|
12
|
+
active.set(response, route);
|
|
13
|
+
response.once("close", () => active.delete(response));
|
|
14
|
+
for (const release of arrivals.get(route) ?? []) {
|
|
15
|
+
release();
|
|
16
|
+
}
|
|
17
|
+
arrivals.delete(route);
|
|
18
|
+
},
|
|
6
19
|
close: () => {
|
|
7
|
-
for (const
|
|
8
|
-
|
|
20
|
+
for (const response of active.keys()) {
|
|
21
|
+
response.destroy();
|
|
22
|
+
}
|
|
23
|
+
holds.close();
|
|
24
|
+
stalls.close();
|
|
25
|
+
arrivals.clear();
|
|
26
|
+
replies.clear();
|
|
27
|
+
},
|
|
28
|
+
fault: (route, fault) => Effect.promise(async () => {
|
|
29
|
+
const response = [...active].find(([candidate, name]) => name === route && !(candidate.destroyed || candidate.writableEnded) && candidate.headersSent === (fault.kind === "reset"))?.[0];
|
|
30
|
+
if (!response) {
|
|
31
|
+
throw new Error(`Fake HTTP route "${route}" has no request ready for a ${fault.kind} fault.\nhelp: Wait for a hold before a status or drop fault, or for response headers before a reset fault.`);
|
|
9
32
|
}
|
|
33
|
+
tell(`fake HTTP ${route} fault ${fault.kind}`);
|
|
34
|
+
if (fault.kind === "status") {
|
|
35
|
+
await fakeHttpSend(response, fault.reply);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
response.destroy();
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
hold: (route) => Effect.sync(() => holds.make(route)),
|
|
42
|
+
pause: async (route, response) => {
|
|
43
|
+
await fakeHttpConnection(response).pause(holds.get(route)?.enter());
|
|
44
|
+
return !(response.destroyed || response.writableEnded || response.headersSent);
|
|
10
45
|
},
|
|
11
|
-
|
|
12
|
-
const queue =
|
|
46
|
+
queue: (route, reply) => Effect.sync(() => {
|
|
47
|
+
const queue = replies.get(route) ?? [];
|
|
13
48
|
queue.push(reply);
|
|
14
|
-
|
|
49
|
+
replies.set(route, queue);
|
|
15
50
|
tell(`fake HTTP ${route} will answer once with ${reply.status ?? 200}`);
|
|
16
51
|
}),
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
releaseOnArrival: (route, arrival) => Effect.sync(() => {
|
|
53
|
+
const hold = holds.get(route);
|
|
54
|
+
if (!hold) {
|
|
55
|
+
throw new Error(`Fake HTTP route "${route}" has no hold.\nhelp: Hold the route before scripting its release on arrival.`);
|
|
20
56
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
tell(`fake HTTP ${route} has a waiting request`);
|
|
26
|
-
Deferred.doneUnsafe(waiting, Effect.void);
|
|
27
|
-
return released.promise;
|
|
28
|
-
},
|
|
29
|
-
release: () => {
|
|
30
|
-
if (holds.get(route) !== hold) {
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
holds.delete(route);
|
|
34
|
-
released.resolve();
|
|
35
|
-
tell(`fake HTTP ${route} released`);
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
holds.set(route, hold);
|
|
39
|
-
tell(`fake HTTP ${route} held`);
|
|
40
|
-
return { release: Effect.sync(hold.release), waiting: Deferred.await(waiting) };
|
|
57
|
+
const releases = arrivals.get(arrival) ?? new Set();
|
|
58
|
+
releases.add(hold.release);
|
|
59
|
+
arrivals.set(arrival, releases);
|
|
60
|
+
tell(`fake HTTP ${route} will release when ${arrival} arrives`);
|
|
41
61
|
}),
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
stall: (route) => Effect.sync(() => stalls.make(route)),
|
|
63
|
+
stallBody: (route) => {
|
|
64
|
+
const stall = stalls.get(route);
|
|
65
|
+
return stall ? () => stall.enter() : undefined;
|
|
44
66
|
},
|
|
45
|
-
|
|
67
|
+
takeReply: (route) => replies.get(route)?.shift(),
|
|
46
68
|
};
|
|
47
69
|
}
|
|
48
70
|
//# sourceMappingURL=fakeHttpControls.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpControls.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fakeHttpControls.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FakeHttpHold } from "#fakeHttpService.ts";
|
|
2
|
+
export declare function fakeHttpGates(tell: (line: string) => void, kind: "hold" | "stall"): {
|
|
3
|
+
close: () => void;
|
|
4
|
+
get: (route: string) => {
|
|
5
|
+
enter: () => Promise<void>;
|
|
6
|
+
release: () => void;
|
|
7
|
+
} | undefined;
|
|
8
|
+
make: (route: string) => FakeHttpHold;
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=fakeHttpGates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeHttpGates.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpGates.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,wBAAgB,aAAa,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAGhF,KAAK;IAKL,GAAG,UAAU,MAAM;eAPmB,MAAM,OAAO,CAAC,IAAI,CAAC;iBAAW,MAAM,IAAI;;IAQ9E,IAAI,UAAU,MAAM,KAAG,YAAY;EA0BpC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Deferred, Effect } from "effect";
|
|
2
|
+
export function fakeHttpGates(tell, kind) {
|
|
3
|
+
const gates = new Map();
|
|
4
|
+
return {
|
|
5
|
+
close: () => {
|
|
6
|
+
for (const gate of gates.values()) {
|
|
7
|
+
gate.release();
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
get: (route) => gates.get(route),
|
|
11
|
+
make: (route) => {
|
|
12
|
+
if (gates.has(route)) {
|
|
13
|
+
throw new Error(`Fake HTTP route "${route}" already has a ${kind}.\nhelp: Release its current ${kind} before scripting another.`);
|
|
14
|
+
}
|
|
15
|
+
const waiting = Deferred.makeUnsafe();
|
|
16
|
+
const released = Promise.withResolvers();
|
|
17
|
+
const gate = {
|
|
18
|
+
enter: () => {
|
|
19
|
+
tell(kind === "hold" ? `fake HTTP ${route} has a waiting request` : `fake HTTP ${route} body stalled`);
|
|
20
|
+
Deferred.doneUnsafe(waiting, Effect.void);
|
|
21
|
+
return released.promise;
|
|
22
|
+
},
|
|
23
|
+
release: () => {
|
|
24
|
+
if (gates.get(route) !== gate) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
gates.delete(route);
|
|
28
|
+
released.resolve();
|
|
29
|
+
tell(kind === "hold" ? `fake HTTP ${route} released` : `fake HTTP ${route} body released`);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
gates.set(route, gate);
|
|
33
|
+
tell(kind === "hold" ? `fake HTTP ${route} held` : `fake HTTP ${route} body will stall`);
|
|
34
|
+
return { release: Effect.sync(gate.release), waiting: Deferred.await(waiting) };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=fakeHttpGates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeHttpGates.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpGates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAG1C,MAAM,UAAU,aAAa,CAAC,IAA4B,EAAE,IAAsB;IACjF,MAAM,KAAK,GAAG,IAAI,GAAG,EAA+D,CAAC;IACrF,OAAO;QACN,KAAK,EAAE,GAAG,EAAE;YACX,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;gBACnC,IAAI,CAAC,OAAO,EAAE,CAAC;YAChB,CAAC;QACF,CAAC;QACD,GAAG,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC;QACxC,IAAI,EAAE,CAAC,KAAa,EAAgB,EAAE;YACrC,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBACtB,MAAM,IAAI,KAAK,CAAC,oBAAoB,KAAK,mBAAmB,IAAI,gCAAgC,IAAI,4BAA4B,CAAC,CAAC;YACnI,CAAC;YACD,MAAM,OAAO,GAAG,QAAQ,CAAC,UAAU,EAAQ,CAAC;YAC5C,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAC;YAC/C,MAAM,IAAI,GAAG;gBACZ,KAAK,EAAE,GAAG,EAAE;oBACX,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,KAAK,wBAAwB,CAAC,CAAC,CAAC,aAAa,KAAK,eAAe,CAAC,CAAC;oBACvG,QAAQ,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;oBAC1C,OAAO,QAAQ,CAAC,OAAO,CAAC;gBACzB,CAAC;gBACD,OAAO,EAAE,GAAG,EAAE;oBACb,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC;wBAC/B,OAAO;oBACR,CAAC;oBACD,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;oBACpB,QAAQ,CAAC,OAAO,EAAE,CAAC;oBACnB,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,KAAK,WAAW,CAAC,CAAC,CAAC,aAAa,KAAK,gBAAgB,CAAC,CAAC;gBAC5F,CAAC;aACD,CAAC;YACF,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YACvB,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,aAAa,KAAK,kBAAkB,CAAC,CAAC;YACzF,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACjF,CAAC;KACD,CAAC;AACH,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpReceive.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAiB,eAAe,EAAiB,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AACjH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"fakeHttpReceive.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAiB,eAAe,EAAiB,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AACjH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AA8CtE,wBAAsB,eAAe,CACpC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,cAAc,EACxB,OAAO,EAAE,sBAAsB,EAC/B,QAAQ,EAAE,eAAe,EAAE,EAC3B,QAAQ,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,EAC7C,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,iBAuD5B"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fakeHttpSend } from "#internal/fakeHttpSend.ts";
|
|
1
2
|
function match(route, method, pathname) {
|
|
2
3
|
if ((route.method ?? "GET").toUpperCase() !== method) {
|
|
3
4
|
return;
|
|
@@ -40,11 +41,6 @@ function pageReply(route, request) {
|
|
|
40
41
|
}
|
|
41
42
|
return { body: { items: route.pages[page - 1] ?? [], next: page < route.pages.length ? page + 1 : null } };
|
|
42
43
|
}
|
|
43
|
-
function send(response, reply) {
|
|
44
|
-
const body = reply.body === undefined ? undefined : JSON.stringify(reply.body);
|
|
45
|
-
response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
|
|
46
|
-
response.end(body);
|
|
47
|
-
}
|
|
48
44
|
export async function fakeHttpReceive(incoming, response, options, requests, controls, tell) {
|
|
49
45
|
try {
|
|
50
46
|
const path = incoming.url ?? "/";
|
|
@@ -71,31 +67,34 @@ export async function fakeHttpReceive(incoming, response, options, requests, con
|
|
|
71
67
|
tell(`fake HTTP received ${method} ${path}`);
|
|
72
68
|
if (!decodeParams(params)) {
|
|
73
69
|
tell(`fake HTTP rejected ${method} ${path} with 400`);
|
|
74
|
-
|
|
70
|
+
await fakeHttpSend(response, { status: 400 });
|
|
75
71
|
return;
|
|
76
72
|
}
|
|
77
73
|
if (options.guard && !options.guard(request)) {
|
|
78
74
|
tell(`fake HTTP rejected ${method} ${path} with 401`);
|
|
79
|
-
|
|
75
|
+
await fakeHttpSend(response, { status: 401 });
|
|
80
76
|
return;
|
|
81
77
|
}
|
|
82
78
|
if (!found) {
|
|
83
79
|
tell(`fake HTTP answered ${method} ${path} with 404`);
|
|
84
|
-
|
|
80
|
+
await fakeHttpSend(response, { status: 404 });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
controls.arrive(found.name, response);
|
|
84
|
+
if (!(await controls.pause(found.name, response))) {
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
const reply = controls.takeFailure(found.name) ?? pageReply(found.route, request);
|
|
87
|
+
const reply = controls.takeReply(found.name) ?? pageReply(found.route, request);
|
|
89
88
|
tell(`fake HTTP answered ${method} ${path} with ${reply.status ?? 200}`);
|
|
90
|
-
|
|
89
|
+
await fakeHttpSend(response, reply, controls.stallBody(found.name));
|
|
91
90
|
}
|
|
92
91
|
catch (error) {
|
|
93
92
|
tell(`fake HTTP could not answer: ${String(error)}`);
|
|
94
93
|
if (response.headersSent) {
|
|
95
|
-
response.
|
|
94
|
+
response.destroy();
|
|
96
95
|
}
|
|
97
96
|
else {
|
|
98
|
-
|
|
97
|
+
await fakeHttpSend(response, { status: 500 });
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fakeHttpReceive.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fakeHttpReceive.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import type { FakeHttpReply } from "#fakeHttpService.ts";
|
|
3
|
+
export declare function fakeHttpConnection(response: ServerResponse): {
|
|
4
|
+
closed: Promise<undefined>;
|
|
5
|
+
pause: (pending: Promise<void> | undefined) => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function fakeHttpSend(response: ServerResponse, reply: FakeHttpReply, pause?: () => Promise<void>): Promise<void>;
|
|
8
|
+
//# sourceMappingURL=fakeHttpSend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeHttpSend.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpSend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,cAAc;;qBAIlC,OAAO,CAAC,IAAI,CAAC,GAAG,SAAS;EAMjD;AAwBD,wBAAsB,YAAY,CAAC,QAAQ,EAAE,cAAc,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,iBAiB7G"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function fakeHttpConnection(response) {
|
|
2
|
+
const closed = new Promise((resolve) => response.once("close", () => resolve(undefined)));
|
|
3
|
+
return {
|
|
4
|
+
closed,
|
|
5
|
+
pause: async (pending) => {
|
|
6
|
+
if (pending !== undefined) {
|
|
7
|
+
await Promise.race([pending, closed]);
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async function sendChunks(response, chunks, closed) {
|
|
13
|
+
const iterator = Symbol.asyncIterator in chunks ? chunks[Symbol.asyncIterator]() : chunks[Symbol.iterator]();
|
|
14
|
+
try {
|
|
15
|
+
while (!(response.destroyed || response.writableEnded)) {
|
|
16
|
+
const next = await Promise.race([iterator.next(), closed]);
|
|
17
|
+
if (!next || next.done) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
await new Promise((resolve, reject) => response.write(next.value, (error) => (error ? reject(error) : resolve())));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
void Promise.resolve(iterator.return?.()).catch(() => response.destroy());
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function replyBody(body) {
|
|
28
|
+
if (body instanceof Uint8Array || body === undefined) {
|
|
29
|
+
return body;
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(body);
|
|
32
|
+
}
|
|
33
|
+
export async function fakeHttpSend(response, reply, pause) {
|
|
34
|
+
if (response.destroyed || response.writableEnded) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const connection = fakeHttpConnection(response);
|
|
38
|
+
const body = replyBody(reply.body);
|
|
39
|
+
response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
|
|
40
|
+
if (pause) {
|
|
41
|
+
response.flushHeaders();
|
|
42
|
+
await connection.pause(pause());
|
|
43
|
+
}
|
|
44
|
+
if (reply.chunks) {
|
|
45
|
+
await sendChunks(response, reply.chunks, connection.closed);
|
|
46
|
+
}
|
|
47
|
+
if (!(response.destroyed || response.writableEnded)) {
|
|
48
|
+
response.end(body);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=fakeHttpSend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fakeHttpSend.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpSend.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/test-story",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Tests that read like stories over the real engine: a story kit per engine with its own it, traits per feature, and a failure that prints the story, where it stopped and the engine's state",
|
|
5
5
|
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/test-story#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@effect/vitest": "4.0.4",
|
|
49
|
-
"@shivaedev/quality": "0.
|
|
49
|
+
"@shivaedev/quality": "0.28.1",
|
|
50
50
|
"@types/node": "24.19.2",
|
|
51
51
|
"effect": "4.0.4",
|
|
52
52
|
"happy-dom": "20.14.6",
|
|
@@ -15,10 +15,13 @@ export interface FakeHttpRequest {
|
|
|
15
15
|
|
|
16
16
|
export interface FakeHttpReply {
|
|
17
17
|
readonly body?: unknown;
|
|
18
|
+
readonly chunks?: Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>;
|
|
18
19
|
readonly headers?: Readonly<Record<string, string>>;
|
|
19
20
|
readonly status?: number;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
export type FakeHttpFault = { readonly kind: "status"; readonly reply: FakeHttpReply } | { readonly kind: "drop" | "reset" };
|
|
24
|
+
|
|
22
25
|
export type FakeHttpRoute = {
|
|
23
26
|
readonly method?: string;
|
|
24
27
|
readonly path: string;
|
|
@@ -54,14 +57,22 @@ export const fakeHttpService = Effect.fnUntraced(function* (options: FakeHttpSer
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
return {
|
|
57
|
-
failOnce: (route: string, reply: FakeHttpReply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.
|
|
60
|
+
failOnce: (route: string, reply: FakeHttpReply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.queue(route, reply))),
|
|
61
|
+
fault: (route: string, fault: FakeHttpFault) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.fault(route, fault))),
|
|
58
62
|
hold: (route: string) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.hold(route))),
|
|
59
63
|
narrate: (sink: (line: string) => void) => {
|
|
60
64
|
narrate = sink;
|
|
61
65
|
},
|
|
66
|
+
releaseOnArrival: (route: string, arrival: string) =>
|
|
67
|
+
Effect.sync(() => {
|
|
68
|
+
checkRoute(route);
|
|
69
|
+
checkRoute(arrival);
|
|
70
|
+
}).pipe(Effect.andThen(controls.releaseOnArrival(route, arrival))),
|
|
71
|
+
replyOnce: (route: string, reply: FakeHttpReply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.queue(route, reply))),
|
|
62
72
|
get requests(): readonly FakeHttpRequest[] {
|
|
63
73
|
return requests.slice();
|
|
64
74
|
},
|
|
75
|
+
stall: (route: string) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.stall(route))),
|
|
65
76
|
url,
|
|
66
77
|
};
|
|
67
78
|
});
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
import { createServer, type RequestListener, request } from "node:http";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
import { Effect } from "effect";
|
|
3
4
|
import { loopbackServer } from "#internal/loopback.ts";
|
|
4
5
|
|
|
5
6
|
export interface ForwardingProxyOptions {
|
|
7
|
+
readonly forwardedHops?: readonly string[];
|
|
6
8
|
readonly headers?: Readonly<Record<string, string>>;
|
|
9
|
+
readonly localAddress?: string;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export const forwardingProxy = Effect.fnUntraced(function* (upstream: string, options: ForwardingProxyOptions = {}) {
|
|
13
|
+
if (options.localAddress !== undefined) {
|
|
14
|
+
yield* Effect.scoped(loopbackServer(createServer(), { host: options.localAddress })).pipe(
|
|
15
|
+
Effect.mapError((cause) => new Error(`Cannot acquire forwarding proxy with local address ${options.localAddress}`, { cause })),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
10
18
|
const forward: RequestListener = (incoming, response) => {
|
|
11
19
|
const outgoing = request(
|
|
12
20
|
`${upstream}${incoming.url}`,
|
|
13
21
|
{
|
|
22
|
+
family: options.localAddress === undefined ? undefined : isIP(options.localAddress),
|
|
14
23
|
headers: {
|
|
15
24
|
...incoming.headers,
|
|
16
|
-
"x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress]
|
|
25
|
+
"x-forwarded-for": [incoming.headers["x-forwarded-for"], ...(options.forwardedHops ?? [incoming.socket.remoteAddress])]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(", "),
|
|
17
28
|
...options.headers,
|
|
18
29
|
},
|
|
30
|
+
localAddress: options.localAddress,
|
|
19
31
|
method: incoming.method,
|
|
20
32
|
},
|
|
21
33
|
(result) => {
|
|
@@ -1,51 +1,79 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { FakeHttpFault, FakeHttpReply } from "#fakeHttpService.ts";
|
|
4
|
+
import { fakeHttpGates } from "#internal/fakeHttpGates.ts";
|
|
5
|
+
import { fakeHttpConnection, fakeHttpSend } from "#internal/fakeHttpSend.ts";
|
|
3
6
|
|
|
4
7
|
export function fakeHttpControls(tell: (line: string) => void) {
|
|
5
|
-
const
|
|
6
|
-
const holds =
|
|
8
|
+
const replies = new Map<string, FakeHttpReply[]>();
|
|
9
|
+
const holds = fakeHttpGates(tell, "hold");
|
|
10
|
+
const stalls = fakeHttpGates(tell, "stall");
|
|
11
|
+
const arrivals = new Map<string, Set<() => void>>();
|
|
12
|
+
const active = new Map<ServerResponse, string>();
|
|
7
13
|
return {
|
|
14
|
+
arrive: (route: string, response: ServerResponse) => {
|
|
15
|
+
active.set(response, route);
|
|
16
|
+
response.once("close", () => active.delete(response));
|
|
17
|
+
for (const release of arrivals.get(route) ?? []) {
|
|
18
|
+
release();
|
|
19
|
+
}
|
|
20
|
+
arrivals.delete(route);
|
|
21
|
+
},
|
|
8
22
|
close: () => {
|
|
9
|
-
for (const
|
|
10
|
-
|
|
23
|
+
for (const response of active.keys()) {
|
|
24
|
+
response.destroy();
|
|
11
25
|
}
|
|
26
|
+
holds.close();
|
|
27
|
+
stalls.close();
|
|
28
|
+
arrivals.clear();
|
|
29
|
+
replies.clear();
|
|
30
|
+
},
|
|
31
|
+
fault: (route: string, fault: FakeHttpFault) =>
|
|
32
|
+
Effect.promise(async () => {
|
|
33
|
+
const response = [...active].find(
|
|
34
|
+
([candidate, name]) =>
|
|
35
|
+
name === route && !(candidate.destroyed || candidate.writableEnded) && candidate.headersSent === (fault.kind === "reset"),
|
|
36
|
+
)?.[0];
|
|
37
|
+
if (!response) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Fake HTTP route "${route}" has no request ready for a ${fault.kind} fault.\nhelp: Wait for a hold before a status or drop fault, or for response headers before a reset fault.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
tell(`fake HTTP ${route} fault ${fault.kind}`);
|
|
43
|
+
if (fault.kind === "status") {
|
|
44
|
+
await fakeHttpSend(response, fault.reply);
|
|
45
|
+
} else {
|
|
46
|
+
response.destroy();
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
hold: (route: string) => Effect.sync(() => holds.make(route)),
|
|
50
|
+
pause: async (route: string, response: ServerResponse) => {
|
|
51
|
+
await fakeHttpConnection(response).pause(holds.get(route)?.enter());
|
|
52
|
+
return !(response.destroyed || response.writableEnded || response.headersSent);
|
|
12
53
|
},
|
|
13
|
-
|
|
54
|
+
queue: (route: string, reply: FakeHttpReply) =>
|
|
14
55
|
Effect.sync(() => {
|
|
15
|
-
const queue =
|
|
56
|
+
const queue = replies.get(route) ?? [];
|
|
16
57
|
queue.push(reply);
|
|
17
|
-
|
|
58
|
+
replies.set(route, queue);
|
|
18
59
|
tell(`fake HTTP ${route} will answer once with ${reply.status ?? 200}`);
|
|
19
60
|
}),
|
|
20
|
-
|
|
61
|
+
releaseOnArrival: (route: string, arrival: string) =>
|
|
21
62
|
Effect.sync(() => {
|
|
22
|
-
|
|
23
|
-
|
|
63
|
+
const hold = holds.get(route);
|
|
64
|
+
if (!hold) {
|
|
65
|
+
throw new Error(`Fake HTTP route "${route}" has no hold.\nhelp: Hold the route before scripting its release on arrival.`);
|
|
24
66
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
tell(`fake HTTP ${route} has a waiting request`);
|
|
30
|
-
Deferred.doneUnsafe(waiting, Effect.void);
|
|
31
|
-
return released.promise;
|
|
32
|
-
},
|
|
33
|
-
release: () => {
|
|
34
|
-
if (holds.get(route) !== hold) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
holds.delete(route);
|
|
38
|
-
released.resolve();
|
|
39
|
-
tell(`fake HTTP ${route} released`);
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
holds.set(route, hold);
|
|
43
|
-
tell(`fake HTTP ${route} held`);
|
|
44
|
-
return { release: Effect.sync(hold.release), waiting: Deferred.await(waiting) };
|
|
67
|
+
const releases = arrivals.get(arrival) ?? new Set<() => void>();
|
|
68
|
+
releases.add(hold.release);
|
|
69
|
+
arrivals.set(arrival, releases);
|
|
70
|
+
tell(`fake HTTP ${route} will release when ${arrival} arrives`);
|
|
45
71
|
}),
|
|
46
|
-
|
|
47
|
-
|
|
72
|
+
stall: (route: string) => Effect.sync(() => stalls.make(route)),
|
|
73
|
+
stallBody: (route: string) => {
|
|
74
|
+
const stall = stalls.get(route);
|
|
75
|
+
return stall ? () => stall.enter() : undefined;
|
|
48
76
|
},
|
|
49
|
-
|
|
77
|
+
takeReply: (route: string) => replies.get(route)?.shift(),
|
|
50
78
|
};
|
|
51
79
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Deferred, Effect } from "effect";
|
|
2
|
+
import type { FakeHttpHold } from "#fakeHttpService.ts";
|
|
3
|
+
|
|
4
|
+
export function fakeHttpGates(tell: (line: string) => void, kind: "hold" | "stall") {
|
|
5
|
+
const gates = new Map<string, { enter: () => Promise<void>; release: () => void }>();
|
|
6
|
+
return {
|
|
7
|
+
close: () => {
|
|
8
|
+
for (const gate of gates.values()) {
|
|
9
|
+
gate.release();
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
get: (route: string) => gates.get(route),
|
|
13
|
+
make: (route: string): FakeHttpHold => {
|
|
14
|
+
if (gates.has(route)) {
|
|
15
|
+
throw new Error(`Fake HTTP route "${route}" already has a ${kind}.\nhelp: Release its current ${kind} before scripting another.`);
|
|
16
|
+
}
|
|
17
|
+
const waiting = Deferred.makeUnsafe<void>();
|
|
18
|
+
const released = Promise.withResolvers<void>();
|
|
19
|
+
const gate = {
|
|
20
|
+
enter: () => {
|
|
21
|
+
tell(kind === "hold" ? `fake HTTP ${route} has a waiting request` : `fake HTTP ${route} body stalled`);
|
|
22
|
+
Deferred.doneUnsafe(waiting, Effect.void);
|
|
23
|
+
return released.promise;
|
|
24
|
+
},
|
|
25
|
+
release: () => {
|
|
26
|
+
if (gates.get(route) !== gate) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
gates.delete(route);
|
|
30
|
+
released.resolve();
|
|
31
|
+
tell(kind === "hold" ? `fake HTTP ${route} released` : `fake HTTP ${route} body released`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
gates.set(route, gate);
|
|
35
|
+
tell(kind === "hold" ? `fake HTTP ${route} held` : `fake HTTP ${route} body will stall`);
|
|
36
|
+
return { release: Effect.sync(gate.release), waiting: Deferred.await(waiting) };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { FakeHttpReply, FakeHttpRequest, FakeHttpRoute, FakeHttpServiceOptions } from "#fakeHttpService.ts";
|
|
3
3
|
import type { fakeHttpControls } from "#internal/fakeHttpControls.ts";
|
|
4
|
+
import { fakeHttpSend } from "#internal/fakeHttpSend.ts";
|
|
4
5
|
|
|
5
6
|
function match(route: FakeHttpRoute, method: string, pathname: string) {
|
|
6
7
|
if ((route.method ?? "GET").toUpperCase() !== method) {
|
|
@@ -45,12 +46,6 @@ function pageReply(route: FakeHttpRoute, request: FakeHttpRequest): FakeHttpRepl
|
|
|
45
46
|
return { body: { items: route.pages[page - 1] ?? [], next: page < route.pages.length ? page + 1 : null } };
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function send(response: ServerResponse, reply: FakeHttpReply) {
|
|
49
|
-
const body = reply.body === undefined ? undefined : JSON.stringify(reply.body);
|
|
50
|
-
response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
|
|
51
|
-
response.end(body);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
49
|
export async function fakeHttpReceive(
|
|
55
50
|
incoming: IncomingMessage,
|
|
56
51
|
response: ServerResponse,
|
|
@@ -84,29 +79,32 @@ export async function fakeHttpReceive(
|
|
|
84
79
|
tell(`fake HTTP received ${method} ${path}`);
|
|
85
80
|
if (!decodeParams(params)) {
|
|
86
81
|
tell(`fake HTTP rejected ${method} ${path} with 400`);
|
|
87
|
-
|
|
82
|
+
await fakeHttpSend(response, { status: 400 });
|
|
88
83
|
return;
|
|
89
84
|
}
|
|
90
85
|
if (options.guard && !options.guard(request)) {
|
|
91
86
|
tell(`fake HTTP rejected ${method} ${path} with 401`);
|
|
92
|
-
|
|
87
|
+
await fakeHttpSend(response, { status: 401 });
|
|
93
88
|
return;
|
|
94
89
|
}
|
|
95
90
|
if (!found) {
|
|
96
91
|
tell(`fake HTTP answered ${method} ${path} with 404`);
|
|
97
|
-
|
|
92
|
+
await fakeHttpSend(response, { status: 404 });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
controls.arrive(found.name, response);
|
|
96
|
+
if (!(await controls.pause(found.name, response))) {
|
|
98
97
|
return;
|
|
99
98
|
}
|
|
100
|
-
|
|
101
|
-
const reply = controls.takeFailure(found.name) ?? pageReply(found.route, request);
|
|
99
|
+
const reply = controls.takeReply(found.name) ?? pageReply(found.route, request);
|
|
102
100
|
tell(`fake HTTP answered ${method} ${path} with ${reply.status ?? 200}`);
|
|
103
|
-
|
|
101
|
+
await fakeHttpSend(response, reply, controls.stallBody(found.name));
|
|
104
102
|
} catch (error) {
|
|
105
103
|
tell(`fake HTTP could not answer: ${String(error)}`);
|
|
106
104
|
if (response.headersSent) {
|
|
107
|
-
response.
|
|
105
|
+
response.destroy();
|
|
108
106
|
} else {
|
|
109
|
-
|
|
107
|
+
await fakeHttpSend(response, { status: 500 });
|
|
110
108
|
}
|
|
111
109
|
}
|
|
112
110
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import type { FakeHttpReply } from "#fakeHttpService.ts";
|
|
3
|
+
|
|
4
|
+
export function fakeHttpConnection(response: ServerResponse) {
|
|
5
|
+
const closed = new Promise<undefined>((resolve) => response.once("close", () => resolve(undefined)));
|
|
6
|
+
return {
|
|
7
|
+
closed,
|
|
8
|
+
pause: async (pending: Promise<void> | undefined) => {
|
|
9
|
+
if (pending !== undefined) {
|
|
10
|
+
await Promise.race([pending, closed]);
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function sendChunks(response: ServerResponse, chunks: NonNullable<FakeHttpReply["chunks"]>, closed: Promise<undefined>) {
|
|
17
|
+
const iterator = Symbol.asyncIterator in chunks ? chunks[Symbol.asyncIterator]() : chunks[Symbol.iterator]();
|
|
18
|
+
try {
|
|
19
|
+
while (!(response.destroyed || response.writableEnded)) {
|
|
20
|
+
const next = await Promise.race([iterator.next(), closed]);
|
|
21
|
+
if (!next || next.done) {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
await new Promise<void>((resolve, reject) => response.write(next.value, (error) => (error ? reject(error) : resolve())));
|
|
25
|
+
}
|
|
26
|
+
} finally {
|
|
27
|
+
void Promise.resolve(iterator.return?.()).catch(() => response.destroy());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function replyBody(body: unknown) {
|
|
32
|
+
if (body instanceof Uint8Array || body === undefined) {
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(body);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fakeHttpSend(response: ServerResponse, reply: FakeHttpReply, pause?: () => Promise<void>) {
|
|
39
|
+
if (response.destroyed || response.writableEnded) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const connection = fakeHttpConnection(response);
|
|
43
|
+
const body = replyBody(reply.body);
|
|
44
|
+
response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
|
|
45
|
+
if (pause) {
|
|
46
|
+
response.flushHeaders();
|
|
47
|
+
await connection.pause(pause());
|
|
48
|
+
}
|
|
49
|
+
if (reply.chunks) {
|
|
50
|
+
await sendChunks(response, reply.chunks, connection.closed);
|
|
51
|
+
}
|
|
52
|
+
if (!(response.destroyed || response.writableEnded)) {
|
|
53
|
+
response.end(body);
|
|
54
|
+
}
|
|
55
|
+
}
|