@velajs/cloudflare 1.5.0 → 1.6.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +8 -8
  3. package/dist/cloudflare-application.d.ts +4 -0
  4. package/dist/cloudflare-application.js +6 -0
  5. package/dist/cloudflare-factory.js +9 -7
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +7 -0
  8. package/dist/modules/create-binding-module.js +19 -22
  9. package/dist/services/kv-cache.store.d.ts +19 -0
  10. package/dist/services/kv-cache.store.js +58 -0
  11. package/dist/storage/index.d.ts +7 -0
  12. package/dist/storage/index.js +6 -0
  13. package/dist/storage/r2-storage.driver.d.ts +19 -0
  14. package/dist/storage/r2-storage.driver.js +61 -0
  15. package/dist/storage/storage-manager.service.d.ts +16 -0
  16. package/dist/storage/storage-manager.service.js +56 -0
  17. package/dist/storage/storage.controller.d.ts +16 -0
  18. package/dist/storage/storage.controller.js +88 -0
  19. package/dist/storage/storage.module.d.ts +7 -0
  20. package/dist/storage/storage.module.js +37 -0
  21. package/dist/storage/storage.service.d.ts +20 -0
  22. package/dist/storage/storage.service.js +78 -0
  23. package/dist/storage/storage.tokens.d.ts +3 -0
  24. package/dist/storage/storage.tokens.js +2 -0
  25. package/dist/storage/storage.types.d.ts +17 -0
  26. package/dist/storage/storage.types.js +1 -0
  27. package/dist/websocket/broadcast.d.ts +15 -0
  28. package/dist/websocket/broadcast.js +26 -0
  29. package/dist/websocket/cf-room-registry.d.ts +23 -0
  30. package/dist/websocket/cf-room-registry.js +65 -0
  31. package/dist/websocket/cf-ws-client.d.ts +28 -0
  32. package/dist/websocket/cf-ws-client.js +83 -0
  33. package/dist/websocket/cloudflare-websocket.module.d.ts +11 -0
  34. package/dist/websocket/cloudflare-websocket.module.js +27 -0
  35. package/dist/websocket/do-bootstrap.d.ts +19 -0
  36. package/dist/websocket/do-bootstrap.js +43 -0
  37. package/dist/websocket/do-state.d.ts +25 -0
  38. package/dist/websocket/do-state.js +4 -0
  39. package/dist/websocket/do-websocket-host.d.ts +27 -0
  40. package/dist/websocket/do-websocket-host.js +67 -0
  41. package/dist/websocket/index.d.ts +13 -0
  42. package/dist/websocket/index.js +13 -0
  43. package/dist/websocket/room-id.d.ts +6 -0
  44. package/dist/websocket/room-id.js +12 -0
  45. package/dist/websocket/websocket-routing.d.ts +14 -0
  46. package/dist/websocket/websocket-routing.js +45 -0
  47. package/dist/websocket/websocket.durable-object.d.ts +15 -0
  48. package/dist/websocket/websocket.durable-object.js +72 -0
  49. package/dist/websocket/ws-server-holder.d.ts +16 -0
  50. package/dist/websocket/ws-server-holder.js +34 -0
  51. package/package.json +8 -17
@@ -0,0 +1,37 @@
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 { ConfigurableModuleBuilder, Module } from "@velajs/vela";
8
+ import { EnvModule } from "../modules/env.module.js";
9
+ import { StorageController } from "./storage.controller.js";
10
+ import { StorageManagerService } from "./storage-manager.service.js";
11
+ import { StorageService } from "./storage.service.js";
12
+ import { STORAGE_OPTIONS } from "./storage.tokens.js";
13
+ const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({
14
+ moduleName: 'Storage',
15
+ optionsInjectionToken: STORAGE_OPTIONS
16
+ }).build();
17
+ export class StorageModule extends ConfigurableModuleClass {
18
+ }
19
+ StorageModule = _ts_decorate([
20
+ Module({
21
+ imports: [
22
+ EnvModule.forRoot()
23
+ ],
24
+ providers: [
25
+ StorageManagerService,
26
+ StorageService
27
+ ],
28
+ controllers: [
29
+ StorageController
30
+ ],
31
+ exports: [
32
+ StorageService,
33
+ StorageManagerService,
34
+ STORAGE_OPTIONS
35
+ ]
36
+ })
37
+ ], StorageModule);
@@ -0,0 +1,20 @@
1
+ import { type DownloadResult, type PresignedUrlResult, type PresignMethod, type StorageBody, type UploadOptions, type UploadResult } from '@velajs/vela/storage';
2
+ import { StorageManagerService } from './storage-manager.service';
3
+ import type { StorageModuleOptions } from './storage.types';
4
+ /**
5
+ * Multi-disk storage facade. Applies each disk's (templated) root, resolves the
6
+ * driver, and validates presign expiry. Injectable anywhere via `StorageService`.
7
+ */
8
+ export declare class StorageService {
9
+ private readonly options;
10
+ private readonly manager;
11
+ constructor(options: StorageModuleOptions, manager: StorageManagerService);
12
+ put(relativePath: string, body: StorageBody, options?: UploadOptions, disk?: string): Promise<UploadResult>;
13
+ get(relativePath: string, disk?: string): Promise<DownloadResult>;
14
+ delete(relativePath: string, disk?: string): Promise<void>;
15
+ exists(relativePath: string, disk?: string): Promise<boolean>;
16
+ url(relativePath: string, method?: PresignMethod, expiresIn?: number, disk?: string): Promise<PresignedUrlResult>;
17
+ private resolveDisk;
18
+ private fullPath;
19
+ private validateExpiry;
20
+ }
@@ -0,0 +1,78 @@
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
+ function _ts_metadata(k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ }
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { Inject, Injectable } from "@velajs/vela";
16
+ import { joinStoragePath } from "@velajs/vela/storage";
17
+ import { StorageManagerService } from "./storage-manager.service.js";
18
+ import { STORAGE_OPTIONS } from "./storage.tokens.js";
19
+ const DEFAULT_PRESIGN = {
20
+ defaultExpiry: 3600,
21
+ maxExpiry: 86400
22
+ };
23
+ export class StorageService {
24
+ options;
25
+ manager;
26
+ constructor(options, manager){
27
+ this.options = options;
28
+ this.manager = manager;
29
+ }
30
+ put(relativePath, body, options = {}, disk) {
31
+ const name = this.resolveDisk(disk);
32
+ return this.manager.getDriver(name).upload(body, this.fullPath(relativePath, name), options);
33
+ }
34
+ get(relativePath, disk) {
35
+ const name = this.resolveDisk(disk);
36
+ return this.manager.getDriver(name).download(this.fullPath(relativePath, name));
37
+ }
38
+ delete(relativePath, disk) {
39
+ const name = this.resolveDisk(disk);
40
+ return this.manager.getDriver(name).delete(this.fullPath(relativePath, name));
41
+ }
42
+ exists(relativePath, disk) {
43
+ const name = this.resolveDisk(disk);
44
+ return this.manager.getDriver(name).exists(this.fullPath(relativePath, name));
45
+ }
46
+ url(relativePath, method = 'GET', expiresIn, disk) {
47
+ const name = this.resolveDisk(disk);
48
+ return this.manager.getDriver(name).getPresignedUrl(this.fullPath(relativePath, name), method, this.validateExpiry(expiresIn));
49
+ }
50
+ resolveDisk(disk) {
51
+ const name = disk ?? this.options.defaultDisk;
52
+ if (!this.manager.hasDisk(name)) throw new Error(`Storage disk "${name}" is not configured.`);
53
+ return name;
54
+ }
55
+ fullPath(relativePath, disk) {
56
+ return joinStoragePath(this.manager.getDiskConfig(disk).root, relativePath);
57
+ }
58
+ validateExpiry(expiresIn) {
59
+ const cfg = this.options.presignedUrl ?? DEFAULT_PRESIGN;
60
+ const value = expiresIn ?? cfg.defaultExpiry;
61
+ // `Number.isFinite` rejects NaN — otherwise `NaN < 1 || NaN > max` is false,
62
+ // NaN slips through, signUrl omits `expires`, and the URL never expires.
63
+ if (!Number.isFinite(value) || value < 1 || value > cfg.maxExpiry) {
64
+ throw new Error(`Presigned URL expiry ${value}s is out of range (1–${cfg.maxExpiry}s).`);
65
+ }
66
+ return value;
67
+ }
68
+ }
69
+ StorageService = _ts_decorate([
70
+ Injectable(),
71
+ _ts_param(0, Inject(STORAGE_OPTIONS)),
72
+ _ts_param(1, Inject(StorageManagerService)),
73
+ _ts_metadata("design:type", Function),
74
+ _ts_metadata("design:paramtypes", [
75
+ typeof StorageModuleOptions === "undefined" ? Object : StorageModuleOptions,
76
+ typeof StorageManagerService === "undefined" ? Object : StorageManagerService
77
+ ])
78
+ ], StorageService);
@@ -0,0 +1,3 @@
1
+ import { InjectionToken } from '@velajs/vela';
2
+ import type { StorageModuleOptions } from './storage.types';
3
+ export declare const STORAGE_OPTIONS: InjectionToken<StorageModuleOptions>;
@@ -0,0 +1,2 @@
1
+ import { InjectionToken } from "@velajs/vela";
2
+ export const STORAGE_OPTIONS = new InjectionToken('STORAGE_OPTIONS');
@@ -0,0 +1,17 @@
1
+ /** A named storage disk backed by an R2 bucket binding. */
2
+ export interface DiskConfig {
3
+ disk: string;
4
+ /** R2Bucket binding name from wrangler.toml. */
5
+ binding: string;
6
+ /** Optional root prefix; supports path-template tokens ({date}/{year}/…). */
7
+ root?: string;
8
+ }
9
+ export interface PresignedUrlConfig {
10
+ defaultExpiry: number;
11
+ maxExpiry: number;
12
+ }
13
+ export interface StorageModuleOptions {
14
+ disks: DiskConfig[];
15
+ defaultDisk: string;
16
+ presignedUrl?: PresignedUrlConfig;
17
+ }
@@ -0,0 +1 @@
1
+ /** A named storage disk backed by an R2 bucket binding. */ export { };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Push to a room from a Worker HTTP handler / cron / queue consumer (server-
3
+ * initiated emit). Resolves the room's Durable Object and calls its `broadcast`
4
+ * RPC method — the same canonical room→DO mapping the upgrade route uses, so it
5
+ * always reaches the DO holding those sockets.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // In a controller — ns from DurableObjectService.namespace
10
+ * await broadcastToRoom(ns, `org:${id}`, 'order.created', order);
11
+ * ```
12
+ */
13
+ export declare function broadcastToRoom(ns: DurableObjectNamespace, room: string, event: string, data?: unknown, options?: {
14
+ exceptIds?: string[];
15
+ }): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { roomToDurableId } from "./room-id.js";
2
+ /**
3
+ * Push to a room from a Worker HTTP handler / cron / queue consumer (server-
4
+ * initiated emit). Resolves the room's Durable Object and calls its `broadcast`
5
+ * RPC method — the same canonical room→DO mapping the upgrade route uses, so it
6
+ * always reaches the DO holding those sockets.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * // In a controller — ns from DurableObjectService.namespace
11
+ * await broadcastToRoom(ns, `org:${id}`, 'order.created', order);
12
+ * ```
13
+ */ export async function broadcastToRoom(ns, room, event, data, options) {
14
+ const cmd = {
15
+ rooms: [
16
+ room
17
+ ],
18
+ exceptIds: options?.exceptIds,
19
+ frame: JSON.stringify({
20
+ event,
21
+ data
22
+ })
23
+ };
24
+ const stub = ns.get(roomToDurableId(ns, room));
25
+ await stub.broadcast(cmd);
26
+ }
@@ -0,0 +1,23 @@
1
+ import type { BroadcastCommand, RoomRegistry, WsClient } from '@velajs/vela/websocket';
2
+ import type { DoStateLike, WsLike } from './do-state';
3
+ /**
4
+ * Core `RoomRegistry` backed by a Durable Object's hibernatable sockets. Room
5
+ * membership lives in tags (hub room, set at accept) + the attachment (dynamic
6
+ * `join()`), never in DO instance fields — so it survives hibernation with zero
7
+ * rehydration.
8
+ */
9
+ export declare class CfRoomRegistry implements RoomRegistry {
10
+ private readonly ctx;
11
+ constructor(ctx: DoStateLike);
12
+ register(_client: WsClient): void;
13
+ leaveAll(_client: WsClient): void;
14
+ join(client: WsClient, room: string): void | Promise<void>;
15
+ leave(client: WsClient, room: string): void | Promise<void>;
16
+ localIdsInRoom(room: string): string[];
17
+ deliverLocal(cmd: BroadcastCommand): void;
18
+ private collectRooms;
19
+ private socketsForRoom;
20
+ private attachmentOf;
21
+ /** Reconstruct a `WsClient` for a raw socket (e.g. inside gateway lifecycle scans). */
22
+ clientFor(ws: WsLike): WsClient;
23
+ }
@@ -0,0 +1,65 @@
1
+ import { CfWsClient } from "./cf-ws-client.js";
2
+ /**
3
+ * Core `RoomRegistry` backed by a Durable Object's hibernatable sockets. Room
4
+ * membership lives in tags (hub room, set at accept) + the attachment (dynamic
5
+ * `join()`), never in DO instance fields — so it survives hibernation with zero
6
+ * rehydration.
7
+ */ export class CfRoomRegistry {
8
+ ctx;
9
+ constructor(ctx){
10
+ this.ctx = ctx;
11
+ }
12
+ // CF sockets are registered with the DO by `acceptWebSocket`; nothing to track.
13
+ register(_client) {}
14
+ leaveAll(_client) {}
15
+ join(client, room) {
16
+ return client.join(room);
17
+ }
18
+ leave(client, room) {
19
+ return client.leave(room);
20
+ }
21
+ localIdsInRoom(room) {
22
+ return this.socketsForRoom(room).map((ws)=>this.attachmentOf(ws).connId);
23
+ }
24
+ deliverLocal(cmd) {
25
+ const excludeIds = new Set(cmd.exceptIds ?? []);
26
+ const excludeRooms = cmd.exceptRooms ?? [];
27
+ // Empty `rooms` => GLOBAL (every socket in this DO). Otherwise the union of
28
+ // targeted rooms, deduped per connection.
29
+ const targets = cmd.rooms.length === 0 ? this.ctx.getWebSockets() : this.collectRooms(cmd.rooms);
30
+ const seen = new Set();
31
+ for (const ws of targets){
32
+ const att = this.attachmentOf(ws);
33
+ if (seen.has(att.connId)) continue;
34
+ seen.add(att.connId);
35
+ if (excludeIds.has(att.connId)) continue;
36
+ if (excludeRooms.some((r)=>att.rooms.includes(r))) continue;
37
+ ws.send(cmd.frame);
38
+ }
39
+ }
40
+ collectRooms(rooms) {
41
+ const set = new Set();
42
+ for (const room of rooms)for (const ws of this.socketsForRoom(room))set.add(ws);
43
+ return [
44
+ ...set
45
+ ];
46
+ }
47
+ socketsForRoom(room) {
48
+ // Membership is the attachment's `rooms`, NOT the hibernation tag: tags are
49
+ // immutable after acceptWebSocket, so a socket that left its hub room still
50
+ // carries the tag. Since one DO ≈ one room, scanning all sockets in the DO
51
+ // and filtering by attachment is both correct and cheap.
52
+ return this.ctx.getWebSockets().filter((ws)=>this.attachmentOf(ws).rooms.includes(room));
53
+ }
54
+ attachmentOf(ws) {
55
+ return ws.deserializeAttachment() ?? {
56
+ connId: '',
57
+ path: '',
58
+ rooms: [],
59
+ data: {}
60
+ };
61
+ }
62
+ /** Reconstruct a `WsClient` for a raw socket (e.g. inside gateway lifecycle scans). */ clientFor(ws) {
63
+ return new CfWsClient(this.ctx, ws);
64
+ }
65
+ }
@@ -0,0 +1,28 @@
1
+ import type { WsClient } from '@velajs/vela/websocket';
2
+ import type { DoStateLike, WsLike } from './do-state';
3
+ /**
4
+ * Core `WsClient` over a native Cloudflare `WebSocket` inside a Durable Object.
5
+ * Per-connection state lives in the hibernation attachment (survives eviction),
6
+ * so a fresh `CfWsClient` is reconstructed per message with no in-memory state.
7
+ */
8
+ export declare class CfWsClient<TData extends Record<string, unknown> = Record<string, unknown>> implements WsClient<TData> {
9
+ private readonly ctx;
10
+ private readonly ws;
11
+ private readonly attachment;
12
+ constructor(ctx: DoStateLike, ws: WsLike);
13
+ get id(): string;
14
+ /** The gateway route path this socket connected on (used to route messages). */
15
+ get path(): string;
16
+ get rooms(): ReadonlySet<string>;
17
+ get data(): TData;
18
+ set data(value: TData);
19
+ get raw(): unknown;
20
+ send(event: string, data?: unknown, id?: string): void;
21
+ sendRaw(payload: string): void;
22
+ join(room: string): void;
23
+ leave(room: string): void;
24
+ /** Persist `data`/room mutations to the hibernation attachment. */
25
+ commit(): void;
26
+ close(code?: number, reason?: string): void;
27
+ private persist;
28
+ }
@@ -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,19 @@
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
+ close(signal?: string): Promise<void>;
11
+ }
12
+ /**
13
+ * Slim DI bootstrap for the Durable Object isolate: wires the container and runs
14
+ * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
15
+ * WITHOUT building the Hono app/routes the DO never serves. Cloudflare binding
16
+ * refs are initialized straight from the DO's `env`, and the ctx-backed server
17
+ * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
18
+ */
19
+ export declare function buildDoRuntime(rootModule: Type, ctx: DoStateLike, env: Record<string, unknown>): Promise<DoRuntime>;
@@ -0,0 +1,43 @@
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
+ return {
38
+ dispatcher: app.get(WsDispatcher),
39
+ registry,
40
+ server,
41
+ close: (signal)=>app.close(signal)
42
+ };
43
+ }
@@ -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,27 @@
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
+ constructor(ctx: DoStateLike, dispatcher: WsDispatcher, registry: CfRoomRegistry);
14
+ /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */
15
+ defaultPath(): string | undefined;
16
+ /**
17
+ * Accept a hibernatable socket: tag it with its hub room + connection id,
18
+ * persist the attachment, then fire `OnGatewayConnection` WITHOUT blocking the
19
+ * 101 response (the caller returns it immediately).
20
+ */
21
+ accept(ws: WsLike, path: string, roomId: string, userId?: string): void;
22
+ onMessage(ws: WsLike, message: string | ArrayBuffer): Promise<void>;
23
+ onClose(ws: WsLike, code: number, reason: string): Promise<void>;
24
+ onError(ws: WsLike, err: unknown): Promise<void>;
25
+ /** Deliver a broadcast command to this DO's local sockets (RPC entry point). */
26
+ broadcast(cmd: BroadcastCommand): void;
27
+ }
@@ -0,0 +1,67 @@
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
+ constructor(ctx, dispatcher, registry){
12
+ this.ctx = ctx;
13
+ this.dispatcher = dispatcher;
14
+ this.registry = registry;
15
+ }
16
+ /** The single registered gateway's path — a fallback when the Worker didn't forward `x-vela-path`. */ defaultPath() {
17
+ return this.dispatcher.gatewayPaths[0];
18
+ }
19
+ /**
20
+ * Accept a hibernatable socket: tag it with its hub room + connection id,
21
+ * persist the attachment, then fire `OnGatewayConnection` WITHOUT blocking the
22
+ * 101 response (the caller returns it immediately).
23
+ */ accept(ws, path, roomId, userId) {
24
+ const connId = crypto.randomUUID();
25
+ this.ctx.acceptWebSocket(ws, [
26
+ roomTag(roomId),
27
+ connTag(connId)
28
+ ]);
29
+ const attachment = {
30
+ connId,
31
+ userId,
32
+ path,
33
+ rooms: [
34
+ roomId
35
+ ],
36
+ data: userId ? {
37
+ userId
38
+ } : {}
39
+ };
40
+ ws.serializeAttachment(attachment);
41
+ void Promise.resolve(this.dispatcher.handleOpen(path, new CfWsClient(this.ctx, ws))).catch((err)=>console.warn('[vela] websocket handleConnection failed:', err));
42
+ }
43
+ async onMessage(ws, message) {
44
+ const client = new CfWsClient(this.ctx, ws);
45
+ await this.dispatcher.dispatchMessage(client.path, client, message);
46
+ }
47
+ async onClose(ws, code, reason) {
48
+ const client = new CfWsClient(this.ctx, ws);
49
+ await this.dispatcher.handleClose(client.path, client, code, reason);
50
+ // Complete the closing handshake. Only 1000 and 3000-4999 are valid for
51
+ // close(); reserved/abnormal codes (1005/1006/1015, etc.) throw a RangeError,
52
+ // so fall back to a codeless close on those.
53
+ try {
54
+ if (code === 1000 || code >= 3000 && code <= 4999) ws.close(code, reason);
55
+ else ws.close();
56
+ } catch {
57
+ // socket already closed
58
+ }
59
+ }
60
+ async onError(ws, err) {
61
+ const client = new CfWsClient(this.ctx, ws);
62
+ await this.dispatcher.handleError(client.path, client, err);
63
+ }
64
+ /** Deliver a broadcast command to this DO's local sockets (RPC entry point). */ broadcast(cmd) {
65
+ this.registry.deliverLocal(cmd);
66
+ }
67
+ }
@@ -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';