@capsulos/sdk 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/dist/capsule.d.ts +51 -0
- package/dist/capsule.js +124 -0
- package/dist/capsule.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +142 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/ports.d.ts +11 -0
- package/dist/ports.js +26 -0
- package/dist/ports.js.map +1 -0
- package/dist/pty.d.ts +23 -0
- package/dist/pty.js +122 -0
- package/dist/pty.js.map +1 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -3
- package/src/capsule.ts +134 -0
- package/src/client.test.ts +49 -0
- package/src/client.ts +166 -0
- package/src/e2e.test.ts +188 -0
- package/src/index.ts +7 -0
- package/src/ports.ts +31 -0
- package/src/pty.ts +126 -0
- package/src/types.ts +156 -0
- package/README.md +0 -3
package/src/client.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Thin HTTP client for capsulod. Every request has a timeout: on a capsule
|
|
2
|
+
// with egress blocked, a dead connection shows up as a hang, never as an error.
|
|
3
|
+
import {
|
|
4
|
+
CapsuleError,
|
|
5
|
+
type Connection,
|
|
6
|
+
type ExecEvent,
|
|
7
|
+
type ExecOptions,
|
|
8
|
+
type ExecResult,
|
|
9
|
+
type FileEntry,
|
|
10
|
+
type Health,
|
|
11
|
+
type MountResult,
|
|
12
|
+
type MountSpec,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
export interface ClientOptions {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
requestTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DaemonClient {
|
|
21
|
+
private readonly fetchImpl: typeof fetch;
|
|
22
|
+
private readonly requestTimeoutMs: number;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
readonly connection: Connection,
|
|
26
|
+
opts: ClientOptions = {},
|
|
27
|
+
) {
|
|
28
|
+
this.fetchImpl = opts.fetch ?? ((...args) => fetch(...args));
|
|
29
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async request(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<Response> {
|
|
33
|
+
const headers = { ...(await this.connection.headers()), ...(init.headers as Record<string, string> | undefined) };
|
|
34
|
+
const res = await this.fetchImpl(`${this.connection.baseUrl}${path}`, {
|
|
35
|
+
...init,
|
|
36
|
+
headers,
|
|
37
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? this.requestTimeoutMs),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
let detail = '';
|
|
41
|
+
try {
|
|
42
|
+
detail = ((await res.json()) as { error?: string }).error ?? '';
|
|
43
|
+
} catch {
|
|
44
|
+
/* non-JSON error body */
|
|
45
|
+
}
|
|
46
|
+
throw new CapsuleError(`${init.method ?? 'GET'} ${path} → HTTP ${res.status}${detail ? `: ${detail}` : ''}`, res.status);
|
|
47
|
+
}
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async health(): Promise<Health> {
|
|
52
|
+
const res = await this.request('/health', { timeoutMs: 10_000 });
|
|
53
|
+
return (await res.json()) as Health;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Apply the mount table when the provider's boot hook did not deliver it. */
|
|
57
|
+
async configure(body: { mounts?: Record<string, MountSpec>; metadata?: Record<string, string> }): Promise<MountResult[]> {
|
|
58
|
+
const res = await this.request('/configure', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
timeoutMs: 60_000,
|
|
63
|
+
});
|
|
64
|
+
return ((await res.json()) as { mounts: MountResult[] }).mounts ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Buffered or streamed depending on whether callbacks are given. */
|
|
68
|
+
async exec(cmd: string, opts: ExecOptions = {}): Promise<ExecResult> {
|
|
69
|
+
const body = JSON.stringify({
|
|
70
|
+
cmd,
|
|
71
|
+
args: opts.args,
|
|
72
|
+
cwd: opts.cwd,
|
|
73
|
+
env: opts.env,
|
|
74
|
+
stdin: opts.stdin,
|
|
75
|
+
timeoutMs: opts.timeoutMs,
|
|
76
|
+
});
|
|
77
|
+
// Request timeout = command timeout + slack; the daemon enforces the real one.
|
|
78
|
+
const timeoutMs = (opts.timeoutMs ?? 60_000) + 15_000;
|
|
79
|
+
const streaming = Boolean(opts.onStdout || opts.onStderr);
|
|
80
|
+
|
|
81
|
+
if (!streaming) {
|
|
82
|
+
const res = await this.request('/exec', { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, timeoutMs });
|
|
83
|
+
return (await res.json()) as ExecResult;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const startedAt = Date.now();
|
|
87
|
+
const res = await this.request('/exec', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body,
|
|
90
|
+
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
91
|
+
timeoutMs,
|
|
92
|
+
});
|
|
93
|
+
const result: ExecResult = { exitCode: -1, stdout: '', stderr: '', timeout: false, durationMs: 0 };
|
|
94
|
+
for await (const ev of parseSse(res)) {
|
|
95
|
+
if (ev.type === 'stdout') {
|
|
96
|
+
result.stdout += ev.data;
|
|
97
|
+
opts.onStdout?.(ev.data);
|
|
98
|
+
} else if (ev.type === 'stderr') {
|
|
99
|
+
result.stderr += ev.data;
|
|
100
|
+
opts.onStderr?.(ev.data);
|
|
101
|
+
} else {
|
|
102
|
+
result.exitCode = ev.exitCode ?? 0;
|
|
103
|
+
result.timeout = ev.timeout ?? false;
|
|
104
|
+
if (ev.error) result.error = ev.error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
result.durationMs = Date.now() - startedAt;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async readFile(path: string): Promise<Uint8Array> {
|
|
112
|
+
const res = await this.request(`/files/read?path=${encodeURIComponent(path)}`);
|
|
113
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async readText(path: string): Promise<string> {
|
|
117
|
+
return new TextDecoder().decode(await this.readFile(path));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async writeFile(path: string, data: string | Uint8Array, opts: { mode?: string } = {}): Promise<void> {
|
|
121
|
+
const q = new URLSearchParams({ path, ...(opts.mode ? { mode: opts.mode } : {}) });
|
|
122
|
+
await this.request(`/files/write?${q}`, {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
body: typeof data === 'string' ? data : (data as BodyInit),
|
|
125
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async list(path: string): Promise<FileEntry[]> {
|
|
130
|
+
const res = await this.request(`/files/list?path=${encodeURIComponent(path)}`);
|
|
131
|
+
return (await res.json()) as FileEntry[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async stat(path: string): Promise<FileEntry> {
|
|
135
|
+
const res = await this.request(`/files/stat?path=${encodeURIComponent(path)}`);
|
|
136
|
+
return (await res.json()) as FileEntry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async mkdir(path: string): Promise<void> {
|
|
140
|
+
await this.request(`/files/mkdir?path=${encodeURIComponent(path)}`, { method: 'POST' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async remove(path: string): Promise<void> {
|
|
144
|
+
await this.request(`/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Minimal SSE parser over a fetch Response body. */
|
|
149
|
+
export async function* parseSse(res: Response): AsyncGenerator<ExecEvent> {
|
|
150
|
+
const reader = res.body?.getReader();
|
|
151
|
+
if (!reader) throw new CapsuleError('exec stream has no body');
|
|
152
|
+
const decoder = new TextDecoder();
|
|
153
|
+
let buffer = '';
|
|
154
|
+
for (;;) {
|
|
155
|
+
const { value, done } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
buffer += decoder.decode(value, { stream: true });
|
|
158
|
+
let idx: number;
|
|
159
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
160
|
+
const frame = buffer.slice(0, idx);
|
|
161
|
+
buffer = buffer.slice(idx + 2);
|
|
162
|
+
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
163
|
+
if (dataLine) yield JSON.parse(dataLine.slice(6)) as ExecEvent;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/e2e.test.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// End-to-end against a real capsulod running locally (no provider in between).
|
|
2
|
+
// Needs the daemon built: `pnpm daemon:build`. Skipped otherwise.
|
|
3
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
|
+
import { existsSync, mkdtempSync } from 'node:fs';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import { Capsule } from './capsule.js';
|
|
10
|
+
import type { CapsuleHandle, Provider } from './types.js';
|
|
11
|
+
|
|
12
|
+
const BIN = join(import.meta.dirname, '..', '..', '..', '.artifacts', 'capsulod');
|
|
13
|
+
const PORT = 18080 + Math.floor(Math.random() * 1000);
|
|
14
|
+
|
|
15
|
+
/** A provider whose "capsule" is a local capsulod process. */
|
|
16
|
+
class LocalProvider implements Provider {
|
|
17
|
+
readonly name = 'local';
|
|
18
|
+
child: ChildProcess | undefined;
|
|
19
|
+
async create(): Promise<CapsuleHandle> {
|
|
20
|
+
this.child = spawn(BIN, [], {
|
|
21
|
+
env: { ...process.env, CAPSULOD_PORT: String(PORT), CAPSULOD_HOOK_PORT: String(PORT + 1) },
|
|
22
|
+
stdio: 'ignore',
|
|
23
|
+
});
|
|
24
|
+
return { id: 'local', provider: 'local', state: 'running', template: null, createdAt: new Date(), connection: await this.connect() };
|
|
25
|
+
}
|
|
26
|
+
async get() {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
async list() {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
async terminate() {
|
|
33
|
+
this.child?.kill();
|
|
34
|
+
}
|
|
35
|
+
async connect() {
|
|
36
|
+
return { baseUrl: `http://127.0.0.1:${PORT}`, headers: async () => ({}) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe.skipIf(!existsSync(BIN))('capsulod e2e', () => {
|
|
41
|
+
const provider = new LocalProvider();
|
|
42
|
+
let capsule: Capsule;
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
capsule = await Capsule.create(provider, { template: 'local' });
|
|
45
|
+
});
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await capsule?.kill();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('runs commands with live output', async () => {
|
|
51
|
+
const chunks: string[] = [];
|
|
52
|
+
const res = await capsule.commands.run('for i in 1 2 3; do echo $i; sleep 0.1; done; exit 7', {
|
|
53
|
+
onStdout: (c) => chunks.push(c),
|
|
54
|
+
});
|
|
55
|
+
expect(res.exitCode).toBe(7);
|
|
56
|
+
expect(res.stdout).toBe('1\n2\n3\n');
|
|
57
|
+
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('keeps exit code 0 in streaming mode', async () => {
|
|
61
|
+
const res = await capsule.commands.run('echo ok', { onStdout: () => {} });
|
|
62
|
+
expect(res.exitCode).toBe(0);
|
|
63
|
+
expect(res.stdout).toBe('ok\n');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('kills on timeout', async () => {
|
|
67
|
+
const res = await capsule.commands.run('sleep 10', { timeoutMs: 300 });
|
|
68
|
+
expect(res.timeout).toBe(true);
|
|
69
|
+
expect(res.durationMs).toBeLessThan(5000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('opens an interactive terminal', async () => {
|
|
73
|
+
let out = '';
|
|
74
|
+
const seen = (text: string) =>
|
|
75
|
+
new Promise<void>((resolve) => {
|
|
76
|
+
const check = () => out.includes(text) && (off(), resolve());
|
|
77
|
+
const off = term.onData(check);
|
|
78
|
+
check();
|
|
79
|
+
});
|
|
80
|
+
const term = await capsule.pty.open({ cmd: '/bin/sh', cols: 100, rows: 30, onData: (c) => (out += new TextDecoder().decode(c)) });
|
|
81
|
+
term.write('echo hello-pty; stty size\n');
|
|
82
|
+
await seen('30 100'); // the first size must be read before we change it
|
|
83
|
+
term.resize(132, 43);
|
|
84
|
+
term.write('stty size; exit 5\n');
|
|
85
|
+
const code = await term.wait();
|
|
86
|
+
expect(code).toBe(5);
|
|
87
|
+
expect(out).toContain('hello-pty');
|
|
88
|
+
expect(out).toContain('30 100');
|
|
89
|
+
expect(out).toContain('43 132');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('kills the process when the terminal is closed', async () => {
|
|
93
|
+
const term = await capsule.pty.open({ cmd: '/bin/sh' });
|
|
94
|
+
term.write('sleep 30\n');
|
|
95
|
+
term.close();
|
|
96
|
+
await expect(term.wait()).rejects.toThrow(/pty closed/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reaches a server listening on a port inside the capsule', async () => {
|
|
100
|
+
// The capsule is this machine, so the "app inside" is a local http server.
|
|
101
|
+
const app = createServer((req, res) => res.end(`app saw ${req.method} ${req.url}`));
|
|
102
|
+
await new Promise<void>((r) => app.listen(0, '127.0.0.1', r));
|
|
103
|
+
const port = (app.address() as { port: number }).port;
|
|
104
|
+
try {
|
|
105
|
+
const res = await capsule.ports.fetch(port, '/hello?x=1');
|
|
106
|
+
expect(res.status).toBe(200);
|
|
107
|
+
expect(await res.text()).toBe('app saw GET /hello?x=1');
|
|
108
|
+
expect(capsule.ports.url(port, 'a/b')).toBe(`http://127.0.0.1:${PORT}/proxy/${port}/a/b`);
|
|
109
|
+
expect(capsule.ports.connection(port).baseUrl).toBe(`http://127.0.0.1:${PORT}/proxy/${port}`);
|
|
110
|
+
} finally {
|
|
111
|
+
app.close();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('explains a port nobody listens on', async () => {
|
|
116
|
+
const res = await capsule.ports.fetch(1, '/');
|
|
117
|
+
expect(res.status).toBe(502);
|
|
118
|
+
expect((await res.json()).error).toMatch(/nothing is listening on port 1/);
|
|
119
|
+
expect(() => capsule.ports.url(0)).toThrow(RangeError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reads and writes files', async () => {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), 'capsulos-'));
|
|
124
|
+
await capsule.files.write(`${dir}/a/hello.txt`, 'hola');
|
|
125
|
+
expect(await capsule.files.read(`${dir}/a/hello.txt`)).toBe('hola');
|
|
126
|
+
expect((await capsule.files.list(`${dir}/a`)).map((e) => e.name)).toEqual(['hello.txt']);
|
|
127
|
+
const out = await capsule.commands.run(`cat ${dir}/a/hello.txt`);
|
|
128
|
+
expect(out.stdout).toBe('hola');
|
|
129
|
+
await capsule.files.remove(dir);
|
|
130
|
+
await expect(capsule.files.stat(dir)).rejects.toThrow(/404/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe.skipIf(!existsSync(BIN))('capsulod e2e: mounts', () => {
|
|
135
|
+
it('applies a local mount table through /configure when the hook did not run', async () => {
|
|
136
|
+
const provider = new LocalProvider();
|
|
137
|
+
const dir = join(mkdtempSync(join(tmpdir(), 'capsulos-')), 'workspace');
|
|
138
|
+
const capsule = await Capsule.create(provider, { template: 'local', mounts: { [dir]: { driver: 'local' } } });
|
|
139
|
+
try {
|
|
140
|
+
const h = await capsule.health();
|
|
141
|
+
expect(h.configured).toBe(true);
|
|
142
|
+
expect(h.mounts?.[0]).toMatchObject({ path: dir, driver: 'local', ok: true });
|
|
143
|
+
expect((await capsule.files.stat(dir)).isDir).toBe(true);
|
|
144
|
+
} finally {
|
|
145
|
+
await capsule.kill();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// An s3 mount cannot work here (no mount-s3, not root), which is exactly
|
|
150
|
+
// what a broken capsule looks like: the failure must reach the caller.
|
|
151
|
+
const badMounts = { '/tmp/capsulos-e2e-s3': { driver: 's3' as const, bucket: 'nope' } };
|
|
152
|
+
|
|
153
|
+
it('throws when a mount applied through /configure failed', async () => {
|
|
154
|
+
const provider = new LocalProvider();
|
|
155
|
+
try {
|
|
156
|
+
await expect(Capsule.create(provider, { template: 'local', mounts: badMounts })).rejects.toThrow(/mounts failed: \/tmp\/capsulos-e2e-s3 \(s3\)/);
|
|
157
|
+
} finally {
|
|
158
|
+
await provider.terminate();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('throws when a mount applied through the /run hook failed', async () => {
|
|
163
|
+
// Same as above, but the mount table arrives the way Lambda delivers it.
|
|
164
|
+
class HookedProvider extends LocalProvider {
|
|
165
|
+
override async create(): Promise<CapsuleHandle> {
|
|
166
|
+
const handle = await super.create();
|
|
167
|
+
const hook = `http://127.0.0.1:${PORT + 1}/aws/lambda-microvms/runtime/v1/run`;
|
|
168
|
+
const body = JSON.stringify({ microvmId: 'local', runHookPayload: JSON.stringify({ mounts: badMounts }) });
|
|
169
|
+
for (let i = 0; i < 50; i++) {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(hook, { method: 'POST', body, signal: AbortSignal.timeout(2000) });
|
|
172
|
+
if (res.ok) return handle;
|
|
173
|
+
} catch {
|
|
174
|
+
/* daemon still booting */
|
|
175
|
+
}
|
|
176
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
177
|
+
}
|
|
178
|
+
throw new Error('hook port never answered');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const provider = new HookedProvider();
|
|
182
|
+
try {
|
|
183
|
+
await expect(Capsule.create(provider, { template: 'local', mounts: badMounts })).rejects.toThrow(/mounts failed: \/tmp\/capsulos-e2e-s3 \(s3\)/);
|
|
184
|
+
} finally {
|
|
185
|
+
await provider.terminate();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Capsule, Commands, Files, Pty } from './capsule.js';
|
|
2
|
+
export { Ports } from './ports.js';
|
|
3
|
+
export { PtySession } from './pty.js';
|
|
4
|
+
export type { CapsuleOptions } from './capsule.js';
|
|
5
|
+
export { DaemonClient, parseSse } from './client.js';
|
|
6
|
+
export type { ClientOptions } from './client.js';
|
|
7
|
+
export * from './types.js';
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Preview URLs: reach a server that listens on a port inside the capsule.
|
|
2
|
+
// capsulod forwards /proxy/<port>/<path> to 127.0.0.1:<port>, so every
|
|
3
|
+
// provider that exposes the daemon (one port, one token) exposes every port,
|
|
4
|
+
// with nothing to declare at creation time.
|
|
5
|
+
import type { Connection } from './types.js';
|
|
6
|
+
|
|
7
|
+
export class Ports {
|
|
8
|
+
constructor(private readonly conn: Connection) {}
|
|
9
|
+
|
|
10
|
+
/** URL of `path` on the given port, as seen through the daemon (headers still apply, see `connection`). */
|
|
11
|
+
url(port: number, path = '/'): string {
|
|
12
|
+
checkPort(port);
|
|
13
|
+
return `${this.conn.baseUrl}/proxy/${port}${path.startsWith('/') ? path : `/${path}`}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A Connection whose baseUrl is the proxied port: build clients, WebSockets, fetches on it. */
|
|
17
|
+
connection(port: number): Connection {
|
|
18
|
+
checkPort(port);
|
|
19
|
+
return { baseUrl: `${this.conn.baseUrl}/proxy/${port}`, headers: () => this.conn.headers() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `fetch()` against the server on `port` inside the capsule, with the capsule's auth headers attached. */
|
|
23
|
+
async fetch(port: number, path = '/', init: RequestInit = {}): Promise<Response> {
|
|
24
|
+
const headers = { ...(await this.conn.headers()), ...(init.headers as Record<string, string> | undefined) };
|
|
25
|
+
return fetch(this.url(port, path), { signal: AbortSignal.timeout(60_000), ...init, headers });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkPort(port: number): void {
|
|
30
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new RangeError(`bad port: ${port}`);
|
|
31
|
+
}
|
package/src/pty.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// An interactive terminal inside the capsule, over a WebSocket to capsulod's
|
|
2
|
+
// /pty: binary frames are terminal bytes in both directions, text frames are
|
|
3
|
+
// JSON control messages (resize from us, exit from the daemon).
|
|
4
|
+
import { CapsuleError, type Connection, type PtyOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
type ControlMessage = { type: 'exit'; exitCode: number } | { type: 'resize'; cols: number; rows: number };
|
|
7
|
+
|
|
8
|
+
// Node's global WebSocket (undici) accepts request headers through a
|
|
9
|
+
// non-standard init object; the DOM typings do not know about it.
|
|
10
|
+
type WebSocketCtor = new (url: string, init?: { headers?: Record<string, string> }) => WebSocket;
|
|
11
|
+
|
|
12
|
+
/** Explain a failed upgrade: unreachable daemon, or one too old to have /pty. */
|
|
13
|
+
async function diagnose(conn: Connection, headers: Record<string, string>): Promise<string> {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${conn.baseUrl}/pty`, { headers, signal: AbortSignal.timeout(5_000) });
|
|
16
|
+
if (res.status === 404) {
|
|
17
|
+
return `this capsule's capsulod has no /pty endpoint: rebuild the template with the current daemon (pnpm docker:base, or capsulos template build) and create a new capsule`;
|
|
18
|
+
}
|
|
19
|
+
return `${conn.baseUrl}/pty answered HTTP ${res.status} instead of upgrading to a WebSocket`;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return `cannot reach ${conn.baseUrl} (${err instanceof Error ? err.message : String(err)})`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PtySession {
|
|
26
|
+
private readonly dataListeners = new Set<(chunk: Uint8Array) => void>();
|
|
27
|
+
private readonly exitPromise: Promise<number>;
|
|
28
|
+
private resolveExit!: (code: number) => void;
|
|
29
|
+
private rejectExit!: (err: Error) => void;
|
|
30
|
+
private settled = false;
|
|
31
|
+
|
|
32
|
+
private constructor(private readonly ws: WebSocket) {
|
|
33
|
+
this.exitPromise = new Promise<number>((resolve, reject) => {
|
|
34
|
+
this.resolveExit = resolve;
|
|
35
|
+
this.rejectExit = reject;
|
|
36
|
+
});
|
|
37
|
+
// A dropped connection rejects wait(); callers that never call it must
|
|
38
|
+
// not take the process down with an unhandled rejection.
|
|
39
|
+
this.exitPromise.catch(() => undefined);
|
|
40
|
+
ws.binaryType = 'arraybuffer';
|
|
41
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
42
|
+
if (typeof ev.data === 'string') {
|
|
43
|
+
const msg = JSON.parse(ev.data) as ControlMessage;
|
|
44
|
+
if (msg.type === 'exit') this.settle(msg.exitCode);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const chunk = new Uint8Array(ev.data as ArrayBuffer);
|
|
48
|
+
for (const l of this.dataListeners) l(chunk);
|
|
49
|
+
});
|
|
50
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
51
|
+
if (!this.settled) this.fail(new CapsuleError(`pty closed without exit (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`));
|
|
52
|
+
});
|
|
53
|
+
ws.addEventListener('error', () => {
|
|
54
|
+
if (!this.settled) this.fail(new CapsuleError('pty connection error'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Open a terminal. Resolves once the socket is up; the process is already running. */
|
|
59
|
+
static open(conn: Connection, opts: PtyOptions = {}): Promise<PtySession> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
void (async () => {
|
|
62
|
+
const q = new URLSearchParams();
|
|
63
|
+
if (opts.cmd) q.set('cmd', opts.cmd);
|
|
64
|
+
for (const a of opts.args ?? []) q.append('arg', a);
|
|
65
|
+
if (opts.cwd) q.set('cwd', opts.cwd);
|
|
66
|
+
for (const [k, v] of Object.entries(opts.env ?? {})) q.append('env', `${k}=${v}`);
|
|
67
|
+
q.set('cols', String(opts.cols ?? 80));
|
|
68
|
+
q.set('rows', String(opts.rows ?? 24));
|
|
69
|
+
const url = `${conn.baseUrl.replace(/^http/, 'ws')}/pty?${q}`;
|
|
70
|
+
const headers = await conn.headers();
|
|
71
|
+
const WS = globalThis.WebSocket as unknown as WebSocketCtor;
|
|
72
|
+
const ws = new WS(url, Object.keys(headers).length ? { headers } : undefined);
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
ws.close();
|
|
75
|
+
reject(new CapsuleError('pty connect timeout'));
|
|
76
|
+
}, opts.connectTimeoutMs ?? 30_000);
|
|
77
|
+
const session = new PtySession(ws);
|
|
78
|
+
if (opts.onData) session.onData(opts.onData);
|
|
79
|
+
ws.addEventListener('open', () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
resolve(session);
|
|
82
|
+
});
|
|
83
|
+
ws.addEventListener('error', () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
// Node's WebSocket error carries no status; ask the daemon directly.
|
|
86
|
+
void diagnose(conn, headers).then((why) => reject(new CapsuleError(`pty connection failed: ${why}`)));
|
|
87
|
+
});
|
|
88
|
+
})().catch(reject);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Send keystrokes (or any bytes) to the terminal. */
|
|
93
|
+
write(data: string | Uint8Array): void {
|
|
94
|
+
this.ws.send(typeof data === 'string' ? new TextEncoder().encode(data) : data);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
resize(cols: number, rows: number): void {
|
|
98
|
+
this.ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies ControlMessage));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Subscribe to terminal output; returns an unsubscribe function. */
|
|
102
|
+
onData(listener: (chunk: Uint8Array) => void): () => void {
|
|
103
|
+
this.dataListeners.add(listener);
|
|
104
|
+
return () => this.dataListeners.delete(listener);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolves with the exit code when the process ends; rejects if the connection drops first. */
|
|
108
|
+
wait(): Promise<number> {
|
|
109
|
+
return this.exitPromise;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Hang up. The daemon kills the process when the socket goes away. */
|
|
113
|
+
close(): void {
|
|
114
|
+
this.ws.close(1000, 'client closed');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private settle(code: number) {
|
|
118
|
+
this.settled = true;
|
|
119
|
+
this.resolveExit(code);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fail(err: Error) {
|
|
123
|
+
this.settled = true;
|
|
124
|
+
this.rejectExit(err);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The three axes of capsulos (docs/ARCHITECTURE.md):
|
|
2
|
+
// Provider — where a capsule runs (lambda, docker, later firecracker)
|
|
3
|
+
// Primitives — what a capsule exposes (process, fs, pty, ports)
|
|
4
|
+
// Drivers — how a primitive is implemented (fs: local, tmpfs, overlay, s3)
|
|
5
|
+
|
|
6
|
+
/** Where a capsule lives. Implemented by @capsulos/provider-* packages. */
|
|
7
|
+
export interface Provider {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
create(spec: CapsuleSpec): Promise<CapsuleHandle>;
|
|
10
|
+
get(id: string): Promise<CapsuleInfo | null>;
|
|
11
|
+
/** Capsules this provider knows about, optionally only those of one template. */
|
|
12
|
+
list(template?: string): Promise<CapsuleInfo[]>;
|
|
13
|
+
terminate(id: string): Promise<void>;
|
|
14
|
+
/** How to reach the daemon of an existing capsule (endpoint + auth headers). */
|
|
15
|
+
connect(id: string): Promise<Connection>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Connection {
|
|
19
|
+
/** Base URL of capsulod, e.g. https://abc.microvm.lambda-url.us-east-1.on.aws */
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
/** Headers every request must carry (e.g. X-aws-proxy-auth). Refreshed by the provider. */
|
|
22
|
+
headers(): Promise<Record<string, string>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CapsuleState = 'pending' | 'running' | 'suspended' | 'terminated' | 'unknown';
|
|
26
|
+
|
|
27
|
+
export interface CapsuleInfo {
|
|
28
|
+
id: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
state: CapsuleState;
|
|
31
|
+
template: string | null;
|
|
32
|
+
createdAt: Date | null;
|
|
33
|
+
/** When the provider terminated it (provider clock); null while alive or unknown. */
|
|
34
|
+
terminatedAt?: Date | null;
|
|
35
|
+
/** The provider's reason for the current state (e.g. why it died), when it gives one. */
|
|
36
|
+
stateReason?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CapsuleHandle extends CapsuleInfo {
|
|
40
|
+
connection: Connection;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A mount declared at creation. `posix` drivers are mounted in the kernel and seen by processes. */
|
|
44
|
+
export type MountSpec =
|
|
45
|
+
| { driver: 'local' }
|
|
46
|
+
| { driver: 'tmpfs'; sizeMb?: number }
|
|
47
|
+
| { driver: 'overlay'; lower: string }
|
|
48
|
+
| { driver: 's3'; bucket: string; prefix?: string; readOnly?: boolean; region?: string };
|
|
49
|
+
|
|
50
|
+
export interface MountResult {
|
|
51
|
+
path: string;
|
|
52
|
+
driver: string;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Health {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
version: string;
|
|
60
|
+
uptimeS: number;
|
|
61
|
+
/** True once the mount table was applied (by the boot hook or /configure). */
|
|
62
|
+
configured: boolean;
|
|
63
|
+
mounts?: MountResult[];
|
|
64
|
+
/** The template's long-running process (CAPSULOD_START), when the template declares one. */
|
|
65
|
+
service?: ServiceStatus;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ServiceStatus {
|
|
69
|
+
cmd: string;
|
|
70
|
+
pid: number;
|
|
71
|
+
running: boolean;
|
|
72
|
+
restarts: number;
|
|
73
|
+
lastExitCode?: number;
|
|
74
|
+
startedAt?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IdlePolicy {
|
|
78
|
+
/** Suspend after this long without traffic (seconds). */
|
|
79
|
+
suspendAfterSeconds: number;
|
|
80
|
+
/** Destroy after this long suspended (seconds). */
|
|
81
|
+
destroyAfterSeconds: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CapsuleSpec {
|
|
85
|
+
/** Template (image) name as registered with the provider. */
|
|
86
|
+
template: string;
|
|
87
|
+
/** Hard wall-clock limit for the capsule, in seconds. */
|
|
88
|
+
maxDurationSeconds?: number;
|
|
89
|
+
idle?: IdlePolicy;
|
|
90
|
+
mounts?: Record<string, MountSpec>;
|
|
91
|
+
/** Opaque per-capsule configuration delivered to the daemon at boot (never secrets). */
|
|
92
|
+
metadata?: Record<string, string>;
|
|
93
|
+
/** Idempotency key: creating twice with the same key returns the same capsule. */
|
|
94
|
+
clientToken?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- process primitive ----
|
|
98
|
+
|
|
99
|
+
export interface ExecOptions {
|
|
100
|
+
args?: string[];
|
|
101
|
+
cwd?: string;
|
|
102
|
+
env?: Record<string, string>;
|
|
103
|
+
stdin?: string;
|
|
104
|
+
timeoutMs?: number;
|
|
105
|
+
onStdout?: (chunk: string) => void;
|
|
106
|
+
onStderr?: (chunk: string) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ExecResult {
|
|
110
|
+
exitCode: number;
|
|
111
|
+
stdout: string;
|
|
112
|
+
stderr: string;
|
|
113
|
+
timeout: boolean;
|
|
114
|
+
error?: string;
|
|
115
|
+
durationMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ExecEvent =
|
|
119
|
+
| { type: 'stdout'; data: string }
|
|
120
|
+
| { type: 'stderr'; data: string }
|
|
121
|
+
| { type: 'exit'; exitCode: number; timeout?: boolean; error?: string };
|
|
122
|
+
|
|
123
|
+
// ---- pty primitive ----
|
|
124
|
+
|
|
125
|
+
export interface PtyOptions {
|
|
126
|
+
/** Program to run; defaults to bash when the template has it, else sh. */
|
|
127
|
+
cmd?: string;
|
|
128
|
+
args?: string[];
|
|
129
|
+
cwd?: string;
|
|
130
|
+
env?: Record<string, string>;
|
|
131
|
+
cols?: number;
|
|
132
|
+
rows?: number;
|
|
133
|
+
onData?: (chunk: Uint8Array) => void;
|
|
134
|
+
connectTimeoutMs?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- fs primitive ----
|
|
138
|
+
|
|
139
|
+
export interface FileEntry {
|
|
140
|
+
name: string;
|
|
141
|
+
path: string;
|
|
142
|
+
isDir: boolean;
|
|
143
|
+
size: number;
|
|
144
|
+
mode: string;
|
|
145
|
+
modTime: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class CapsuleError extends Error {
|
|
149
|
+
constructor(
|
|
150
|
+
message: string,
|
|
151
|
+
readonly status?: number,
|
|
152
|
+
) {
|
|
153
|
+
super(message);
|
|
154
|
+
this.name = 'CapsuleError';
|
|
155
|
+
}
|
|
156
|
+
}
|
package/README.md
DELETED