@johnhenry/wsh 0.20.0 → 0.22.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,263 @@
1
+ /**
2
+ * Session registry for `@johnhenry/wsh/server`: what lets an exec/pty session
3
+ * outlive the connection that opened it (`createWshServer({ sessions })`).
4
+ *
5
+ * The registry is per server and holds a `HostedSession` for every pty/exec
6
+ * session. A connection's channel is an *attachment* to one: output is
7
+ * appended to the session's ring buffer and fanned out to every attachment,
8
+ * so when a socket drops the process keeps running and its newest output keeps
9
+ * accumulating until `detachTtlMs` elapses or a client resumes.
10
+ *
11
+ * `seq` -- the one design question the wire does not answer -- is defined as
12
+ * the cumulative count of output BYTES the host has produced for a session.
13
+ * A client counts what it has received (it sees every byte), passes that as
14
+ * `Resume.last_seq`, and the host replays `ring[last_seq - ringStart ..]`.
15
+ * See spec/wsh-v1.yaml (`Resume`, `AttachmentInfo`).
16
+ */
17
+
18
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
19
+
20
+ export const DEFAULT_RING_BYTES = 1024 * 1024;
21
+ export const DEFAULT_DETACH_TTL_MS = 300_000;
22
+ export const DEFAULT_MAX_DETACHED = 16;
23
+ export const MAX_ATTACHMENTS = 16;
24
+
25
+ const NO_EXPIRY = Buffer.alloc(8, 0xff);
26
+
27
+ /**
28
+ * A bounded byte history addressed by absolute stream position.
29
+ * `start` is the seq of the oldest retained byte, `end` the seq one past the newest.
30
+ */
31
+ export class ByteRing {
32
+ #chunks = [];
33
+ #size = 0;
34
+ start = 0;
35
+ end = 0;
36
+
37
+ constructor(maxBytes) {
38
+ this.max = maxBytes;
39
+ }
40
+
41
+ push(bytes) {
42
+ if (!bytes.byteLength) return;
43
+ this.#chunks.push(Uint8Array.from(bytes));
44
+ this.#size += bytes.byteLength;
45
+ this.end += bytes.byteLength;
46
+ while (this.#size > this.max) {
47
+ const excess = this.#size - this.max;
48
+ const first = this.#chunks[0];
49
+ if (first.byteLength <= excess) {
50
+ this.#chunks.shift();
51
+ this.#size -= first.byteLength;
52
+ this.start += first.byteLength;
53
+ } else {
54
+ this.#chunks[0] = first.subarray(excess);
55
+ this.#size -= excess;
56
+ this.start += excess;
57
+ }
58
+ }
59
+ }
60
+
61
+ /** Bytes from absolute position `from` (clamped to `start`) to the end. */
62
+ read(from = this.start) {
63
+ const skip = Math.max(0, from - this.start);
64
+ const out = new Uint8Array(Math.max(0, this.#size - skip));
65
+ let at = 0;
66
+ let seen = 0;
67
+ for (const c of this.#chunks) {
68
+ if (seen + c.byteLength > skip) {
69
+ const part = c.subarray(Math.max(0, skip - seen));
70
+ out.set(part, at);
71
+ at += part.byteLength;
72
+ }
73
+ seen += c.byteLength;
74
+ }
75
+ return out;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Mint a session token in the spec's format: `[8B expiry][32B HMAC-SHA256(secret, session_id || expiry)]`.
81
+ * A session's lifetime is governed by `detachTtlMs`, not by its token, so the expiry is the maximum.
82
+ */
83
+ export function mintToken(secret, sessionId) {
84
+ const mac = createHmac('sha256', secret).update(sessionId).update(NO_EXPIRY).digest();
85
+ return new Uint8Array(Buffer.concat([NO_EXPIRY, mac]));
86
+ }
87
+
88
+ /** Constant-time verification of a token minted by `mintToken` for exactly this session id. */
89
+ export function verifyToken(secret, sessionId, token) {
90
+ if (!(token instanceof Uint8Array) || token.byteLength !== 40) return false;
91
+ const buf = Buffer.from(token.buffer, token.byteOffset, token.byteLength);
92
+ const expiry = buf.subarray(0, 8);
93
+ if (expiry.readBigUInt64BE() < BigInt(Math.floor(Date.now() / 1000))) return false;
94
+ const want = createHmac('sha256', secret).update(sessionId).update(expiry).digest();
95
+ return timingSafeEqual(want, buf.subarray(8));
96
+ }
97
+
98
+ /**
99
+ * One attached connection's view of a session; `connection.mjs` builds them.
100
+ * @typedef {object} Attachment
101
+ * @property {number} connId
102
+ * @property {number} channelId
103
+ * @property {'control' | 'readonly'} mode
104
+ * @property {string} username
105
+ * @property {string} principal - who this connection authenticated as (see `principalKey`)
106
+ * @property {(bytes: Uint8Array) => unknown} write - live output
107
+ * @property {(msg: object) => unknown} notify - an ordered control message (e.g. Presence)
108
+ * @property {(code: number) => unknown} exit - the session ended
109
+ */
110
+
111
+ export class HostedSession {
112
+ /** @type {Set<Attachment>} */
113
+ attachments = new Set();
114
+ state = 'attached';
115
+ exitCode = null;
116
+ createdAt = Date.now();
117
+ lastActivity = Date.now();
118
+ detachedAt = 0;
119
+ name;
120
+ #timer = null;
121
+
122
+ constructor(registry, { id, token, owner, kind, command, ringBytes }) {
123
+ this.registry = registry;
124
+ this.id = id;
125
+ this.token = token;
126
+ this.owner = owner;
127
+ this.kind = kind;
128
+ this.command = command;
129
+ this.ring = new ByteRing(ringBytes);
130
+ /** Set by the connection once the process exists: `{ input, inputEnd, resize, signal, kill }`. */
131
+ this.handlers = {};
132
+ }
133
+
134
+ /** Output from the process: retained in the ring, delivered to every attachment. */
135
+ push(bytes) {
136
+ this.lastActivity = Date.now();
137
+ this.ring.push(bytes);
138
+ for (const a of this.attachments) a.write(bytes);
139
+ }
140
+
141
+ attach(att) {
142
+ this.attachments.add(att);
143
+ this.state = 'attached';
144
+ this.#clearTimer();
145
+ this.broadcastPresence(att);
146
+ }
147
+
148
+ detach(att) {
149
+ if (!this.attachments.delete(att)) return;
150
+ this.broadcastPresence();
151
+ if (this.attachments.size === 0) this.#markDetached();
152
+ }
153
+
154
+ roster() {
155
+ return [...this.attachments].map((a) => ({ session_id: this.id, mode: a.mode, username: a.username }));
156
+ }
157
+
158
+ /** Tell every attachment (except `except`) who is attached now. */
159
+ broadcastPresence(except) {
160
+ if (this.attachments.size === 0) return;
161
+ const msg = { attachments: this.roster() };
162
+ for (const a of this.attachments) if (a !== except) a.notify(msg);
163
+ }
164
+
165
+ /** The process ended. Everyone attached hears Exit + Close; the record lingers for late resumes. */
166
+ finish(code) {
167
+ if (this.state === 'exited') return;
168
+ this.exitCode = code;
169
+ this.state = 'exited';
170
+ const att = [...this.attachments];
171
+ this.attachments.clear();
172
+ for (const a of att) a.exit(code);
173
+ this.#markDetached();
174
+ }
175
+
176
+ kill() {
177
+ try { this.handlers.kill?.(); } catch { /* already gone */ }
178
+ }
179
+
180
+ /** Forget the session (and stop it if it is still running). */
181
+ dispose() {
182
+ this.#clearTimer();
183
+ this.registry._forget(this);
184
+ if (this.state !== 'exited') {
185
+ this.kill();
186
+ this.state = 'exited';
187
+ }
188
+ }
189
+
190
+ #markDetached() {
191
+ this.detachedAt = Date.now();
192
+ if (this.state !== 'exited') this.state = 'detached';
193
+ this.#clearTimer();
194
+ const ttl = this.registry.detachTtlMs;
195
+ if (ttl <= 0) { this.dispose(); return; }
196
+ this.#timer = setTimeout(() => {
197
+ this.registry.log(`session ${this.id} expired after ${ttl}ms detached`);
198
+ this.dispose();
199
+ }, ttl);
200
+ this.#timer.unref?.();
201
+ this.registry._enforceMaxDetached(this);
202
+ }
203
+
204
+ #clearTimer() {
205
+ clearTimeout(this.#timer);
206
+ this.#timer = null;
207
+ }
208
+ }
209
+
210
+ export class SessionRegistry {
211
+ /** @type {Map<string, HostedSession>} */
212
+ #sessions = new Map();
213
+
214
+ constructor({ secret, detachTtlMs = DEFAULT_DETACH_TTL_MS, maxDetached = DEFAULT_MAX_DETACHED, ringBytes = DEFAULT_RING_BYTES, log = () => {} } = {}) {
215
+ this.secret = secret ?? randomBytes(32);
216
+ this.detachTtlMs = detachTtlMs;
217
+ this.maxDetached = maxDetached;
218
+ this.ringBytes = ringBytes;
219
+ this.log = log;
220
+ }
221
+
222
+ /** @param {{ owner: { username: string, principal: string, fingerprint: string | null }, kind: 'pty' | 'exec', command?: string }} spec */
223
+ create({ owner, kind, command }) {
224
+ const id = randomUUID();
225
+ const s = new HostedSession(this, { id, token: mintToken(this.secret, id), owner, kind, command, ringBytes: this.ringBytes });
226
+ this.#sessions.set(id, s);
227
+ return s;
228
+ }
229
+
230
+ get(id) {
231
+ return typeof id === 'string' ? this.#sessions.get(id) : undefined;
232
+ }
233
+
234
+ checkToken(session, token) {
235
+ return verifyToken(this.secret, session.id, token);
236
+ }
237
+
238
+ /** Sessions this principal owns. */
239
+ listFor(principal) {
240
+ return [...this.#sessions.values()].filter((s) => s.owner.principal === principal);
241
+ }
242
+
243
+ get size() { return this.#sessions.size; }
244
+
245
+ /** Kill everything (server shutdown). */
246
+ closeAll() {
247
+ for (const s of [...this.#sessions.values()]) s.dispose();
248
+ }
249
+
250
+ _forget(session) {
251
+ this.#sessions.delete(session.id);
252
+ }
253
+
254
+ /** Keep at most `maxDetached` unattended sessions: the longest-detached are killed first. */
255
+ _enforceMaxDetached() {
256
+ const detached = [...this.#sessions.values()].filter((s) => s.attachments.size === 0).sort((a, b) => a.detachedAt - b.detachedAt);
257
+ while (detached.length > this.maxDetached) {
258
+ const victim = detached.shift();
259
+ this.log(`session ${victim.id} evicted (more than ${this.maxDetached} detached)`);
260
+ victim.dispose();
261
+ }
262
+ }
263
+ }
package/src/session.mjs CHANGED
@@ -76,6 +76,12 @@ export class WshSession {
76
76
  */
77
77
  #resumeToken;
78
78
 
79
+ /**
80
+ * Cumulative session OUTPUT BYTES received so far (see `seq`).
81
+ * @type {number}
82
+ */
83
+ #seq = 0;
84
+
79
85
  /**
80
86
  * Stream IDs returned by the server in OPEN_OK.
81
87
  * Typically { stdin: number, stdout: number } or a single bidirectional ID.
@@ -270,6 +276,27 @@ export class WshSession {
270
276
  return this.#resumeToken;
271
277
  }
272
278
 
279
+ /**
280
+ * How many bytes of this session's output this client has received: the
281
+ * host's `seq` for the next byte. Pass it as `lastSeq` to
282
+ * `WshClient.resumeSession()` so a resume replays only what was missed. For a
283
+ * session obtained from `attachSession()`/`resumeSession()` it starts at the
284
+ * host's position of the first replayed byte, so it is an absolute position
285
+ * either way. Counts bytes delivered to `onData`.
286
+ * @returns {number}
287
+ */
288
+ get seq() {
289
+ return this.#seq;
290
+ }
291
+
292
+ /**
293
+ * Set the starting position of a session obtained by attach/resume.
294
+ * @internal
295
+ */
296
+ _setSeq(n) {
297
+ this.#seq = n;
298
+ }
299
+
273
300
  /** Current session state. */
274
301
  get state() {
275
302
  return this.#state;
@@ -657,6 +684,7 @@ export class WshSession {
657
684
  if (msg.data && msg.data.byteLength > 0) {
658
685
  this.#virtualBackend?.pushData(msg.data);
659
686
  try {
687
+ this.#seq += msg.data.byteLength;
660
688
  this.onData?.(msg.data);
661
689
  } catch (err) {
662
690
  console.error('[wsh:session] onData handler error:', err);
@@ -688,6 +716,7 @@ export class WshSession {
688
716
  .then((plaintext) => {
689
717
  this.#virtualBackend?.pushData(plaintext);
690
718
  try {
719
+ this.#seq += plaintext.byteLength;
691
720
  this.onData?.(plaintext);
692
721
  } catch (err) {
693
722
  console.error('[wsh:session] onData handler error:', err);
@@ -898,6 +927,7 @@ export class WshSession {
898
927
  }
899
928
  for (const plaintext of deliverable) {
900
929
  try {
930
+ this.#seq += plaintext.byteLength;
901
931
  this.onData?.(plaintext);
902
932
  } catch (err) {
903
933
  console.error('[wsh:session] onData handler error:', err);
@@ -905,6 +935,7 @@ export class WshSession {
905
935
  }
906
936
  } else {
907
937
  try {
938
+ this.#seq += value.byteLength;
908
939
  this.onData?.(value);
909
940
  } catch (err) {
910
941
  console.error('[wsh:session] onData handler error:', err);