@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
@@ -1,4 +1,4 @@
1
- import { ACCOUNT, b64decode, holder, sha256, TUNNEL, tunnelUrl, validId } from "./shared.js";
1
+ import { ACCOUNT, b64decode, bufferedBody, claimant, requests, responseHeaders, sha256, TUNNEL, tunnelUrl } from "./shared.js";
2
2
  const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
3
3
  const streams = (s) => (s.state.streams ??= new Map());
4
4
  const headersOf = (v) => { const h = new Headers(); for (const [k, x] of Object.entries(obj(v)))
@@ -11,29 +11,42 @@ function reject(s, message, code, reason) {
11
11
  async function register(s, msg, ctx) {
12
12
  const token = typeof msg.secret === 'string' ? msg.secret : '';
13
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"
14
15
  if (!account)
15
- 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');
16
+ return reject(s, 'Invalid tunnel secret', 4003, 'Invalid tunnel secret');
16
17
  // the control URL's ?id= names the tunnel (the routing relay's authority), else the register frame's, else one minted
17
18
  // This id names a stored reservation: mint it, then record the reservation with its owner below. ctx.issue is for
18
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"
19
21
  const id = String(s.state.tunnelId || msg.tunnelId || ctx.mint(TUNNEL));
20
- if (!validId(id))
21
- return reject(s, `Tunnel rejected: invalid tunnel id ${id}`, 4029, 'invalidId');
22
22
  const held = ctx.row(TUNNEL, id);
23
- // Where the documentation stops: a tunnel id another account holds is refused, as the relay's reservation refuses it
23
+ // source: spec:/x-reference/relay "Tunnel ID reserved"
24
24
  if (held && held._owner !== account.id)
25
- return reject(s, `Tunnel rejected: ${id} is reserved by another account`, 4029, 'reserved');
26
- const live = holder(ctx, id);
25
+ return reject(s, `Tunnel ID '${id}' is reserved by another account.`, 4002, 'Tunnel ID reserved');
26
+ const live = claimant(ctx, id);
27
27
  if (live && live !== s) {
28
- // Where the documentation stops: a live registration of the id is refused unless this one replaces it
28
+ // source: spec:/x-reference/relay "Pass { replace: true } to take over."
29
29
  if (msg.replace !== true)
30
- return reject(s, `Tunnel rejected: ${id} is already connected`, 4029, 'inUse');
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';
31
34
  live.state.registered = false;
35
+ // source: spec:/x-reference/relay "Replaced by new client"
32
36
  live.close(4001, 'Replaced by new client');
33
37
  }
34
- if (!held)
35
- await ctx.record(TUNNEL, { _owner: account.id, reservedAt: ctx.occurredAt }, id);
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;
36
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."
37
50
  s.send({ type: 'registered', tunnelId: id, url: tunnelUrl(id) });
38
51
  }
39
52
  export const control = {
@@ -53,22 +66,66 @@ export const control = {
53
66
  if (s.state.registered !== true)
54
67
  return;
55
68
  const reqId = String(msg.reqId ?? '');
69
+ const request = requests(s).get(reqId);
70
+ // source: spec:/x-reference/protocol "A complete (buffered) response"
56
71
  if (msg.type === 'response') {
57
- s.fulfil(reqId, new Response(msg.body ? b64decode(String(msg.body)) : null, { status: Number(msg.status ?? 502), headers: headersOf(msg.headers) }));
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"
58
82
  }
59
83
  else if (msg.type === 'response-start') {
60
- const body = new ReadableStream({ start: (c) => { streams(s).set(reqId, c); } });
61
- s.fulfil(reqId, new Response(body, { status: Number(msg.status ?? 502), headers: headersOf(msg.headers) }));
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."
62
98
  }
63
99
  else if (msg.type === 'response-chunk') {
64
- streams(s).get(reqId)?.enqueue(b64decode(String(msg.data ?? '')));
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."
65
113
  }
66
114
  else if (msg.type === 'response-end') {
67
- streams(s).get(reqId)?.close();
115
+ const stream = streams(s).get(reqId);
68
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
+ }
69
124
  }
70
125
  },
71
126
  close(s) {
127
+ s.state.registered = false;
128
+ s.state.closed = true;
72
129
  for (const c of streams(s).values()) {
73
130
  try {
74
131
  c.error(new Error('Tunnel closed'));
@@ -78,3 +135,27 @@ export const control = {
78
135
  streams(s).clear();
79
136
  },
80
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,109 @@
1
+ {
2
+ "vendor": "tunnel",
3
+ "missing": 9,
4
+ "stale": [],
5
+ "code": {
6
+ "lines": 255,
7
+ "run": 242,
8
+ "recorded": 4,
9
+ "missing": 9
10
+ },
11
+ "state": {
12
+ "declared": 0,
13
+ "reached": 0,
14
+ "recorded": 0,
15
+ "missing": [],
16
+ "undeclared": []
17
+ },
18
+ "files": [
19
+ {
20
+ "file": "semantics/doors.ts",
21
+ "lines": {
22
+ "found": 13,
23
+ "hit": 13
24
+ },
25
+ "missing": []
26
+ },
27
+ {
28
+ "file": "semantics/sockets.ts",
29
+ "lines": {
30
+ "found": 82,
31
+ "hit": 74
32
+ },
33
+ "missing": [
34
+ {
35
+ "from": 114,
36
+ "to": 118,
37
+ "fn": "expireStream"
38
+ },
39
+ {
40
+ "from": 123,
41
+ "to": 125,
42
+ "fn": "cancelStream"
43
+ }
44
+ ]
45
+ },
46
+ {
47
+ "file": "semantics/shared.ts",
48
+ "lines": {
49
+ "found": 46,
50
+ "hit": 46
51
+ },
52
+ "missing": []
53
+ },
54
+ {
55
+ "file": "semantics/around.ts",
56
+ "lines": {
57
+ "found": 40,
58
+ "hit": 39
59
+ },
60
+ "missing": [
61
+ {
62
+ "from": 66,
63
+ "to": 66,
64
+ "fn": "timeout"
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "file": "semantics/index.ts",
70
+ "lines": {
71
+ "found": 2,
72
+ "hit": 2
73
+ },
74
+ "missing": []
75
+ },
76
+ {
77
+ "file": "manifest.ts",
78
+ "lines": {
79
+ "found": 43,
80
+ "hit": 43
81
+ },
82
+ "missing": []
83
+ },
84
+ {
85
+ "file": "index.ts",
86
+ "lines": {
87
+ "found": 7,
88
+ "hit": 7
89
+ },
90
+ "missing": []
91
+ },
92
+ {
93
+ "file": "server.ts",
94
+ "lines": {
95
+ "found": 6,
96
+ "hit": 2
97
+ },
98
+ "missing": []
99
+ },
100
+ {
101
+ "file": "fetch.ts",
102
+ "lines": {
103
+ "found": 16,
104
+ "hit": 16
105
+ },
106
+ "missing": []
107
+ }
108
+ ]
109
+ }