@velajs/cloudflare 1.28.0 → 1.30.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 +113 -0
- package/README.md +190 -69
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js +8 -0
- package/dist/cloudflare-application.js +309 -0
- package/dist/cloudflare-application.js.map +1 -0
- package/dist/cloudflare-factory.js +109 -0
- package/dist/cloudflare-factory.js.map +1 -0
- package/dist/decorators/queue-consumer.js +39 -0
- package/dist/decorators/queue-consumer.js.map +1 -0
- package/dist/durable-objects.d.ts +11 -11
- package/dist/durable-objects.js +2 -512
- package/dist/environment.js +19 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +146 -111
- package/dist/index.js +21 -1130
- package/dist/nonce/durable-object-nonce.store.js +33 -0
- package/dist/nonce/durable-object-nonce.store.js.map +1 -0
- package/dist/nonce/nonce-validation.js +18 -0
- package/dist/nonce/nonce-validation.js.map +1 -0
- package/dist/nonce/nonce.durable-object.js +54 -0
- package/dist/nonce/nonce.durable-object.js.map +1 -0
- package/dist/{nonce.durable-object-DffHMRnU.d.ts → nonce.durable-object-Ci9AGssY.d.ts} +9 -7
- package/dist/queue/cloudflare-queues.js +215 -0
- package/dist/queue/cloudflare-queues.js.map +1 -0
- package/dist/queues.d.ts +48 -0
- package/dist/queues.js +2 -0
- package/dist/rate-limit/cloudflare-rate-limit.store.js +40 -0
- package/dist/rate-limit/cloudflare-rate-limit.store.js.map +1 -0
- package/dist/schedule-diagnostics.js +51 -0
- package/dist/schedule-diagnostics.js.map +1 -0
- package/dist/scheduled-event.js +68 -0
- package/dist/scheduled-event.js.map +1 -0
- package/dist/services/flagship-flag.driver.js +52 -0
- package/dist/services/flagship-flag.driver.js.map +1 -0
- package/dist/services/kv-cache.store.js +82 -0
- package/dist/services/kv-cache.store.js.map +1 -0
- package/dist/services/kv-flag.driver.js +62 -0
- package/dist/services/kv-flag.driver.js.map +1 -0
- package/dist/storage/r2-storage.driver.js +67 -0
- package/dist/storage/r2-storage.driver.js.map +1 -0
- package/dist/storage/storage-key-claim.js +74 -0
- package/dist/storage/storage-key-claim.js.map +1 -0
- package/dist/storage/storage-manager.service.js +38 -0
- package/dist/storage/storage-manager.service.js.map +1 -0
- package/dist/storage/storage.controller.js +64 -0
- package/dist/storage/storage.controller.js.map +1 -0
- package/dist/storage/storage.module.js +25 -0
- package/dist/storage/storage.module.js.map +1 -0
- package/dist/storage/storage.service.js +64 -0
- package/dist/storage/storage.service.js.map +1 -0
- package/dist/storage/storage.tokens.js +7 -0
- package/dist/storage/storage.tokens.js.map +1 -0
- package/dist/vela-env-DFvyoNT3.d.ts +9 -0
- package/dist/websocket/broadcast.js +32 -0
- package/dist/websocket/broadcast.js.map +1 -0
- package/dist/websocket/cf-room-registry.js +137 -0
- package/dist/websocket/cf-room-registry.js.map +1 -0
- package/dist/websocket/cf-ws-client.js +94 -0
- package/dist/websocket/cf-ws-client.js.map +1 -0
- package/dist/websocket/cloudflare-websocket.module.js +25 -0
- package/dist/websocket/cloudflare-websocket.module.js.map +1 -0
- package/dist/websocket/do-bootstrap.js +56 -0
- package/dist/websocket/do-bootstrap.js.map +1 -0
- package/dist/websocket/do-live.js +184 -0
- package/dist/websocket/do-live.js.map +1 -0
- package/dist/websocket/do-pitr.js +81 -0
- package/dist/websocket/do-pitr.js.map +1 -0
- package/dist/websocket/do-state.js +7 -0
- package/dist/websocket/do-state.js.map +1 -0
- package/dist/websocket/do-websocket-host.js +169 -0
- package/dist/websocket/do-websocket-host.js.map +1 -0
- package/dist/websocket/room-id.js +27 -0
- package/dist/websocket/room-id.js.map +1 -0
- package/dist/websocket/websocket-routing.js +133 -0
- package/dist/websocket/websocket-routing.js.map +1 -0
- package/dist/websocket/websocket.durable-object.js +138 -0
- package/dist/websocket/websocket.durable-object.js.map +1 -0
- package/dist/websocket/ws-attachment.js +79 -0
- package/dist/websocket/ws-attachment.js.map +1 -0
- package/dist/websocket/ws-server-holder.js +38 -0
- package/dist/websocket/ws-server-holder.js.map +1 -0
- package/package.json +14 -11
- package/dist/durable-objects.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nonce-validation-CeVihShV.js +0 -517
- package/dist/nonce-validation-CeVihShV.js.map +0 -1
- package/dist/queue.d.ts +0 -32
- package/dist/queue.js +0 -78
- package/dist/queue.js.map +0 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { STORAGE_OPTIONS } from "./storage.tokens.js";
|
|
2
|
+
import __decorateMetadata from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
|
|
3
|
+
import __decorateParam from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js";
|
|
4
|
+
import __decorate from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
|
|
5
|
+
import { StorageManagerService } from "./storage-manager.service.js";
|
|
6
|
+
import { Inject, Injectable } from "@velajs/vela";
|
|
7
|
+
import { joinStoragePath } from "@velajs/vela/storage";
|
|
8
|
+
//#region src/storage/storage.service.ts
|
|
9
|
+
const DEFAULT_PRESIGN = {
|
|
10
|
+
defaultExpiry: 3600,
|
|
11
|
+
maxExpiry: 86400
|
|
12
|
+
};
|
|
13
|
+
let StorageService = class StorageService {
|
|
14
|
+
options;
|
|
15
|
+
manager;
|
|
16
|
+
constructor(options, manager) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
this.manager = manager;
|
|
19
|
+
}
|
|
20
|
+
put(relativePath, body, options = {}, disk) {
|
|
21
|
+
const name = this.resolveDisk(disk);
|
|
22
|
+
return this.manager.getDriver(name).upload(body, this.fullPath(relativePath, name), options);
|
|
23
|
+
}
|
|
24
|
+
get(relativePath, disk) {
|
|
25
|
+
const name = this.resolveDisk(disk);
|
|
26
|
+
return this.manager.getDriver(name).download(this.fullPath(relativePath, name));
|
|
27
|
+
}
|
|
28
|
+
delete(relativePath, disk) {
|
|
29
|
+
const name = this.resolveDisk(disk);
|
|
30
|
+
return this.manager.getDriver(name).delete(this.fullPath(relativePath, name));
|
|
31
|
+
}
|
|
32
|
+
exists(relativePath, disk) {
|
|
33
|
+
const name = this.resolveDisk(disk);
|
|
34
|
+
return this.manager.getDriver(name).exists(this.fullPath(relativePath, name));
|
|
35
|
+
}
|
|
36
|
+
url(relativePath, method = "GET", expiresIn, disk) {
|
|
37
|
+
const name = this.resolveDisk(disk);
|
|
38
|
+
return this.manager.getDriver(name).getPresignedUrl(this.fullPath(relativePath, name), method, this.validateExpiry(expiresIn));
|
|
39
|
+
}
|
|
40
|
+
resolveDisk(disk) {
|
|
41
|
+
const name = disk ?? this.options.defaultDisk;
|
|
42
|
+
if (!this.manager.hasDisk(name)) throw new Error(`Storage disk "${name}" is not configured.`);
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
fullPath(relativePath, disk) {
|
|
46
|
+
return joinStoragePath(this.manager.getDiskConfig(disk).root, relativePath);
|
|
47
|
+
}
|
|
48
|
+
validateExpiry(expiresIn) {
|
|
49
|
+
const cfg = this.options.presignedUrl ?? DEFAULT_PRESIGN;
|
|
50
|
+
const value = expiresIn ?? cfg.defaultExpiry;
|
|
51
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > cfg.maxExpiry) throw new Error(`Presigned URL expiry ${value}s is out of range (1–${cfg.maxExpiry}s).`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
StorageService = __decorate([
|
|
56
|
+
Injectable(),
|
|
57
|
+
__decorateParam(0, Inject(STORAGE_OPTIONS)),
|
|
58
|
+
__decorateParam(1, Inject(StorageManagerService)),
|
|
59
|
+
__decorateMetadata("design:paramtypes", [Object, typeof StorageManagerService === "undefined" ? Object : StorageManagerService])
|
|
60
|
+
], StorageService);
|
|
61
|
+
//#endregion
|
|
62
|
+
export { StorageService };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=storage.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.service.js","names":[],"sources":["../../src/storage/storage.service.ts"],"sourcesContent":["import { Inject, Injectable } from '@velajs/vela';\nimport {\n joinStoragePath,\n type DownloadResult,\n type PresignedUrlResult,\n type PresignMethod,\n type StorageBody,\n type UploadOptions,\n type UploadResult,\n} from '@velajs/vela/storage';\nimport { StorageManagerService } from './storage-manager.service';\nimport { STORAGE_OPTIONS } from './storage.tokens';\nimport type { StorageModuleOptions } from './storage.types';\n\nconst DEFAULT_PRESIGN = { defaultExpiry: 3600, maxExpiry: 86400 };\n\n/**\n * Multi-disk storage facade. Applies each disk's (templated) root, resolves the\n * driver, and validates presign expiry. Injectable anywhere via `StorageService`.\n */\n@Injectable()\nexport class StorageService {\n constructor(\n @Inject(STORAGE_OPTIONS) private readonly options: StorageModuleOptions,\n @Inject(StorageManagerService) private readonly manager: StorageManagerService,\n ) {}\n\n put(\n relativePath: string,\n body: StorageBody,\n options: UploadOptions = {},\n disk?: string,\n ): Promise<UploadResult> {\n const name = this.resolveDisk(disk);\n return this.manager.getDriver(name).upload(body, this.fullPath(relativePath, name), options);\n }\n\n get(relativePath: string, disk?: string): Promise<DownloadResult> {\n const name = this.resolveDisk(disk);\n return this.manager.getDriver(name).download(this.fullPath(relativePath, name));\n }\n\n delete(relativePath: string, disk?: string): Promise<void> {\n const name = this.resolveDisk(disk);\n return this.manager.getDriver(name).delete(this.fullPath(relativePath, name));\n }\n\n exists(relativePath: string, disk?: string): Promise<boolean> {\n const name = this.resolveDisk(disk);\n return this.manager.getDriver(name).exists(this.fullPath(relativePath, name));\n }\n\n url(\n relativePath: string,\n method: PresignMethod = 'GET',\n expiresIn?: number,\n disk?: string,\n ): Promise<PresignedUrlResult> {\n const name = this.resolveDisk(disk);\n return this.manager\n .getDriver(name)\n .getPresignedUrl(this.fullPath(relativePath, name), method, this.validateExpiry(expiresIn));\n }\n\n private resolveDisk(disk?: string): string {\n const name = disk ?? this.options.defaultDisk;\n if (!this.manager.hasDisk(name)) throw new Error(`Storage disk \"${name}\" is not configured.`);\n return name;\n }\n\n private fullPath(relativePath: string, disk: string): string {\n return joinStoragePath(this.manager.getDiskConfig(disk).root, relativePath);\n }\n\n private validateExpiry(expiresIn?: number): number {\n const cfg = this.options.presignedUrl ?? DEFAULT_PRESIGN;\n const value = expiresIn ?? cfg.defaultExpiry;\n // `Number.isSafeInteger` rejects NaN/fractional/infinite values — otherwise\n // `NaN < 1 || NaN > max` is false,\n // NaN slips through, signUrl omits `expires`, and the URL never expires.\n if (!Number.isSafeInteger(value) || value < 1 || value > cfg.maxExpiry) {\n throw new Error(`Presigned URL expiry ${value}s is out of range (1–${cfg.maxExpiry}s).`);\n }\n return value;\n }\n}\n"],"mappings":";;;;;;;;AAcA,MAAM,kBAAkB;CAAE,eAAe;CAAM,WAAW;AAAM;AAOzD,IAAM,iBAAN,MAAM,eAAe;CAEkB;CACM;CAFlD,YACE,SACA,SACA;EAF0C,KAAA,UAAA;EACM,KAAA,UAAA;CAC/C;CAEH,IACE,cACA,MACA,UAAyB,CAAC,GAC1B,MACuB;EACvB,MAAM,OAAO,KAAK,YAAY,IAAI;EAClC,OAAO,KAAK,QAAQ,UAAU,IAAI,CAAC,CAAC,OAAO,MAAM,KAAK,SAAS,cAAc,IAAI,GAAG,OAAO;CAC7F;CAEA,IAAI,cAAsB,MAAwC;EAChE,MAAM,OAAO,KAAK,YAAY,IAAI;EAClC,OAAO,KAAK,QAAQ,UAAU,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,cAAc,IAAI,CAAC;CAChF;CAEA,OAAO,cAAsB,MAA8B;EACzD,MAAM,OAAO,KAAK,YAAY,IAAI;EAClC,OAAO,KAAK,QAAQ,UAAU,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS,cAAc,IAAI,CAAC;CAC9E;CAEA,OAAO,cAAsB,MAAiC;EAC5D,MAAM,OAAO,KAAK,YAAY,IAAI;EAClC,OAAO,KAAK,QAAQ,UAAU,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS,cAAc,IAAI,CAAC;CAC9E;CAEA,IACE,cACA,SAAwB,OACxB,WACA,MAC6B;EAC7B,MAAM,OAAO,KAAK,YAAY,IAAI;EAClC,OAAO,KAAK,QACT,UAAU,IAAI,CAAC,CACf,gBAAgB,KAAK,SAAS,cAAc,IAAI,GAAG,QAAQ,KAAK,eAAe,SAAS,CAAC;CAC9F;CAEA,YAAoB,MAAuB;EACzC,MAAM,OAAO,QAAQ,KAAK,QAAQ;EAClC,IAAI,CAAC,KAAK,QAAQ,QAAQ,IAAI,GAAG,MAAM,IAAI,MAAM,iBAAiB,KAAK,qBAAqB;EAC5F,OAAO;CACT;CAEA,SAAiB,cAAsB,MAAsB;EAC3D,OAAO,gBAAgB,KAAK,QAAQ,cAAc,IAAI,CAAC,CAAC,MAAM,YAAY;CAC5E;CAEA,eAAuB,WAA4B;EACjD,MAAM,MAAM,KAAK,QAAQ,gBAAgB;EACzC,MAAM,QAAQ,aAAa,IAAI;EAI/B,IAAI,CAAC,OAAO,cAAc,KAAK,KAAK,QAAQ,KAAK,QAAQ,IAAI,WAC3D,MAAM,IAAI,MAAM,wBAAwB,MAAM,uBAAuB,IAAI,UAAU,IAAI;EAEzF,OAAO;CACT;AACF;;CAjEC,WAAW;CAGP,gBAAA,GAAA,OAAO,eAAe,CAAA;CACtB,gBAAA,GAAA,OAAO,qBAAqB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.tokens.js","names":[],"sources":["../../src/storage/storage.tokens.ts"],"sourcesContent":["import { InjectionToken } from '@velajs/vela';\nimport type { StorageModuleOptions } from './storage.types';\n\nexport const STORAGE_OPTIONS = new InjectionToken<StorageModuleOptions>('STORAGE_OPTIONS');\n"],"mappings":";;AAGA,MAAa,kBAAkB,IAAI,eAAqC,iBAAiB"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/vela-env.d.ts
|
|
2
|
+
declare module '@velajs/vela' {
|
|
3
|
+
/**
|
|
4
|
+
* On Workers the framework ENV is the native environment: the bindings,
|
|
5
|
+
* variables and secrets `wrangler types` declares on `Cloudflare.Env`.
|
|
6
|
+
*/
|
|
7
|
+
interface VelaEnv extends Cloudflare.Env {}
|
|
8
|
+
}
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { roomToDurableId } from "./room-id.js";
|
|
2
|
+
import { DEFAULT_WS_MAX_FRAME_BYTES, assertBroadcastCommandFits, resolveMaxFrameBytes } from "@velajs/vela/websocket";
|
|
3
|
+
//#region src/websocket/broadcast.ts
|
|
4
|
+
/**
|
|
5
|
+
* Push to a room from a Worker HTTP handler / cron / queue consumer (server-
|
|
6
|
+
* initiated emit). Resolves the room's Durable Object and calls its `broadcast`
|
|
7
|
+
* RPC method — the same canonical room→DO mapping the upgrade route uses, so it
|
|
8
|
+
* always reaches the DO holding those sockets.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // In a controller — ns from the typed Worker environment
|
|
13
|
+
* await broadcastToRoom(ns, '/orgs/:orgId/ws', `org:${id}`, 'order.created', order);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
async function broadcastToRoom(ns, gatewayPath, room, event, data, options) {
|
|
17
|
+
const cmd = {
|
|
18
|
+
rooms: [room],
|
|
19
|
+
exceptIds: options?.exceptIds,
|
|
20
|
+
frame: JSON.stringify({
|
|
21
|
+
event,
|
|
22
|
+
data
|
|
23
|
+
})
|
|
24
|
+
};
|
|
25
|
+
const maxFrameBytes = resolveMaxFrameBytes({ maxFrameBytes: options?.maxFrameBytes ?? DEFAULT_WS_MAX_FRAME_BYTES });
|
|
26
|
+
assertBroadcastCommandFits(cmd, maxFrameBytes);
|
|
27
|
+
await ns.get(roomToDurableId(ns, gatewayPath, room)).broadcast(cmd);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { broadcastToRoom };
|
|
31
|
+
|
|
32
|
+
//# sourceMappingURL=broadcast.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"broadcast.js","names":[],"sources":["../../src/websocket/broadcast.ts"],"sourcesContent":["import {\n assertBroadcastCommandFits,\n DEFAULT_WS_MAX_FRAME_BYTES,\n resolveMaxFrameBytes,\n} from '@velajs/vela/websocket';\nimport type { BroadcastCommand } from '@velajs/vela/websocket';\nimport { roomToDurableId } from './room-id';\n\nexport interface WsBroadcastStub {\n broadcast(cmd: BroadcastCommand): Promise<void>;\n}\n\nexport interface BroadcastNamespace {\n idFromName(name: string): DurableObjectId;\n get(id: DurableObjectId): WsBroadcastStub;\n}\n\n/**\n * Push to a room from a Worker HTTP handler / cron / queue consumer (server-\n * initiated emit). Resolves the room's Durable Object and calls its `broadcast`\n * RPC method — the same canonical room→DO mapping the upgrade route uses, so it\n * always reaches the DO holding those sockets.\n *\n * @example\n * ```ts\n * // In a controller — ns from the typed Worker environment\n * await broadcastToRoom(ns, '/orgs/:orgId/ws', `org:${id}`, 'order.created', order);\n * ```\n */\nexport async function broadcastToRoom(\n ns: BroadcastNamespace,\n gatewayPath: string,\n room: string,\n event: string,\n data?: unknown,\n options?: { exceptIds?: string[]; maxFrameBytes?: number },\n): Promise<void> {\n const cmd: BroadcastCommand = {\n rooms: [room],\n exceptIds: options?.exceptIds,\n frame: JSON.stringify({ event, data }),\n };\n const maxFrameBytes = resolveMaxFrameBytes({\n maxFrameBytes: options?.maxFrameBytes ?? DEFAULT_WS_MAX_FRAME_BYTES,\n });\n assertBroadcastCommandFits(cmd, maxFrameBytes);\n const stub = ns.get(roomToDurableId(ns, gatewayPath, room));\n await stub.broadcast(cmd);\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,eAAsB,gBACpB,IACA,aACA,MACA,OACA,MACA,SACe;CACf,MAAM,MAAwB;EAC5B,OAAO,CAAC,IAAI;EACZ,WAAW,SAAS;EACpB,OAAO,KAAK,UAAU;GAAE;GAAO;EAAK,CAAC;CACvC;CACA,MAAM,gBAAgB,qBAAqB,EACzC,eAAe,SAAS,iBAAiB,2BAC3C,CAAC;CACD,2BAA2B,KAAK,aAAa;CAE7C,MADa,GAAG,IAAI,gBAAgB,IAAI,aAAa,IAAI,CAChD,CAAC,CAAC,UAAU,GAAG;AAC1B"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { rejectedAttachment, socketAttachment } from "./ws-attachment.js";
|
|
2
|
+
import { CfWsClient } from "./cf-ws-client.js";
|
|
3
|
+
import { WebSocketSendGate } from "@velajs/vela/websocket";
|
|
4
|
+
//#region src/websocket/cf-room-registry.ts
|
|
5
|
+
/**
|
|
6
|
+
* Core `RoomRegistry` backed by a Durable Object's hibernatable sockets. Room
|
|
7
|
+
* membership lives in tags (hub room, set at accept) + the attachment (dynamic
|
|
8
|
+
* `join()`), never in DO instance fields — so it survives hibernation with zero
|
|
9
|
+
* rehydration.
|
|
10
|
+
*/
|
|
11
|
+
var CfRoomRegistry = class {
|
|
12
|
+
ctx;
|
|
13
|
+
#sendGates = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#admitting = /* @__PURE__ */ new WeakSet();
|
|
15
|
+
#sendPolicyForPath;
|
|
16
|
+
setSendPolicyResolver(resolve) {
|
|
17
|
+
this.#sendPolicyForPath = resolve;
|
|
18
|
+
}
|
|
19
|
+
deliveryAuthorizer;
|
|
20
|
+
frameLimitForPath;
|
|
21
|
+
constructor(ctx) {
|
|
22
|
+
this.ctx = ctx;
|
|
23
|
+
}
|
|
24
|
+
setDeliveryAuthorizer(authorizer) {
|
|
25
|
+
this.deliveryAuthorizer = authorizer;
|
|
26
|
+
}
|
|
27
|
+
/** Reconcile pre-migration/woken attachments with authoritative gateway metadata. */
|
|
28
|
+
setFrameLimitResolver(resolver) {
|
|
29
|
+
this.frameLimitForPath = resolver;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run `admit` (the connection hook) with `ws` marked as being admitted, so a
|
|
33
|
+
* broadcast issued meanwhile (for example `server.emit()` announcing the new
|
|
34
|
+
* connection) skips the still-pending socket instead of rejecting it.
|
|
35
|
+
*/
|
|
36
|
+
async admitting(ws, admit) {
|
|
37
|
+
this.#admitting.add(ws);
|
|
38
|
+
try {
|
|
39
|
+
return await admit();
|
|
40
|
+
} finally {
|
|
41
|
+
this.#admitting.delete(ws);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
register(_client) {}
|
|
45
|
+
leaveAll(_client) {}
|
|
46
|
+
join(client, room) {
|
|
47
|
+
return client.join(room);
|
|
48
|
+
}
|
|
49
|
+
leave(client, room) {
|
|
50
|
+
return client.leave(room);
|
|
51
|
+
}
|
|
52
|
+
localIdsInRoom(room) {
|
|
53
|
+
return this.socketsForRoom(room).map((ws) => this.attachmentOf(ws).connId);
|
|
54
|
+
}
|
|
55
|
+
deliverLocal(cmd) {
|
|
56
|
+
const excludeIds = new Set(cmd.exceptIds ?? []);
|
|
57
|
+
const excludeRooms = cmd.exceptRooms ?? [];
|
|
58
|
+
const targets = cmd.rooms.length === 0 ? this.ctx.getWebSockets() : this.collectRooms(cmd.rooms);
|
|
59
|
+
const seen = /* @__PURE__ */ new Set();
|
|
60
|
+
const selected = [];
|
|
61
|
+
for (const ws of targets) {
|
|
62
|
+
const att = this.reconcileFrameLimit(ws, this.attachmentOf(ws));
|
|
63
|
+
if (att.state === "pending" && this.#admitting.has(ws)) continue;
|
|
64
|
+
if (att.state !== "active" || att.expiresAtMs !== void 0 && (!Number.isSafeInteger(att.expiresAtMs) || att.expiresAtMs <= Date.now())) {
|
|
65
|
+
try {
|
|
66
|
+
att.state = "rejected";
|
|
67
|
+
ws.serializeAttachment(att);
|
|
68
|
+
ws.close(1008, "identity expired or connection rejected");
|
|
69
|
+
} catch {}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (seen.has(att.connId)) continue;
|
|
73
|
+
seen.add(att.connId);
|
|
74
|
+
if (excludeIds.has(att.connId)) continue;
|
|
75
|
+
if (excludeRooms.some((r) => att.rooms.includes(r))) continue;
|
|
76
|
+
selected.push({
|
|
77
|
+
ws,
|
|
78
|
+
client: this.clientFor(ws)
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (!this.deliveryAuthorizer) {
|
|
82
|
+
for (const { client } of selected) client.sendRaw(cmd.frame);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
return Promise.all(selected.map(async ({ ws, client }) => {
|
|
86
|
+
let allowed = false;
|
|
87
|
+
try {
|
|
88
|
+
allowed = await this.deliveryAuthorizer(client) === true;
|
|
89
|
+
} catch {
|
|
90
|
+
allowed = false;
|
|
91
|
+
}
|
|
92
|
+
if (allowed) client.sendRaw(cmd.frame);
|
|
93
|
+
else this.reject(ws, "delivery authorization revoked");
|
|
94
|
+
})).then(() => void 0);
|
|
95
|
+
}
|
|
96
|
+
collectRooms(rooms) {
|
|
97
|
+
const set = /* @__PURE__ */ new Set();
|
|
98
|
+
for (const room of rooms) for (const ws of this.socketsForRoom(room)) set.add(ws);
|
|
99
|
+
return [...set];
|
|
100
|
+
}
|
|
101
|
+
socketsForRoom(room) {
|
|
102
|
+
return this.ctx.getWebSockets().filter((ws) => this.attachmentOf(ws).rooms.includes(room));
|
|
103
|
+
}
|
|
104
|
+
attachmentOf(ws) {
|
|
105
|
+
return socketAttachment(ws) ?? rejectedAttachment();
|
|
106
|
+
}
|
|
107
|
+
/** Reconstruct a `WsClient` for a raw socket (e.g. inside gateway lifecycle scans). */
|
|
108
|
+
clientFor(ws) {
|
|
109
|
+
this.reconcileFrameLimit(ws, this.attachmentOf(ws));
|
|
110
|
+
let gate = this.#sendGates.get(ws);
|
|
111
|
+
if (!gate) {
|
|
112
|
+
gate = new WebSocketSendGate(this.#sendPolicyForPath?.(this.attachmentOf(ws).path));
|
|
113
|
+
this.#sendGates.set(ws, gate);
|
|
114
|
+
}
|
|
115
|
+
return new CfWsClient(this.ctx, ws, gate);
|
|
116
|
+
}
|
|
117
|
+
reconcileFrameLimit(ws, attachment) {
|
|
118
|
+
const expected = this.frameLimitForPath?.(attachment.path);
|
|
119
|
+
if (expected !== void 0 && attachment.maxFrameBytes !== expected) {
|
|
120
|
+
attachment.maxFrameBytes = expected;
|
|
121
|
+
ws.serializeAttachment(attachment);
|
|
122
|
+
}
|
|
123
|
+
return attachment;
|
|
124
|
+
}
|
|
125
|
+
reject(ws, reason) {
|
|
126
|
+
try {
|
|
127
|
+
const attachment = this.attachmentOf(ws);
|
|
128
|
+
attachment.state = "rejected";
|
|
129
|
+
ws.serializeAttachment(attachment);
|
|
130
|
+
ws.close(1008, reason);
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
export { CfRoomRegistry };
|
|
136
|
+
|
|
137
|
+
//# sourceMappingURL=cf-room-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cf-room-registry.js","names":[],"sources":["../../src/websocket/cf-room-registry.ts"],"sourcesContent":["import { WebSocketSendGate, type WebSocketSendPolicy } from '@velajs/vela/websocket';\nimport { socketAttachment, rejectedAttachment } from './ws-attachment';\nimport type { BroadcastCommand, RoomRegistry, WsClient } from '@velajs/vela/websocket';\nimport type { DoStateLike, WsAttachment, WsLike } from './do-state';\nimport { CfWsClient } from './cf-ws-client';\n\n/**\n * Core `RoomRegistry` backed by a Durable Object's hibernatable sockets. Room\n * membership lives in tags (hub room, set at accept) + the attachment (dynamic\n * `join()`), never in DO instance fields — so it survives hibernation with zero\n * rehydration.\n */\nexport class CfRoomRegistry implements RoomRegistry {\n readonly #sendGates = new WeakMap<WsLike, WebSocketSendGate>();\n // Sockets whose OnGatewayConnection hook is running in this instance. They\n // are still `pending`, so broadcasts skip them; any other pending socket\n // never finished admission and is closed when a broadcast reaches it.\n readonly #admitting = new WeakSet<WsLike>();\n #sendPolicyForPath?: (path: string) => WebSocketSendPolicy | undefined;\n\n setSendPolicyResolver(resolve: (path: string) => WebSocketSendPolicy | undefined): void {\n this.#sendPolicyForPath = resolve;\n }\n\n private deliveryAuthorizer?: (client: WsClient) => boolean | Promise<boolean>;\n private frameLimitForPath?: (path: string) => number | undefined;\n\n constructor(private readonly ctx: DoStateLike) {}\n\n setDeliveryAuthorizer(authorizer: (client: WsClient) => boolean | Promise<boolean>): void {\n this.deliveryAuthorizer = authorizer;\n }\n\n /** Reconcile pre-migration/woken attachments with authoritative gateway metadata. */\n setFrameLimitResolver(resolver: (path: string) => number | undefined): void {\n this.frameLimitForPath = resolver;\n }\n\n /**\n * Run `admit` (the connection hook) with `ws` marked as being admitted, so a\n * broadcast issued meanwhile (for example `server.emit()` announcing the new\n * connection) skips the still-pending socket instead of rejecting it.\n */\n async admitting<T>(ws: WsLike, admit: () => Promise<T>): Promise<T> {\n this.#admitting.add(ws);\n try {\n return await admit();\n } finally {\n this.#admitting.delete(ws);\n }\n }\n\n // CF sockets are registered with the DO by `acceptWebSocket`; nothing to track.\n register(_client: WsClient): void {}\n leaveAll(_client: WsClient): void {}\n\n join(client: WsClient, room: string): void | Promise<void> {\n return client.join(room);\n }\n\n leave(client: WsClient, room: string): void | Promise<void> {\n return client.leave(room);\n }\n\n localIdsInRoom(room: string): string[] {\n return this.socketsForRoom(room).map((ws) => this.attachmentOf(ws).connId);\n }\n\n deliverLocal(cmd: BroadcastCommand): void | Promise<void> {\n const excludeIds = new Set(cmd.exceptIds ?? []);\n const excludeRooms = cmd.exceptRooms ?? [];\n\n // Empty `rooms` => GLOBAL (every socket in this DO). Otherwise the union of\n // targeted rooms, deduped per connection.\n const targets =\n cmd.rooms.length === 0 ? this.ctx.getWebSockets() : this.collectRooms(cmd.rooms);\n\n const seen = new Set<string>();\n const selected: Array<{ ws: WsLike; client: WsClient }> = [];\n for (const ws of targets) {\n const att = this.reconcileFrameLimit(ws, this.attachmentOf(ws));\n // A socket still in its connection hook is not admitted yet: it receives no\n // room frames, and its own accept() settles it. Rejecting it here would\n // close every client whose handleConnection broadcasts to the room. Other\n // pending sockets fall through and are rejected below.\n if (att.state === 'pending' && this.#admitting.has(ws)) continue;\n if (\n att.state !== 'active' ||\n (att.expiresAtMs !== undefined &&\n (!Number.isSafeInteger(att.expiresAtMs) || att.expiresAtMs <= Date.now()))\n ) {\n try {\n att.state = 'rejected';\n ws.serializeAttachment(att);\n ws.close(1008, 'identity expired or connection rejected');\n } catch {\n // already closed or malformed attachment\n }\n continue;\n }\n if (seen.has(att.connId)) continue;\n seen.add(att.connId);\n if (excludeIds.has(att.connId)) continue;\n if (excludeRooms.some((r) => att.rooms.includes(r))) continue;\n selected.push({ ws, client: this.clientFor(ws) });\n }\n\n if (!this.deliveryAuthorizer) {\n for (const { client } of selected) client.sendRaw(cmd.frame);\n return;\n }\n return Promise.all(\n selected.map(async ({ ws, client }) => {\n let allowed = false;\n try {\n allowed = (await this.deliveryAuthorizer!(client)) === true;\n } catch {\n allowed = false;\n }\n if (allowed) client.sendRaw(cmd.frame);\n else this.reject(ws, 'delivery authorization revoked');\n }),\n ).then(() => undefined);\n }\n\n private collectRooms(rooms: string[]): WsLike[] {\n const set = new Set<WsLike>();\n for (const room of rooms) for (const ws of this.socketsForRoom(room)) set.add(ws);\n return [...set];\n }\n\n private socketsForRoom(room: string): WsLike[] {\n // Membership is the attachment's `rooms`, NOT the hibernation tag: tags are\n // immutable after acceptWebSocket, so a socket that left its hub room still\n // carries the tag. Since one DO ≈ one room, scanning all sockets in the DO\n // and filtering by attachment is both correct and cheap.\n return this.ctx.getWebSockets().filter((ws) => this.attachmentOf(ws).rooms.includes(room));\n }\n\n private attachmentOf(ws: WsLike): WsAttachment {\n return socketAttachment(ws) ?? rejectedAttachment();\n }\n\n /** Reconstruct a `WsClient` for a raw socket (e.g. inside gateway lifecycle scans). */\n clientFor(ws: WsLike): CfWsClient {\n this.reconcileFrameLimit(ws, this.attachmentOf(ws));\n let gate = this.#sendGates.get(ws);\n if (!gate) {\n gate = new WebSocketSendGate(this.#sendPolicyForPath?.(this.attachmentOf(ws).path));\n this.#sendGates.set(ws, gate);\n }\n return new CfWsClient(this.ctx, ws, gate);\n }\n\n private reconcileFrameLimit(ws: WsLike, attachment: WsAttachment): WsAttachment {\n const expected = this.frameLimitForPath?.(attachment.path);\n if (expected !== undefined && attachment.maxFrameBytes !== expected) {\n attachment.maxFrameBytes = expected;\n ws.serializeAttachment(attachment);\n }\n return attachment;\n }\n\n private reject(ws: WsLike, reason: string): void {\n try {\n const attachment = this.attachmentOf(ws);\n attachment.state = 'rejected';\n ws.serializeAttachment(attachment);\n ws.close(1008, reason);\n } catch {\n // already closed or malformed attachment\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { rejectedAttachment, socketAttachment } from "./ws-attachment.js";
|
|
2
|
+
import { MAX_WS_ATTACHMENT_BYTES } from "./do-state.js";
|
|
3
|
+
import { DEFAULT_WS_MAX_FRAME_BYTES, DEFAULT_WS_MAX_JOINED_ROOMS, WebSocketSendGate, assertWebSocketRoomId } from "@velajs/vela/websocket";
|
|
4
|
+
//#region src/websocket/cf-ws-client.ts
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
/**
|
|
7
|
+
* Core `WsClient` over a native Cloudflare `WebSocket` inside a Durable Object.
|
|
8
|
+
* Per-connection state lives in the hibernation attachment (survives eviction),
|
|
9
|
+
* so a fresh `CfWsClient` is reconstructed per message with no in-memory state.
|
|
10
|
+
*/
|
|
11
|
+
var CfWsClient = class {
|
|
12
|
+
ctx;
|
|
13
|
+
ws;
|
|
14
|
+
sendGate;
|
|
15
|
+
#attachment;
|
|
16
|
+
constructor(ctx, ws, sendGate = new WebSocketSendGate()) {
|
|
17
|
+
this.ctx = ctx;
|
|
18
|
+
this.ws = ws;
|
|
19
|
+
this.sendGate = sendGate;
|
|
20
|
+
this.#attachment = socketAttachment(ws) ?? rejectedAttachment();
|
|
21
|
+
}
|
|
22
|
+
get id() {
|
|
23
|
+
return this.#attachment.connId;
|
|
24
|
+
}
|
|
25
|
+
/** The gateway route path this socket connected on (used to route messages). */
|
|
26
|
+
get path() {
|
|
27
|
+
return this.#attachment.path;
|
|
28
|
+
}
|
|
29
|
+
get rooms() {
|
|
30
|
+
return new Set(this.#attachment.rooms);
|
|
31
|
+
}
|
|
32
|
+
get data() {
|
|
33
|
+
return this.#attachment.data;
|
|
34
|
+
}
|
|
35
|
+
set data(value) {
|
|
36
|
+
this.#attachment.data = value;
|
|
37
|
+
}
|
|
38
|
+
get raw() {
|
|
39
|
+
return this.ws;
|
|
40
|
+
}
|
|
41
|
+
get maxFrameBytes() {
|
|
42
|
+
const value = this.#attachment.maxFrameBytes;
|
|
43
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_WS_MAX_FRAME_BYTES;
|
|
44
|
+
}
|
|
45
|
+
send(event, data, id) {
|
|
46
|
+
this.sendRaw(JSON.stringify(id !== void 0 ? {
|
|
47
|
+
id,
|
|
48
|
+
event,
|
|
49
|
+
data
|
|
50
|
+
} : {
|
|
51
|
+
event,
|
|
52
|
+
data
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
sendRaw(payload) {
|
|
56
|
+
this.trySendRaw(payload);
|
|
57
|
+
}
|
|
58
|
+
trySendRaw(payload) {
|
|
59
|
+
if (this.#attachment.state === "rejected") return "closed";
|
|
60
|
+
return this.sendGate.trySend(this.ws, payload, this.maxFrameBytes);
|
|
61
|
+
}
|
|
62
|
+
join(room) {
|
|
63
|
+
assertWebSocketRoomId(room);
|
|
64
|
+
if (!this.#attachment.rooms.includes(room)) {
|
|
65
|
+
if (this.#attachment.rooms.length >= DEFAULT_WS_MAX_JOINED_ROOMS) throw new Error(`A WebSocket may join at most ${DEFAULT_WS_MAX_JOINED_ROOMS} rooms`);
|
|
66
|
+
this.#attachment.rooms.push(room);
|
|
67
|
+
this.persist();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
leave(room) {
|
|
71
|
+
const next = this.#attachment.rooms.filter((r) => r !== room);
|
|
72
|
+
if (next.length !== this.#attachment.rooms.length) {
|
|
73
|
+
this.#attachment.rooms = next;
|
|
74
|
+
this.persist();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Persist `data`/room mutations to the hibernation attachment. */
|
|
78
|
+
commit() {
|
|
79
|
+
this.persist();
|
|
80
|
+
}
|
|
81
|
+
close(code, reason) {
|
|
82
|
+
this.ws.close(code, reason);
|
|
83
|
+
}
|
|
84
|
+
persist() {
|
|
85
|
+
if (this.#attachment.state === "rejected") throw new Error("Invalid WebSocket attachment");
|
|
86
|
+
const serialized = JSON.stringify(this.#attachment);
|
|
87
|
+
if (encoder.encode(serialized).length > MAX_WS_ATTACHMENT_BYTES) throw new Error("WebSocket attachment exceeds the 16 KiB Cloudflare limit. Store large per-connection state in Durable Object storage keyed by connId instead.");
|
|
88
|
+
this.ws.serializeAttachment(this.#attachment);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
export { CfWsClient };
|
|
93
|
+
|
|
94
|
+
//# sourceMappingURL=cf-ws-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cf-ws-client.js","names":[],"sources":["../../src/websocket/cf-ws-client.ts"],"sourcesContent":["import { socketAttachment, rejectedAttachment } from './ws-attachment';\nimport type { WebSocketSendResult, WsClient } from '@velajs/vela/websocket';\nimport {\n assertWebSocketRoomId,\n DEFAULT_WS_MAX_FRAME_BYTES,\n DEFAULT_WS_MAX_JOINED_ROOMS,\n WebSocketSendGate,\n} from '@velajs/vela/websocket';\nimport {\n MAX_WS_ATTACHMENT_BYTES,\n type DoStateLike,\n type WsAttachment,\n type WsLike,\n} from './do-state';\nconst encoder = new TextEncoder();\n\n/**\n * Core `WsClient` over a native Cloudflare `WebSocket` inside a Durable Object.\n * Per-connection state lives in the hibernation attachment (survives eviction),\n * so a fresh `CfWsClient` is reconstructed per message with no in-memory state.\n */\nexport class CfWsClient<\n TData extends Record<string, unknown> = Record<string, unknown>,\n> implements WsClient<TData> {\n readonly #attachment: WsAttachment;\n\n constructor(\n private readonly ctx: DoStateLike,\n private readonly ws: WsLike,\n private readonly sendGate = new WebSocketSendGate(),\n ) {\n this.#attachment = socketAttachment(ws) ?? rejectedAttachment();\n }\n\n get id(): string {\n return this.#attachment.connId;\n }\n\n /** The gateway route path this socket connected on (used to route messages). */\n get path(): string {\n return this.#attachment.path;\n }\n\n get rooms(): ReadonlySet<string> {\n return new Set(this.#attachment.rooms);\n }\n\n get data(): TData {\n return this.#attachment.data as TData;\n }\n\n set data(value: TData) {\n this.#attachment.data = value;\n }\n\n get raw(): unknown {\n return this.ws;\n }\n\n get maxFrameBytes(): number {\n const value = this.#attachment.maxFrameBytes;\n return typeof value === 'number' && Number.isSafeInteger(value) && value > 0\n ? value\n : DEFAULT_WS_MAX_FRAME_BYTES;\n }\n\n send(event: string, data?: unknown, id?: string): void {\n this.sendRaw(JSON.stringify(id !== undefined ? { id, event, data } : { event, data }));\n }\n\n sendRaw(payload: string): void {\n this.trySendRaw(payload);\n }\n\n trySendRaw(payload: string): WebSocketSendResult {\n if (this.#attachment.state === 'rejected') return 'closed';\n return this.sendGate.trySend(this.ws, payload, this.maxFrameBytes);\n }\n\n join(room: string): void {\n assertWebSocketRoomId(room);\n if (!this.#attachment.rooms.includes(room)) {\n if (this.#attachment.rooms.length >= DEFAULT_WS_MAX_JOINED_ROOMS) {\n throw new Error(`A WebSocket may join at most ${DEFAULT_WS_MAX_JOINED_ROOMS} rooms`);\n }\n this.#attachment.rooms.push(room);\n this.persist();\n }\n }\n\n leave(room: string): void {\n const next = this.#attachment.rooms.filter((r) => r !== room);\n if (next.length !== this.#attachment.rooms.length) {\n this.#attachment.rooms = next;\n this.persist();\n }\n }\n\n /** Persist `data`/room mutations to the hibernation attachment. */\n commit(): void {\n this.persist();\n }\n\n close(code?: number, reason?: string): void {\n this.ws.close(code, reason);\n }\n\n private persist(): void {\n if (this.#attachment.state === 'rejected') throw new Error('Invalid WebSocket attachment');\n const serialized = JSON.stringify(this.#attachment);\n if (encoder.encode(serialized).length > MAX_WS_ATTACHMENT_BYTES) {\n throw new Error(\n `WebSocket attachment exceeds the 16 KiB Cloudflare limit. Store large ` +\n `per-connection state in Durable Object storage keyed by connId instead.`,\n );\n }\n this.ws.serializeAttachment(this.#attachment);\n }\n}\n"],"mappings":";;;;AAcA,MAAM,UAAU,IAAI,YAAY;;;;;;AAOhC,IAAa,aAAb,MAE6B;CAIR;CACA;CACA;CALnB;CAEA,YACE,KACA,IACA,WAA4B,IAAI,kBAAkB,GAClD;EAHiB,KAAA,MAAA;EACA,KAAA,KAAA;EACA,KAAA,WAAA;EAEjB,KAAK,cAAc,iBAAiB,EAAE,KAAK,mBAAmB;CAChE;CAEA,IAAI,KAAa;EACf,OAAO,KAAK,YAAY;CAC1B;;CAGA,IAAI,OAAe;EACjB,OAAO,KAAK,YAAY;CAC1B;CAEA,IAAI,QAA6B;EAC/B,OAAO,IAAI,IAAI,KAAK,YAAY,KAAK;CACvC;CAEA,IAAI,OAAc;EAChB,OAAO,KAAK,YAAY;CAC1B;CAEA,IAAI,KAAK,OAAc;EACrB,KAAK,YAAY,OAAO;CAC1B;CAEA,IAAI,MAAe;EACjB,OAAO,KAAK;CACd;CAEA,IAAI,gBAAwB;EAC1B,MAAM,QAAQ,KAAK,YAAY;EAC/B,OAAO,OAAO,UAAU,YAAY,OAAO,cAAc,KAAK,KAAK,QAAQ,IACvE,QACA;CACN;CAEA,KAAK,OAAe,MAAgB,IAAmB;EACrD,KAAK,QAAQ,KAAK,UAAU,OAAO,KAAA,IAAY;GAAE;GAAI;GAAO;EAAK,IAAI;GAAE;GAAO;EAAK,CAAC,CAAC;CACvF;CAEA,QAAQ,SAAuB;EAC7B,KAAK,WAAW,OAAO;CACzB;CAEA,WAAW,SAAsC;EAC/C,IAAI,KAAK,YAAY,UAAU,YAAY,OAAO;EAClD,OAAO,KAAK,SAAS,QAAQ,KAAK,IAAI,SAAS,KAAK,aAAa;CACnE;CAEA,KAAK,MAAoB;EACvB,sBAAsB,IAAI;EAC1B,IAAI,CAAC,KAAK,YAAY,MAAM,SAAS,IAAI,GAAG;GAC1C,IAAI,KAAK,YAAY,MAAM,UAAU,6BACnC,MAAM,IAAI,MAAM,gCAAgC,4BAA4B,OAAO;GAErF,KAAK,YAAY,MAAM,KAAK,IAAI;GAChC,KAAK,QAAQ;EACf;CACF;CAEA,MAAM,MAAoB;EACxB,MAAM,OAAO,KAAK,YAAY,MAAM,QAAQ,MAAM,MAAM,IAAI;EAC5D,IAAI,KAAK,WAAW,KAAK,YAAY,MAAM,QAAQ;GACjD,KAAK,YAAY,QAAQ;GACzB,KAAK,QAAQ;EACf;CACF;;CAGA,SAAe;EACb,KAAK,QAAQ;CACf;CAEA,MAAM,MAAe,QAAuB;EAC1C,KAAK,GAAG,MAAM,MAAM,MAAM;CAC5B;CAEA,UAAwB;EACtB,IAAI,KAAK,YAAY,UAAU,YAAY,MAAM,IAAI,MAAM,8BAA8B;EACzF,MAAM,aAAa,KAAK,UAAU,KAAK,WAAW;EAClD,IAAI,QAAQ,OAAO,UAAU,CAAC,CAAC,SAAS,yBACtC,MAAM,IAAI,MACR,+IAEF;EAEF,KAAK,GAAG,oBAAoB,KAAK,WAAW;CAC9C;AACF"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { WsServerHolder } from "./ws-server-holder.js";
|
|
2
|
+
import { defineProvider } from "@velajs/vela";
|
|
3
|
+
import { WS_SERVER, WsDispatcher } from "@velajs/vela/websocket";
|
|
4
|
+
//#region src/websocket/cloudflare-websocket.module.ts
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in
|
|
7
|
+
* your `AppModule` instead: it provides the gateway dispatcher plus a late-bound
|
|
8
|
+
* `WS_SERVER` (`WsServerHolder`) that the WebSocket Durable Object wires to a
|
|
9
|
+
* ctx-backed server per instance. `useClass` ensures a fresh holder per DI
|
|
10
|
+
* container so colocated DO instances never share a server.
|
|
11
|
+
*/
|
|
12
|
+
var CloudflareWebSocketModule = class CloudflareWebSocketModule {
|
|
13
|
+
static forRoot() {
|
|
14
|
+
const providers = [defineProvider(WS_SERVER, { useClass: WsServerHolder }), WsDispatcher];
|
|
15
|
+
return {
|
|
16
|
+
module: CloudflareWebSocketModule,
|
|
17
|
+
providers,
|
|
18
|
+
exports: [WS_SERVER, WsDispatcher]
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { CloudflareWebSocketModule };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=cloudflare-websocket.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-websocket.module.js","names":[],"sources":["../../src/websocket/cloudflare-websocket.module.ts"],"sourcesContent":["import { defineProvider, type DynamicModule } from '@velajs/vela';\nimport { WsDispatcher, WS_SERVER } from '@velajs/vela/websocket';\nimport { WsServerHolder } from './ws-server-holder';\n\n/**\n * Cloudflare counterpart to the core `WebSocketModule.forRoot()`. Import this in\n * your `AppModule` instead: it provides the gateway dispatcher plus a late-bound\n * `WS_SERVER` (`WsServerHolder`) that the WebSocket Durable Object wires to a\n * ctx-backed server per instance. `useClass` ensures a fresh holder per DI\n * container so colocated DO instances never share a server.\n */\nexport class CloudflareWebSocketModule {\n static forRoot(): DynamicModule {\n const providers = [defineProvider(WS_SERVER, { useClass: WsServerHolder }), WsDispatcher];\n\n return {\n module: CloudflareWebSocketModule,\n providers,\n exports: [WS_SERVER, WsDispatcher],\n };\n }\n}\n"],"mappings":";;;;;;;;;;;AAWA,IAAa,4BAAb,MAAa,0BAA0B;CACrC,OAAO,UAAyB;EAC9B,MAAM,YAAY,CAAC,eAAe,WAAW,EAAE,UAAU,eAAe,CAAC,GAAG,YAAY;EAExF,OAAO;GACL,QAAQ;GACR;GACA,SAAS,CAAC,WAAW,YAAY;EACnC;CACF;AACF"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { registerCloudflareEnvironment } from "../environment.js";
|
|
2
|
+
import { CfWsClient } from "./cf-ws-client.js";
|
|
3
|
+
import { initDoLive, initializeDoLiveResources } from "./do-live.js";
|
|
4
|
+
import { WsServerHolder } from "./ws-server-holder.js";
|
|
5
|
+
import { CfRoomRegistry } from "./cf-room-registry.js";
|
|
6
|
+
import { VelaApplication } from "@velajs/vela";
|
|
7
|
+
import { WS_SERVER, WsDispatcher, WsServerImpl, local, readWsEntrypointMeta } from "@velajs/vela/websocket";
|
|
8
|
+
import { bootstrap } from "@velajs/vela/internal";
|
|
9
|
+
//#region src/websocket/do-bootstrap.ts
|
|
10
|
+
/**
|
|
11
|
+
* Slim DI bootstrap for the Durable Object isolate: wires the container and runs
|
|
12
|
+
* `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)
|
|
13
|
+
* WITHOUT building the Hono app/routes the DO never serves. The DO's `env` is
|
|
14
|
+
* seeded as the global ENV before providers construct, and the ctx-backed server
|
|
15
|
+
* is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.
|
|
16
|
+
*/
|
|
17
|
+
async function buildDoRuntime(rootModule, ctx, options) {
|
|
18
|
+
const { container, routeManager, loader } = await bootstrap(rootModule, { configureContainer: (container) => {
|
|
19
|
+
registerCloudflareEnvironment(container, options.env);
|
|
20
|
+
} });
|
|
21
|
+
const registry = new CfRoomRegistry(ctx);
|
|
22
|
+
const driver = local();
|
|
23
|
+
driver.bind(registry);
|
|
24
|
+
const server = new WsServerImpl(driver);
|
|
25
|
+
if (container.has(WS_SERVER)) {
|
|
26
|
+
const holder = await container.resolveAsync(WS_SERVER);
|
|
27
|
+
if (!(holder instanceof WsServerHolder)) throw new Error("[vela] The WebSocket Durable Object found a WS_SERVER from the core WebSocketModule, which cannot reach Durable Object sockets. On Cloudflare, import CloudflareWebSocketModule.forRoot() instead of WebSocketModule.forRoot().");
|
|
28
|
+
holder.setTarget(server);
|
|
29
|
+
}
|
|
30
|
+
const app = new VelaApplication(container, routeManager);
|
|
31
|
+
app.setInstances(await loader.resolveAllInstances());
|
|
32
|
+
initializeDoLiveResources(container, ctx);
|
|
33
|
+
await app.callOnModuleInit();
|
|
34
|
+
await app.callOnApplicationBootstrap();
|
|
35
|
+
const wsEntrypoints = app.entrypoints.ofKind("websocket", readWsEntrypointMeta);
|
|
36
|
+
const dispatcher = wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher);
|
|
37
|
+
registry.setSendPolicyResolver((path) => wsEntrypoints.find((entry) => entry.meta.path === path)?.meta.options.sendPolicy);
|
|
38
|
+
registry.setFrameLimitResolver((path) => dispatcher.getGatewayMaxFrameBytes(path));
|
|
39
|
+
registry.setDeliveryAuthorizer((client) => {
|
|
40
|
+
const path = client instanceof CfWsClient ? client.path : "";
|
|
41
|
+
return dispatcher.authorizeDelivery(path, client);
|
|
42
|
+
});
|
|
43
|
+
const live = initDoLive(app, ctx, registry);
|
|
44
|
+
return {
|
|
45
|
+
dispatcher,
|
|
46
|
+
registry,
|
|
47
|
+
server,
|
|
48
|
+
gatewayPaths: wsEntrypoints.map((ep) => ep.meta.path),
|
|
49
|
+
live,
|
|
50
|
+
close: (signal) => app.close(signal)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { buildDoRuntime };
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=do-bootstrap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"do-bootstrap.js","names":[],"sources":["../../src/websocket/do-bootstrap.ts"],"sourcesContent":["import { VelaApplication } from '@velajs/vela';\nimport { bootstrap } from '@velajs/vela/internal';\nimport type { DynamicModule, Type, VelaEnv } from '@velajs/vela';\nimport {\n local,\n readWsEntrypointMeta,\n WsDispatcher,\n WsServerImpl,\n WS_SERVER,\n} from '@velajs/vela/websocket';\nimport type { WsServer } from '@velajs/vela/websocket';\nimport type { LiveEngine } from '@velajs/vela/live';\nimport { registerCloudflareEnvironment } from '../environment';\nimport { CfWsClient } from './cf-ws-client';\nimport { CfRoomRegistry } from './cf-room-registry';\nimport { initDoLive, initializeDoLiveResources } from './do-live';\nimport type { DoStateLike } from './do-state';\nimport { WsServerHolder } from './ws-server-holder';\n\nexport interface DoRuntime {\n dispatcher: WsDispatcher;\n registry: CfRoomRegistry;\n server: WsServer;\n /** Gateway paths from `app.entrypoints.ofKind('websocket')` (discovery order). */\n gatewayPaths: string[];\n /** The live-query engine (undefined when the app doesn't import LiveModule). */\n live?: LiveEngine;\n close(signal?: string): Promise<void>;\n}\n\n/**\n * Slim DI bootstrap for the Durable Object isolate: wires the container and runs\n * `OnModuleInit`/`OnApplicationBootstrap` (so `WsDispatcher` discovers gateways)\n * WITHOUT building the Hono app/routes the DO never serves. The DO's `env` is\n * seeded as the global ENV before providers construct, and the ctx-backed server\n * is bound before bootstrap lifecycle so gateway `afterInit`/handlers see it.\n */\nexport async function buildDoRuntime(\n rootModule: Type | DynamicModule,\n ctx: DoStateLike,\n options: { env: VelaEnv },\n): Promise<DoRuntime> {\n const { container, routeManager, loader } = await bootstrap(rootModule, {\n configureContainer: (container) => {\n registerCloudflareEnvironment(container, options.env);\n },\n });\n\n const registry = new CfRoomRegistry(ctx);\n const driver = local();\n driver.bind(registry);\n const server = new WsServerImpl(driver);\n\n if (container.has(WS_SERVER)) {\n const holder = await container.resolveAsync(WS_SERVER);\n // The core WebSocketModule's server broadcasts through its own sync driver,\n // which never reaches this Durable Object's hibernatable sockets.\n if (!(holder instanceof WsServerHolder)) {\n throw new Error(\n '[vela] The WebSocket Durable Object found a WS_SERVER from the core WebSocketModule, ' +\n 'which cannot reach Durable Object sockets. On Cloudflare, import ' +\n 'CloudflareWebSocketModule.forRoot() instead of WebSocketModule.forRoot().',\n );\n }\n holder.setTarget(server);\n }\n\n const app = new VelaApplication(container, routeManager);\n app.setInstances(await loader.resolveAllInstances());\n initializeDoLiveResources(container, ctx);\n await app.callOnModuleInit();\n await app.callOnApplicationBootstrap();\n\n // The entrypoint registry is the transport contract: one 'websocket' entry\n // per discovered gateway ({ meta: { path, dispatcher } }). Built by\n // callOnApplicationBootstrap(), so this slim no-routes path has it too.\n const wsEntrypoints = app.entrypoints.ofKind('websocket', readWsEntrypointMeta);\n const dispatcher = wsEntrypoints[0]?.meta.dispatcher ?? app.get(WsDispatcher);\n registry.setSendPolicyResolver(\n (path) => wsEntrypoints.find((entry) => entry.meta.path === path)?.meta.options.sendPolicy,\n );\n registry.setFrameLimitResolver((path) => dispatcher.getGatewayMaxFrameBytes(path));\n registry.setDeliveryAuthorizer((client) => {\n const path = client instanceof CfWsClient ? client.path : '';\n return dispatcher.authorizeDelivery(path, client);\n });\n\n // Live queries: wire the SQLite cursor log + local driver mode and replay\n // hibernation-persisted subscriptions into the fresh engine.\n const live = initDoLive(app, ctx, registry);\n\n return {\n // Zero gateways still yields a live dispatcher (module imported, nothing\n // decorated) — fall back to resolving it directly.\n dispatcher,\n registry,\n server,\n gatewayPaths: wsEntrypoints.map((ep) => ep.meta.path),\n live,\n close: (signal?: string) => app.close(signal),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqCA,eAAsB,eACpB,YACA,KACA,SACoB;CACpB,MAAM,EAAE,WAAW,cAAc,WAAW,MAAM,UAAU,YAAY,EACtE,qBAAqB,cAAc;EACjC,8BAA8B,WAAW,QAAQ,GAAG;CACtD,EACF,CAAC;CAED,MAAM,WAAW,IAAI,eAAe,GAAG;CACvC,MAAM,SAAS,MAAM;CACrB,OAAO,KAAK,QAAQ;CACpB,MAAM,SAAS,IAAI,aAAa,MAAM;CAEtC,IAAI,UAAU,IAAI,SAAS,GAAG;EAC5B,MAAM,SAAS,MAAM,UAAU,aAAa,SAAS;EAGrD,IAAI,EAAE,kBAAkB,iBACtB,MAAM,IAAI,MACR,iOAGF;EAEF,OAAO,UAAU,MAAM;CACzB;CAEA,MAAM,MAAM,IAAI,gBAAgB,WAAW,YAAY;CACvD,IAAI,aAAa,MAAM,OAAO,oBAAoB,CAAC;CACnD,0BAA0B,WAAW,GAAG;CACxC,MAAM,IAAI,iBAAiB;CAC3B,MAAM,IAAI,2BAA2B;CAKrC,MAAM,gBAAgB,IAAI,YAAY,OAAO,aAAa,oBAAoB;CAC9E,MAAM,aAAa,cAAc,EAAE,EAAE,KAAK,cAAc,IAAI,IAAI,YAAY;CAC5E,SAAS,uBACN,SAAS,cAAc,MAAM,UAAU,MAAM,KAAK,SAAS,IAAI,CAAC,EAAE,KAAK,QAAQ,UAClF;CACA,SAAS,uBAAuB,SAAS,WAAW,wBAAwB,IAAI,CAAC;CACjF,SAAS,uBAAuB,WAAW;EACzC,MAAM,OAAO,kBAAkB,aAAa,OAAO,OAAO;EAC1D,OAAO,WAAW,kBAAkB,MAAM,MAAM;CAClD,CAAC;CAID,MAAM,OAAO,WAAW,KAAK,KAAK,QAAQ;CAE1C,OAAO;EAGL;EACA;EACA;EACA,cAAc,cAAc,KAAK,OAAO,GAAG,KAAK,IAAI;EACpD;EACA,QAAQ,WAAoB,IAAI,MAAM,MAAM;CAC9C;AACF"}
|