@sproutboat/wire 0.12.2 → 0.13.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.
- package/CHANGELOG.md +50 -0
- package/README.md +42 -0
- package/package.json +6 -4
- package/src/broker.test.ts +147 -1
- package/src/broker.ts +324 -197
- package/src/build-d1-budget.ts +38 -0
- package/src/d1-budget.c +67 -0
- package/src/d1-pool.test.ts +209 -0
- package/src/d1-pool.ts +156 -0
- package/src/d1-sqlite.ts +14 -0
- package/src/d1-worker.ts +22 -0
- package/src/d1.ts +164 -0
- package/src/pool.test.ts +53 -0
- package/src/pool.ts +60 -0
package/src/pool.test.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { frameOf } from "./broker";
|
|
6
|
+
|
|
7
|
+
test("shared worker isolates tokens, state and replay IDs, and revokes only one session", async () => {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(),"sb-pool-test-"));
|
|
9
|
+
const child = Bun.spawn([process.execPath,join(import.meta.dir,"pool.ts"),"2"],{stdin:"pipe",stdout:"pipe",stderr:"inherit"});
|
|
10
|
+
const reader = child.stdout.getReader();
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let buffered = "";
|
|
13
|
+
let sequence = 0;
|
|
14
|
+
async function command(id:string,op:string,args?:string[]) {
|
|
15
|
+
child.stdin.write(JSON.stringify({requestId:++sequence,id,op,args})+"\n");
|
|
16
|
+
while (!buffered.includes("\n")) {
|
|
17
|
+
const part=await reader.read();
|
|
18
|
+
if(part.done) throw new Error("worker exited");
|
|
19
|
+
buffered+=decoder.decode(part.value);
|
|
20
|
+
}
|
|
21
|
+
const index=buffered.indexOf("\n");
|
|
22
|
+
// SAFETY: this is the worker's acknowledgement, not a tenant reply.
|
|
23
|
+
const reply=JSON.parse(buffered.slice(0,index)) as {ok:boolean};
|
|
24
|
+
buffered=buffered.slice(index+1);
|
|
25
|
+
return reply;
|
|
26
|
+
}
|
|
27
|
+
async function request(port:number,token:string,op:string,id:number,value="") {
|
|
28
|
+
return new Promise<string>((resolve,reject)=>{
|
|
29
|
+
let bytes=Buffer.alloc(0);
|
|
30
|
+
void Bun.connect({hostname:"127.0.0.1",port,socket:{
|
|
31
|
+
open(socket){socket.write(frameOf(Buffer.from(token+"\n"+JSON.stringify({op,id,ns:"DATA",key:"key",value}))));},
|
|
32
|
+
data(socket,chunk){bytes=Buffer.concat([bytes,chunk]);if(bytes.length>=4&&bytes.length>=bytes.readUInt32LE(0)+4){resolve(bytes.subarray(4).toString());socket.end();}},
|
|
33
|
+
error(_socket,error){reject(error);},
|
|
34
|
+
}}).catch(reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const freePort=()=>{const s=Bun.listen({hostname:"127.0.0.1",port:0,socket:{data(){}}});const p=s.port!;s.stop(true);return p;};
|
|
38
|
+
try {
|
|
39
|
+
const binding=join(root,"bindings.json");writeFileSync(binding,JSON.stringify({kv:["DATA"]}));
|
|
40
|
+
const a=freePort();let b=freePort();while(b===a)b=freePort();
|
|
41
|
+
expect((await command("a","register",["--port",String(a),"--token","token-a","--db",join(root,"a.sqlite"),"--bindings",binding])).ok).toBe(true);
|
|
42
|
+
expect((await command("b","register",["--port",String(b),"--token","token-b","--db",join(root,"b.sqlite"),"--bindings",binding])).ok).toBe(true);
|
|
43
|
+
expect((await command("overflow","register",[])).ok).toBe(false);
|
|
44
|
+
expect(await request(a,"token-b","kv.get",2)).toContain("unauthorized");
|
|
45
|
+
await request(a,"token-a","kv.put",1,"A");
|
|
46
|
+
await request(b,"token-b","kv.put",1,"B");
|
|
47
|
+
expect(await request(a,"token-a","kv.get",2)).toContain('"value":"A"');
|
|
48
|
+
expect(await request(b,"token-b","kv.get",2)).toContain('"value":"B"');
|
|
49
|
+
expect((await command("a","remove")).ok).toBe(true);
|
|
50
|
+
expect(await request(b,"token-b","kv.get",3)).toContain('"value":"B"');
|
|
51
|
+
expect((await command("a","enable")).ok).toBe(false);
|
|
52
|
+
} finally {child.kill(9);await child.exited;reader.releaseLock();rmSync(root,{recursive:true,force:true});}
|
|
53
|
+
},10_000);
|
package/src/pool.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Trusted supervisor control travels over private stdio, never tenant TCP. */
|
|
2
|
+
import { isString } from "./json";
|
|
3
|
+
import { D1Pool } from "./d1-pool";
|
|
4
|
+
import { startBroker } from "./broker";
|
|
5
|
+
|
|
6
|
+
const sql = new D1Pool();
|
|
7
|
+
// Reuse supervisor worker-death recovery for an unrecoverable SQL thread.
|
|
8
|
+
// Keeping the process alive would leave sessions with permanently failed D1.
|
|
9
|
+
sql.onFailure = (error) => {
|
|
10
|
+
console.error(`sproutboat broker: ${error.message}; revoking broker sessions`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
};
|
|
13
|
+
const sessions = new Map<string, ReturnType<typeof startBroker>>();
|
|
14
|
+
const limit = Number(process.argv[2] ?? 64);
|
|
15
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256)
|
|
16
|
+
throw new Error("invalid session limit");
|
|
17
|
+
try {
|
|
18
|
+
for await (const line of console) {
|
|
19
|
+
let requestId = 0;
|
|
20
|
+
try {
|
|
21
|
+
// SAFETY: private stdin is owned by the trusted supervisor. Validate
|
|
22
|
+
// fields before allowing it to create a session or open any files.
|
|
23
|
+
const msg = JSON.parse(line) as {
|
|
24
|
+
requestId: number;
|
|
25
|
+
id: string;
|
|
26
|
+
op: string;
|
|
27
|
+
args?: string[];
|
|
28
|
+
};
|
|
29
|
+
requestId = msg.requestId;
|
|
30
|
+
if (!Number.isSafeInteger(requestId) || !isString(msg.id) || msg.id.length > 256)
|
|
31
|
+
throw new Error("invalid command");
|
|
32
|
+
if (msg.op === "register") {
|
|
33
|
+
if (sessions.has(msg.id)) throw new Error("session already registered");
|
|
34
|
+
if (sessions.size >= limit) throw new Error("broker session limit reached");
|
|
35
|
+
if (!Array.isArray(msg.args) || !msg.args.every(isString))
|
|
36
|
+
throw new Error("invalid session arguments");
|
|
37
|
+
sessions.set(msg.id, startBroker(msg.args, sql));
|
|
38
|
+
} else if (msg.op === "remove") {
|
|
39
|
+
sessions.get(msg.id)?.close();
|
|
40
|
+
sessions.delete(msg.id);
|
|
41
|
+
} else if (msg.op === "enable") {
|
|
42
|
+
const session = sessions.get(msg.id);
|
|
43
|
+
if (!session) throw new Error("unknown session");
|
|
44
|
+
session.enableDispatch();
|
|
45
|
+
} else throw new Error("unknown command");
|
|
46
|
+
console.log(JSON.stringify({ requestId, ok: true }));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.log(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
requestId,
|
|
51
|
+
ok: false,
|
|
52
|
+
error: error instanceof Error ? error.message : String(error),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
for (const session of sessions.values()) session.close();
|
|
59
|
+
sql.close();
|
|
60
|
+
}
|