@buley/relay 4.1.0 → 4.2.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.
@@ -0,0 +1,10 @@
1
+ import { SyncRoom } from "./sync-room";
2
+ interface Env {
3
+ SYNC_ROOM: DurableObjectNamespace<SyncRoom>;
4
+ }
5
+ export { SyncRoom };
6
+ declare const _default: {
7
+ fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
8
+ };
9
+ export default _default;
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,UAAU,GAAG;IACX,SAAS,EAAE,sBAAsB,CAAC,QAAQ,CAAC,CAAC;CAC7C;AAED,OAAO,EAAE,QAAQ,EAAE,CAAC;;mBAGG,OAAO,OAAO,GAAG,OAAO,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC;;AADnF,wBAcE"}
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { SyncRoom } from "./sync-room";
2
+ export { SyncRoom };
3
+ export default {
4
+ async fetch(request, env, ctx) {
5
+ const url = new URL(request.url);
6
+ const path = url.pathname.slice(1).split("/"); // ["sync", "roomName"]
7
+ if (path[0] === "sync" && path[1]) {
8
+ const roomName = path[1];
9
+ const id = env.SYNC_ROOM.idFromName(roomName);
10
+ const stub = env.SYNC_ROOM.get(id);
11
+ return stub.fetch(request);
12
+ }
13
+ return new Response("Not Found", { status: 404 });
14
+ },
15
+ };
@@ -0,0 +1,17 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ interface Env {
3
+ BACKUPS: R2Bucket;
4
+ }
5
+ export declare class SyncRoom extends DurableObject<Env> {
6
+ sql: SqlStorage;
7
+ constructor(ctx: DurableObjectState, env: Env);
8
+ fetch(request: Request): Promise<Response>;
9
+ handleWebTransportSession(session: any): Promise<void>;
10
+ handleStream(stream: any): Promise<void>;
11
+ webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise<void>;
12
+ private broadcast;
13
+ webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void>;
14
+ alarm(): Promise<void>;
15
+ }
16
+ export {};
17
+ //# sourceMappingURL=sync-room.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-room.d.ts","sourceRoot":"","sources":["../src/sync-room.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGnD,UAAU,GAAG;IACX,OAAO,EAAE,QAAQ,CAAC;CACnB;AAED,qBAAa,QAAS,SAAQ,aAAa,CAAC,GAAG,CAAC;IAC9C,GAAG,EAAE,UAAU,CAAC;gBAEJ,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,GAAG;IAcvC,KAAK,CAAC,OAAO,EAAE,OAAO;IAkDtB,yBAAyB,CAAC,OAAO,EAAE,GAAG;IAgBtC,YAAY,CAAC,MAAM,EAAE,GAAG;IAuCxB,gBAAgB,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,GAAG,MAAM;IAqBnE,OAAO,CAAC,SAAS;IAaX,cAAc,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO;IAI7E,KAAK;CAgBZ"}
@@ -0,0 +1,159 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import * as ucans from 'ucans';
3
+ export class SyncRoom extends DurableObject {
4
+ sql;
5
+ constructor(ctx, env) {
6
+ super(ctx, env);
7
+ this.sql = ctx.storage.sql;
8
+ // Initialize Schema
9
+ this.sql.exec(`
10
+ CREATE TABLE IF NOT EXISTS messages (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ data BLOB,
13
+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
14
+ );
15
+ `);
16
+ }
17
+ async fetch(request) {
18
+ // 0. UCAN Verification
19
+ const url = new URL(request.url);
20
+ const token = url.searchParams.get("token");
21
+ if (!token) {
22
+ return new Response("Unauthorized: Missing UCAN", { status: 401 });
23
+ }
24
+ try {
25
+ const result = await ucans.verify(token, {
26
+ // In a real system, you would verify against a root DID or specific issuer.
27
+ // For this P2P/Star architecture, we check if the signature is valid
28
+ // and if it grants access to THIS room.
29
+ audience: "did:web:relay.buley.dev",
30
+ requiredCapabilities: [
31
+ {
32
+ capability: {
33
+ with: { scheme: "room", hierPart: `//${url.pathname.split('/').pop()}` },
34
+ can: { namespace: "room", segments: ["write"] }
35
+ },
36
+ rootIssuer: null // Allow self-issued for now (P2P style)
37
+ }
38
+ ]
39
+ });
40
+ if (!result.ok) {
41
+ return new Response(`Unauthorized: Invalid UCAN - ${result.error}`, { status: 403 });
42
+ }
43
+ }
44
+ catch (e) {
45
+ return new Response("Unauthorized: Malformed Token", { status: 403 });
46
+ }
47
+ // 1. WebSocket Upgrade
48
+ if (request.headers.get("Upgrade") === "websocket") {
49
+ const pair = new WebSocketPair();
50
+ const [client, server] = Object.values(pair);
51
+ this.ctx.acceptWebSocket(server);
52
+ return new Response(null, { status: 101, webSocket: client });
53
+ }
54
+ // 2. WebTransport Session
55
+ // Note: Use 'webtransport' npm package or specific Cloudflare types if available.
56
+ // For this snippet, we assume the environment passes a session or we accept it.
57
+ // This logic demonstrates the BYOB loop requested.
58
+ return new Response("Expected WebSocket Upgrade", { status: 426 });
59
+ }
60
+ async handleWebTransportSession(session) {
61
+ // 1. Accept incoming streams
62
+ const reader = session.incomingBidirectionalStreams.getReader();
63
+ try {
64
+ while (true) {
65
+ const { value: stream, done } = await reader.read();
66
+ if (done)
67
+ break;
68
+ // Handle individual stream with BYOB
69
+ this.handleStream(stream);
70
+ }
71
+ }
72
+ catch (e) {
73
+ // session closed
74
+ }
75
+ }
76
+ async handleStream(stream) {
77
+ // 2. Zero-Copy Reader (BYOB)
78
+ let reader;
79
+ try {
80
+ reader = stream.readable.getReader({ mode: 'byob' });
81
+ }
82
+ catch (e) {
83
+ reader = stream.readable.getReader();
84
+ }
85
+ // Buffer Reuse (Zero Allocation Loop)
86
+ let buffer = new Uint8Array(65536);
87
+ try {
88
+ while (true) {
89
+ let result;
90
+ if (reader.readAtLeast) {
91
+ // Pass view to kernel/runtime
92
+ result = await reader.read(new Uint8Array(buffer.buffer, 0, buffer.byteLength));
93
+ if (result.value)
94
+ buffer = result.value; // Valid buffer returned
95
+ }
96
+ else {
97
+ result = await reader.read();
98
+ }
99
+ if (result.done)
100
+ break;
101
+ if (result.value) {
102
+ // 3. Zero-Copy Insert to Native SQLite
103
+ // The buffer 'result.value' contains the data directly from the network packet
104
+ this.sql.exec("INSERT INTO messages (data) VALUES (?)", result.value);
105
+ // 4. Broadcast (Zero-Copy to outbound buffers?)
106
+ // Cloudflare's WebSocket.send() accepts ArrayBuffer, so no copy needed here either ideally.
107
+ this.broadcast(result.value);
108
+ }
109
+ }
110
+ }
111
+ catch (e) {
112
+ // stream error
113
+ }
114
+ }
115
+ async webSocketMessage(ws, message) {
116
+ // 1. Store Delta (Zero Latency)
117
+ // We assume message is a binary CRDT update
118
+ const data = typeof message === 'string' ? new TextEncoder().encode(message) : message;
119
+ this.sql.exec("INSERT INTO messages (data) VALUES (?)", data);
120
+ this.sql.exec("INSERT INTO messages (data) VALUES (?)", data);
121
+ // 2. Broadcast to others
122
+ this.broadcast(message, ws);
123
+ // 3. Set Alarm for Write-back (Debounced)
124
+ // Hibernation Note: The alarm will wake the DO up even if all clients are hibernating.
125
+ const currentAlarm = await this.ctx.storage.getAlarm();
126
+ if (currentAlarm === null) {
127
+ // Set alarm for 10 seconds from now
128
+ await this.ctx.storage.setAlarm(Date.now() + 10000);
129
+ }
130
+ }
131
+ broadcast(message, exclude) {
132
+ // Determine the message to send once
133
+ for (const client of this.ctx.getWebSockets()) {
134
+ if (client !== exclude) {
135
+ try {
136
+ client.send(message);
137
+ }
138
+ catch (e) {
139
+ // fast-fail on dead sockets
140
+ }
141
+ }
142
+ }
143
+ }
144
+ async webSocketClose(ws, code, reason, wasClean) {
145
+ // Clean up if needed
146
+ }
147
+ async alarm() {
148
+ // 1. Snapshot SQLite to R2
149
+ // In a real app, you might maintain a separate logical "Document" file
150
+ // Here we just backup the raw messages for safety.
151
+ const messages = this.sql.exec("SELECT * FROM messages").toArray();
152
+ const blob = JSON.stringify(messages); // Simple serialization for backup
153
+ const key = `backups/${this.ctx.id.toString()}/${Date.now()}.json`;
154
+ await this.env.BACKUPS.put(key, blob);
155
+ // Optional: Truncate local history if relying on full-state sync later
156
+ // this.sql.exec("DELETE FROM messages");
157
+ console.log(`Backed up ${messages.length} messages to ${key}`);
158
+ }
159
+ }
package/package.json CHANGED
@@ -1,15 +1,31 @@
1
1
  {
2
2
  "name": "@buley/relay",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
5
19
  "scripts": {
6
20
  "dev": "wrangler dev",
7
21
  "deploy": "wrangler deploy",
8
- "build": "wrangler deploy --dry-run --outdir dist",
22
+ "build": "tsc -p tsconfig.build.json && wrangler deploy --dry-run --outdir dist",
23
+ "build:types": "tsc -p tsconfig.build.json",
9
24
  "test": "NODE_OPTIONS=--experimental-vm-modules vitest"
10
25
  },
11
26
  "dependencies": {
12
- "hono": "^3.12.0"
27
+ "hono": "^3.12.0",
28
+ "ucans": "^0.10.0"
13
29
  },
14
30
  "devDependencies": {
15
31
  "@cloudflare/workers-types": "^4.20240117.0",
@@ -1 +0,0 @@
1
- [{"id":1,"data":{},"created_at":1769231824},{"id":2,"data":{},"created_at":1769231825}]
package/CHANGELOG.md DELETED
@@ -1,17 +0,0 @@
1
- # Changelog
2
-
3
- ## [4.1.0] - 2026-01-23
4
-
5
- ### Improvements
6
-
7
- - **Hibernation Refinements**: Explicit broadcast handling and improved comments for Cloudflare Hibernation behavior.
8
- - **Code Cleanup**: Extracted broadcast logic.
9
-
10
- ## [4.0.0] - 2026-01-23
11
-
12
- ### Features
13
-
14
- - Initial release of `@buley/relay`.
15
- - "Stateful Serverless" architecture using Cloudflare Durable Objects.
16
- - Native SQLite support.
17
- - Hybrid WebSocket/WebTransport Sync Protocol.
package/src/index.ts DELETED
@@ -1,23 +0,0 @@
1
- import { SyncRoom } from "./sync-room";
2
-
3
- interface Env {
4
- SYNC_ROOM: DurableObjectNamespace<SyncRoom>;
5
- }
6
-
7
- export { SyncRoom };
8
-
9
- export default {
10
- async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
11
- const url = new URL(request.url);
12
- const path = url.pathname.slice(1).split("/"); // ["sync", "roomName"]
13
-
14
- if (path[0] === "sync" && path[1]) {
15
- const roomName = path[1];
16
- const id = env.SYNC_ROOM.idFromName(roomName);
17
- const stub = env.SYNC_ROOM.get(id);
18
- return stub.fetch(request);
19
- }
20
-
21
- return new Response("Not Found", { status: 404 });
22
- },
23
- };
package/src/sync-room.ts DELETED
@@ -1,95 +0,0 @@
1
- import { DurableObject } from "cloudflare:workers";
2
-
3
- interface Env {
4
- BACKUPS: R2Bucket;
5
- }
6
-
7
- export class SyncRoom extends DurableObject<Env> {
8
- sql: SqlStorage;
9
-
10
- constructor(ctx: DurableObjectState, env: Env) {
11
- super(ctx, env);
12
- this.sql = ctx.storage.sql;
13
-
14
- // Initialize Schema
15
- this.sql.exec(`
16
- CREATE TABLE IF NOT EXISTS messages (
17
- id INTEGER PRIMARY KEY AUTOINCREMENT,
18
- data BLOB,
19
- created_at INTEGER DEFAULT (strftime('%s', 'now'))
20
- );
21
- `);
22
- }
23
-
24
- async fetch(request: Request) {
25
- // 1. WebSocket Upgrade
26
- if (request.headers.get("Upgrade") === "websocket") {
27
- const pair = new WebSocketPair();
28
- const [client, server] = Object.values(pair);
29
-
30
- this.ctx.acceptWebSocket(server);
31
- return new Response(null, { status: 101, webSocket: client });
32
- }
33
-
34
- // 2. WebTransport Session (Example - requires parsing Connect)
35
- // For simplicity in this v1, we focus on the WebSocket 'Hibernate' path
36
- // accessible via fetch post for high-throughput if needed, or upgrade.
37
-
38
- return new Response("Expected WebSocket Upgrade", { status: 426 });
39
- }
40
-
41
- async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
42
- // 1. Store Delta (Zero Latency)
43
- // We assume message is a binary CRDT update
44
- const data = typeof message === 'string' ? new TextEncoder().encode(message) : message;
45
-
46
- this.sql.exec("INSERT INTO messages (data) VALUES (?)", data as any);
47
-
48
- this.sql.exec("INSERT INTO messages (data) VALUES (?)", data as any);
49
-
50
- // 2. Broadcast to others
51
- this.broadcast(message, ws);
52
-
53
- // 3. Set Alarm for Write-back (Debounced)
54
- // Hibernation Note: The alarm will wake the DO up even if all clients are hibernating.
55
- const currentAlarm = await this.ctx.storage.getAlarm();
56
- if (currentAlarm === null) {
57
- // Set alarm for 10 seconds from now
58
- await this.ctx.storage.setAlarm(Date.now() + 10000);
59
- }
60
- }
61
-
62
- private broadcast(message: ArrayBuffer | string, exclude?: WebSocket) {
63
- // Determine the message to send once
64
- for (const client of this.ctx.getWebSockets()) {
65
- if (client !== exclude) {
66
- try {
67
- client.send(message);
68
- } catch (e) {
69
- // fast-fail on dead sockets
70
- }
71
- }
72
- }
73
- }
74
-
75
- async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
76
- // Clean up if needed
77
- }
78
-
79
- async alarm() {
80
- // 1. Snapshot SQLite to R2
81
- // In a real app, you might maintain a separate logical "Document" file
82
- // Here we just backup the raw messages for safety.
83
-
84
- const messages = this.sql.exec("SELECT * FROM messages").toArray();
85
- const blob = JSON.stringify(messages); // Simple serialization for backup
86
-
87
- const key = `backups/${this.ctx.id.toString()}/${Date.now()}.json`;
88
- await this.env.BACKUPS.put(key, blob);
89
-
90
- // Optional: Truncate local history if relying on full-state sync later
91
- // this.sql.exec("DELETE FROM messages");
92
-
93
- console.log(`Backed up ${messages.length} messages to ${key}`);
94
- }
95
- }
@@ -1,72 +0,0 @@
1
- // @vitest-environment miniflare
2
- import { describe, it, expect, vi } from "vitest";
3
-
4
- // In vitest-environment-miniflare, bindings are often exposed as globals directly.
5
- declare global {
6
- var SYNC_ROOM: DurableObjectNamespace;
7
- var BACKUPS: R2Bucket;
8
- }
9
-
10
- describe("SyncRoom", () => {
11
- it("should upgrade to websocket", async () => {
12
- const id = SYNC_ROOM.idFromName("test-room");
13
- const stub = SYNC_ROOM.get(id);
14
-
15
- // Simulate WebSocket Upgrade Request
16
- const resp = await stub.fetch("http://localhost/sync/test-room", {
17
- headers: { Upgrade: "websocket" },
18
- });
19
- expect(resp.status).toBe(101);
20
- });
21
-
22
- it("should store messages and broadcast", async () => {
23
- const id = SYNC_ROOM.idFromName("room-2");
24
- const stub = SYNC_ROOM.get(id);
25
-
26
- const { webSocket: ws1, response: resp1 } = await stub.fetch("http://localhost/sync/room-2", {
27
- headers: { Upgrade: "websocket" },
28
- });
29
- expect(resp1.status).toBe(101);
30
-
31
- const { webSocket: ws2, response: resp2 } = await stub.fetch("http://localhost/sync/room-2", {
32
- headers: { Upgrade: "websocket" },
33
- });
34
- expect(resp2.status).toBe(101);
35
-
36
- if (!ws1 || !ws2) throw new Error("Failed to get sockets");
37
-
38
- ws1.accept();
39
- ws2.accept();
40
-
41
- const received: any[] = [];
42
- ws2.addEventListener("message", (event) => received.push(event.data));
43
-
44
- // Send Message
45
- const data = new Uint8Array([1, 2, 3]);
46
- ws1.send(data);
47
-
48
- // Wait for broadcast
49
- await new Promise(r => setTimeout(r, 200));
50
-
51
- expect(received.length).toBe(1);
52
- // Note: Miniflare/Workerd might deliver as string or blob depending on config, checking length for now
53
- });
54
-
55
- it("should trigger backup alarm", async () => {
56
- // Alarm testing is tricky in unit tests without advancing time.
57
- // We can check if alarm is set.
58
- const id = SYNC_ROOM.idFromName("room-alarm");
59
- const stub = SYNC_ROOM.get(id);
60
-
61
-
62
- const { webSocket: ws1 } = await stub.fetch("http://localhost", { headers: { Upgrade: "websocket" } });
63
- ws1?.accept();
64
- ws1?.send("test data");
65
-
66
- // Wait for write
67
- await new Promise(r => setTimeout(r, 100));
68
-
69
- // Unfortunately we can't easily inspect DO internal state from outside unless we add a debug endpoint.
70
- // For 100% coverage, we trust the integration or add a method to SyncRoom exposed for testing.
71
- });
72
- });
package/tsconfig.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "esnext",
4
- "module": "esnext",
5
- "moduleResolution": "bundler",
6
- "types": ["@cloudflare/workers-types/2023-07-01"],
7
- "strict": true,
8
- "skipLibCheck": true,
9
- "noEmit": true
10
- },
11
- "include": ["src/**/*"]
12
- }
package/vitest.config.ts DELETED
@@ -1,23 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- environment: "miniflare",
6
- environmentOptions: {
7
- modules: true,
8
- scriptPath: "dist/index.js",
9
- compatibilityDate: "2024-04-01",
10
- shouldLog: true,
11
- bindings: { BACKUPS: { type: "R2Bucket" } },
12
- durableObjects: {
13
- SYNC_ROOM: "SyncRoom",
14
- },
15
- migrations: [
16
- {
17
- tag: "v1",
18
- new_sqlite_classes: ["SyncRoom"],
19
- }
20
- ]
21
- },
22
- },
23
- });
package/wrangler.toml DELETED
@@ -1,22 +0,0 @@
1
- name = "dash-relay"
2
- main = "src/index.ts"
3
- compatibility_date = "2024-01-17"
4
-
5
- # Durable Object Bindings
6
- [[durable_objects.bindings]]
7
- name = "SYNC_ROOM"
8
- class_name = "SyncRoom"
9
-
10
- # Durable Object Migrations (Required for Native SQLite)
11
- [[migrations]]
12
- tag = "v1"
13
- new_classes = []
14
- new_sqlite_classes = ["SyncRoom"]
15
-
16
- # R2 Bucket for Backups (Alarms)
17
- [[r2_buckets]]
18
- binding = "BACKUPS"
19
- bucket_name = "dash-backups"
20
-
21
- [triggers]
22
- crons = []