@super-line/transport-cloudflare 0.0.0-stage → 0.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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/index.d.ts +62 -0
- package/dist/index.js +221 -0
- package/package.json +52 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Generated from conventional commits by [git-cliff](https://git-cliff.org) —
|
|
4
|
+
run `pnpm changelog` rather than editing by hand.
|
|
5
|
+
|
|
6
|
+
## [transport-cloudflare-v0.2.0] — 2026-10-18
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- A refused credential closes 4401, and the client stops retrying it ([35c6e25])
|
|
11
|
+
|
|
12
|
+
## [transport-cloudflare-v0.1.0] — 2026-10-15
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
- **server:** Ready waits for the connections a transport resumes ([dc6989f])
|
|
17
|
+
- **transport-cloudflare:** A Durable Object is a super-line node ([fc912e8])
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
- Teach hosting a node on Cloudflare Durable Objects ([a893f83])
|
|
22
|
+
|
|
23
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @super-line/transport-cloudflare
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a [**super-line**](https://super-line.dogar.biz/) server inside a Cloudflare **Durable Object**: one object is one node. Its WebSockets use the Hibernation API, so the object leaves memory between events while every socket stays open, and the next message wakes a fresh instance that resumes every subscription, open document, room, and `env` before it handles anything. The client never sees a disconnect.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @super-line/core @super-line/server @super-line/transport-cloudflare
|
|
7
|
+
pnpm add -D wrangler @cloudflare/workers-types
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The node must be a **SQLite-backed** class — the transport keeps each connection's snapshot in the object's SQLite:
|
|
11
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
// wrangler.jsonc
|
|
14
|
+
{
|
|
15
|
+
"main": "./src/worker.ts",
|
|
16
|
+
"compatibility_flags": ["nodejs_compat"], // only for plugin-auth / plugin-inspector
|
|
17
|
+
"durable_objects": { "bindings": [{ "name": "NODE", "class_name": "Node" }] },
|
|
18
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Node"] }]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/worker.ts
|
|
24
|
+
import { createSuperLineServer } from '@super-line/server'
|
|
25
|
+
import { createDurableNode, routeToNode } from '@super-line/transport-cloudflare'
|
|
26
|
+
import { contract } from './contract'
|
|
27
|
+
|
|
28
|
+
interface Env {
|
|
29
|
+
NODE: DurableObjectNamespace
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The factory runs on first use AND on every wake: keep it cheap and idempotent.
|
|
33
|
+
export class Node extends createDurableNode((ctx, env: Env, base) =>
|
|
34
|
+
createSuperLineServer(contract, {
|
|
35
|
+
...base, // the Durable Object transport + a nodeKey stable for this object
|
|
36
|
+
authenticate: (h) => ({ role: 'user', ctx: verify(h.query.token) }),
|
|
37
|
+
}).implement({ user: { ping: async () => 'pong' } }),
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
// Every request the object receives wakes it, so forward only the socket path.
|
|
42
|
+
fetch: (request, env) =>
|
|
43
|
+
new URL(request.url).pathname === '/ws'
|
|
44
|
+
? routeToNode(request, env.NODE)
|
|
45
|
+
: new Response('Not found', { status: 404 }),
|
|
46
|
+
} satisfies ExportedHandler<Env>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Clients use the ordinary WebSocket client. The node runs no heartbeat (a timer would keep it awake), so set `keepalive` — Cloudflare answers those pings without waking the object:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { webSocketClientTransport } from '@super-line/transport-websocket'
|
|
53
|
+
|
|
54
|
+
createSuperLineClient(contract, {
|
|
55
|
+
transport: webSocketClientTransport({ url: 'wss://my-app.example.workers.dev/ws' }),
|
|
56
|
+
role: 'user',
|
|
57
|
+
keepalive: 30_000,
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
For durable state in the same object, pair it with [`@super-line/collections-cloudflare`](https://github.com/mertdogar/super-line/tree/main/packages/collections-cloudflare) (rows) and [`@super-line/collections-crdt-cloudflare`](https://github.com/mertdogar/super-line/tree/main/packages/collections-crdt-cloudflare) (CRDT documents).
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
| Export | Meaning |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `createDurableNode(build)` | A Durable Object class that is one node. `build(ctx, env, base)` returns the server built with `...base`; the class forwards `fetch` and the WebSocket handlers to the transport and resumes every surviving connection before any event is delivered. Exposes `this.server` and `this.transport`. |
|
|
68
|
+
| `routeToNode(request, namespace, { name? })` | Forward a Worker request to the node object — `'super-line'` by default; `name(request)` picks one object per tenant. |
|
|
69
|
+
| `cloudflareServerTransport(ctx)` | The transport itself, for a Durable Object class you write by hand: forward `fetch` / `webSocketMessage` / `webSocketClose` / `webSocketError`, and await `server.ready` inside `blockConcurrencyWhile`: on a wake it resolves once every surviving socket is back. |
|
|
70
|
+
| `CLOSE_NOT_RESUMABLE` | `4409` — closes a socket whose connection could not be resumed on wake; the client reconnects as after any drop. |
|
|
71
|
+
|
|
72
|
+
## Behavior notes
|
|
73
|
+
|
|
74
|
+
- **Handshake** — `transport: 'cloudflare'`, `query` from the URL, `headers` from the request, `raw` is the `Request`. An `authenticate` that throws `SuperLineError('UNAUTHORIZED')`, and a refused Control Center connection, are closed `4401` after the upgrade, so the client can tell them from an outage; any other throw gets a plain `401` and no socket.
|
|
75
|
+
- **What a wake loses** — everything in memory: `conn.data` (`onConnection` runs again for each resumed socket), memory collection backends, module state. Keep `ctx` and `env` JSON-serializable; the snapshot is stored as JSON.
|
|
76
|
+
- **A subscription can end with the socket open** — a wake runs each subscription's policy again, and one it now refuses reaches the client's `onError` with `kind: 'resubscribe'`.
|
|
77
|
+
- **Timers keep the node awake** — your own, `plugin-queue`'s polling (always), `plugin-inspector`'s keepalive (while a Control Center watches). A plugin's `ctx.hold()` keeps it awake on purpose.
|
|
78
|
+
- **One object, one node** — there is no adapter; objects don't share rooms, topics, or state.
|
|
79
|
+
|
|
80
|
+
- 📖 Guide: [Host a node on Cloudflare Durable Objects](https://super-line.dogar.biz/how-to/cloudflare-durable-objects)
|
|
81
|
+
- 🧪 Example: [`examples/cloudflare`](https://github.com/mertdogar/super-line/tree/main/examples/cloudflare)
|
|
82
|
+
- 🧩 Source: <https://github.com/mertdogar/super-line>
|
|
83
|
+
|
|
84
|
+
MIT © Mert
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ServerTransport } from '@super-line/core';
|
|
2
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
3
|
+
|
|
4
|
+
/** Closes a socket whose connection could not be resumed on wake; the client reconnects on it like any drop. */
|
|
5
|
+
declare const CLOSE_NOT_RESUMABLE = 4409;
|
|
6
|
+
/**
|
|
7
|
+
* The transport a Durable Object hosts: a {@link ServerTransport} plus the handlers the object forwards from
|
|
8
|
+
* its own `fetch` and `webSocket*` methods. {@link createDurableNode} does that forwarding for you.
|
|
9
|
+
*/
|
|
10
|
+
interface CloudflareServerTransport extends ServerTransport {
|
|
11
|
+
/** Accept a WebSocket upgrade (any other request gets a 426). */
|
|
12
|
+
fetch(request: Request): Promise<Response>;
|
|
13
|
+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void;
|
|
14
|
+
webSocketClose(ws: WebSocket, code: number, reason: string): void;
|
|
15
|
+
webSocketError(ws: WebSocket, error: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A super-line transport over the Durable Object WebSocket Hibernation API. Sockets are accepted with
|
|
19
|
+
* `ctx.acceptWebSocket`, so the object can hibernate while they stay open; each connection's snapshot is kept in
|
|
20
|
+
* the object's SQLite storage (the object must be a SQLite-backed class). When the object wakes, the server's
|
|
21
|
+
* `ready` resolves only after every surviving socket is back with its subscriptions (one that cannot be resumed is
|
|
22
|
+
* closed 4409) — await it inside `blockConcurrencyWhile` so no event lands before then. Liveness is the socket's:
|
|
23
|
+
* client pings are answered by the platform without waking the object, so the server runs no heartbeat.
|
|
24
|
+
*/
|
|
25
|
+
declare function cloudflareServerTransport(ctx: DurableObjectState): CloudflareServerTransport;
|
|
26
|
+
|
|
27
|
+
/** Spread into `createSuperLineServer`'s options: the Durable Object transport and a `nodeKey` stable per object. */
|
|
28
|
+
interface DurableNodeBase {
|
|
29
|
+
transports: ServerTransport[];
|
|
30
|
+
nodeKey: string;
|
|
31
|
+
}
|
|
32
|
+
/** The class {@link createDurableNode} returns: a Durable Object exposing its transport and its server. */
|
|
33
|
+
type DurableNodeClass<Env, S> = new (ctx: DurableObjectState, env: Env) => DurableObject<Env> & {
|
|
34
|
+
readonly transport: CloudflareServerTransport;
|
|
35
|
+
readonly server: S;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A Durable Object class that is one super-line node. `build` runs in the constructor — on first use AND on every
|
|
39
|
+
* wake from hibernation — and returns the server, built with `...base`. The class forwards `fetch` and the
|
|
40
|
+
* WebSocket handlers to the transport and resumes every surviving connection before any event is delivered.
|
|
41
|
+
* Extend it and export the subclass under the name your wrangler config binds; `this.server` is the server.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* export class Node extends createDurableNode((ctx, env: Env, base) =>
|
|
45
|
+
* createSuperLineServer(contract, { ...base, authenticate }).implement({ user: { ping: async () => 'pong' } }),
|
|
46
|
+
* ) {}
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
declare function createDurableNode<Env = Cloudflare.Env, S extends {
|
|
50
|
+
readonly ready: Promise<void>;
|
|
51
|
+
} = {
|
|
52
|
+
readonly ready: Promise<void>;
|
|
53
|
+
}>(build: (ctx: DurableObjectState, env: Env, base: DurableNodeBase) => S): DurableNodeClass<Env, S>;
|
|
54
|
+
/**
|
|
55
|
+
* Forward a Worker request to the Durable Object that is the node. One named object per deployment by default;
|
|
56
|
+
* pass `name` to pick one per request (a tenant from the host or path).
|
|
57
|
+
*/
|
|
58
|
+
declare function routeToNode(request: Request, namespace: DurableObjectNamespace, opts?: {
|
|
59
|
+
name?: (request: Request) => string;
|
|
60
|
+
}): Promise<Response>;
|
|
61
|
+
|
|
62
|
+
export { CLOSE_NOT_RESUMABLE, type CloudflareServerTransport, type DurableNodeBase, type DurableNodeClass, cloudflareServerTransport, createDurableNode, routeToNode };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// src/transport.ts
|
|
2
|
+
import { getLogger } from "@logtape/logtape";
|
|
3
|
+
import { SuperLineError } from "@super-line/core";
|
|
4
|
+
var log = getLogger(["super-line", "transport-cloudflare"]);
|
|
5
|
+
var CLOSE_NOT_RESUMABLE = 4409;
|
|
6
|
+
var SNAPSHOTS = "_sl_conn";
|
|
7
|
+
var encoder = new TextEncoder();
|
|
8
|
+
function cloudflareServerTransport(ctx) {
|
|
9
|
+
let hooks;
|
|
10
|
+
const conns = /* @__PURE__ */ new Map();
|
|
11
|
+
const sql = ctx.storage.sql;
|
|
12
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS ${SNAPSHOTS} (id TEXT PRIMARY KEY, snapshot TEXT NOT NULL)`);
|
|
13
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('{"t":"ping"}', '{"t":"pong"}'));
|
|
14
|
+
const forget = (ws, id) => {
|
|
15
|
+
conns.delete(ws);
|
|
16
|
+
sql.exec(`DELETE FROM ${SNAPSHOTS} WHERE id = ?`, id);
|
|
17
|
+
};
|
|
18
|
+
function adopt(ws, id) {
|
|
19
|
+
const conn = new DurableConn(ws, id, () => forget(ws, id));
|
|
20
|
+
conns.set(ws, conn);
|
|
21
|
+
return conn;
|
|
22
|
+
}
|
|
23
|
+
let busy;
|
|
24
|
+
async function fetch(request) {
|
|
25
|
+
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade", { status: 426 });
|
|
26
|
+
if (!hooks) return new Response("Not started", { status: 503 });
|
|
27
|
+
const handshake = buildHandshake(request);
|
|
28
|
+
const reserved = reservedFor(hooks.reserved ?? [], request, handshake);
|
|
29
|
+
let auth;
|
|
30
|
+
if (reserved) {
|
|
31
|
+
try {
|
|
32
|
+
auth = { role: reserved.role, ctx: await reserved.authenticate?.(handshake) ?? {} };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
log.warn("reserved connection rejected for role={role} {error}", { role: reserved.role, error: err });
|
|
35
|
+
return refuseAfterUpgrade(reserved.subprotocol, err);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
try {
|
|
39
|
+
auth = await hooks.authenticate(handshake);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
log.warn("authenticate threw \u2014 refusing the connection {error}", { error: err });
|
|
42
|
+
if (err instanceof SuperLineError && err.code === "UNAUTHORIZED") return refuseAfterUpgrade(void 0, err);
|
|
43
|
+
return new Response("Unauthorized", { status: 401 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const id = auth.connectionId ?? crypto.randomUUID();
|
|
47
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
48
|
+
ctx.acceptWebSocket(server, [id]);
|
|
49
|
+
server.serializeAttachment({ id });
|
|
50
|
+
hooks.onConnection(adopt(server, id), { ...auth, connectionId: id });
|
|
51
|
+
return new Response(null, {
|
|
52
|
+
status: 101,
|
|
53
|
+
webSocket: client,
|
|
54
|
+
...reserved?.subprotocol ? { headers: { "Sec-WebSocket-Protocol": reserved.subprotocol } } : {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function resume() {
|
|
58
|
+
const adoptConnection = hooks?.adoptConnection;
|
|
59
|
+
const live = /* @__PURE__ */ new Set();
|
|
60
|
+
for (const ws of ctx.getWebSockets()) {
|
|
61
|
+
const id = ws.deserializeAttachment()?.id;
|
|
62
|
+
const row = id ? sql.exec(`SELECT snapshot FROM ${SNAPSHOTS} WHERE id = ?`, id).toArray()[0] : void 0;
|
|
63
|
+
if (!id || !row || !adoptConnection) {
|
|
64
|
+
ws.close(CLOSE_NOT_RESUMABLE, "connection could not be resumed");
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
live.add(id);
|
|
68
|
+
try {
|
|
69
|
+
await adoptConnection(adopt(ws, id), JSON.parse(row.snapshot));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.error("resuming connection {id} failed {error}", { id, error: err });
|
|
72
|
+
conns.delete(ws);
|
|
73
|
+
ws.close(CLOSE_NOT_RESUMABLE, "connection could not be resumed");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const { id } of sql.exec(`SELECT id FROM ${SNAPSHOTS}`).toArray())
|
|
77
|
+
if (!live.has(id)) sql.exec(`DELETE FROM ${SNAPSHOTS} WHERE id = ?`, id);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
liveness: "socket",
|
|
81
|
+
start(h) {
|
|
82
|
+
hooks = h;
|
|
83
|
+
return Promise.resolve().then(resume);
|
|
84
|
+
},
|
|
85
|
+
stop() {
|
|
86
|
+
hooks = void 0;
|
|
87
|
+
},
|
|
88
|
+
onSnapshotChanged(id, snapshot) {
|
|
89
|
+
sql.exec(`INSERT OR REPLACE INTO ${SNAPSHOTS} (id, snapshot) VALUES (?, ?)`, id, JSON.stringify(snapshot));
|
|
90
|
+
},
|
|
91
|
+
onHold(count) {
|
|
92
|
+
if (count > 0 && busy === void 0) busy = setInterval(() => {
|
|
93
|
+
}, 3e4);
|
|
94
|
+
if (count === 0 && busy !== void 0) {
|
|
95
|
+
clearInterval(busy);
|
|
96
|
+
busy = void 0;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
fetch,
|
|
100
|
+
webSocketMessage(ws, message) {
|
|
101
|
+
const conn = conns.get(ws);
|
|
102
|
+
if (!conn) {
|
|
103
|
+
ws.close(CLOSE_NOT_RESUMABLE, "connection could not be resumed");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
conn.deliver(typeof message === "string" ? encoder.encode(message) : new Uint8Array(message));
|
|
107
|
+
},
|
|
108
|
+
webSocketClose(ws, code, reason) {
|
|
109
|
+
conns.get(ws)?.closed(code, reason);
|
|
110
|
+
},
|
|
111
|
+
webSocketError(ws) {
|
|
112
|
+
conns.get(ws)?.closed(1006);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
var DurableConn = class {
|
|
117
|
+
constructor(ws, id, onGone) {
|
|
118
|
+
this.ws = ws;
|
|
119
|
+
this.id = id;
|
|
120
|
+
this.onGone = onGone;
|
|
121
|
+
}
|
|
122
|
+
ws;
|
|
123
|
+
id;
|
|
124
|
+
onGone;
|
|
125
|
+
msgCb;
|
|
126
|
+
closeCb;
|
|
127
|
+
done = false;
|
|
128
|
+
get writable() {
|
|
129
|
+
return !this.done && this.ws.readyState === WebSocket.OPEN;
|
|
130
|
+
}
|
|
131
|
+
send(bytes) {
|
|
132
|
+
if (this.writable) this.ws.send(bytes);
|
|
133
|
+
}
|
|
134
|
+
onMessage(cb) {
|
|
135
|
+
this.msgCb = cb;
|
|
136
|
+
}
|
|
137
|
+
onClose(cb) {
|
|
138
|
+
this.closeCb = cb;
|
|
139
|
+
}
|
|
140
|
+
onDrain() {
|
|
141
|
+
}
|
|
142
|
+
// the platform exposes no send-buffer signal
|
|
143
|
+
close(code = 1e3, reason) {
|
|
144
|
+
if (this.done) return;
|
|
145
|
+
this.ws.close(code, reason);
|
|
146
|
+
this.closed(code, reason);
|
|
147
|
+
}
|
|
148
|
+
terminate() {
|
|
149
|
+
this.close(1011, "terminated");
|
|
150
|
+
}
|
|
151
|
+
deliver(bytes) {
|
|
152
|
+
if (!this.done) this.msgCb?.(bytes);
|
|
153
|
+
}
|
|
154
|
+
closed(code, reason) {
|
|
155
|
+
if (this.done) return;
|
|
156
|
+
this.done = true;
|
|
157
|
+
this.closeCb?.(code, reason);
|
|
158
|
+
this.onGone();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function buildHandshake(request) {
|
|
162
|
+
const url = new URL(request.url);
|
|
163
|
+
const query = {};
|
|
164
|
+
for (const [k, v] of url.searchParams) query[k] = v;
|
|
165
|
+
const headers = {};
|
|
166
|
+
request.headers.forEach((v, k) => {
|
|
167
|
+
headers[k] = v;
|
|
168
|
+
});
|
|
169
|
+
return { transport: "cloudflare", headers, query, raw: request };
|
|
170
|
+
}
|
|
171
|
+
function reservedFor(list, request, handshake) {
|
|
172
|
+
if (list.length === 0) return void 0;
|
|
173
|
+
const offered = (request.headers.get("Sec-WebSocket-Protocol") ?? "").split(",").map((p) => p.trim());
|
|
174
|
+
return list.find((rc) => rc.subprotocol && offered.includes(rc.subprotocol)) ?? list.find((rc) => rc.match?.(handshake));
|
|
175
|
+
}
|
|
176
|
+
function refuseAfterUpgrade(subprotocol, err) {
|
|
177
|
+
const [client, server] = Object.values(new WebSocketPair());
|
|
178
|
+
server.accept();
|
|
179
|
+
server.close(4401, truncateReason(err instanceof Error && err.message ? err.message : "unauthorized"));
|
|
180
|
+
return new Response(null, { status: 101, webSocket: client, ...subprotocol ? { headers: { "Sec-WebSocket-Protocol": subprotocol } } : {} });
|
|
181
|
+
}
|
|
182
|
+
function truncateReason(reason) {
|
|
183
|
+
const bytes = encoder.encode(reason);
|
|
184
|
+
return bytes.length <= 123 ? reason : new TextDecoder().decode(bytes.slice(0, 123)).replace(/�$/, "");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/node.ts
|
|
188
|
+
import { DurableObject } from "cloudflare:workers";
|
|
189
|
+
function createDurableNode(build) {
|
|
190
|
+
return class DurableNode extends DurableObject {
|
|
191
|
+
transport;
|
|
192
|
+
server;
|
|
193
|
+
constructor(ctx, env) {
|
|
194
|
+
super(ctx, env);
|
|
195
|
+
this.transport = cloudflareServerTransport(ctx);
|
|
196
|
+
this.server = build(ctx, env, { transports: [this.transport], nodeKey: ctx.id.toString() });
|
|
197
|
+
void ctx.blockConcurrencyWhile(() => this.server.ready);
|
|
198
|
+
}
|
|
199
|
+
fetch(request) {
|
|
200
|
+
return this.transport.fetch(request);
|
|
201
|
+
}
|
|
202
|
+
webSocketMessage(ws, message) {
|
|
203
|
+
this.transport.webSocketMessage(ws, message);
|
|
204
|
+
}
|
|
205
|
+
webSocketClose(ws, code, reason) {
|
|
206
|
+
this.transport.webSocketClose(ws, code, reason);
|
|
207
|
+
}
|
|
208
|
+
webSocketError(ws, error) {
|
|
209
|
+
this.transport.webSocketError(ws, error);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function routeToNode(request, namespace, opts = {}) {
|
|
214
|
+
return namespace.getByName(opts.name?.(request) ?? "super-line").fetch(request);
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
CLOSE_NOT_RESUMABLE,
|
|
218
|
+
cloudflareServerTransport,
|
|
219
|
+
createDurableNode,
|
|
220
|
+
routeToNode
|
|
221
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-line/transport-cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Cloudflare Durable Object transport for super-line: one Durable Object is one node, with hibernatable WebSockets that resume transparently on wake.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mert",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"super-line",
|
|
10
|
+
"transport",
|
|
11
|
+
"cloudflare",
|
|
12
|
+
"durable-objects",
|
|
13
|
+
"websocket",
|
|
14
|
+
"hibernation",
|
|
15
|
+
"realtime"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mertdogar/super-line.git",
|
|
20
|
+
"directory": "packages/transport-cloudflare"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://mertdogar.github.io/super-line/",
|
|
23
|
+
"bugs": "https://github.com/mertdogar/super-line/issues",
|
|
24
|
+
"module": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@logtape/logtape": "^2.2.4"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@super-line/core": ">=0.21.0 <1.0.0",
|
|
45
|
+
"@super-line/server": ">=0.22.0 <1.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@cloudflare/workers-types": "^5.20261013.1",
|
|
49
|
+
"@super-line/core": "^0.22.0",
|
|
50
|
+
"@super-line/server": "^0.23.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup"
|
|
54
|
+
}
|
|
6
55
|
}
|