@volter/twin-tunnel 0.1.37 → 1.0.1
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/LICENSE +202 -0
- package/README.md +13 -121
- package/dist/generated/pack-facts.json +54 -0
- package/dist/journeys/coverage.json +109 -0
- package/dist/journeys/customer-life.json +588 -0
- package/dist/journeys/customer-life.outline.md +34 -0
- package/dist/journeys/customer-life.outline.verdict.json +42 -0
- package/dist/journeys/customer-life.verdict.json +51 -0
- package/dist/journeys/decisions.json +23 -0
- package/dist/journeys/demand.json +24 -0
- package/dist/journeys/score.json +134 -0
- package/dist/journeys/unreachable.json +12 -0
- package/dist/journeys/vendor-examples.json +7 -0
- package/dist/journeys/vendor-examples.verdict.json +18 -0
- package/dist/spec/SOURCE.md +30 -0
- package/dist/spec/openapi.json +50 -0
- package/dist/spec/sources.json +1 -0
- package/dist/src/cli.js +15 -53
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +19 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +5 -7
- package/dist/src/index.js +9 -42
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +49 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +72 -0
- package/dist/src/semantics/doors.d.ts +3 -0
- package/dist/src/semantics/doors.js +18 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +1 -0
- package/dist/src/semantics/shared.d.ts +22 -0
- package/dist/src/semantics/shared.js +73 -0
- package/dist/src/semantics/sockets.d.ts +2 -0
- package/dist/src/semantics/sockets.js +161 -0
- package/dist/src/semantics/states.d.ts +1 -0
- package/dist/src/semantics/states.js +3 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +9 -0
- package/generated/pack-facts.json +54 -0
- package/journeys/coverage.json +109 -0
- package/journeys/customer-life.json +588 -0
- package/journeys/customer-life.outline.md +34 -0
- package/journeys/customer-life.outline.verdict.json +42 -0
- package/journeys/customer-life.verdict.json +51 -0
- package/journeys/decisions.json +23 -0
- package/journeys/demand.json +24 -0
- package/journeys/score.json +134 -0
- package/journeys/unreachable.json +12 -0
- package/journeys/vendor-examples.json +7 -0
- package/journeys/vendor-examples.verdict.json +18 -0
- package/package.json +17 -32
- package/spec/SOURCE.md +30 -0
- package/spec/openapi.json +50 -0
- package/spec/sources.json +1 -0
- package/src/cli.ts +15 -41
- package/src/fetch.ts +20 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +10 -52
- package/src/manifest.ts +54 -0
- package/src/semantics/around.ts +68 -0
- package/src/semantics/doors.ts +21 -0
- package/src/semantics/index.ts +6 -0
- package/src/semantics/shared.ts +78 -0
- package/src/semantics/sockets.ts +127 -0
- package/src/semantics/states.ts +3 -0
- package/src/server.ts +10 -0
- package/dist/src/tunnel-budget.d.ts +0 -21
- package/dist/src/tunnel-budget.js +0 -26
- package/dist/src/tunnel-capabilities.d.ts +0 -4
- package/dist/src/tunnel-capabilities.js +0 -2091
- package/dist/src/tunnel-conformance.d.ts +0 -10
- package/dist/src/tunnel-conformance.js +0 -63
- package/dist/src/tunnel-connector.d.ts +0 -21
- package/dist/src/tunnel-connector.js +0 -68
- package/dist/src/tunnel-server.d.ts +0 -109
- package/dist/src/tunnel-server.js +0 -1197
- package/dist/src/tunnel-twin.d.ts +0 -175
- package/dist/src/tunnel-twin.js +0 -236
- package/src/tunnel-budget.ts +0 -34
- package/src/tunnel-capabilities.ts +0 -1770
- package/src/tunnel-conformance.ts +0 -62
- package/src/tunnel-connector.ts +0 -64
- package/src/tunnel-server.ts +0 -1194
- package/src/tunnel-twin.ts +0 -339
package/dist/src/cli.js
CHANGED
|
@@ -1,59 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// world-tunnel: serve the tunnel twin.
|
|
3
3
|
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const port =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readOnly: hasFlag(args, '--read-only'),
|
|
18
|
-
requireTid: hasFlag(args, '--require-tid'),
|
|
19
|
-
});
|
|
20
|
-
process.stdout.write(`tunnel twin listening on ${server.url}\n`);
|
|
21
|
-
process.stdout.write(`Volter SDK host=${server.url}; Cloudflare quick service=${server.url}\n`);
|
|
22
|
-
await keepProcessAlive();
|
|
23
|
-
}
|
|
24
|
-
else if (command === 'quick' || command === 'tunnel') {
|
|
25
|
-
const origin = optionValue(args, '--url');
|
|
26
|
-
if (!origin) {
|
|
27
|
-
process.stderr.write('world-tunnel quick requires --url http://127.0.0.1:<port>\n');
|
|
28
|
-
process.exit(2);
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
parseTunnelLoopbackOrigin(origin);
|
|
32
|
-
}
|
|
33
|
-
catch (error) {
|
|
34
|
-
process.stderr.write(`world-tunnel quick: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
35
|
-
process.exit(2);
|
|
36
|
-
}
|
|
37
|
-
const server = createTunnelTwinServer({ ...(port ? { port } : {}), ...(root ? { root } : {}) });
|
|
38
|
-
const allocation = await fetch(`${server.url}/tunnel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
39
|
-
const body = await allocation.json();
|
|
40
|
-
if (!allocation.ok || !body.success || !body.result)
|
|
41
|
-
throw new Error('quick-tunnel allocation failed');
|
|
42
|
-
server.registerQuickOrigin(body.result.id, origin);
|
|
43
|
-
// The world runtime discovers the first URL in stdout. This is deliberately a loopback URL:
|
|
44
|
-
// the twin proves the control plane and genuine HTTP forwarding, not Cloudflare edge/DNS/TLS.
|
|
45
|
-
process.stdout.write(`VOLTER_SHARE_URL=${server.url}\n`);
|
|
4
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
5
|
+
import { createTunnelTwinServer } from "./server.js";
|
|
6
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
7
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
8
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
9
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
10
|
+
// the World's managed Postgres, which the runtime binds a pack declaring managedDatabase to
|
|
11
|
+
const database = optionValue(rest, '--database') || undefined;
|
|
12
|
+
// the World's scenario file, for a model vendor's turns
|
|
13
|
+
const scenarioPath = optionValue(rest, '--scenario') || undefined;
|
|
14
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
15
|
+
const s = await createTunnelTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {}) });
|
|
16
|
+
process.stdout.write(`tunnel twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
46
17
|
await keepProcessAlive();
|
|
47
18
|
}
|
|
48
|
-
else if (command === 'conformance') {
|
|
49
|
-
// Dev-only and lazy: the runtime entrypoint does not pull conformance into normal serve/share.
|
|
50
|
-
const { checkTunnelConformance } = await import("./tunnel-conformance.js");
|
|
51
|
-
const report = await checkTunnelConformance({ ...(root ? { root } : {}) });
|
|
52
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
53
|
-
if (!report.ok)
|
|
54
|
-
process.exitCode = 1;
|
|
55
|
-
}
|
|
56
19
|
else {
|
|
57
|
-
process.stdout.write('Usage: world-tunnel serve
|
|
58
|
-
process.stdout.write(' world-tunnel quick --url http://127.0.0.1:<port> [--port N] [--root DIR]\n');
|
|
20
|
+
process.stdout.write('Usage: world-tunnel serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]\n');
|
|
59
21
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the discovery door, the clock,
|
|
2
|
+
// the World's doors, the vendor's screens and the API from the pack's parts (architecture, "The pack contract").
|
|
3
|
+
import { createPackFetch } from '@volter/world-core';
|
|
4
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
5
|
+
import { manifest } from "./manifest.js";
|
|
6
|
+
import { around } from "./semantics/around.js";
|
|
7
|
+
import * as sockets from "./semantics/sockets.js";
|
|
8
|
+
import * as doors from "./semantics/doors.js";
|
|
9
|
+
import { tunnelSemantics } from "./semantics/index.js";
|
|
10
|
+
export function createTunnelFetch(options = {}) {
|
|
11
|
+
return createPackFetch({
|
|
12
|
+
manifest,
|
|
13
|
+
surface: surface,
|
|
14
|
+
handlers: tunnelSemantics,
|
|
15
|
+
doors,
|
|
16
|
+
around,
|
|
17
|
+
sockets,
|
|
18
|
+
}, options);
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","spec":"openapi.json","format":"openapi","version":"2","resources":[],"operations":[{"id":"getStatus","method":"get","path":"/api/status","pathParams":[],"query":[],"body":[],"bodyEncoding":"none","successStatus":200,"class":"computed"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"_account":{"actions":[],"states":{},"embeds":{}},"_tunnel":{"actions":[],"states":{},"embeds":{}}}}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export
|
|
6
|
-
import type { TwinPack } from '@volter/world-core';
|
|
7
|
-
export declare const pack: TwinPack;
|
|
1
|
+
import { manifest } from './manifest.js';
|
|
2
|
+
export { createTunnelFetch } from './fetch.js';
|
|
3
|
+
export { createTunnelTwinServer } from './server.js';
|
|
4
|
+
export { manifest };
|
|
5
|
+
export declare const pack: import("@volter/world-core").PackDescriptor;
|
package/dist/src/index.js
CHANGED
|
@@ -1,42 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
rateBudget: RATE_BUDGET,
|
|
11
|
-
// The CONTROL plane is HTTP/REST (POST /tunnel, GET /api/status). The relay's control link is a
|
|
12
|
-
// WebSocket and its data path is real loopback HTTP — neither is a raw line protocol, so this is
|
|
13
|
-
// not the `raw-tcp` class; the split is described in the README and spec-sources.json.
|
|
14
|
-
transport: 'rest',
|
|
15
|
-
archetype: 'proxy',
|
|
16
|
-
bin: 'world-tunnel',
|
|
17
|
-
// The subject types the twin stores: `quick_tunnel`/`relay_tunnel` written by tunnel-twin.ts,
|
|
18
|
-
// and `relay` folded by the connector's status pull (mapTunnelRelay).
|
|
19
|
-
resources: ['quick_tunnel', 'relay_tunnel', 'relay'],
|
|
20
|
-
specSource: 'No single first-party machine-readable spec spans this combined pack. The Cloudflare Quick Tunnel ' +
|
|
21
|
-
'half is pinned to first-party cloudflared commit 733bfb939963e150dcf5c4faddb1603f744fbc98 (POST ' +
|
|
22
|
-
'/tunnel and its {success,result,errors} envelope) plus Cloudflare\'s own Quick Tunnels docs; the ' +
|
|
23
|
-
'owned Volter relay half is pinned to volter-ai/volter-tunnel commit ' +
|
|
24
|
-
'a5abc6287384429d32cde200d8ecd042814e6619 (@volter/tunnel 2.0.5, @volter/tunnel-core\'s 15 control ' +
|
|
25
|
-
'frames). See spec-sources.json.',
|
|
26
|
-
description: 'Cloudflare Quick Tunnel control-plane and owned Volter relay twin — POST /tunnel allocation and the ' +
|
|
27
|
-
'@volter/tunnel WebSocket control protocol, with a genuine loopback HTTP data path (no Cloudflare ' +
|
|
28
|
-
'edge, DNS, TLS or SLA claimed).',
|
|
29
|
-
// INTERCEPTION RULING — hostsNone, the pack's own home for it: no injector entry by design:
|
|
30
|
-
// native cloudflared is an external binary, while @volter/tunnel opens a configurable
|
|
31
|
-
// WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read
|
|
32
|
-
// endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect
|
|
33
|
-
// cloudflared and would falsely imply the Cloudflare edge data plane was twinned.
|
|
34
|
-
hostsNone: "no injector entry by design: native cloudflared is an external binary, while @volter/tunnel opens a configurable WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect cloudflared and would falsely imply the Cloudflare edge data plane was twinned",
|
|
35
|
-
// Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
36
|
-
// 2026-08-31).
|
|
37
|
-
adoption: {
|
|
38
|
-
// Internal Volter tunnel; the client is `@volter/tunnel` and there is no Python distribution.
|
|
39
|
-
pypi: [],
|
|
40
|
-
sdks: ['@volter/tunnel'], envStems: ['TUNNEL', 'VOLTERTUNNEL'],
|
|
41
|
-
},
|
|
42
|
-
};
|
|
1
|
+
// @volter/twin-tunnel — the tunnel twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
|
|
2
|
+
// kernel derives the vendor-backed half from the generated surface (architecture, "The real-system adapters").
|
|
3
|
+
import { packOf, registerPack } from '@volter/world-core';
|
|
4
|
+
import { manifest } from "./manifest.js";
|
|
5
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
6
|
+
export { createTunnelFetch } from "./fetch.js";
|
|
7
|
+
export { createTunnelTwinServer } from "./server.js";
|
|
8
|
+
export { manifest };
|
|
9
|
+
export const pack = registerPack(packOf(manifest, surface));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const manifest = {
|
|
2
|
+
vendor: 'tunnel',
|
|
3
|
+
service: 'tunnel',
|
|
4
|
+
body: { json: 'always' },
|
|
5
|
+
unmodeled: ['getStatus'],
|
|
6
|
+
vendorBacked: { none: 'The served wire forwards HTTP over a live control socket; account and reservation rows are World bookkeeping, with no served vendor resource API to perform or refresh.' },
|
|
7
|
+
// source: spec:/x-reference/relay "crypto.randomUUID().slice(0, 8)"
|
|
8
|
+
ids: { template: '{letters:8}' },
|
|
9
|
+
time: 'iso',
|
|
10
|
+
error: '{message}',
|
|
11
|
+
readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' },
|
|
12
|
+
// The unserved HTTP operations use the pack gap; the served socket refuses in protocol frames.
|
|
13
|
+
notFound: { status: 404, message: 'Not Found' },
|
|
14
|
+
gap: { status: 404, message: 'Not Found' },
|
|
15
|
+
list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 100, max: 100 } },
|
|
16
|
+
deleted: {},
|
|
17
|
+
resources: {
|
|
18
|
+
_account: { idPrefix: '' },
|
|
19
|
+
// a tunnel id an account holds (one minted when a client names none)
|
|
20
|
+
_tunnel: { idPrefix: '' },
|
|
21
|
+
},
|
|
22
|
+
// source: spec:/x-reference/protocol "for the messages exchanged over the control WebSocket between a client and the"
|
|
23
|
+
sockets: [
|
|
24
|
+
{ id: 'control', path: '/ws', note: "a client's control channel: register, then answer each request the relay sends", source: 'https://github.com/volter-ai/volter-tunnel/blob/main/packages/core/src/protocol.ts' },
|
|
25
|
+
],
|
|
26
|
+
doors: [
|
|
27
|
+
{ id: 'accounts', method: 'POST', path: '/_twin/accounts', note: '{ slug }: an account on the relay and its token (the World application\'s TUNNEL_SECRET)' },
|
|
28
|
+
],
|
|
29
|
+
discovery: {
|
|
30
|
+
twinOf: "Volter's tunnel relay (volter-tunnel) as Volter Harness's remote access uses it: a client's control socket, and every request to its tunnel answered through it",
|
|
31
|
+
stores: 'accounts and their tokens (by their SHA-256), and the tunnel ids they hold',
|
|
32
|
+
identity: "the account's token in the control channel's register frame",
|
|
33
|
+
},
|
|
34
|
+
descriptor: {
|
|
35
|
+
protocol: '3',
|
|
36
|
+
transport: 'rest',
|
|
37
|
+
archetype: 'proxy',
|
|
38
|
+
bin: 'world-tunnel',
|
|
39
|
+
resources: [],
|
|
40
|
+
specSource: 'written from volter-ai/volter-tunnel (spec/SOURCE.md)',
|
|
41
|
+
description: "Tunnel relay twin — a client's control socket (register, then each public request sent down it and answered by the frames it sends back), as Volter Harness's remote access shares a local server.",
|
|
42
|
+
// source: spec:/x-reference/protocol "Shared secret or api/login token authenticating the client."
|
|
43
|
+
adoption: { sdks: ['@volter/tunnel'], envStems: ['TUNNEL', 'VOLTERTUNNEL'] },
|
|
44
|
+
// source: spec:/x-reference/domain "voltertest.xyz"
|
|
45
|
+
hosts: [{ host: 'voltertest.xyz' }, { suffix: '.voltertest.xyz' }, { host: 'www.voltertest.xyz', exclude: true }, { host: 'twins.voltertest.xyz', exclude: true }],
|
|
46
|
+
credentialDoor: { path: '/_twin/accounts', body: { slug: 'world' }, fill: { TUNNEL_SECRET: 'token', TUNNEL_SERVER_URL: 'server', SUPERCODE_TUNNEL_SERVER_URL: 'server' } },
|
|
47
|
+
endpointEnvNone: 'the relay URL is the credential door\'s (TUNNEL_SERVER_URL, SUPERCODE_TUNNEL_SERVER_URL), the World claiming voltertest.xyz',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { b64encode, cors, DOMAIN, holder, requests, RESERVED } from "./shared.js";
|
|
2
|
+
const text = (status, body) => new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
3
|
+
// World transport metadata belongs to the World; preserve the application's own x-volter-* headers.
|
|
4
|
+
const INTERNAL = new Set(['x-volter-token', 'x-volter-pass', 'x-volter-read-only', 'x-volter-twin-original-host', 'x-volter-scenario-decision', 'x-volter-path-prefix-params', 'x-volter-lane']);
|
|
5
|
+
/** The tunnel a request is for: its host's label, or `?__tunnel=` on a direct host (the relay's local testing). */
|
|
6
|
+
function tunnelOf(request) {
|
|
7
|
+
const url = new URL(request.url);
|
|
8
|
+
const host = (request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host).toLowerCase().replace(/:\d+$/, '');
|
|
9
|
+
const label = host.endsWith(`.${DOMAIN}`) ? host.slice(0, -DOMAIN.length - 1) : undefined;
|
|
10
|
+
if (label && !label.includes('.') && !RESERVED.has(label))
|
|
11
|
+
return label;
|
|
12
|
+
// source: spec:/x-reference/routing "host.endsWith('.workers.dev')"
|
|
13
|
+
const direct = host.endsWith('.workers.dev') || host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0';
|
|
14
|
+
return direct ? url.searchParams.get('__tunnel') ?? undefined : undefined;
|
|
15
|
+
}
|
|
16
|
+
export async function around(ctx, next) {
|
|
17
|
+
const request = ctx.call.request;
|
|
18
|
+
const id = tunnelOf(request);
|
|
19
|
+
if (!id)
|
|
20
|
+
return next();
|
|
21
|
+
// source: spec:/x-reference/auth "The relay answers preflight itself"
|
|
22
|
+
if (request.method === 'OPTIONS')
|
|
23
|
+
return new Response(null, { status: 204, headers: cors(request) });
|
|
24
|
+
const client = holder(ctx, id);
|
|
25
|
+
// source: spec:/x-reference/relay "Tunnel not connected"
|
|
26
|
+
if (!client)
|
|
27
|
+
return text(502, 'Tunnel not connected');
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
// source: spec:/x-reference/auth "u.searchParams.delete('__volter_token');"
|
|
30
|
+
url.searchParams.delete('__tunnel');
|
|
31
|
+
url.searchParams.delete('__volter_token');
|
|
32
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? null : new Uint8Array(await request.arrayBuffer());
|
|
33
|
+
// source: spec:/x-reference/relay "bodyBytes.length > 750_000"
|
|
34
|
+
if (body && body.length > 750_000)
|
|
35
|
+
return text(413, 'Request body too large to tunnel (limit ~750KB)');
|
|
36
|
+
const headers = Object.fromEntries([...request.headers].filter(([k]) => !INTERNAL.has(k.toLowerCase())));
|
|
37
|
+
// The injector preserves the visitor's vendor Host in the same metadata used by kernel host routing.
|
|
38
|
+
headers.host = request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host;
|
|
39
|
+
// source: spec:/x-reference/relay "Never trust a visitor-supplied"
|
|
40
|
+
delete headers['x-forwarded-for'];
|
|
41
|
+
headers['x-forwarded-for'] = request.headers.get('cf-connecting-ip') || '0.0.0.0';
|
|
42
|
+
// source: spec:/x-reference/auth "Strip the __volter_auth cookie"
|
|
43
|
+
if (headers.cookie) {
|
|
44
|
+
const cookie = headers.cookie.split(';').filter((pair) => pair.trim().split('=')[0] !== '__volter_auth').join(';').trim();
|
|
45
|
+
if (cookie)
|
|
46
|
+
headers.cookie = cookie;
|
|
47
|
+
else
|
|
48
|
+
delete headers.cookie;
|
|
49
|
+
}
|
|
50
|
+
// the relay's correlation key for this request on this client's socket: the socket's own count, not World state (a
|
|
51
|
+
// request the relay forwards is no subject of the World)
|
|
52
|
+
const reqId = `${id}-${(client.state.requests = Number(client.state.requests ?? 0) + 1)}`;
|
|
53
|
+
// Register the wait before sending so a synchronous client can answer the frame.
|
|
54
|
+
requests(client).set(reqId, request);
|
|
55
|
+
const answered = client.expect(reqId, 30_000);
|
|
56
|
+
// source: spec:/x-reference/protocol "An inbound HTTP request to forward to the local server."
|
|
57
|
+
client.send({ type: 'request', reqId, method: request.method, path: `${url.pathname}${url.search}`, headers, body: body?.length ? b64encode(body) : null });
|
|
58
|
+
try {
|
|
59
|
+
return (await answered);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// source: spec:/x-reference/relay "Tunnel disconnected"
|
|
63
|
+
return !ctx.sockets('control').includes(client) ? text(502, String(client.state.disconnectReason ?? 'Tunnel disconnected')) : timeout();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
requests(client).delete(reqId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// source: spec:/x-reference/relay "Tunnel timeout"
|
|
70
|
+
function timeout() {
|
|
71
|
+
return text(504, 'Tunnel timeout');
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ACCOUNT, sha256 } from "./shared.js";
|
|
2
|
+
/** An account by its slug (answered again for the same slug) and its token, drawn from the World's secret. */
|
|
3
|
+
export async function accounts(ctx) {
|
|
4
|
+
const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {});
|
|
5
|
+
const slug = String(b.slug ?? '');
|
|
6
|
+
// source: spec:/x-reference/credentials "const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;"
|
|
7
|
+
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(slug))
|
|
8
|
+
return Response.json({ message: 'slug must be lowercase letters, digits and dashes' }, { status: 400 });
|
|
9
|
+
let account = ctx.rowsRaw(ACCOUNT).find((a) => a.deleted !== true && a.slug === slug);
|
|
10
|
+
// Where the documentation stops: a token's form is the relay's own; the twin's is `vt_` and 40 letters and digits
|
|
11
|
+
const tokenOf = async (id) => `vt_${ctx.crypto.base62From(await ctx.secret(`tunnel-token:${id}`), 40)}`;
|
|
12
|
+
if (!account) {
|
|
13
|
+
const id = ctx.mint(ACCOUNT);
|
|
14
|
+
await ctx.record(ACCOUNT, { slug, token_sha256: sha256(ctx, await tokenOf(id)) }, id);
|
|
15
|
+
account = ctx.row(ACCOUNT, id);
|
|
16
|
+
}
|
|
17
|
+
return Response.json({ slug, token: await tokenOf(String(account.id)), server: `https://voltertest.xyz` }, { status: 201 });
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const tunnelSemantics = {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HandlerContext, SocketSession } from '@volter/world-core';
|
|
2
|
+
export type Row = Record<string, unknown>;
|
|
3
|
+
export type { HandlerContext };
|
|
4
|
+
export declare const ACCOUNT = "_account";
|
|
5
|
+
export declare const TUNNEL = "_tunnel";
|
|
6
|
+
export declare const DOMAIN = "voltertest.xyz";
|
|
7
|
+
/** Labels of the domain that are not tunnels: the relay's own front (`www`, its RESERVED_HOSTS default) and the twins
|
|
8
|
+
* cloud Volter serves there. */
|
|
9
|
+
export declare const RESERVED: Set<string>;
|
|
10
|
+
export declare const sha256: (ctx: Pick<HandlerContext, "crypto">, v: string) => string;
|
|
11
|
+
export declare const tunnelUrl: (id: string) => string;
|
|
12
|
+
/** The registered control session holding a tunnel, if a client holds it now. */
|
|
13
|
+
export declare const holder: (ctx: Pick<HandlerContext, "sockets">, id: string) => SocketSession | undefined;
|
|
14
|
+
export declare const b64encode: (bytes: Uint8Array) => string;
|
|
15
|
+
export declare const b64decode: (text: string) => Uint8Array<ArrayBuffer>;
|
|
16
|
+
export declare function cors(request: Request): Record<string, string>;
|
|
17
|
+
/** Request metadata lasts only until the local client starts its reply, on that live connection. */
|
|
18
|
+
export declare function requests(s: SocketSession): Map<string, Request>;
|
|
19
|
+
export declare function responseHeaders(downstream: Headers, request: Request): Headers;
|
|
20
|
+
export declare function bufferedBody(text: unknown): Uint8Array<ArrayBuffer> | null | undefined;
|
|
21
|
+
/** A provisional connection claim prevents two initial registrants passing the live-session decision. */
|
|
22
|
+
export declare function claimant(ctx: Pick<HandlerContext, 'sockets'>, id: string): SocketSession | undefined;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const ACCOUNT = '_account';
|
|
2
|
+
export const TUNNEL = '_tunnel';
|
|
3
|
+
// source: spec:/x-reference/domain "voltertest.xyz"
|
|
4
|
+
export const DOMAIN = 'voltertest.xyz';
|
|
5
|
+
/** Labels of the domain that are not tunnels: the relay's own front (`www`, its RESERVED_HOSTS default) and the twins
|
|
6
|
+
* cloud Volter serves there. */
|
|
7
|
+
export const RESERVED = new Set(['www', 'twins']);
|
|
8
|
+
export const sha256 = (ctx, v) => ctx.crypto.digest('sha256', v);
|
|
9
|
+
export const tunnelUrl = (id) => `https://${id}.${DOMAIN}`;
|
|
10
|
+
/** The registered control session holding a tunnel, if a client holds it now. */
|
|
11
|
+
export const holder = (ctx, id) => ctx.sockets('control').find((s) => s.state.registered === true && s.state.tunnelId === id);
|
|
12
|
+
export const b64encode = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(''));
|
|
13
|
+
export const b64decode = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
14
|
+
// source: spec:/x-reference/auth "The relay answers preflight itself"
|
|
15
|
+
export function cors(request) {
|
|
16
|
+
const origin = request.headers.get('origin');
|
|
17
|
+
return origin ? {
|
|
18
|
+
'access-control-allow-origin': origin,
|
|
19
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
20
|
+
'access-control-allow-headers': request.headers.get('access-control-request-headers') || 'Content-Type, Authorization, X-Sandbox-Id',
|
|
21
|
+
'access-control-allow-credentials': 'true',
|
|
22
|
+
} : {};
|
|
23
|
+
}
|
|
24
|
+
/** Request metadata lasts only until the local client starts its reply, on that live connection. */
|
|
25
|
+
export function requests(s) {
|
|
26
|
+
return (s.state.pendingRequests ??= new Map());
|
|
27
|
+
}
|
|
28
|
+
// source: spec:/x-reference/auth "Build the downstream response headers: drop hop-by-hop + downstream CORS"
|
|
29
|
+
export function responseHeaders(downstream, request) {
|
|
30
|
+
const headers = new Headers(downstream);
|
|
31
|
+
for (const key of ['transfer-encoding', 'connection', 'access-control-allow-origin', 'access-control-allow-methods', 'access-control-allow-headers', 'access-control-allow-credentials', 'x-frame-options'])
|
|
32
|
+
headers.delete(key);
|
|
33
|
+
// source: spec:/x-reference/auth "strip CSP frame-ancestors / X-Frame-Options"
|
|
34
|
+
for (const key of ['content-security-policy', 'content-security-policy-report-only']) {
|
|
35
|
+
const value = headers.get(key);
|
|
36
|
+
if (value) {
|
|
37
|
+
const kept = value.split(';').filter((d) => !d.trim().startsWith('frame-ancestors')).join(';').trim();
|
|
38
|
+
if (kept)
|
|
39
|
+
headers.set(key, kept);
|
|
40
|
+
else
|
|
41
|
+
headers.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// source: spec:/x-reference/auth "only a downstream response that clearly says"
|
|
45
|
+
const cache = (headers.get('cache-control') ?? '').toLowerCase();
|
|
46
|
+
const publicCache = /(?:^|,)\s*public(?:\s|,|$)/.test(cache) && !/(?:^|,)\s*(?:private|no-store|no-cache)(?:\s|,|=|$)/.test(cache);
|
|
47
|
+
const path = new URL(request.url).pathname;
|
|
48
|
+
if (!publicCache || path === '/api' || path.startsWith('/api/')) {
|
|
49
|
+
headers.set('cache-control', 'private, no-store, max-age=0');
|
|
50
|
+
headers.set('cloudflare-cdn-cache-control', 'no-store');
|
|
51
|
+
headers.set('cdn-cache-control', 'no-store');
|
|
52
|
+
const vary = new Set((headers.get('vary') ?? '').split(',').map((v) => v.trim()).filter(Boolean));
|
|
53
|
+
vary.add('Cookie');
|
|
54
|
+
vary.add('Authorization');
|
|
55
|
+
headers.set('vary', [...vary].join(', '));
|
|
56
|
+
}
|
|
57
|
+
for (const [key, value] of Object.entries(cors(request)))
|
|
58
|
+
headers.set(key, value);
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
61
|
+
// source: spec:/x-reference/relay "Malformed tunnel response"
|
|
62
|
+
export function bufferedBody(text) {
|
|
63
|
+
try {
|
|
64
|
+
return text ? b64decode(String(text)) : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** A provisional connection claim prevents two initial registrants passing the live-session decision. */
|
|
71
|
+
export function claimant(ctx, id) {
|
|
72
|
+
return ctx.sockets('control').find((s) => s.state.superseded !== true && (s.state.registered === true || s.state.registering === true) && s.state.tunnelId === id);
|
|
73
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ACCOUNT, b64decode, bufferedBody, claimant, requests, responseHeaders, sha256, TUNNEL, tunnelUrl } from "./shared.js";
|
|
2
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
3
|
+
const streams = (s) => (s.state.streams ??= new Map());
|
|
4
|
+
const headersOf = (v) => { const h = new Headers(); for (const [k, x] of Object.entries(obj(v)))
|
|
5
|
+
h.set(k, String(x)); return h; };
|
|
6
|
+
/** A rejected registration: the error frame, then the close (protocol.ts `ErrorMsg`; tunnel-do's rejection). */
|
|
7
|
+
function reject(s, message, code, reason) {
|
|
8
|
+
s.send({ type: 'error', message });
|
|
9
|
+
s.close(code, reason);
|
|
10
|
+
}
|
|
11
|
+
async function register(s, msg, ctx) {
|
|
12
|
+
const token = typeof msg.secret === 'string' ? msg.secret : '';
|
|
13
|
+
const account = token ? ctx.rowsRaw(ACCOUNT).find((a) => a.deleted !== true && a.token_sha256 === sha256(ctx, token)) : undefined;
|
|
14
|
+
// source: spec:/x-reference/relay "Invalid tunnel secret"
|
|
15
|
+
if (!account)
|
|
16
|
+
return reject(s, 'Invalid tunnel secret', 4003, 'Invalid tunnel secret');
|
|
17
|
+
// the control URL's ?id= names the tunnel (the routing relay's authority), else the register frame's, else one minted
|
|
18
|
+
// This id names a stored reservation: mint it, then record the reservation with its owner below. ctx.issue is for
|
|
19
|
+
// issued ids whose subjects the vendor does not store; its empty row would look like another account's reservation.
|
|
20
|
+
// source: spec:/x-reference/relay "Prefer the ?id= captured at accept (authoritative"
|
|
21
|
+
const id = String(s.state.tunnelId || msg.tunnelId || ctx.mint(TUNNEL));
|
|
22
|
+
const held = ctx.row(TUNNEL, id);
|
|
23
|
+
// source: spec:/x-reference/relay "Tunnel ID reserved"
|
|
24
|
+
if (held && held._owner !== account.id)
|
|
25
|
+
return reject(s, `Tunnel ID '${id}' is reserved by another account.`, 4002, 'Tunnel ID reserved');
|
|
26
|
+
const live = claimant(ctx, id);
|
|
27
|
+
if (live && live !== s) {
|
|
28
|
+
// source: spec:/x-reference/relay "Pass { replace: true } to take over."
|
|
29
|
+
if (msg.replace !== true)
|
|
30
|
+
return reject(s, `Tunnel ID '${id}' is already in use by another client. Pass { replace: true } to take over.`, 4002, 'Tunnel ID already in use');
|
|
31
|
+
// source: spec:/x-reference/relay "Tunnel replaced"
|
|
32
|
+
live.state.superseded = true;
|
|
33
|
+
live.state.disconnectReason = 'Tunnel replaced';
|
|
34
|
+
live.state.registered = false;
|
|
35
|
+
// source: spec:/x-reference/relay "Replaced by new client"
|
|
36
|
+
live.close(4001, 'Replaced by new client');
|
|
37
|
+
}
|
|
38
|
+
Object.assign(s.state, { registering: true, tunnelId: id, account: account.id });
|
|
39
|
+
try {
|
|
40
|
+
if (!held)
|
|
41
|
+
await ctx.record(TUNNEL, { _owner: account.id, reservedAt: ctx.occurredAt }, id);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
s.state.registering = false;
|
|
45
|
+
}
|
|
46
|
+
if (s.state.superseded === true || !ctx.sockets('control').includes(s))
|
|
47
|
+
return;
|
|
48
|
+
Object.assign(s.state, { registered: true, tunnelId: id, account: account.id, authRequired: msg.authRequired !== false });
|
|
49
|
+
// source: spec:/x-reference/protocol "Acknowledges a successful registration; carries the public URL + usage."
|
|
50
|
+
s.send({ type: 'registered', tunnelId: id, url: tunnelUrl(id) });
|
|
51
|
+
}
|
|
52
|
+
export const control = {
|
|
53
|
+
open(s, ctx) {
|
|
54
|
+
s.state.tunnelId = new URL(ctx.call.request.url).searchParams.get('id') ?? '';
|
|
55
|
+
},
|
|
56
|
+
async message(s, data, ctx) {
|
|
57
|
+
let msg;
|
|
58
|
+
try {
|
|
59
|
+
msg = obj(JSON.parse(data));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (msg.type === 'register')
|
|
65
|
+
return register(s, msg, ctx);
|
|
66
|
+
if (s.state.registered !== true)
|
|
67
|
+
return;
|
|
68
|
+
const reqId = String(msg.reqId ?? '');
|
|
69
|
+
const request = requests(s).get(reqId);
|
|
70
|
+
// source: spec:/x-reference/protocol "A complete (buffered) response"
|
|
71
|
+
if (msg.type === 'response') {
|
|
72
|
+
if (!request)
|
|
73
|
+
return;
|
|
74
|
+
const headers = responseHeaders(headersOf(msg.headers), request);
|
|
75
|
+
const body = bufferedBody(msg.body);
|
|
76
|
+
// source: spec:/x-reference/relay "Malformed tunnel response"
|
|
77
|
+
if (body === undefined)
|
|
78
|
+
s.fulfil(reqId, new Response('Malformed tunnel response', { status: 502, headers }));
|
|
79
|
+
else
|
|
80
|
+
s.fulfil(reqId, new Response(request.method === 'HEAD' ? null : body, { status: Number(msg.status || 200), headers }));
|
|
81
|
+
// source: spec:/x-reference/protocol "Start of a streamed response"
|
|
82
|
+
}
|
|
83
|
+
else if (msg.type === 'response-start') {
|
|
84
|
+
if (!request)
|
|
85
|
+
return;
|
|
86
|
+
const status = Number(msg.status || 200);
|
|
87
|
+
const headers = responseHeaders(headersOf(msg.headers), request);
|
|
88
|
+
// source: spec:/x-reference/relay "HEAD is bodyless by contract too."
|
|
89
|
+
if (request.method === 'HEAD' || status === 204 || status === 205 || status === 304) {
|
|
90
|
+
s.fulfil(reqId, new Response(null, { status, headers }));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const body = new ReadableStream({ start: (c) => { streams(s).set(reqId, c); }, cancel: () => cancelStream(s, reqId) });
|
|
94
|
+
armStreamIdle(s, reqId, ctx);
|
|
95
|
+
s.fulfil(reqId, new Response(body, { status, headers }));
|
|
96
|
+
}
|
|
97
|
+
// source: spec:/x-reference/protocol "One streamed response body chunk."
|
|
98
|
+
}
|
|
99
|
+
else if (msg.type === 'response-chunk') {
|
|
100
|
+
const stream = streams(s).get(reqId);
|
|
101
|
+
if (stream) {
|
|
102
|
+
try {
|
|
103
|
+
stream.enqueue(b64decode(String(msg.data ?? '')));
|
|
104
|
+
s.fulfil(`idle:${reqId}`, null);
|
|
105
|
+
armStreamIdle(s, reqId, ctx);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
streams(s).delete(reqId);
|
|
109
|
+
s.fulfil(`idle:${reqId}`, null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// source: spec:/x-reference/protocol "End of a streamed response."
|
|
113
|
+
}
|
|
114
|
+
else if (msg.type === 'response-end') {
|
|
115
|
+
const stream = streams(s).get(reqId);
|
|
116
|
+
streams(s).delete(reqId);
|
|
117
|
+
s.fulfil(`idle:${reqId}`, null);
|
|
118
|
+
if (stream) {
|
|
119
|
+
try {
|
|
120
|
+
stream.close();
|
|
121
|
+
}
|
|
122
|
+
catch { /* already closed */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
close(s) {
|
|
127
|
+
s.state.registered = false;
|
|
128
|
+
s.state.closed = true;
|
|
129
|
+
for (const c of streams(s).values()) {
|
|
130
|
+
try {
|
|
131
|
+
c.error(new Error('Tunnel closed'));
|
|
132
|
+
}
|
|
133
|
+
catch { /* already ended */ }
|
|
134
|
+
}
|
|
135
|
+
streams(s).clear();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
// source: spec:/x-reference/relay "Idle watchdog for a streaming response"
|
|
139
|
+
function armStreamIdle(s, reqId, ctx) {
|
|
140
|
+
// source: spec:/x-reference/relay "120000"
|
|
141
|
+
void s.expect(`idle:${reqId}`, 120_000).catch(() => { if (ctx.sockets('control').includes(s))
|
|
142
|
+
expireStream(s, reqId); });
|
|
143
|
+
}
|
|
144
|
+
// source: spec:/x-reference/relay "stream idle timeout"
|
|
145
|
+
function expireStream(s, reqId) {
|
|
146
|
+
const stream = streams(s).get(reqId);
|
|
147
|
+
if (!stream)
|
|
148
|
+
return;
|
|
149
|
+
streams(s).delete(reqId);
|
|
150
|
+
try {
|
|
151
|
+
stream.error(new Error('stream idle timeout'));
|
|
152
|
+
}
|
|
153
|
+
catch { /* already closed */ }
|
|
154
|
+
s.send({ type: 'request-abort', reqId });
|
|
155
|
+
}
|
|
156
|
+
// source: spec:/x-reference/protocol "The relay asks the client to abort an in-flight request."
|
|
157
|
+
function cancelStream(s, reqId) {
|
|
158
|
+
streams(s).delete(reqId);
|
|
159
|
+
s.fulfil(`idle:${reqId}`, null);
|
|
160
|
+
s.send({ type: 'request-abort', reqId });
|
|
161
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const states: {};
|