@volter/twin-tunnel 0.1.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.
@@ -0,0 +1,10 @@
1
+ export type TunnelConformanceReport = {
2
+ ok: boolean;
3
+ probes: number;
4
+ violations: string[];
5
+ };
6
+ /** Literal control-plane probes through the same Bun HTTP/WebSocket adapter callers use. Payload
7
+ * transport is covered separately, including an unmodified @volter/tunnel SDK integration test. */
8
+ export declare function checkTunnelConformance(options?: {
9
+ root?: string;
10
+ }): Promise<TunnelConformanceReport>;
@@ -0,0 +1,63 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { createTunnelTwinServer } from "./tunnel-server.js";
5
+ /** Literal control-plane probes through the same Bun HTTP/WebSocket adapter callers use. Payload
6
+ * transport is covered separately, including an unmodified @volter/tunnel SDK integration test. */
7
+ export async function checkTunnelConformance(options = {}) {
8
+ const root = options.root ?? mkdtempSync(join(tmpdir(), 'tunnel-conformance-'));
9
+ const violations = [];
10
+ let probes = 0;
11
+ let socket;
12
+ let relay;
13
+ const require = (condition, label) => { probes++; if (!condition)
14
+ violations.push(label); };
15
+ try {
16
+ relay = createTunnelTwinServer({ root });
17
+ const allocated = await fetch(`${relay.url}/tunnel`, {
18
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
19
+ });
20
+ if (!allocated.ok)
21
+ throw new Error(`POST /tunnel transport returned HTTP ${allocated.status}`);
22
+ const allocationBody = await allocated.json();
23
+ require(allocated.status === 200 && allocated.headers.get('content-type')?.startsWith('application/json') === true
24
+ && allocationBody.success === true && typeof allocationBody.result?.hostname === 'string', 'POST /tunnel response');
25
+ const status = await fetch(`${relay.url}/api/status`);
26
+ const statusBody = await status.json();
27
+ require(status.status === 200 && status.headers.get('content-type')?.startsWith('application/json') === true
28
+ && statusBody.relay === 'twin-local' && statusBody.tunnels === 0, 'GET /api/status response');
29
+ const missing = await fetch(`${relay.url}/accounts`);
30
+ const missingBody = await missing.json();
31
+ require(missing.status === 404 && missingBody.error === 'not found', 'unknown route refusal');
32
+ socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
33
+ const registered = new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => reject(new Error('relay register transport timed out')), 3_000);
35
+ socket.addEventListener('message', (event) => {
36
+ const message = JSON.parse(String(event.data));
37
+ if (message.type !== 'registered' && message.type !== 'error')
38
+ return;
39
+ clearTimeout(timer);
40
+ resolve(message);
41
+ });
42
+ socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('relay register transport failed')); }, { once: true });
43
+ });
44
+ await new Promise((resolve, reject) => {
45
+ socket.addEventListener('open', () => resolve(), { once: true });
46
+ socket.addEventListener('error', () => reject(new Error('relay websocket did not open')), { once: true });
47
+ });
48
+ socket.send(JSON.stringify({ type: 'register', tunnelId: 'conformance', secret: 'vt_fake_local', authRequired: false }));
49
+ const registration = await registered;
50
+ require(registration.type === 'registered' && registration.tunnelId === 'conformance'
51
+ && registration.url === `${relay.url}/__twin/tunnels/conformance`, 'relay register frame');
52
+ }
53
+ catch (error) {
54
+ violations.push(`conformance probe threw: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ finally {
57
+ socket?.close();
58
+ relay?.stop();
59
+ if (!options.root)
60
+ rmSync(root, { recursive: true, force: true });
61
+ }
62
+ return { ok: violations.length === 0, probes, violations };
63
+ }
@@ -0,0 +1,21 @@
1
+ import { type SyncResource } from '@volter/world-core';
2
+ import { TunnelBudget, type TunnelBudgetOptions } from './tunnel-budget.js';
3
+ export type TunnelExecute = (method: 'GET' | 'POST' | 'DELETE', path: string, body?: unknown) => Promise<{
4
+ status: number;
5
+ body: any;
6
+ }>;
7
+ export declare function liveTunnelExecute(token: string, base?: string, opts?: {
8
+ fetchImpl?: typeof fetch;
9
+ budget?: TunnelBudget;
10
+ budgetOptions?: TunnelBudgetOptions;
11
+ }): TunnelExecute;
12
+ export declare function mapTunnelRelay(status: Record<string, unknown>): SyncResource;
13
+ export declare function pullTunnelRelay(execute: TunnelExecute): Promise<SyncResource[]>;
14
+ export declare function syncTunnelFromReal(execute: TunnelExecute, opts?: {
15
+ root?: string;
16
+ occurredAt?: string;
17
+ }): Promise<{
18
+ observed: any;
19
+ deltasAppended: any;
20
+ }>;
21
+ export declare function pushPendingTunnelActions(): Promise<never>;
@@ -0,0 +1,68 @@
1
+ import { assertBudgetGuardIntact, syncPull } from '@volter/world-core';
2
+ import { TunnelBudget, TunnelBudgetError, tunnelCallWeight } from "./tunnel-budget.js";
3
+ export function liveTunnelExecute(token, base = 'https://voltertest.xyz', opts = {}) {
4
+ const doFetch = opts.fetchImpl ?? fetch;
5
+ const budget = opts.budget !== undefined && opts.budget !== null
6
+ ? assertBudgetGuardIntact(opts.budget, TunnelBudget, 'liveTunnelExecute')
7
+ : new TunnelBudget({ token, ...(opts.budgetOptions ?? {}) });
8
+ return async (method, path, body) => {
9
+ const headers = { authorization: `Bearer ${token}` };
10
+ const init = { method, headers };
11
+ if (body !== undefined) {
12
+ headers['content-type'] = 'application/json';
13
+ init.body = JSON.stringify(body);
14
+ }
15
+ const weight = tunnelCallWeight(method, path.split('?')[0] ?? path);
16
+ const reservation = budget.checkBudget(weight);
17
+ const response = await doFetch(`${base.replace(/\/$/, '')}${path}`, init);
18
+ const responseHeaders = {};
19
+ response.headers.forEach((v, k) => { responseHeaders[k.toLowerCase()] = v; });
20
+ const text = await response.text();
21
+ // An answer the relay ACCEPTED is kept even if recordCall then throws (a back-off beyond the
22
+ // cap, cooldown already armed): a write that landed is never recorded as failed and redone.
23
+ try {
24
+ budget.recordCall(weight, responseHeaders, { status: response.status, reservation });
25
+ }
26
+ catch (error) {
27
+ if (!(error instanceof TunnelBudgetError) || !response.ok)
28
+ throw error;
29
+ }
30
+ let parsed = {};
31
+ if (text) {
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ }
35
+ catch {
36
+ parsed = { error: text };
37
+ }
38
+ }
39
+ return { status: response.status, body: parsed };
40
+ };
41
+ }
42
+ export function mapTunnelRelay(status) {
43
+ return {
44
+ type: 'relay', id: 'relay:default',
45
+ // The owned public endpoint is exactly { ok: true, relay: 'cloudflare-do' }. Live socket
46
+ // counts are a useful twin-local diagnostic, not a real field and therefore never folded.
47
+ fields: { ok: status.ok === true, relay: String(status.relay ?? 'unknown') },
48
+ };
49
+ }
50
+ export async function pullTunnelRelay(execute) {
51
+ const response = await execute('GET', '/api/status');
52
+ if (response.status < 200 || response.status >= 300)
53
+ throw new Error(`tunnel pull status refused: HTTP ${response.status}`);
54
+ if (!response.body || typeof response.body !== 'object' || Array.isArray(response.body)
55
+ || response.body.ok !== true || response.body.relay !== 'cloudflare-do'
56
+ || Object.keys(response.body).length !== 2) {
57
+ throw new Error('tunnel pull status returned no healthy relay — refusing to fold an empty observation');
58
+ }
59
+ return [mapTunnelRelay(response.body)];
60
+ }
61
+ export async function syncTunnelFromReal(execute, opts = {}) {
62
+ const resources = await pullTunnelRelay(execute);
63
+ const result = syncPull({ service: 'tunnel', resources, occurredAt: opts.occurredAt ?? new Date().toISOString(), ...(opts.root ? { root: opts.root } : {}) });
64
+ return { observed: result.observed, deltasAppended: result.deltasAppended };
65
+ }
66
+ export async function pushPendingTunnelActions() {
67
+ throw new Error('tunnel push is not implemented: publishing a local tunnel to a real edge needs an explicit operator-selected provider and credential');
68
+ }
@@ -0,0 +1,109 @@
1
+ import { persistTunnelRelayRegistration, type RelaySession } from './tunnel-twin.js';
2
+ /**
3
+ * Socket state. ONE shape for both socket kinds because Bun's `serve` takes a single
4
+ * `WebSocketHandler` data type: a control socket is a registered relay client, a visitor
5
+ * socket is a browser that opened `wss://…/__twin/tunnels/<id>/<path>` and is being bridged
6
+ * to that client's local server. `kind` is the discriminator every handler branches on
7
+ * first — a visitor frame must never reach the control-frame reducer, and a control frame
8
+ * must never be mistaken for visitor payload.
9
+ */
10
+ type VisitorState = {
11
+ connId: string;
12
+ tunnelId: string;
13
+ owner: Socket;
14
+ path: string;
15
+ headers: Record<string, string>;
16
+ /** The client has answered `ws-ready`; before that, visitor frames queue rather than vanish. */
17
+ ready: boolean;
18
+ queue: Array<{
19
+ data: Uint8Array;
20
+ binary: boolean;
21
+ }>;
22
+ queuedBytes: number;
23
+ /** A `ws-close`/`ws-error` already relayed for this connection — do not send a second. */
24
+ closeRelayed: boolean;
25
+ };
26
+ type ControlData = {
27
+ kind: 'control' | 'visitor';
28
+ socketId: string;
29
+ authoritativeId?: string;
30
+ session: RelaySession;
31
+ closed: boolean;
32
+ /** Where this relay is reached: what a registration hands its client as the tunnel's URL. */
33
+ publicBase?: string;
34
+ /** Set iff `kind === 'visitor'`. */
35
+ visitor?: VisitorState;
36
+ };
37
+ /** A relay socket as the handler uses it: Bun's `ServerWebSocket` locally, a `WebSocketPair` end on a
38
+ * hosted World (createTunnelTwinFetch). */
39
+ type Socket = {
40
+ data: ControlData;
41
+ send(message: string | Uint8Array): unknown;
42
+ close(code?: number, reason?: string): void;
43
+ readonly readyState: number;
44
+ };
45
+ /** Answer an upgrade with a socket carrying `data` (and `headers` on the 101); false when not one. */
46
+ type Upgrade = (request: Request, init: {
47
+ data: ControlData;
48
+ headers?: Record<string, string>;
49
+ }) => boolean;
50
+ export declare function createTunnelTwinJwt(payload: {
51
+ sub?: string;
52
+ tid?: string;
53
+ exp?: number;
54
+ [key: string]: unknown;
55
+ }, secret?: string): string;
56
+ export declare function tunnelControlPathIsReserved(pathname: string): boolean;
57
+ export declare function parseTunnelLoopbackOrigin(origin: string): URL;
58
+ type TunnelTwinOptions = {
59
+ root?: string;
60
+ port?: number;
61
+ readOnly?: boolean;
62
+ requestTimeoutMs?: number;
63
+ jwtSecret?: string;
64
+ /** Match the owned relay's optional REQUIRE_TID=true mode. Default shared SSO accepts a valid
65
+ * token without tid; a present tid is always bound to its exact tunnel. */
66
+ requireTid?: boolean;
67
+ /** Deterministic capability seam for forcing registration-persistence overlap. */
68
+ persistRegistration?: typeof persistTunnelRelayRegistration;
69
+ };
70
+ /**
71
+ * THE RELAY, host-neutral: a fetch that may answer an upgrade, and the socket handlers. The loopback
72
+ * server below runs it under `Bun.serve`; a hosted World runs it with `WebSocketPair`
73
+ * (createTunnelTwinFetch), since a Worker answers WebSocket upgrades as it answers HTTP.
74
+ */
75
+ export declare function createTunnelTwinHandler(options?: TunnelTwinOptions): {
76
+ fetch: (request: Request, bunServer: {
77
+ upgrade: Upgrade;
78
+ }) => Promise<Response | undefined>;
79
+ websocket: {
80
+ open: (ws: Socket) => void;
81
+ message: (ws: Socket, raw: string | Uint8Array) => Promise<void>;
82
+ close: (ws: Socket, code: number, reason?: string) => void;
83
+ };
84
+ /** Where the relay is reached when a request does not say (the loopback server's own origin). */
85
+ setBaseUrl(url: string): void;
86
+ readonly url: string;
87
+ registerQuickOrigin(id: string, origin: string): void;
88
+ quickUrl(id: string): string;
89
+ authUrl(tunnelId: string, token: string): string;
90
+ mintToken(tunnelId: string, payload?: Record<string, unknown>): string;
91
+ stop(): void;
92
+ };
93
+ /** The relay on loopback, under `Bun.serve`. */
94
+ export declare function createTunnelTwinServer(options?: TunnelTwinOptions): {
95
+ port: number | undefined;
96
+ url: string;
97
+ registerQuickOrigin: (id: string, origin: string) => void;
98
+ quickUrl: (id: string) => string;
99
+ authUrl: (tunnelId: string, token: string) => string;
100
+ mintToken: (tunnelId: string, payload?: Record<string, unknown>) => string;
101
+ stop(): void;
102
+ };
103
+ /**
104
+ * The relay as a hosted World's twin wire: the same handler, its upgrades answered with a
105
+ * `WebSocketPair` (a Worker takes a WebSocket as it takes HTTP). Frames are handed to the handler in
106
+ * arrival order, as `Bun.serve` hands them.
107
+ */
108
+ export declare function createTunnelTwinFetch(options?: TunnelTwinOptions): (request: Request) => Promise<Response>;
109
+ export {};