@shivaedev/test-story 0.12.0 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.12.0 - 2026-10-18
4
10
 
5
11
  ### Added
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. The proxy returns 502 if it cannot connect to the host. Both listeners close with the story's Scope.
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
 
@@ -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
- }, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
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":"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"}
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].filter(Boolean).join(", "),
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;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
+ {"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/test-story",
3
- "version": "0.12.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.28.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",
@@ -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].filter(Boolean).join(", "),
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) => {