@volter/twin-standard 1.0.72 → 1.0.74

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.
@@ -32,5 +32,8 @@ export type ClientCase = {
32
32
  * proxy forwards a caller of the vendor's https host (twin-fetch.ts, twinPublicBase: the links and socket URLs the pack
33
33
  * answers are then the vendor's secure ones). Answers the proxy's port. */
34
34
  export declare function worldProxy(served: number): Promise<number>;
35
+ /** HTTP to a served pack, with the vendor Host and manual redirects. Each call owns a fresh connection;
36
+ * its caller's abort signal retires that request, including an unfinished response body. */
37
+ export declare function servedHttpFetch(port: number): typeof fetch;
35
38
  /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
36
39
  export declare function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string): ClientContext;
@@ -37,37 +37,60 @@ export async function worldProxy(served) {
37
37
  server.unref();
38
38
  return server.address().port;
39
39
  }
40
- /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
41
- export function clientContext(port, host, env, scenarioPath) {
42
- const routedFetch = (async (input, init) => {
40
+ /** HTTP to a served pack, with the vendor Host and manual redirects. Each call owns a fresh connection;
41
+ * its caller's abort signal retires that request, including an unfinished response body. */
42
+ export function servedHttpFetch(port) {
43
+ return (async (input, init) => {
43
44
  const request = new Request(input, init);
44
45
  const url = new URL(request.url);
45
46
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : Buffer.from(await request.arrayBuffer());
46
47
  const headers = Object.fromEntries(request.headers);
47
48
  headers.host = url.host;
49
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : request.signal);
48
50
  return new Promise((resolve, reject) => {
49
- const sent = httpRequest({ host: '127.0.0.1', port, method: request.method, path: `${url.pathname}${url.search}`, headers }, (res) => {
51
+ const cleanup = () => signal.removeEventListener('abort', abort);
52
+ const failed = (error) => { cleanup(); reject(error); };
53
+ const sent = httpRequest({ host: '127.0.0.1', port, method: request.method, path: `${url.pathname}${url.search}`, headers, agent: false }, (res) => {
50
54
  const chunks = [];
51
55
  res.on('data', (c) => chunks.push(c));
52
56
  res.on('end', () => {
57
+ cleanup();
53
58
  const answer = new Headers();
54
59
  for (const [k, v] of Object.entries(res.headers))
55
60
  if (v !== undefined)
56
61
  for (const one of Array.isArray(v) ? v : [v])
57
62
  answer.append(k, one);
58
- resolve(new Response(res.statusCode === 204 || res.statusCode === 304 ? null : Buffer.concat(chunks), { status: res.statusCode ?? 0, headers: answer }));
63
+ try {
64
+ resolve(new Response(request.method === 'HEAD' || res.statusCode === 204 || res.statusCode === 205 || res.statusCode === 304 ? null : Buffer.concat(chunks), { status: res.statusCode ?? 0, headers: answer }));
65
+ }
66
+ catch (error) {
67
+ reject(error);
68
+ }
59
69
  });
60
- res.on('error', reject);
70
+ res.on('error', failed);
61
71
  });
62
- sent.on('error', reject);
72
+ const abort = () => {
73
+ const error = new Error('served HTTP request aborted');
74
+ sent.destroy(error);
75
+ failed(error);
76
+ };
77
+ sent.on('error', failed);
78
+ signal.addEventListener('abort', abort, { once: true });
79
+ if (signal.aborted) {
80
+ abort();
81
+ return;
82
+ }
63
83
  sent.end(body);
64
84
  });
65
85
  });
86
+ }
87
+ /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
88
+ export function clientContext(port, host, env, scenarioPath) {
66
89
  const agent = new (class extends HttpsAgent {
67
90
  createConnection() { return connect(port, '127.0.0.1'); }
68
91
  })({ keepAlive: false });
69
92
  return {
70
- host, env, fetch: routedFetch, agent,
93
+ host, env, fetch: servedHttpFetch(port), agent,
71
94
  connect: () => connect(port, '127.0.0.1'),
72
95
  scenario: (doc) => writeFileSync(scenarioPath, JSON.stringify(doc)),
73
96
  };
@@ -26,6 +26,7 @@ import { tmpdir } from 'node:os';
26
26
  import { basename, dirname, extname, join, matchesGlob, relative, resolve } from 'node:path';
27
27
  import { citations, sourceHeld } from "./check-sources.js";
28
28
  import { fileURLToPath } from 'node:url';
29
+ import { servedHttpFetch } from "./clients/harness.js";
29
30
  const pascal = (s) => s.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
30
31
  const readJson = (p) => (existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : undefined);
31
32
  /** The pack's units: the pack and each lane in it (a directory with its own src/manifest.ts and no package.json). */
@@ -379,15 +380,18 @@ async function lifeServed(pack) {
379
380
  env.VOLTER_WORLD_BOOT_ID = before;
380
381
  }
381
382
  const port = server.inspectPort ?? server.port;
383
+ const http = servedHttpFetch(port);
382
384
  return {
383
385
  // the vendor's URL as the application called it, sent to the served pack: its host carried in the Host header
384
386
  fetch: async (request) => {
385
- const url = new URL(request.url);
386
- const headers = new Headers(request.headers);
387
- headers.set('host', url.host);
388
- const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
389
- const answer = await within(10_000, fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual', keepalive: false, ...(body ? { body } : {}) }));
390
- return answer === 'timeout' ? new Response(`no answer in 10 s from the served pack (${request.method} ${url.pathname})`, { status: 599 }) : answer;
387
+ const abort = new AbortController();
388
+ try {
389
+ const answer = await within(10_000, http(request, { signal: abort.signal }));
390
+ return answer === 'timeout' ? new Response(`no answer in 10 s from the served pack (${request.method} ${new URL(request.url).pathname})`, { status: 599 }) : answer;
391
+ }
392
+ finally {
393
+ abort.abort();
394
+ }
391
395
  },
392
396
  // a socket opened as an application opens it: a WebSocket to the served port, the vendor's host in its Host header
393
397
  socket: (url, headers) => new WebSocket(`ws://127.0.0.1:${port}${url}`, { headers }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.72",
3
+ "version": "1.0.74",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -75,7 +75,7 @@
75
75
  "yaml": "2.9.0"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/world-core": "3.0.80",
78
+ "@volter/world-core": "3.0.82",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
@@ -67,34 +67,51 @@ export async function worldProxy(served: number): Promise<number> {
67
67
  return (server.address() as AddressInfo).port;
68
68
  }
69
69
 
70
- /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
71
- export function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string): ClientContext {
72
- const routedFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
70
+ /** HTTP to a served pack, with the vendor Host and manual redirects. Each call owns a fresh connection;
71
+ * its caller's abort signal retires that request, including an unfinished response body. */
72
+ export function servedHttpFetch(port: number): typeof fetch {
73
+ return (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
73
74
  const request = new Request(input, init);
74
75
  const url = new URL(request.url);
75
76
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : Buffer.from(await request.arrayBuffer());
76
77
  const headers: Record<string, string> = Object.fromEntries(request.headers);
77
78
  headers.host = url.host;
79
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : request.signal);
78
80
  return new Promise<Response>((resolve, reject) => {
79
- const sent = httpRequest({ host: '127.0.0.1', port, method: request.method, path: `${url.pathname}${url.search}`, headers }, (res) => {
81
+ const cleanup = () => signal.removeEventListener('abort', abort);
82
+ const failed = (error: Error) => { cleanup(); reject(error); };
83
+ const sent = httpRequest({ host: '127.0.0.1', port, method: request.method, path: `${url.pathname}${url.search}`, headers, agent: false }, (res) => {
80
84
  const chunks: Buffer[] = [];
81
85
  res.on('data', (c: Buffer) => chunks.push(c));
82
86
  res.on('end', () => {
87
+ cleanup();
83
88
  const answer = new Headers();
84
89
  for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) for (const one of Array.isArray(v) ? v : [v]) answer.append(k, one);
85
- resolve(new Response(res.statusCode === 204 || res.statusCode === 304 ? null : Buffer.concat(chunks), { status: res.statusCode ?? 0, headers: answer }));
90
+ try { resolve(new Response(request.method === 'HEAD' || res.statusCode === 204 || res.statusCode === 205 || res.statusCode === 304 ? null : Buffer.concat(chunks), { status: res.statusCode ?? 0, headers: answer })); }
91
+ catch (error) { reject(error); }
86
92
  });
87
- res.on('error', reject);
93
+ res.on('error', failed);
88
94
  });
89
- sent.on('error', reject);
95
+ const abort = () => {
96
+ const error = new Error('served HTTP request aborted');
97
+ sent.destroy(error);
98
+ failed(error);
99
+ };
100
+ sent.on('error', failed);
101
+ signal.addEventListener('abort', abort, { once: true });
102
+ if (signal.aborted) { abort(); return; }
90
103
  sent.end(body);
91
104
  });
92
105
  }) as typeof fetch;
106
+ }
107
+
108
+ /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
109
+ export function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string): ClientContext {
93
110
  const agent = new (class extends HttpsAgent {
94
111
  createConnection(): Socket { return connect(port, '127.0.0.1'); }
95
112
  })({ keepAlive: false });
96
113
  return {
97
- host, env, fetch: routedFetch, agent,
114
+ host, env, fetch: servedHttpFetch(port), agent,
98
115
  connect: () => connect(port, '127.0.0.1'),
99
116
  scenario: (doc) => writeFileSync(scenarioPath, JSON.stringify(doc)),
100
117
  };
@@ -19,6 +19,7 @@ import { basename, dirname, extname, join, matchesGlob, relative, resolve } from
19
19
  import type { TwinStream } from '@volter/world-core';
20
20
  import { citations, sourceHeld, type SourceRecord } from './check-sources.ts';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { servedHttpFetch } from './clients/harness.ts';
22
23
 
23
24
  export type ConformanceResult = { id: string; asks: string; failures: string[]; note?: string };
24
25
 
@@ -334,15 +335,15 @@ async function lifeServed(pack: string): Promise<string[]> {
334
335
  let server: Served;
335
336
  try { server = await serve(pack, { root, ...options }); } finally { if (before === undefined) delete env.VOLTER_WORLD_BOOT_ID; else env.VOLTER_WORLD_BOOT_ID = before; }
336
337
  const port = server.inspectPort ?? server.port;
338
+ const http = servedHttpFetch(port);
337
339
  return {
338
340
  // the vendor's URL as the application called it, sent to the served pack: its host carried in the Host header
339
341
  fetch: async (request: Request): Promise<Response> => {
340
- const url = new URL(request.url);
341
- const headers = new Headers(request.headers);
342
- headers.set('host', url.host);
343
- const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
344
- const answer = await within(10_000, fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, { method: request.method, headers, redirect: 'manual', keepalive: false, ...(body ? { body } : {}) }));
345
- return answer === 'timeout' ? new Response(`no answer in 10 s from the served pack (${request.method} ${url.pathname})`, { status: 599 }) : answer;
342
+ const abort = new AbortController();
343
+ try {
344
+ const answer = await within(10_000, http(request, { signal: abort.signal }));
345
+ return answer === 'timeout' ? new Response(`no answer in 10 s from the served pack (${request.method} ${new URL(request.url).pathname})`, { status: 599 }) : answer;
346
+ } finally { abort.abort(); }
346
347
  },
347
348
  // a socket opened as an application opens it: a WebSocket to the served port, the vendor's host in its Host header
348
349
  socket: (url: string, headers: Record<string, string>) => new WebSocket(`ws://127.0.0.1:${port}${url}`, { headers } as unknown as string[]),