@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/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/index.d.ts +421 -0
- package/dist/index.js +2483 -0
- package/dist/testing.d.ts +30 -0
- package/dist/testing.js +131 -0
- package/package.json +48 -4
- package/src/bridge.ts +642 -0
- package/src/client.ts +335 -0
- package/src/index.ts +6 -0
- package/src/judge-gate.ts +552 -0
- package/src/lock.ts +113 -0
- package/src/ndjson.ts +95 -0
- package/src/paths.ts +132 -0
- package/src/server.ts +1079 -0
- package/src/socket.ts +57 -0
- package/src/sqlite.ts +90 -0
- package/src/testing.ts +168 -0
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
|
+
}
|