@scp-dev/broker 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/src/socket.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { Socket } from "node:net"
2
+
3
+ export interface ConnHandlers<T> {
4
+ data(conn: Conn<T>, chunk: Buffer): void
5
+ drain(conn: Conn<T>): void
6
+ close(conn: Conn<T>, error?: Error): void
7
+ }
8
+
9
+ /** how long an ended connection waits for its peer to close before it is cut */
10
+ const END_GRACE_MS = 2000
11
+
12
+ /** a unix socket connection with per-connection state, writing through LineWriter's partial-write contract */
13
+ export class Conn<T> {
14
+ private ending = false
15
+
16
+ constructor(
17
+ readonly socket: Socket,
18
+ public data: T,
19
+ handlers: ConnHandlers<T>,
20
+ ) {
21
+ socket.on("data", (chunk: Buffer) => {
22
+ if (!this.ending) handlers.data(this, chunk)
23
+ })
24
+ socket.on("drain", () => handlers.drain(this))
25
+ socket.on("error", (error) => handlers.close(this, error))
26
+ socket.on("close", () => handlers.close(this))
27
+ }
28
+
29
+ /** bytes the socket took but has not sent yet */
30
+ get unsent(): number {
31
+ return this.socket.writableLength
32
+ }
33
+
34
+ /** takes all of `bytes`, or none while the socket waits to drain */
35
+ write(bytes: Uint8Array): number {
36
+ if (this.ending || this.socket.destroyed || this.socket.writableNeedDrain) return 0
37
+ this.socket.write(bytes)
38
+ return bytes.length
39
+ }
40
+
41
+ /** sends what was written, then closes once the peer does, or after a grace period; what the peer still sends is discarded */
42
+ end(): void {
43
+ if (this.ending || this.socket.destroyed) return
44
+ this.ending = true
45
+ this.socket.resume()
46
+ this.socket.end()
47
+ setTimeout(() => this.socket.destroy(), END_GRACE_MS).unref()
48
+ }
49
+
50
+ pause(): void {
51
+ if (!this.ending) this.socket.pause()
52
+ }
53
+
54
+ resume(): void {
55
+ this.socket.resume()
56
+ }
57
+ }
package/src/sqlite.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { createRequire } from "node:module"
2
+ import type { Sql, SqlValue } from "@scp-dev/core"
3
+
4
+ /** the broker's SQLite file: bun:sqlite under Bun, node:sqlite under Node, each loaded only by its own runtime */
5
+ export interface Database extends Sql {
6
+ /** statements that bind nothing, such as pragmas */
7
+ exec(statement: string): void
8
+ /** runs `fn` in one IMMEDIATE transaction; a call inside one is a savepoint */
9
+ transaction<T>(fn: () => T): T
10
+ close(): void
11
+ }
12
+
13
+ interface Driver {
14
+ exec(statement: string): void
15
+ all(query: string, params: SqlValue[]): unknown[]
16
+ close(): void
17
+ }
18
+
19
+ const STATEMENT_CACHE = 512
20
+
21
+ /** a require that bundlers leave alone, so neither runtime resolves the other's module, and node:sqlite keeps its prefix */
22
+ const load = createRequire(import.meta.url)
23
+
24
+ function bunDriver(path: string): Driver {
25
+ const { Database } = load("bun:sqlite") as typeof import("bun:sqlite")
26
+ const db = new Database(path, { create: true })
27
+ return {
28
+ exec: (statement) => void db.run(statement),
29
+ all: (query, params) => db.query(query).all(...params),
30
+ close: () => db.close(),
31
+ }
32
+ }
33
+
34
+ function nodeDriver(path: string): Driver {
35
+ const { DatabaseSync } = load("node:sqlite") as typeof import("node:sqlite")
36
+ const db = new DatabaseSync(path)
37
+ const statements = new Map<string, ReturnType<typeof db.prepare>>()
38
+ return {
39
+ exec: (statement) => db.exec(statement),
40
+ all: (query, params) => {
41
+ let statement = statements.get(query)
42
+ if (!statement) {
43
+ if (statements.size >= STATEMENT_CACHE) statements.clear()
44
+ statement = db.prepare(query)
45
+ statements.set(query, statement)
46
+ }
47
+ return statement.all(...params)
48
+ },
49
+ close: () => db.close(),
50
+ }
51
+ }
52
+
53
+ export function openDatabase(path: string): Database {
54
+ const driver = typeof process.versions.bun === "string" ? bunDriver(path) : nodeDriver(path)
55
+ let depth = 0
56
+ return {
57
+ exec: (statement) => driver.exec(statement),
58
+ all: <T>(query: string, ...params: SqlValue[]) => driver.all(query, params) as T[],
59
+ close: () => driver.close(),
60
+ transaction<T>(fn: () => T): T {
61
+ const savepoint = depth ? `tx_${depth}` : undefined
62
+ driver.exec(savepoint ? `SAVEPOINT ${savepoint}` : "BEGIN IMMEDIATE")
63
+ depth++
64
+ let result: T
65
+ try {
66
+ result = fn()
67
+ } catch (error) {
68
+ depth--
69
+ try {
70
+ if (savepoint) {
71
+ driver.exec(`ROLLBACK TO ${savepoint}`)
72
+ driver.exec(`RELEASE ${savepoint}`)
73
+ } else driver.exec("ROLLBACK")
74
+ } catch {}
75
+ throw error
76
+ }
77
+ depth--
78
+ try {
79
+ driver.exec(savepoint ? `RELEASE ${savepoint}` : "COMMIT")
80
+ } catch (error) {
81
+ if (!savepoint)
82
+ try {
83
+ driver.exec("ROLLBACK")
84
+ } catch {}
85
+ throw error
86
+ }
87
+ return result
88
+ },
89
+ }
90
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { Database } from "bun:sqlite"
2
+ import type { Server, ServerWebSocket } from "bun"
3
+ import { type CrossAccountLink, PASS_GATE, sha256Hex, type Sql, type SqlValue, type XContact } from "@scp-dev/core"
4
+ import { type HubGrant, type HubSocket, RelayHub } from "@scp-dev/hub"
5
+
6
+ export interface FakeToken {
7
+ tokenId: string
8
+ /** the account handle the token acts as */
9
+ user: string
10
+ }
11
+
12
+ export interface FakeHubServer {
13
+ /** the http origin, as a bridge's url takes it */
14
+ url: string
15
+ hub(account: string): RelayHub
16
+ /** closes every socket of `machine` (code 1012), as a dropped network would */
17
+ drop(account: string, machine: string): void
18
+ /** the next frame calling `method` from `machine` closes its socket instead of reaching the hub; resolves once it did */
19
+ dropOn(account: string, machine: string, method: string): Promise<void>
20
+ /** the open sockets of an account, with their state */
21
+ sockets(account: string): HubSocket[]
22
+ stop(): Promise<void>
23
+ }
24
+
25
+ type Data = { account: string; grant: HubGrant; socket?: HubSocket }
26
+
27
+ interface Account {
28
+ hub: RelayHub
29
+ sockets: Map<ServerWebSocket<Data>, HubSocket>
30
+ traps: Map<string, { method: string; fired: () => void }>
31
+ }
32
+
33
+ /**
34
+ * a stand-in for a hosted hub's /v1/relay routes: one RelayHub per account over bun:sqlite, bearer credentials from a table; `contacts` pairs
35
+ * account handles that are active contacts both ways (default allowlist: every root), their relays linked directly
36
+ */
37
+ export function startFakeHubServer(options: {
38
+ tokens: Record<string, FakeToken>
39
+ now?: (() => number) | undefined
40
+ contacts?: [string, string][] | undefined
41
+ }): FakeHubServer {
42
+ const accounts = new Map<string, Account>()
43
+
44
+ function linkOf(name: string): CrossAccountLink {
45
+ const peers = () => (options.contacts ?? []).flatMap(([a, b]) => (a === name ? [b] : b === name ? [a] : []))
46
+ const contact = (peer: string): XContact => ({
47
+ peerId: `usr_${peer}`,
48
+ peerHandle: peer,
49
+ allow: [{ machine: "*", sel: "tree:root" }],
50
+ dir: "both",
51
+ limits: null,
52
+ })
53
+ return {
54
+ self: { id: `usr_${name}`, handle: name },
55
+ contactByHandle: async (handle) => (peers().includes(handle) ? contact(handle) : null),
56
+ contactById: async (id) => (id.startsWith("usr_") && peers().includes(id.slice(4)) ? contact(id.slice(4)) : null),
57
+ contacts: async () => peers().map(contact),
58
+ deliver: async (peer, msg) => account(peer.peerHandle).hub.xreceive(`usr_${name}`, JSON.parse(JSON.stringify(msg))),
59
+ list: async (peer) => account(peer.peerHandle).hub.xlist(`usr_${name}`),
60
+ gate: PASS_GATE,
61
+ }
62
+ }
63
+
64
+ function account(name: string): Account {
65
+ let found = accounts.get(name)
66
+ if (found) return found
67
+ const db = new Database(":memory:")
68
+ const sql: Sql = { all: <T>(query: string, ...params: SqlValue[]) => db.query(query).all(...params) as T[] }
69
+ const sockets = new Map<ServerWebSocket<Data>, HubSocket>()
70
+ const hub = new RelayHub({
71
+ sql,
72
+ hash: sha256Hex,
73
+ transaction: (fn) => db.transaction(fn)(),
74
+ ...(options.now ? { now: options.now } : {}),
75
+ sockets: () => [...sockets.values()],
76
+ save: () => {},
77
+ ...(options.contacts ? { crossAccount: linkOf(name) } : {}),
78
+ })
79
+ hub.init({ user: name })
80
+ found = { hub, sockets, traps: new Map() }
81
+ accounts.set(name, found)
82
+ return found
83
+ }
84
+
85
+ function grantOf(request: Request): { account: string; grant: HubGrant } | null {
86
+ const match = /^Bearer (\S+)$/.exec(request.headers.get("authorization") ?? "")
87
+ const token = match ? options.tokens[match[1]!] : undefined
88
+ if (!token) return null
89
+ return {
90
+ account: token.user,
91
+ grant: { tokenId: token.tokenId, userId: `usr_${token.user}`, actorId: null, expiresAt: Date.now() + 3_600_000, scoped: false, role: null },
92
+ }
93
+ }
94
+
95
+ const server: Server<Data> = Bun.serve<Data>({
96
+ hostname: "127.0.0.1",
97
+ port: 0,
98
+ async fetch(request, srv) {
99
+ const path = new URL(request.url).pathname
100
+ if (path !== "/v1/relay/ws" && path !== "/v1/relay/rpc") return Response.json({ error: "not_found" }, { status: 404 })
101
+ const principal = grantOf(request)
102
+ if (!principal) return Response.json({ error: "unauthorized" }, { status: 401 })
103
+ const { hub } = account(principal.account)
104
+ if (path === "/v1/relay/ws") {
105
+ if (hub.full()) return Response.json({ error: "too_many_sockets" }, { status: 429 })
106
+ if (srv.upgrade(request, { data: principal })) return undefined
107
+ return Response.json({ error: "upgrade_required" }, { status: 426 })
108
+ }
109
+ if (request.method !== "POST") return Response.json({ error: "not_found" }, { status: 404 })
110
+ let body: unknown
111
+ try {
112
+ body = JSON.parse(await request.text())
113
+ } catch {
114
+ return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })
115
+ }
116
+ return Response.json(await hub.batch(principal.grant, body))
117
+ },
118
+ websocket: {
119
+ open(ws) {
120
+ const state = account(ws.data.account)
121
+ const socket: HubSocket = {
122
+ conn: RelayHub.connection(ws.data.grant),
123
+ send: (text) => void ws.send(text),
124
+ close: (code, reason) => ws.close(code, reason),
125
+ }
126
+ ws.data.socket = socket
127
+ state.sockets.set(ws, socket)
128
+ },
129
+ async message(ws, message) {
130
+ const state = account(ws.data.account)
131
+ const socket = ws.data.socket!
132
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message)
133
+ const machine = socket.conn.machine
134
+ if (machine && state.traps.size) {
135
+ const method = (JSON.parse(text) as { method?: string }).method
136
+ const trap = state.traps.get(machine)
137
+ if (method && trap?.method === method) {
138
+ state.traps.delete(machine)
139
+ ws.close(1012, "dropped by the test")
140
+ trap.fired()
141
+ return
142
+ }
143
+ }
144
+ await state.hub.message(socket, text)
145
+ },
146
+ close(ws) {
147
+ const state = account(ws.data.account)
148
+ state.sockets.delete(ws)
149
+ if (ws.data.socket) state.hub.closed(ws.data.socket)
150
+ },
151
+ },
152
+ })
153
+
154
+ return {
155
+ url: `http://127.0.0.1:${server.port}`,
156
+ hub: (name) => account(name).hub,
157
+ drop(name, machine) {
158
+ for (const [ws, socket] of account(name).sockets) if (socket.conn.machine === machine) ws.close(1012, "dropped by the test")
159
+ },
160
+ dropOn(name, machine, method) {
161
+ return new Promise((fired) => account(name).traps.set(machine, { method, fired }))
162
+ },
163
+ sockets: (name) => [...account(name).sockets.values()],
164
+ async stop() {
165
+ await server.stop(true)
166
+ },
167
+ }
168
+ }