@velajs/cloudflare 1.5.0 → 1.8.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 +17 -0
- package/README.md +8 -8
- package/dist/cloudflare-application.d.ts +25 -5
- package/dist/cloudflare-application.js +79 -35
- package/dist/cloudflare-factory.d.ts +15 -1
- package/dist/cloudflare-factory.js +45 -24
- package/dist/decorators/queue-consumer.js +9 -1
- package/dist/decorators/scheduled.js +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/modules/create-binding-module.js +19 -22
- package/dist/services/kv-cache.store.d.ts +19 -0
- package/dist/services/kv-cache.store.js +58 -0
- package/dist/storage/index.d.ts +7 -0
- package/dist/storage/index.js +6 -0
- package/dist/storage/r2-storage.driver.d.ts +19 -0
- package/dist/storage/r2-storage.driver.js +61 -0
- package/dist/storage/storage-manager.service.d.ts +16 -0
- package/dist/storage/storage-manager.service.js +56 -0
- package/dist/storage/storage.controller.d.ts +16 -0
- package/dist/storage/storage.controller.js +88 -0
- package/dist/storage/storage.module.d.ts +7 -0
- package/dist/storage/storage.module.js +37 -0
- package/dist/storage/storage.service.d.ts +20 -0
- package/dist/storage/storage.service.js +78 -0
- package/dist/storage/storage.tokens.d.ts +3 -0
- package/dist/storage/storage.tokens.js +2 -0
- package/dist/storage/storage.types.d.ts +17 -0
- package/dist/storage/storage.types.js +1 -0
- package/dist/websocket/broadcast.d.ts +15 -0
- package/dist/websocket/broadcast.js +26 -0
- package/dist/websocket/cf-room-registry.d.ts +23 -0
- package/dist/websocket/cf-room-registry.js +65 -0
- package/dist/websocket/cf-ws-client.d.ts +28 -0
- package/dist/websocket/cf-ws-client.js +83 -0
- package/dist/websocket/cloudflare-websocket.module.d.ts +11 -0
- package/dist/websocket/cloudflare-websocket.module.js +27 -0
- package/dist/websocket/do-bootstrap.d.ts +21 -0
- package/dist/websocket/do-bootstrap.js +50 -0
- package/dist/websocket/do-state.d.ts +25 -0
- package/dist/websocket/do-state.js +4 -0
- package/dist/websocket/do-websocket-host.d.ts +28 -0
- package/dist/websocket/do-websocket-host.js +69 -0
- package/dist/websocket/index.d.ts +13 -0
- package/dist/websocket/index.js +13 -0
- package/dist/websocket/room-id.d.ts +6 -0
- package/dist/websocket/room-id.js +12 -0
- package/dist/websocket/websocket-routing.d.ts +14 -0
- package/dist/websocket/websocket-routing.js +45 -0
- package/dist/websocket/websocket.durable-object.d.ts +15 -0
- package/dist/websocket/websocket.durable-object.js +72 -0
- package/dist/websocket/ws-server-holder.d.ts +16 -0
- package/dist/websocket/ws-server-holder.js +34 -0
- package/package.json +8 -17
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const MAX_ATTACHMENT_BYTES = 16_384; // Cloudflare hibernation attachment limit (16 KiB).
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
const EMPTY = {
|
|
4
|
+
connId: '',
|
|
5
|
+
path: '',
|
|
6
|
+
rooms: [],
|
|
7
|
+
data: {}
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Core `WsClient` over a native Cloudflare `WebSocket` inside a Durable Object.
|
|
11
|
+
* Per-connection state lives in the hibernation attachment (survives eviction),
|
|
12
|
+
* so a fresh `CfWsClient` is reconstructed per message with no in-memory state.
|
|
13
|
+
*/ export class CfWsClient {
|
|
14
|
+
ctx;
|
|
15
|
+
ws;
|
|
16
|
+
attachment;
|
|
17
|
+
constructor(ctx, ws){
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.ws = ws;
|
|
20
|
+
const raw = ws.deserializeAttachment();
|
|
21
|
+
this.attachment = raw ?? {
|
|
22
|
+
...EMPTY,
|
|
23
|
+
data: {}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
get id() {
|
|
27
|
+
return this.attachment.connId;
|
|
28
|
+
}
|
|
29
|
+
/** The gateway route path this socket connected on (used to route messages). */ get path() {
|
|
30
|
+
return this.attachment.path;
|
|
31
|
+
}
|
|
32
|
+
get rooms() {
|
|
33
|
+
return new Set(this.attachment.rooms);
|
|
34
|
+
}
|
|
35
|
+
get data() {
|
|
36
|
+
return this.attachment.data;
|
|
37
|
+
}
|
|
38
|
+
set data(value) {
|
|
39
|
+
this.attachment.data = value;
|
|
40
|
+
}
|
|
41
|
+
get raw() {
|
|
42
|
+
return this.ws;
|
|
43
|
+
}
|
|
44
|
+
send(event, data, id) {
|
|
45
|
+
this.ws.send(JSON.stringify(id !== undefined ? {
|
|
46
|
+
id,
|
|
47
|
+
event,
|
|
48
|
+
data
|
|
49
|
+
} : {
|
|
50
|
+
event,
|
|
51
|
+
data
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
sendRaw(payload) {
|
|
55
|
+
this.ws.send(payload);
|
|
56
|
+
}
|
|
57
|
+
join(room) {
|
|
58
|
+
if (!this.attachment.rooms.includes(room)) {
|
|
59
|
+
this.attachment.rooms.push(room);
|
|
60
|
+
this.persist();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
leave(room) {
|
|
64
|
+
const next = this.attachment.rooms.filter((r)=>r !== room);
|
|
65
|
+
if (next.length !== this.attachment.rooms.length) {
|
|
66
|
+
this.attachment.rooms = next;
|
|
67
|
+
this.persist();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Persist `data`/room mutations to the hibernation attachment. */ commit() {
|
|
71
|
+
this.persist();
|
|
72
|
+
}
|
|
73
|
+
close(code, reason) {
|
|
74
|
+
this.ws.close(code, reason);
|
|
75
|
+
}
|
|
76
|
+
persist() {
|
|
77
|
+
const serialized = JSON.stringify(this.attachment);
|
|
78
|
+
if (encoder.encode(serialized).length > MAX_ATTACHMENT_BYTES) {
|
|
79
|
+
throw new Error(`WebSocket attachment exceeds the 16 KiB Cloudflare limit. Store large ` + `per-connection state in Durable Object storage keyed by connId instead.`);
|
|
80
|
+
}
|
|
81
|
+
this.ws.serializeAttachment(this.attachment);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DynamicModule } from '@velajs/vela';
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in
|
|
4
|
+
* your `AppModule` instead: it provides the gateway dispatcher plus a late-bound
|
|
5
|
+
* `WS_SERVER` (`WsServerHolder`) that the WebSocket Durable Object wires to a
|
|
6
|
+
* ctx-backed server per instance. `useClass` ensures a fresh holder per DI
|
|
7
|
+
* container so colocated DO instances never share a server.
|
|
8
|
+
*/
|
|
9
|
+
export declare class CloudflareWebSocketModule {
|
|
10
|
+
static forRoot(): DynamicModule;
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { WsDispatcher, WS_SERVER } from "@velajs/vela/websocket";
|
|
2
|
+
import { WsServerHolder } from "./ws-server-holder.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in
|
|
5
|
+
* your `AppModule` instead: it provides the gateway dispatcher plus a late-bound
|
|
6
|
+
* `WS_SERVER` (`WsServerHolder`) that the WebSocket Durable Object wires to a
|
|
7
|
+
* ctx-backed server per instance. `useClass` ensures a fresh holder per DI
|
|
8
|
+
* container so colocated DO instances never share a server.
|
|
9
|
+
*/ export class CloudflareWebSocketModule {
|
|
10
|
+
static forRoot() {
|
|
11
|
+
const providers = [
|
|
12
|
+
{
|
|
13
|
+
provide: WS_SERVER,
|
|
14
|
+
useClass: WsServerHolder
|
|
15
|
+
},
|
|
16
|
+
WsDispatcher
|
|
17
|
+
];
|
|
18
|
+
return {
|
|
19
|
+
module: CloudflareWebSocketModule,
|
|
20
|
+
providers,
|
|
21
|
+
exports: [
|
|
22
|
+
WS_SERVER,
|
|
23
|
+
WsDispatcher
|
|
24
|
+
]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Type } from '@velajs/vela';
|
|
2
|
+
import { WsDispatcher } from '@velajs/vela/websocket';
|
|
3
|
+
import type { WsServer } from '@velajs/vela/websocket';
|
|
4
|
+
import { CfRoomRegistry } from './cf-room-registry';
|
|
5
|
+
import type { DoStateLike } from './do-state';
|
|
6
|
+
export interface DoRuntime {
|
|
7
|
+
dispatcher: WsDispatcher;
|
|
8
|
+
registry: CfRoomRegistry;
|
|
9
|
+
server: WsServer;
|
|
10
|
+
/** Gateway paths from `app.entrypoints.ofKind('websocket')` (discovery order). */
|
|
11
|
+
gatewayPaths: string[];
|
|
12
|
+
close(signal?: string): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Slim DI bootstrap for the Durable Object isolate: wires the container and runs
|
|
16
|
+
* `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
|
|
17
|
+
* WITHOUT building the Hono app/routes the DO never serves. Cloudflare binding
|
|
18
|
+
* refs are initialized straight from the DO's `env`, and the ctx-backed server
|
|
19
|
+
* is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildDoRuntime(rootModule: Type, ctx: DoStateLike, env: Record<string, unknown>): Promise<DoRuntime>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { bootstrap, VelaApplication } from "@velajs/vela";
|
|
2
|
+
import { local, WsDispatcher, WsServerImpl, WS_SERVER } from "@velajs/vela/websocket";
|
|
3
|
+
import { BindingRef } from "../binding-ref.js";
|
|
4
|
+
import { EnvRef } from "../env-ref.js";
|
|
5
|
+
import { CfRoomRegistry } from "./cf-room-registry.js";
|
|
6
|
+
import { WsServerHolder } from "./ws-server-holder.js";
|
|
7
|
+
/**
|
|
8
|
+
* Slim DI bootstrap for the Durable Object isolate: wires the container and runs
|
|
9
|
+
* `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
|
|
10
|
+
* WITHOUT building the Hono app/routes the DO never serves. Cloudflare binding
|
|
11
|
+
* refs are initialized straight from the DO's `env`, and the ctx-backed server
|
|
12
|
+
* is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
|
|
13
|
+
*/ export async function buildDoRuntime(rootModule, ctx, env) {
|
|
14
|
+
const { container, routeManager, loader } = await bootstrap(rootModule);
|
|
15
|
+
// Init binding refs from env directly (no request middleware inside a DO).
|
|
16
|
+
// Enumerate per-instance useValue providers across ALL module buckets — two
|
|
17
|
+
// same-type binding modules share one token but live in distinct buckets, so
|
|
18
|
+
// resolving the token would return only the first and leave the rest uninitialized.
|
|
19
|
+
for (const value of container.getUseValues()){
|
|
20
|
+
if (value instanceof EnvRef) value._initialize(env);
|
|
21
|
+
else if (value instanceof BindingRef) value._initialize(env[value.bindingName]);
|
|
22
|
+
}
|
|
23
|
+
const registry = new CfRoomRegistry(ctx);
|
|
24
|
+
const driver = local();
|
|
25
|
+
driver.bind(registry);
|
|
26
|
+
const server = new WsServerImpl(driver);
|
|
27
|
+
try {
|
|
28
|
+
const holder = container.resolve(WS_SERVER);
|
|
29
|
+
if (holder instanceof WsServerHolder) holder.setTarget(server);
|
|
30
|
+
} catch {
|
|
31
|
+
// CloudflareWebSocketModule not imported — gateways won't have a server.
|
|
32
|
+
}
|
|
33
|
+
const app = new VelaApplication(container, routeManager);
|
|
34
|
+
app.setInstances(await loader.resolveAllInstances());
|
|
35
|
+
await app.callOnModuleInit();
|
|
36
|
+
await app.callOnApplicationBootstrap();
|
|
37
|
+
// The entrypoint registry is the transport contract: one 'websocket' entry
|
|
38
|
+
// per discovered gateway ({ meta: { path, dispatcher } }). Built by
|
|
39
|
+
// callOnApplicationBootstrap(), so this slim no-routes path has it too.
|
|
40
|
+
const wsEntrypoints = app.entrypoints.ofKind('websocket');
|
|
41
|
+
return {
|
|
42
|
+
// Zero gateways still yields a live dispatcher (module imported, nothing
|
|
43
|
+
// decorated) — fall back to resolving it directly.
|
|
44
|
+
dispatcher: wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher),
|
|
45
|
+
registry,
|
|
46
|
+
server,
|
|
47
|
+
gatewayPaths: wsEntrypoints.map((ep)=>ep.meta.path),
|
|
48
|
+
close: (signal)=>app.close(signal)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface WsLike {
|
|
2
|
+
send(message: string | ArrayBuffer): void;
|
|
3
|
+
close(code?: number, reason?: string): void;
|
|
4
|
+
serializeAttachment(value: unknown): void;
|
|
5
|
+
deserializeAttachment(): unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface DoStateLike {
|
|
8
|
+
readonly id: {
|
|
9
|
+
toString(): string;
|
|
10
|
+
readonly name?: string | null;
|
|
11
|
+
};
|
|
12
|
+
acceptWebSocket(ws: WsLike, tags?: string[]): void;
|
|
13
|
+
getWebSockets(tag?: string): WsLike[];
|
|
14
|
+
setWebSocketAutoResponse?(pair: unknown): void;
|
|
15
|
+
}
|
|
16
|
+
/** Per-connection metadata persisted in the hibernation attachment (≤ 16 KiB). */
|
|
17
|
+
export interface WsAttachment {
|
|
18
|
+
connId: string;
|
|
19
|
+
userId?: string;
|
|
20
|
+
/** The gateway route path this socket belongs to — used to route messages. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** Dynamically-joined room names (the hub room is also a hibernation tag). */
|
|
23
|
+
rooms: string[];
|
|
24
|
+
data: Record<string, unknown>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Minimal structural views of the Durable Object runtime, so the transport
|
|
2
|
+
// logic is unit-testable in Node with fakes. Real `DurableObjectState` and
|
|
3
|
+
// `WebSocket` (from @cloudflare/workers-types) satisfy these structurally.
|
|
4
|
+
/** Per-connection metadata persisted in the hibernation attachment (≤ 16 KiB). */ export { };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BroadcastCommand, WsDispatcher } from '@velajs/vela/websocket';
|
|
2
|
+
import type { CfRoomRegistry } from './cf-room-registry';
|
|
3
|
+
import type { DoStateLike, WsLike } from './do-state';
|
|
4
|
+
/**
|
|
5
|
+
* The socket lifecycle inside a WebSocket Durable Object, decoupled from the
|
|
6
|
+
* `cloudflare:workers` base class so it is unit-testable with fakes. The thin
|
|
7
|
+
* `VelaWebSocketDurableObject` shell forwards its hibernation callbacks here.
|
|
8
|
+
*/
|
|
9
|
+
export declare class DoWebSocketHost {
|
|
10
|
+
private readonly ctx;
|
|
11
|
+
private readonly dispatcher;
|
|
12
|
+
private readonly registry;
|
|
13
|
+
private readonly gatewayPaths;
|
|
14
|
+
constructor(ctx: DoStateLike, dispatcher: WsDispatcher, registry: CfRoomRegistry, gatewayPaths?: readonly string[]);
|
|
15
|
+
/** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */
|
|
16
|
+
defaultPath(): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Accept a hibernatable socket: tag it with its hub room + connection id,
|
|
19
|
+
* persist the attachment, then fire `OnGatewayConnection` WITHOUT blocking the
|
|
20
|
+
* 101 response (the caller returns it immediately).
|
|
21
|
+
*/
|
|
22
|
+
accept(ws: WsLike, path: string, roomId: string, userId?: string): void;
|
|
23
|
+
onMessage(ws: WsLike, message: string | ArrayBuffer): Promise<void>;
|
|
24
|
+
onClose(ws: WsLike, code: number, reason: string): Promise<void>;
|
|
25
|
+
onError(ws: WsLike, err: unknown): Promise<void>;
|
|
26
|
+
/** Deliver a broadcast command to this DO's local sockets (RPC entry point). */
|
|
27
|
+
broadcast(cmd: BroadcastCommand): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CfWsClient } from "./cf-ws-client.js";
|
|
2
|
+
import { connTag, roomTag } from "./room-id.js";
|
|
3
|
+
/**
|
|
4
|
+
* The socket lifecycle inside a WebSocket Durable Object, decoupled from the
|
|
5
|
+
* `cloudflare:workers` base class so it is unit-testable with fakes. The thin
|
|
6
|
+
* `VelaWebSocketDurableObject` shell forwards its hibernation callbacks here.
|
|
7
|
+
*/ export class DoWebSocketHost {
|
|
8
|
+
ctx;
|
|
9
|
+
dispatcher;
|
|
10
|
+
registry;
|
|
11
|
+
gatewayPaths;
|
|
12
|
+
constructor(ctx, dispatcher, registry, gatewayPaths = []){
|
|
13
|
+
this.ctx = ctx;
|
|
14
|
+
this.dispatcher = dispatcher;
|
|
15
|
+
this.registry = registry;
|
|
16
|
+
this.gatewayPaths = gatewayPaths;
|
|
17
|
+
}
|
|
18
|
+
/** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */ defaultPath() {
|
|
19
|
+
return this.gatewayPaths[0];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Accept a hibernatable socket: tag it with its hub room + connection id,
|
|
23
|
+
* persist the attachment, then fire `OnGatewayConnection` WITHOUT blocking the
|
|
24
|
+
* 101 response (the caller returns it immediately).
|
|
25
|
+
*/ accept(ws, path, roomId, userId) {
|
|
26
|
+
const connId = crypto.randomUUID();
|
|
27
|
+
this.ctx.acceptWebSocket(ws, [
|
|
28
|
+
roomTag(roomId),
|
|
29
|
+
connTag(connId)
|
|
30
|
+
]);
|
|
31
|
+
const attachment = {
|
|
32
|
+
connId,
|
|
33
|
+
userId,
|
|
34
|
+
path,
|
|
35
|
+
rooms: [
|
|
36
|
+
roomId
|
|
37
|
+
],
|
|
38
|
+
data: userId ? {
|
|
39
|
+
userId
|
|
40
|
+
} : {}
|
|
41
|
+
};
|
|
42
|
+
ws.serializeAttachment(attachment);
|
|
43
|
+
void Promise.resolve(this.dispatcher.handleOpen(path, new CfWsClient(this.ctx, ws))).catch((err)=>console.warn('[vela] websocket handleConnection failed:', err));
|
|
44
|
+
}
|
|
45
|
+
async onMessage(ws, message) {
|
|
46
|
+
const client = new CfWsClient(this.ctx, ws);
|
|
47
|
+
await this.dispatcher.dispatchMessage(client.path, client, message);
|
|
48
|
+
}
|
|
49
|
+
async onClose(ws, code, reason) {
|
|
50
|
+
const client = new CfWsClient(this.ctx, ws);
|
|
51
|
+
await this.dispatcher.handleClose(client.path, client, code, reason);
|
|
52
|
+
// Complete the closing handshake. Only 1000 and 3000-4999 are valid for
|
|
53
|
+
// close(); reserved/abnormal codes (1005/1006/1015, etc.) throw a RangeError,
|
|
54
|
+
// so fall back to a codeless close on those.
|
|
55
|
+
try {
|
|
56
|
+
if (code === 1000 || code >= 3000 && code <= 4999) ws.close(code, reason);
|
|
57
|
+
else ws.close();
|
|
58
|
+
} catch {
|
|
59
|
+
// socket already closed
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async onError(ws, err) {
|
|
63
|
+
const client = new CfWsClient(this.ctx, ws);
|
|
64
|
+
await this.dispatcher.handleError(client.path, client, err);
|
|
65
|
+
}
|
|
66
|
+
/** Deliver a broadcast command to this DO's local sockets (RPC entry point). */ broadcast(cmd) {
|
|
67
|
+
this.registry.deliverLocal(cmd);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { VelaWebSocketDurableObject } from './websocket.durable-object';
|
|
2
|
+
export { CloudflareWebSocketModule } from './cloudflare-websocket.module';
|
|
3
|
+
export { broadcastToRoom } from './broadcast';
|
|
4
|
+
export { CfWsClient } from './cf-ws-client';
|
|
5
|
+
export { CfRoomRegistry } from './cf-room-registry';
|
|
6
|
+
export { DoWebSocketHost } from './do-websocket-host';
|
|
7
|
+
export { WsServerHolder } from './ws-server-holder';
|
|
8
|
+
export { buildDoRuntime } from './do-bootstrap';
|
|
9
|
+
export type { DoRuntime } from './do-bootstrap';
|
|
10
|
+
export { registerWebSocketRoutes, collectWsGatewayRoutes } from './websocket-routing';
|
|
11
|
+
export type { WsGatewayRoute } from './websocket-routing';
|
|
12
|
+
export { roomTag, connTag, roomToDurableId } from './room-id';
|
|
13
|
+
export type { DoStateLike, WsLike, WsAttachment } from './do-state';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// DO base class (imports `cloudflare:workers` — only runs in workerd).
|
|
2
|
+
export { VelaWebSocketDurableObject } from "./websocket.durable-object.js";
|
|
3
|
+
// Module + server-initiated emit helper
|
|
4
|
+
export { CloudflareWebSocketModule } from "./cloudflare-websocket.module.js";
|
|
5
|
+
export { broadcastToRoom } from "./broadcast.js";
|
|
6
|
+
// Transport internals (advanced use / testing)
|
|
7
|
+
export { CfWsClient } from "./cf-ws-client.js";
|
|
8
|
+
export { CfRoomRegistry } from "./cf-room-registry.js";
|
|
9
|
+
export { DoWebSocketHost } from "./do-websocket-host.js";
|
|
10
|
+
export { WsServerHolder } from "./ws-server-holder.js";
|
|
11
|
+
export { buildDoRuntime } from "./do-bootstrap.js";
|
|
12
|
+
export { registerWebSocketRoutes, collectWsGatewayRoutes } from "./websocket-routing.js";
|
|
13
|
+
export { roomTag, connTag, roomToDurableId } from "./room-id.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Hibernation tag marking a socket's hub room (set at accept; immutable after). */
|
|
2
|
+
export declare function roomTag(roomId: string): string;
|
|
3
|
+
/** Hibernation tag addressing one connection directly. */
|
|
4
|
+
export declare function connTag(connId: string): string;
|
|
5
|
+
/** One Durable Object instance per room, addressed by name. */
|
|
6
|
+
export declare function roomToDurableId(ns: DurableObjectNamespace, roomId: string): DurableObjectId;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Canonical room ↔ Durable Object mappings. The SAME functions are used by the
|
|
2
|
+
// Worker upgrade route (to pick the DO) and by every server-initiated emit — so
|
|
3
|
+
// a connection and a later broadcast always resolve to the same DO instance.
|
|
4
|
+
/** Hibernation tag marking a socket's hub room (set at accept; immutable after). */ export function roomTag(roomId) {
|
|
5
|
+
return `room:${roomId}`;
|
|
6
|
+
}
|
|
7
|
+
/** Hibernation tag addressing one connection directly. */ export function connTag(connId) {
|
|
8
|
+
return `conn:${connId}`;
|
|
9
|
+
}
|
|
10
|
+
/** One Durable Object instance per room, addressed by name. */ export function roomToDurableId(ns, roomId) {
|
|
11
|
+
return ns.idFromName(roomId);
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
export interface WsGatewayRoute {
|
|
3
|
+
path: string;
|
|
4
|
+
binding: string;
|
|
5
|
+
}
|
|
6
|
+
/** Read `@WebSocketGateway({ path, binding })` off a resolved instance (CF-hosted gateways only). */
|
|
7
|
+
export declare function collectWsGatewayRoutes(instance: object): WsGatewayRoute[];
|
|
8
|
+
/**
|
|
9
|
+
* Registers the upgrade routes on the Worker's Hono app. Each route validates
|
|
10
|
+
* the `Upgrade` header, resolves the room's Durable Object, and forwards the raw
|
|
11
|
+
* request — injecting spoof-safe `x-vela-*` headers the DO reads. The DO returns
|
|
12
|
+
* the `101` with the client socket.
|
|
13
|
+
*/
|
|
14
|
+
export declare function registerWebSocketRoutes(hono: Hono, routes: WsGatewayRoute[]): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getMetadata } from "@velajs/vela";
|
|
2
|
+
import { WS_GATEWAY_METADATA } from "@velajs/vela/websocket";
|
|
3
|
+
import { roomToDurableId } from "./room-id.js";
|
|
4
|
+
/** Read `@WebSocketGateway({ path, binding })` off a resolved instance (CF-hosted gateways only). */ export function collectWsGatewayRoutes(instance) {
|
|
5
|
+
const options = getMetadata(WS_GATEWAY_METADATA, instance.constructor);
|
|
6
|
+
if (!options?.path || !options?.binding) return [];
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
path: options.path,
|
|
10
|
+
binding: options.binding
|
|
11
|
+
}
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Registers the upgrade routes on the Worker's Hono app. Each route validates
|
|
16
|
+
* the `Upgrade` header, resolves the room's Durable Object, and forwards the raw
|
|
17
|
+
* request — injecting spoof-safe `x-vela-*` headers the DO reads. The DO returns
|
|
18
|
+
* the `101` with the client socket.
|
|
19
|
+
*/ export function registerWebSocketRoutes(hono, routes) {
|
|
20
|
+
for (const route of routes){
|
|
21
|
+
hono.get(route.path, async (c)=>{
|
|
22
|
+
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
|
|
23
|
+
return c.text('Expected WebSocket upgrade', 426);
|
|
24
|
+
}
|
|
25
|
+
const ns = c.env[route.binding];
|
|
26
|
+
if (!ns) {
|
|
27
|
+
return c.text(`Durable Object binding '${route.binding}' is not configured`, 500);
|
|
28
|
+
}
|
|
29
|
+
const roomId = c.req.param('id') ?? route.path;
|
|
30
|
+
const stub = ns.get(roomToDurableId(ns, roomId));
|
|
31
|
+
// Strip any client-supplied x-vela-* (anti-spoof), then set server values.
|
|
32
|
+
const headers = new Headers(c.req.raw.headers);
|
|
33
|
+
headers.delete('x-vela-room');
|
|
34
|
+
headers.delete('x-vela-path');
|
|
35
|
+
headers.delete('x-vela-user');
|
|
36
|
+
headers.set('x-vela-room', roomId);
|
|
37
|
+
headers.set('x-vela-path', route.path);
|
|
38
|
+
const userId = c.get('userId');
|
|
39
|
+
if (userId) headers.set('x-vela-user', String(userId));
|
|
40
|
+
return stub.fetch(new Request(c.req.raw, {
|
|
41
|
+
headers
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import type { Type } from '@velajs/vela';
|
|
3
|
+
/**
|
|
4
|
+
* Base class for the WebSocket Durable Object. The user exports a named subclass
|
|
5
|
+
* (matching their `wrangler.toml` `class_name`) built from their `AppModule`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* export class ChatRoom extends VelaWebSocketDurableObject(AppModule) {}
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* It owns the raw hibernation socket lifecycle (Hono's `upgradeWebSocket` cannot
|
|
12
|
+
* bridge DO hibernation) and forwards every event into the runtime-agnostic
|
|
13
|
+
* `WsDispatcher` via {@link DoWebSocketHost}.
|
|
14
|
+
*/
|
|
15
|
+
export declare function VelaWebSocketDurableObject(rootModule: Type): new (ctx: DurableObjectState, env: Record<string, unknown>) => DurableObject<Record<string, unknown>>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { buildDoRuntime } from "./do-bootstrap.js";
|
|
3
|
+
import { DoWebSocketHost } from "./do-websocket-host.js";
|
|
4
|
+
const PING = '{"event":"ping"}';
|
|
5
|
+
const PONG = '{"event":"pong"}';
|
|
6
|
+
/**
|
|
7
|
+
* Base class for the WebSocket Durable Object. The user exports a named subclass
|
|
8
|
+
* (matching their `wrangler.toml` `class_name`) built from their `AppModule`:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export class ChatRoom extends VelaWebSocketDurableObject(AppModule) {}
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* It owns the raw hibernation socket lifecycle (Hono's `upgradeWebSocket` cannot
|
|
15
|
+
* bridge DO hibernation) and forwards every event into the runtime-agnostic
|
|
16
|
+
* `WsDispatcher` via {@link DoWebSocketHost}.
|
|
17
|
+
*/ export function VelaWebSocketDurableObject(rootModule) {
|
|
18
|
+
return class VelaWsDurableObject extends DurableObject {
|
|
19
|
+
host;
|
|
20
|
+
ready;
|
|
21
|
+
constructor(ctx, env){
|
|
22
|
+
super(ctx, env);
|
|
23
|
+
// Application-level ping/pong answered WITHOUT waking a hibernated DO.
|
|
24
|
+
try {
|
|
25
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
|
|
26
|
+
} catch {
|
|
27
|
+
// Older runtimes without auto-response — fine, protocol pings still work.
|
|
28
|
+
}
|
|
29
|
+
this.ready = ctx.blockConcurrencyWhile(async ()=>{
|
|
30
|
+
const runtime = await buildDoRuntime(rootModule, ctx, env);
|
|
31
|
+
this.host = new DoWebSocketHost(ctx, runtime.dispatcher, runtime.registry, runtime.gatewayPaths);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async fetch(request) {
|
|
35
|
+
await this.ready;
|
|
36
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
37
|
+
return new Response('Expected WebSocket upgrade', {
|
|
38
|
+
status: 426
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
// Headers are primary (carry auth + multi-gateway routing); fall back to the
|
|
44
|
+
// DO's own name (set via idFromName(room)) and the single gateway path so a
|
|
45
|
+
// plain `stub.fetch(request)` forward still works.
|
|
46
|
+
const roomId = request.headers.get('x-vela-room') ?? this.ctx.id.name ?? url.pathname;
|
|
47
|
+
const path = request.headers.get('x-vela-path') ?? this.host.defaultPath() ?? url.pathname;
|
|
48
|
+
const userId = request.headers.get('x-vela-user') || undefined;
|
|
49
|
+
this.host.accept(server, path, roomId, userId);
|
|
50
|
+
return new Response(null, {
|
|
51
|
+
status: 101,
|
|
52
|
+
webSocket: client
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async webSocketMessage(ws, message) {
|
|
56
|
+
await this.ready;
|
|
57
|
+
await this.host.onMessage(ws, message);
|
|
58
|
+
}
|
|
59
|
+
async webSocketClose(ws, code, reason) {
|
|
60
|
+
await this.ready;
|
|
61
|
+
await this.host.onClose(ws, code, reason);
|
|
62
|
+
}
|
|
63
|
+
async webSocketError(ws, error) {
|
|
64
|
+
await this.ready;
|
|
65
|
+
await this.host.onError(ws, error);
|
|
66
|
+
}
|
|
67
|
+
/** DO RPC — server-initiated broadcast forwarded from a Worker (see `broadcastToRoom`). */ async broadcast(cmd) {
|
|
68
|
+
await this.ready;
|
|
69
|
+
this.host.broadcast(cmd);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BroadcastOperator, WsServer } from '@velajs/vela/websocket';
|
|
2
|
+
/**
|
|
3
|
+
* Late-bound `WsServer`. Provided as `WS_SERVER` (one per DI container, i.e. per
|
|
4
|
+
* Durable Object instance), then pointed at the ctx-backed server once the DO
|
|
5
|
+
* builds. Gateways inject it via `@WebSocketServer()`; it throws if used before
|
|
6
|
+
* a runtime binds it (e.g. from the stateless Worker isolate).
|
|
7
|
+
*/
|
|
8
|
+
export declare class WsServerHolder implements WsServer {
|
|
9
|
+
private target?;
|
|
10
|
+
setTarget(server: WsServer): void;
|
|
11
|
+
private get resolved();
|
|
12
|
+
emit(event: string, data?: unknown): void | Promise<void>;
|
|
13
|
+
to(room: string): BroadcastOperator;
|
|
14
|
+
in(room: string): BroadcastOperator;
|
|
15
|
+
except(room: string): BroadcastOperator;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
}
|
|
7
|
+
import { Injectable } from "@velajs/vela";
|
|
8
|
+
export class WsServerHolder {
|
|
9
|
+
target;
|
|
10
|
+
setTarget(server) {
|
|
11
|
+
this.target = server;
|
|
12
|
+
}
|
|
13
|
+
get resolved() {
|
|
14
|
+
if (!this.target) {
|
|
15
|
+
throw new Error('WebSocket server is only available inside a WebSocket Durable Object. To ' + 'push from a Worker HTTP handler, use broadcastToRoom(namespace, room, ...).');
|
|
16
|
+
}
|
|
17
|
+
return this.target;
|
|
18
|
+
}
|
|
19
|
+
emit(event, data) {
|
|
20
|
+
return this.resolved.emit(event, data);
|
|
21
|
+
}
|
|
22
|
+
to(room) {
|
|
23
|
+
return this.resolved.to(room);
|
|
24
|
+
}
|
|
25
|
+
in(room) {
|
|
26
|
+
return this.resolved.in(room);
|
|
27
|
+
}
|
|
28
|
+
except(room) {
|
|
29
|
+
return this.resolved.except(room);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
WsServerHolder = _ts_decorate([
|
|
33
|
+
Injectable()
|
|
34
|
+
], WsServerHolder);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velajs/cloudflare",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Cloudflare Workers integration for Vela framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,13 +17,6 @@
|
|
|
17
17
|
"LICENSE",
|
|
18
18
|
"CHANGELOG.md"
|
|
19
19
|
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
|
|
22
|
-
"test": "vitest run",
|
|
23
|
-
"typecheck": "tsc --noEmit",
|
|
24
|
-
"prepare": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
|
|
25
|
-
"prepublishOnly": "pnpm run typecheck && pnpm test"
|
|
26
|
-
},
|
|
27
20
|
"sideEffects": false,
|
|
28
21
|
"keywords": [
|
|
29
22
|
"vela",
|
|
@@ -52,24 +45,22 @@
|
|
|
52
45
|
},
|
|
53
46
|
"peerDependencies": {
|
|
54
47
|
"@cloudflare/workers-types": ">=4",
|
|
55
|
-
"@velajs/vela": ">=1.
|
|
48
|
+
"@velajs/vela": ">=1.11.0",
|
|
56
49
|
"hono": ">=4"
|
|
57
50
|
},
|
|
58
51
|
"devDependencies": {
|
|
59
52
|
"@cloudflare/workers-types": "^4.20260624.1",
|
|
60
53
|
"@swc/cli": "^0.8.1",
|
|
61
54
|
"@swc/core": "^1.15.43",
|
|
62
|
-
"@velajs/vela": "^1.
|
|
55
|
+
"@velajs/vela": "^1.12.0",
|
|
63
56
|
"hono": "^4.12.27",
|
|
64
57
|
"typescript": "^6.0.3",
|
|
65
58
|
"unplugin-swc": "^1.5.9",
|
|
66
59
|
"vitest": "^4.1.9"
|
|
67
60
|
},
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
"esbuild"
|
|
73
|
-
]
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"typecheck": "tsc --noEmit"
|
|
74
65
|
}
|
|
75
|
-
}
|
|
66
|
+
}
|