@volter/twin-tunnel 1.0.0 → 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.
Files changed (43) hide show
  1. package/README.md +3 -1
  2. package/dist/journeys/coverage.json +109 -0
  3. package/dist/journeys/customer-life.json +588 -0
  4. package/dist/journeys/customer-life.outline.md +34 -0
  5. package/dist/journeys/customer-life.outline.verdict.json +42 -0
  6. package/dist/journeys/customer-life.verdict.json +51 -0
  7. package/dist/journeys/decisions.json +23 -0
  8. package/dist/journeys/demand.json +24 -0
  9. package/dist/journeys/score.json +134 -0
  10. package/dist/journeys/unreachable.json +12 -0
  11. package/dist/journeys/vendor-examples.json +7 -0
  12. package/dist/journeys/vendor-examples.verdict.json +18 -0
  13. package/dist/spec/SOURCE.md +30 -14
  14. package/dist/spec/openapi.json +47 -15
  15. package/dist/spec/sources.json +1 -0
  16. package/dist/src/index.js +4 -3
  17. package/dist/src/manifest.js +7 -0
  18. package/dist/src/semantics/around.js +42 -9
  19. package/dist/src/semantics/doors.js +2 -2
  20. package/dist/src/semantics/shared.d.ts +8 -3
  21. package/dist/src/semantics/shared.js +61 -3
  22. package/dist/src/semantics/sockets.js +97 -16
  23. package/journeys/coverage.json +109 -0
  24. package/journeys/customer-life.json +588 -0
  25. package/journeys/customer-life.outline.md +34 -0
  26. package/journeys/customer-life.outline.verdict.json +42 -0
  27. package/journeys/customer-life.verdict.json +51 -0
  28. package/journeys/decisions.json +23 -0
  29. package/journeys/demand.json +24 -0
  30. package/journeys/score.json +134 -0
  31. package/journeys/unreachable.json +12 -0
  32. package/journeys/vendor-examples.json +7 -0
  33. package/journeys/vendor-examples.verdict.json +18 -0
  34. package/package.json +3 -2
  35. package/spec/SOURCE.md +30 -14
  36. package/spec/openapi.json +47 -15
  37. package/spec/sources.json +1 -0
  38. package/src/index.ts +5 -4
  39. package/src/manifest.ts +7 -0
  40. package/src/semantics/around.ts +37 -10
  41. package/src/semantics/doors.ts +2 -2
  42. package/src/semantics/shared.ts +56 -4
  43. package/src/semantics/sockets.ts +72 -14
@@ -3,7 +3,7 @@
3
3
  // and the client answers with a whole `response` or a stream (`response-start`, `response-chunk`s, `response-end`),
4
4
  // which settle the request waiting on it by its `reqId`. Frames as volter-tunnel's protocol.ts defines them.
5
5
  import type { HandlerContext, SocketEngine, SocketSession } from '@volter/world-core';
6
- import { ACCOUNT, b64decode, holder, type Row, sha256, TUNNEL, tunnelUrl, validId } from './shared.ts';
6
+ import { ACCOUNT, b64decode, bufferedBody, claimant, requests, responseHeaders, type Row, sha256, TUNNEL, tunnelUrl } from './shared.ts';
7
7
 
8
8
  type Streams = Map<string, ReadableStreamDefaultController<Uint8Array>>;
9
9
  const obj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
@@ -19,24 +19,33 @@ function reject(s: SocketSession, message: string, code: number, reason: string)
19
19
  async function register(s: SocketSession, msg: Row, ctx: HandlerContext): Promise<void> {
20
20
  const token = typeof msg.secret === 'string' ? msg.secret : '';
21
21
  const account = token ? ctx.rowsRaw(ACCOUNT).find((a) => a.deleted !== true && a.token_sha256 === sha256(ctx, token)) : undefined;
22
- if (!account) return reject(s, 'Tunnel rejected: credential is invalid or revoked. On an owner-authenticated device, run "volter-tunnel tokens" and restore this host token, or sign in again on the host.', 4003, 'badToken');
22
+ // source: spec:/x-reference/relay "Invalid tunnel secret"
23
+ if (!account) return reject(s, 'Invalid tunnel secret', 4003, 'Invalid tunnel secret');
23
24
  // the control URL's ?id= names the tunnel (the routing relay's authority), else the register frame's, else one minted
24
25
  // This id names a stored reservation: mint it, then record the reservation with its owner below. ctx.issue is for
25
26
  // issued ids whose subjects the vendor does not store; its empty row would look like another account's reservation.
27
+ // source: spec:/x-reference/relay "Prefer the ?id= captured at accept (authoritative"
26
28
  const id = String(s.state.tunnelId || msg.tunnelId || ctx.mint(TUNNEL));
27
- if (!validId(id)) return reject(s, `Tunnel rejected: invalid tunnel id ${id}`, 4029, 'invalidId');
28
29
  const held = ctx.row(TUNNEL, id);
29
- // Where the documentation stops: a tunnel id another account holds is refused, as the relay's reservation refuses it
30
- if (held && held._owner !== account.id) return reject(s, `Tunnel rejected: ${id} is reserved by another account`, 4029, 'reserved');
31
- const live = holder(ctx, id);
30
+ // source: spec:/x-reference/relay "Tunnel ID reserved"
31
+ if (held && held._owner !== account.id) return reject(s, `Tunnel ID '${id}' is reserved by another account.`, 4002, 'Tunnel ID reserved');
32
+ const live = claimant(ctx, id);
32
33
  if (live && live !== s) {
33
- // Where the documentation stops: a live registration of the id is refused unless this one replaces it
34
- if (msg.replace !== true) return reject(s, `Tunnel rejected: ${id} is already connected`, 4029, 'inUse');
34
+ // source: spec:/x-reference/relay "Pass { replace: true } to take over."
35
+ if (msg.replace !== true) 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');
36
+ // source: spec:/x-reference/relay "Tunnel replaced"
37
+ live.state.superseded = true;
38
+ live.state.disconnectReason = 'Tunnel replaced';
35
39
  live.state.registered = false;
40
+ // source: spec:/x-reference/relay "Replaced by new client"
36
41
  live.close(4001, 'Replaced by new client');
37
42
  }
38
- if (!held) await ctx.record(TUNNEL, { _owner: account.id, reservedAt: ctx.occurredAt }, id);
43
+ Object.assign(s.state, { registering: true, tunnelId: id, account: account.id });
44
+ try { if (!held) await ctx.record(TUNNEL, { _owner: account.id, reservedAt: ctx.occurredAt }, id); }
45
+ finally { s.state.registering = false; }
46
+ if (s.state.superseded === true || !ctx.sockets('control').includes(s)) return;
39
47
  Object.assign(s.state, { registered: true, tunnelId: id, account: account.id, authRequired: msg.authRequired !== false });
48
+ // source: spec:/x-reference/protocol "Acknowledges a successful registration; carries the public URL + usage."
40
49
  s.send({ type: 'registered', tunnelId: id, url: tunnelUrl(id) });
41
50
  }
42
51
 
@@ -50,20 +59,69 @@ export const control: SocketEngine<HandlerContext> = {
50
59
  if (msg.type === 'register') return register(s, msg, ctx);
51
60
  if (s.state.registered !== true) return;
52
61
  const reqId = String(msg.reqId ?? '');
62
+ const request = requests(s).get(reqId);
63
+ // source: spec:/x-reference/protocol "A complete (buffered) response"
53
64
  if (msg.type === 'response') {
54
- s.fulfil(reqId, new Response(msg.body ? b64decode(String(msg.body)) as Uint8Array<ArrayBuffer> : null, { status: Number(msg.status ?? 502), headers: headersOf(msg.headers) }));
65
+ if (!request) return;
66
+ const headers = responseHeaders(headersOf(msg.headers), request);
67
+ const body = bufferedBody(msg.body);
68
+ // source: spec:/x-reference/relay "Malformed tunnel response"
69
+ if (body === undefined) s.fulfil(reqId, new Response('Malformed tunnel response', { status: 502, headers }));
70
+ else s.fulfil(reqId, new Response(request.method === 'HEAD' ? null : body, { status: Number(msg.status || 200), headers }));
71
+ // source: spec:/x-reference/protocol "Start of a streamed response"
55
72
  } else if (msg.type === 'response-start') {
56
- const body = new ReadableStream<Uint8Array>({ start: (c) => { streams(s).set(reqId, c); } });
57
- s.fulfil(reqId, new Response(body, { status: Number(msg.status ?? 502), headers: headersOf(msg.headers) }));
73
+ if (!request) return;
74
+ const status = Number(msg.status || 200);
75
+ const headers = responseHeaders(headersOf(msg.headers), request);
76
+ // source: spec:/x-reference/relay "HEAD is bodyless by contract too."
77
+ if (request.method === 'HEAD' || status === 204 || status === 205 || status === 304) {
78
+ s.fulfil(reqId, new Response(null, { status, headers }));
79
+ } else {
80
+ const body = new ReadableStream<Uint8Array>({ start: (c) => { streams(s).set(reqId, c); }, cancel: () => cancelStream(s, reqId) });
81
+ armStreamIdle(s, reqId, ctx);
82
+ s.fulfil(reqId, new Response(body, { status, headers }));
83
+ }
84
+ // source: spec:/x-reference/protocol "One streamed response body chunk."
58
85
  } else if (msg.type === 'response-chunk') {
59
- streams(s).get(reqId)?.enqueue(b64decode(String(msg.data ?? '')));
86
+ const stream = streams(s).get(reqId);
87
+ if (stream) {
88
+ try { stream.enqueue(b64decode(String(msg.data ?? ''))); s.fulfil(`idle:${reqId}`, null); armStreamIdle(s, reqId, ctx); }
89
+ catch { streams(s).delete(reqId); s.fulfil(`idle:${reqId}`, null); }
90
+ }
91
+ // source: spec:/x-reference/protocol "End of a streamed response."
60
92
  } else if (msg.type === 'response-end') {
61
- streams(s).get(reqId)?.close();
93
+ const stream = streams(s).get(reqId);
62
94
  streams(s).delete(reqId);
95
+ s.fulfil(`idle:${reqId}`, null);
96
+ if (stream) { try { stream.close(); } catch { /* already closed */ } }
63
97
  }
64
98
  },
65
99
  close(s) {
100
+ s.state.registered = false;
101
+ s.state.closed = true;
66
102
  for (const c of streams(s).values()) { try { c.error(new Error('Tunnel closed')); } catch { /* already ended */ } }
67
103
  streams(s).clear();
68
104
  },
69
105
  };
106
+
107
+ // source: spec:/x-reference/relay "Idle watchdog for a streaming response"
108
+ function armStreamIdle(s: SocketSession, reqId: string, ctx: HandlerContext): void {
109
+ // source: spec:/x-reference/relay "120000"
110
+ void s.expect(`idle:${reqId}`, 120_000).catch(() => { if (ctx.sockets('control').includes(s)) expireStream(s, reqId); });
111
+ }
112
+
113
+ // source: spec:/x-reference/relay "stream idle timeout"
114
+ function expireStream(s: SocketSession, reqId: string): void {
115
+ const stream = streams(s).get(reqId);
116
+ if (!stream) return;
117
+ streams(s).delete(reqId);
118
+ try { stream.error(new Error('stream idle timeout')); } catch { /* already closed */ }
119
+ s.send({ type: 'request-abort', reqId });
120
+ }
121
+
122
+ // source: spec:/x-reference/protocol "The relay asks the client to abort an in-flight request."
123
+ function cancelStream(s: SocketSession, reqId: string): void {
124
+ streams(s).delete(reqId);
125
+ s.fulfil(`idle:${reqId}`, null);
126
+ s.send({ type: 'request-abort', reqId });
127
+ }