@shivaedev/test-story 0.8.0 → 0.9.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 - 2026-10-18
4
+
5
+ ### Added
6
+
7
+ - `forwardingProxy(upstream, options?)` and `ForwardingProxyOptions` from `@shivaedev/test-story/forwardingProxy.ts` provide a scoped loopback proxy for host stories. It forwards requests and responses, appends the caller to `x-forwarded-for`, and applies the story's configured headers.
8
+
3
9
  ## 0.8.0 - 2026-10-18
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -233,6 +233,24 @@ app.it("a request continues its supplied trace", [], function* ({ request, reque
233
233
 
234
234
  Detachment only removes the incoming parent: spans that application startup explicitly creates still follow native Effect semantics.
235
235
 
236
+ #### Forwarding requests to a host
237
+
238
+ Use `forwardingProxy` from `@shivaedev/test-story/forwardingProxy.ts` in the kit's scoped `create` or `after` hook after starting the host under test:
239
+
240
+ ```ts
241
+ import { forwardingProxy } from "@shivaedev/test-story/forwardingProxy.ts";
242
+
243
+ const proxy = yield* forwardingProxy(upstream, {
244
+ headers: {
245
+ host: "internal.test",
246
+ "x-forwarded-host": "app.example",
247
+ "x-forwarded-proto": "https",
248
+ },
249
+ });
250
+ ```
251
+
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. The proxy returns 502 if it cannot connect to the host. Both listeners close with the story's Scope.
253
+
236
254
  #### Reading OTLP exports
237
255
 
238
256
  Use `otlpReceiver` from `@shivaedev/test-story/otlpReceiver.ts` in a kit's scoped `create` hook. It listens on an allocated loopback port and closes with the story scope. Point the application's real exporter at `${receiver.url}/v1/traces`, then flush the exporter before reading:
@@ -519,6 +537,7 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
519
537
  | `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
520
538
  | `domEnvironment.config.ts` | Default Vitest environment preserving Node host globals and happy-dom document transport |
521
539
  | `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpHold`, `FakeHttpServiceOptions` |
540
+ | `forwardingProxy.ts` | `forwardingProxy(upstream, options?)`, `ForwardingProxyOptions` |
522
541
  | `otlpReceiver.ts` | `otlpReceiver(options?)`, `OtlpSpan`, `OtlpExport`, `OtlpReceiverOptions` |
523
542
 
524
543
  ### Install and limits
@@ -0,0 +1,8 @@
1
+ import { Effect } from "effect";
2
+ export interface ForwardingProxyOptions {
3
+ readonly headers?: Readonly<Record<string, string>>;
4
+ }
5
+ export declare const forwardingProxy: (upstream: string, options?: ForwardingProxyOptions | undefined) => Effect.Effect<{
6
+ url: string;
7
+ }, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
8
+ //# sourceMappingURL=forwardingProxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forwardingProxy.d.ts","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,eAAO,MAAM,eAAe;;qEA8B1B,CAAC"}
@@ -0,0 +1,32 @@
1
+ import { createServer, request } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
4
+ export const forwardingProxy = Effect.fnUntraced(function* (upstream, options = {}) {
5
+ const forward = (incoming, response) => {
6
+ const outgoing = request(`${upstream}${incoming.url}`, {
7
+ headers: {
8
+ ...incoming.headers,
9
+ "x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress].filter(Boolean).join(", "),
10
+ ...options.headers,
11
+ },
12
+ method: incoming.method,
13
+ }, (result) => {
14
+ response.writeHead(result.statusCode ?? 502, result.headers);
15
+ result.pipe(response);
16
+ });
17
+ outgoing.on("error", (error) => {
18
+ if (response.headersSent) {
19
+ response.destroy(error);
20
+ }
21
+ else {
22
+ response.writeHead(502).end();
23
+ }
24
+ });
25
+ response.on("close", () => outgoing.destroy());
26
+ incoming.pipe(outgoing);
27
+ };
28
+ const url = yield* loopbackServer(createServer(forward));
29
+ yield* loopbackServer(createServer(forward), { host: "::1", port: Number(new URL(url).port) });
30
+ return { url };
31
+ });
32
+ //# sourceMappingURL=forwardingProxy.js.map
@@ -0,0 +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;AAMvD,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,QAAgB,EAAE,OAAO,GAA2B,EAAE;IACjH,MAAM,OAAO,GAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE;QACvD,MAAM,QAAQ,GAAG,OAAO,CACvB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG,EAAE,EAC5B;YACC,OAAO,EAAE;gBACR,GAAG,QAAQ,CAAC,OAAO;gBACnB,iBAAiB,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;gBAClH,GAAG,OAAO,CAAC,OAAO;aAClB;YACD,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,4 +1,7 @@
1
1
  import type { Server } from "node:http";
2
2
  import { Effect } from "effect";
3
- export declare const loopbackServer: (server: Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>) => Effect.Effect<string, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
3
+ export declare const loopbackServer: (server: Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, options?: {
4
+ readonly host?: string;
5
+ readonly port?: number;
6
+ } | undefined) => Effect.Effect<string, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
4
7
  //# sourceMappingURL=loopback.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"loopback.d.ts","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,eAAO,MAAM,cAAc,2LA0BzB,CAAC"}
1
+ {"version":3,"file":"loopback.d.ts","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,eAAO,MAAM,cAAc;oBAA2E,MAAM;oBAAkB,MAAM;0GA0BlI,CAAC"}
@@ -1,8 +1,8 @@
1
1
  import { Effect } from "effect";
2
- export const loopbackServer = Effect.fnUntraced(function* (server) {
2
+ export const loopbackServer = Effect.fnUntraced(function* (server, options = {}) {
3
3
  yield* Effect.acquireRelease(Effect.tryPromise(() => new Promise((resolve, reject) => {
4
4
  server.once("error", reject);
5
- server.listen(0, "127.0.0.1", () => {
5
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
6
6
  server.off("error", reject);
7
7
  resolve();
8
8
  });
@@ -1 +1 @@
1
- {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAc;IACxE,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAC3B,MAAM,CAAC,UAAU,CAChB,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE;YAClC,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC5B,OAAO,EAAE,CAAC;QACX,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CACH,EACD,GAAG,EAAE,CACJ,MAAM,CAAC,OAAO,CACb,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC7D,MAAM,CAAC,mBAAmB,EAAE,CAAC;IAC9B,CAAC,CAAC,CACH,CACF,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;IACjC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACrD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,+GAA+G,CAAC,CAAC;IAC3I,CAAC;IACD,OAAO,oBAAoB,OAAO,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAc,EAAE,OAAO,GAAuD,EAAE;IAC1I,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAC3B,MAAM,CAAC,UAAU,CAChB,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,EAAE,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,GAAG,EAAE;YAClE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC5B,OAAO,EAAE,CAAC;QACX,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CACH,EACD,GAAG,EAAE,CACJ,MAAM,CAAC,OAAO,CACb,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC7D,MAAM,CAAC,mBAAmB,EAAE,CAAC;IAC9B,CAAC,CAAC,CACH,CACF,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;IACjC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACrD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,+GAA+G,CAAC,CAAC;IAC3I,CAAC;IACD,OAAO,oBAAoB,OAAO,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/test-story",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.26.0",
49
+ "@shivaedev/quality": "0.27.0",
50
50
  "@types/node": "24.19.2",
51
51
  "effect": "4.0.4",
52
52
  "happy-dom": "20.14.6",
@@ -0,0 +1,39 @@
1
+ import { createServer, type RequestListener, request } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
4
+
5
+ export interface ForwardingProxyOptions {
6
+ readonly headers?: Readonly<Record<string, string>>;
7
+ }
8
+
9
+ export const forwardingProxy = Effect.fnUntraced(function* (upstream: string, options: ForwardingProxyOptions = {}) {
10
+ const forward: RequestListener = (incoming, response) => {
11
+ const outgoing = request(
12
+ `${upstream}${incoming.url}`,
13
+ {
14
+ headers: {
15
+ ...incoming.headers,
16
+ "x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress].filter(Boolean).join(", "),
17
+ ...options.headers,
18
+ },
19
+ method: incoming.method,
20
+ },
21
+ (result) => {
22
+ response.writeHead(result.statusCode ?? 502, result.headers);
23
+ result.pipe(response);
24
+ },
25
+ );
26
+ outgoing.on("error", (error) => {
27
+ if (response.headersSent) {
28
+ response.destroy(error);
29
+ } else {
30
+ response.writeHead(502).end();
31
+ }
32
+ });
33
+ response.on("close", () => outgoing.destroy());
34
+ incoming.pipe(outgoing);
35
+ };
36
+ const url = yield* loopbackServer(createServer(forward));
37
+ yield* loopbackServer(createServer(forward), { host: "::1", port: Number(new URL(url).port) });
38
+ return { url };
39
+ });
@@ -1,13 +1,13 @@
1
1
  import type { Server } from "node:http";
2
2
  import { Effect } from "effect";
3
3
 
4
- export const loopbackServer = Effect.fnUntraced(function* (server: Server) {
4
+ export const loopbackServer = Effect.fnUntraced(function* (server: Server, options: { readonly host?: string; readonly port?: number } = {}) {
5
5
  yield* Effect.acquireRelease(
6
6
  Effect.tryPromise(
7
7
  () =>
8
8
  new Promise<void>((resolve, reject) => {
9
9
  server.once("error", reject);
10
- server.listen(0, "127.0.0.1", () => {
10
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
11
11
  server.off("error", reject);
12
12
  resolve();
13
13
  });