@shardflux/sdk 0.14.0 → 0.16.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -23
  2. package/README.md +226 -7
  3. package/dist/account.d.ts +2 -0
  4. package/dist/account.js +6 -0
  5. package/dist/cell.d.ts +27 -0
  6. package/dist/cell.js +71 -0
  7. package/dist/client.d.ts +54 -5
  8. package/dist/client.js +100 -7
  9. package/dist/computer.d.ts +153 -0
  10. package/dist/computer.js +229 -0
  11. package/dist/errors.d.ts +5 -1
  12. package/dist/errors.js +9 -0
  13. package/dist/executions.d.ts +2 -6
  14. package/dist/executions.js +9 -0
  15. package/dist/exit-code.d.ts +7 -0
  16. package/dist/exit-code.js +12 -0
  17. package/dist/generated/app-api.d.ts +1055 -119
  18. package/dist/generated/cell-api.d.ts +334 -0
  19. package/dist/http.d.ts +7 -1
  20. package/dist/http.js +34 -13
  21. package/dist/index.d.ts +13 -6
  22. package/dist/index.js +7 -2
  23. package/dist/ports.d.ts +7 -0
  24. package/dist/ports.js +1 -1
  25. package/dist/progress.d.ts +2 -2
  26. package/dist/progress.js +1 -1
  27. package/dist/templates.d.ts +12 -0
  28. package/dist/templates.js +9 -0
  29. package/dist/testing/index.d.ts +62 -0
  30. package/dist/testing/index.js +585 -0
  31. package/dist/testing/seed.d.ts +433 -0
  32. package/dist/testing/seed.js +449 -0
  33. package/dist/tools.d.ts +21 -3
  34. package/dist/tools.js +113 -22
  35. package/dist/tunnel-assets/linux-amd64.gz +0 -0
  36. package/dist/tunnel-assets/linux-arm64.gz +0 -0
  37. package/dist/tunnel-assets.d.ts +10 -0
  38. package/dist/tunnel-assets.js +11 -0
  39. package/dist/tunnel-packet.d.ts +3 -0
  40. package/dist/tunnel-packet.js +43 -0
  41. package/dist/tunnel-pty.d.ts +86 -0
  42. package/dist/tunnel-pty.js +243 -0
  43. package/dist/tunnels.d.ts +47 -0
  44. package/dist/tunnels.js +454 -0
  45. package/dist/workspace-ref.d.ts +87 -0
  46. package/dist/workspace-ref.js +173 -0
  47. package/dist/workspace.d.ts +40 -1
  48. package/dist/workspace.js +111 -2
  49. package/package.json +7 -2
@@ -0,0 +1,243 @@
1
+ import { WebSocket, EnvHttpProxyAgent } from 'undici';
2
+ import { cellPath } from "./cell.js";
3
+ import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
4
+ import { tunnelPacket } from "./tunnel-packet.js";
5
+ /** Native PTY owns the channel; helper acknowledgements own bytes, independently of an HTTP request outcome. */
6
+ export class PtyTunnelTransport {
7
+ maxInputConcurrency = 64;
8
+ kind = 'pty';
9
+ #cell;
10
+ #socket;
11
+ #dispatcher = new EnvHttpProxyAgent();
12
+ #open;
13
+ #pending = new Map();
14
+ #rawOffset = 0;
15
+ #sentOffset = 0;
16
+ #logicalOffset = 0;
17
+ #buffer = Buffer.alloc(0);
18
+ #queue = [];
19
+ #wake;
20
+ #error;
21
+ #closed = false;
22
+ #timer;
23
+ #recovery;
24
+ #recoveryAbort = new AbortController();
25
+ #sessionId = "";
26
+ constructor(cell, open = (url, opts) => new WebSocket(url, opts)) { this.#cell = cell; this.#open = open; }
27
+ files = { remove: (path, opts) => this.#cell.files.remove(path, opts) };
28
+ exec = {
29
+ start: (...args) => this.#cell.exec.start(...args),
30
+ input: (id, bytes, opts) => this.input(id, bytes, opts),
31
+ output: (id, opts) => this.output(id, opts),
32
+ cancel: async (id) => {
33
+ this.#closed = true;
34
+ this.#recoveryAbort.abort();
35
+ clearInterval(this.#timer);
36
+ this.#socket?.close(1000);
37
+ this.#wake?.();
38
+ for (const p of this.#pending.values()) {
39
+ p.cleanup();
40
+ p.reject(new Error('reverse tunnel closed'));
41
+ }
42
+ this.#pending.clear();
43
+ await this.#recovery;
44
+ try {
45
+ return await this.#cell.pty.close(id);
46
+ }
47
+ finally {
48
+ await this.#dispatcher.close();
49
+ }
50
+ },
51
+ };
52
+ async input(id, value, opts) {
53
+ this.#sessionId = id;
54
+ const bytes = typeof value === 'string' ? Buffer.from(value) : value;
55
+ const old = this.#pending.get(opts.offset);
56
+ if (old)
57
+ throw new Error('duplicate pending tunnel packet');
58
+ if (this.#closed)
59
+ throw new Error('reverse tunnel closed');
60
+ return new Promise((resolve, reject) => {
61
+ const abort = () => { this.#pending.delete(opts.offset); reject(opts.signal?.reason instanceof Error ? opts.signal.reason : new Error('aborted')); };
62
+ const packet = tunnelPacket(opts.offset, bytes);
63
+ const p = { offset: opts.offset, end: opts.offset + bytes.length, packet, resolve, reject,
64
+ cleanup: () => opts.signal?.removeEventListener('abort', abort) };
65
+ this.#sentOffset = Math.max(this.#sentOffset, p.end);
66
+ this.#pending.set(opts.offset, p);
67
+ opts.signal?.addEventListener('abort', abort, { once: true });
68
+ if (opts.signal?.aborted) {
69
+ abort();
70
+ return;
71
+ }
72
+ this.#send(packet);
73
+ this.#armRetry();
74
+ this.#recoverInput();
75
+ });
76
+ }
77
+ #send(packet) {
78
+ const socket = this.#socket;
79
+ if (socket?.readyState === WebSocket.OPEN && socket.bufferedAmount + packet.length <= 4 * 1024 * 1024)
80
+ socket.send(packet);
81
+ }
82
+ #armRetry() {
83
+ if (this.#timer)
84
+ return;
85
+ this.#timer = setInterval(() => {
86
+ if (!this.#pending.size) {
87
+ clearInterval(this.#timer);
88
+ this.#timer = undefined;
89
+ return;
90
+ }
91
+ // The byte window bounds both this replay and the gateway's queued native input.
92
+ for (const p of this.#pending.values())
93
+ this.#send(p.packet);
94
+ this.#recoverInput();
95
+ }, 500);
96
+ }
97
+ #recoverInput() {
98
+ const first = this.#pending.values().next().value;
99
+ if (this.#closed || this.#recovery || !first || this.#socket?.readyState === WebSocket.OPEN)
100
+ return;
101
+ // Only queued TCP data invokes wake-on-use; idle output reattach never wakes a workspace.
102
+ this.#recovery = this.#cell.request('POST', cellPath('/v1/workspaces/{workspace_id}/pty/{session_id}/input', {
103
+ workspace_id: this.#cell.workspaceId, session_id: this.#sessionId,
104
+ }), { json: { data: tunnelPacket(0, Buffer.alloc(0)).toString('base64') }, signal: this.#recoveryAbort.signal }).then(async (response) => { await response.arrayBuffer(); }).catch((error) => {
105
+ if (!this.#closed && error instanceof ShardfluxApiError && !error.retryable && !['workspace_not_running', 'workspace_busy'].includes(error.code)) {
106
+ this.#error = error;
107
+ this.#wake?.();
108
+ }
109
+ }).finally(() => { this.#recovery = undefined; });
110
+ }
111
+ #feed(bytes) {
112
+ this.#buffer = Buffer.concat([this.#buffer, bytes]);
113
+ const data = [];
114
+ while (this.#buffer.length >= 9) {
115
+ const n = this.#buffer.readUInt32BE(5);
116
+ if (n > 65536)
117
+ throw new Error('oversized tunnel output frame');
118
+ if (this.#buffer.length < n + 9)
119
+ break;
120
+ const framed = this.#buffer.subarray(0, n + 9);
121
+ this.#buffer = this.#buffer.subarray(n + 9);
122
+ if (framed[0] === 8) {
123
+ if (n !== 8 || framed.readUInt32BE(1) !== 0)
124
+ throw new Error('invalid tunnel acknowledgement');
125
+ const offset = Number(framed.readBigUInt64BE(9));
126
+ if (!Number.isSafeInteger(offset) || offset > this.#sentOffset)
127
+ throw new Error('invalid tunnel acknowledgement offset');
128
+ for (const [start, p] of this.#pending)
129
+ if (p.end <= offset) {
130
+ this.#pending.delete(start);
131
+ p.cleanup();
132
+ p.resolve({ offset: p.end, closed: false });
133
+ }
134
+ }
135
+ else
136
+ data.push(framed);
137
+ }
138
+ if (data.length) {
139
+ const joined = Buffer.concat(data);
140
+ this.#queue.push({ type: 'output', stream: 'stdout', offset: this.#logicalOffset, data: joined.toString('base64') });
141
+ this.#logicalOffset += joined.length;
142
+ this.#wake?.();
143
+ }
144
+ }
145
+ async output(id, opts = {}) {
146
+ this.#sessionId = id;
147
+ this.#error = undefined;
148
+ const { url, token } = await this.#cell.pty.attachUrl(id, this.#rawOffset);
149
+ const u = new URL(url);
150
+ u.searchParams.set('output_format', 'binary');
151
+ const socket = this.#open(u.toString(), { headers: { authorization: `Bearer ${token}` }, dispatcher: this.#dispatcher });
152
+ socket.binaryType = 'arraybuffer';
153
+ this.#socket = socket;
154
+ const fail = (error) => { if (this.#socket !== socket)
155
+ return; this.#error = error; this.#wake?.(); };
156
+ socket.addEventListener('message', (event) => {
157
+ try {
158
+ if (this.#socket !== socket)
159
+ return;
160
+ if (event.data instanceof ArrayBuffer) {
161
+ const bytes = Buffer.from(event.data);
162
+ if (bytes.length < 9)
163
+ throw new Error('invalid binary PTY output');
164
+ const start = Number(bytes.readBigUInt64BE(0));
165
+ this.#outputBytes(start, bytes.subarray(8));
166
+ return;
167
+ }
168
+ const m = JSON.parse(String(event.data));
169
+ if (this.#socket !== socket)
170
+ return;
171
+ if (m.type === 'output' && m.data) {
172
+ const bytes = Buffer.from(m.data, 'base64');
173
+ const start = m.offset ?? this.#rawOffset;
174
+ this.#outputBytes(start, bytes);
175
+ }
176
+ else if (m.type === 'exit') {
177
+ this.#queue.push({ type: 'exit', session: m.session });
178
+ this.#wake?.();
179
+ }
180
+ else if (m.type === 'error' && m.error)
181
+ fail(this.#apiError(m.error));
182
+ else if (!m.type && 'error' in m)
183
+ fail(this.#apiError(m));
184
+ }
185
+ catch (e) {
186
+ fail(e instanceof Error ? e : new Error(String(e)));
187
+ }
188
+ });
189
+ socket.addEventListener('error', () => {
190
+ // An HTTP refusal before upgrade has no close code/message in undici. Refresh ownership before reattach.
191
+ if (this.#socket !== socket)
192
+ return;
193
+ this.#cell.tokens.invalidate();
194
+ fail(new ShardfluxProtocolError('reverse tunnel PTY stream dropped', 0, 'cell'));
195
+ });
196
+ socket.addEventListener('close', (event) => {
197
+ if (this.#socket !== socket)
198
+ return;
199
+ if (this.#closed || opts.signal?.aborted) {
200
+ this.#wake?.();
201
+ return;
202
+ }
203
+ if (event.code === 4401 || event.code === 4409)
204
+ this.#cell.tokens.invalidate();
205
+ fail(this.#error ?? new ShardfluxProtocolError(`reverse tunnel PTY closed (${event.code})`, 0, 'cell'));
206
+ });
207
+ socket.addEventListener('open', () => { this.#send(tunnelPacket(0, Buffer.alloc(0))); for (const p of this.#pending.values())
208
+ this.#send(p.packet); });
209
+ return this.#events(socket, opts);
210
+ }
211
+ #outputBytes(start, bytes) {
212
+ if (!Number.isSafeInteger(start) || start < 0 || start > this.#rawOffset)
213
+ throw new Error('tunnel PTY output has a byte gap');
214
+ const fresh = bytes.subarray(Math.min(bytes.length, this.#rawOffset - start));
215
+ this.#rawOffset += fresh.length;
216
+ this.#feed(fresh);
217
+ }
218
+ #apiError(body) {
219
+ const error = new ShardfluxApiError(502, body, 'cell');
220
+ if (['stale_epoch', 'token_expired', 'workspace_busy', 'workspace_not_running'].includes(error.code)) {
221
+ this.#cell.tokens.invalidate();
222
+ return new ShardfluxProtocolError('reverse tunnel PTY ownership changed', 0, 'cell');
223
+ }
224
+ return error;
225
+ }
226
+ async *#events(socket, opts) {
227
+ const abort = () => { socket.close(1000); this.#wake?.(); };
228
+ opts.signal?.addEventListener('abort', abort, { once: true });
229
+ try {
230
+ while (!this.#closed && !opts.signal?.aborted) {
231
+ while (this.#queue.length)
232
+ yield this.#queue.shift();
233
+ if (this.#error)
234
+ throw this.#error;
235
+ await new Promise(resolve => { this.#wake = resolve; });
236
+ }
237
+ }
238
+ finally {
239
+ opts.signal?.removeEventListener('abort', abort);
240
+ socket.close(1000);
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,47 @@
1
+ import type { CellClient } from './cell.js';
2
+ export interface ReverseTunnelOptions {
3
+ remotePort: number;
4
+ /** Dialed on this machine: host:port, or [IPv6]:port. */
5
+ target: string;
6
+ bindAddress?: '127.0.0.1' | '0.0.0.0';
7
+ signal?: AbortSignal;
8
+ transport?: 'auto' | 'pty' | 'exec';
9
+ }
10
+ export interface ReverseTunnelStats {
11
+ connections: number;
12
+ activeConnections: number;
13
+ bytesToTarget: number;
14
+ bytesToWorkspace: number;
15
+ reconnects: number;
16
+ }
17
+ export type TunnelTransport = {
18
+ maxInputConcurrency?: number;
19
+ kind?: 'pty' | 'exec';
20
+ exec: Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'>;
21
+ files: Pick<CellClient['files'], 'remove'>;
22
+ };
23
+ export declare function tunnelTarget(target: string): {
24
+ host: string;
25
+ port: number;
26
+ };
27
+ /** An owned foreground connection to the developer machine. Close in finally. */
28
+ export declare class ReverseTunnel {
29
+ #private;
30
+ readonly sessionId: string;
31
+ readonly remotePort: number;
32
+ readonly target: string;
33
+ readonly transport: 'pty' | 'exec';
34
+ readonly closed: Promise<void>;
35
+ private constructor();
36
+ get stats(): ReverseTunnelStats;
37
+ /** Internal transport entry point, also exercised against the real helper in tests. */
38
+ static attach(transport: TunnelTransport, sessionId: string, dir: string, opts: ReverseTunnelOptions): Promise<ReverseTunnel>;
39
+ close(): Promise<void>;
40
+ }
41
+ /** Workspace → this client's machine; unused workspaces pay no tunnel setup or lifecycle cost. */
42
+ export declare class WorkspaceTunnels {
43
+ #private;
44
+ constructor(cell: () => CellClient, grants?: () => string[] | null);
45
+ reverse(opts: ReverseTunnelOptions): Promise<ReverseTunnel>;
46
+ close(): Promise<void>;
47
+ }