@edge-base/server 0.1.1
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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoomsDO v2 — Durable Object for ephemeral, in-memory real-time state rooms.
|
|
3
|
+
*
|
|
4
|
+
* Each room is isolated to its own DO instance, identified by namespace::roomId.
|
|
5
|
+
*: Complete redesign from v1.
|
|
6
|
+
*
|
|
7
|
+
* Key changes from v1:
|
|
8
|
+
* - 3 state areas: sharedState (all clients), playerState (per-player), serverState (server-only)
|
|
9
|
+
* - Client can only read + subscribe + send(). All writes are server-only.
|
|
10
|
+
* - No Direct/Authoritative mode distinction — single path: send → onAction → state change → broadcast
|
|
11
|
+
* - Config-driven namespace handlers (onCreate, onJoin, onLeave, onDestroy, onAction)
|
|
12
|
+
* - namespace::roomId identification (replaces tenant + room name)
|
|
13
|
+
* - Updater function pattern for state mutations: setSharedState(s => { s.x = 1; return s; })
|
|
14
|
+
*/
|
|
15
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
16
|
+
import {
|
|
17
|
+
getRoomActionHandlers,
|
|
18
|
+
getRoomLifecycleHandlers,
|
|
19
|
+
getRoomTimerHandlers,
|
|
20
|
+
type AuthContext as SharedAuthContext,
|
|
21
|
+
type EdgeBaseConfig,
|
|
22
|
+
type RoomNamespaceConfig,
|
|
23
|
+
type RoomServerAPI,
|
|
24
|
+
type RoomSender,
|
|
25
|
+
type RoomHandlerContext,
|
|
26
|
+
} from '@edge-base/shared';
|
|
27
|
+
import { parseConfig as getGlobalConfig } from '../lib/do-router.js';
|
|
28
|
+
import { resolveAuthContextFromToken } from '../middleware/auth.js';
|
|
29
|
+
import { buildFunctionContext } from '../lib/functions.js';
|
|
30
|
+
import { resolveDbLiveAuthTimeoutMs } from '../lib/database-live-config.js';
|
|
31
|
+
import {
|
|
32
|
+
persistRoomMonitoringSnapshot,
|
|
33
|
+
type RoomMonitoringSnapshot,
|
|
34
|
+
} from '../lib/room-monitoring.js';
|
|
35
|
+
import { resolveRootServiceKey } from '../lib/service-key.js';
|
|
36
|
+
|
|
37
|
+
// ─── Types ───
|
|
38
|
+
|
|
39
|
+
export interface RoomDOEnv {
|
|
40
|
+
JWT_USER_SECRET?: string;
|
|
41
|
+
ROOM: DurableObjectNamespace;
|
|
42
|
+
KV: KVNamespace;
|
|
43
|
+
DATABASE?: DurableObjectNamespace;
|
|
44
|
+
AUTH?: DurableObjectNamespace;
|
|
45
|
+
AUTH_DB?: unknown;
|
|
46
|
+
SERVICE_KEY?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RoomWSMeta {
|
|
50
|
+
authenticated: boolean;
|
|
51
|
+
userId?: string;
|
|
52
|
+
role?: string;
|
|
53
|
+
auth?: SharedAuthContext;
|
|
54
|
+
ip?: string;
|
|
55
|
+
userAgent?: string;
|
|
56
|
+
connectionId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PlayerInfo {
|
|
60
|
+
userId: string;
|
|
61
|
+
connectionId: string;
|
|
62
|
+
joinedAt: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Constants ───
|
|
66
|
+
|
|
67
|
+
const DEFAULT_MAX_PLAYERS = 100;
|
|
68
|
+
const DEFAULT_MAX_STATE_SIZE = 1048576; // 1MB
|
|
69
|
+
const DEFAULT_DELTA_BATCH_MS = 50;
|
|
70
|
+
const DEFAULT_RATE_LIMIT_ACTIONS = 10;
|
|
71
|
+
const DEFAULT_RECONNECT_TIMEOUT_MS = 30000;
|
|
72
|
+
const ROOM_CLIENT_LEAVE_CLOSE_CODE = 4005;
|
|
73
|
+
const EMPTY_ROOM_CLEANUP_DELAY_MS = 100;
|
|
74
|
+
const DEFAULT_IDLE_TIMEOUT_SEC = 300;
|
|
75
|
+
const ACTION_TIMEOUT_MS = 5000;
|
|
76
|
+
const DEFAULT_STATE_SAVE_INTERVAL_MS = 60000; // 1 minute
|
|
77
|
+
const DEFAULT_STATE_TTL_MS = 86400000; // 24 hours
|
|
78
|
+
const roomFallbackWarnings = new Set<string>();
|
|
79
|
+
|
|
80
|
+
function isRoomOperationPublic(
|
|
81
|
+
namespaceConfig: RoomNamespaceConfig | null,
|
|
82
|
+
operation: 'metadata' | 'join' | 'action',
|
|
83
|
+
): boolean {
|
|
84
|
+
if (!namespaceConfig?.public) return false;
|
|
85
|
+
if (namespaceConfig.public === true) return true;
|
|
86
|
+
return !!namespaceConfig.public[operation];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Compute delta between two states ───
|
|
90
|
+
|
|
91
|
+
function computeDelta(
|
|
92
|
+
oldState: Record<string, unknown>,
|
|
93
|
+
newState: Record<string, unknown>,
|
|
94
|
+
): Record<string, unknown> | null {
|
|
95
|
+
const delta: Record<string, unknown> = {};
|
|
96
|
+
let hasChanges = false;
|
|
97
|
+
|
|
98
|
+
for (const key of Object.keys(newState)) {
|
|
99
|
+
if (JSON.stringify(oldState[key]) !== JSON.stringify(newState[key])) {
|
|
100
|
+
delta[key] = newState[key];
|
|
101
|
+
hasChanges = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const key of Object.keys(oldState)) {
|
|
105
|
+
if (!(key in newState)) {
|
|
106
|
+
delta[key] = null;
|
|
107
|
+
hasChanges = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return hasChanges ? delta : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── structuredClone polyfill (in case not available in older runtimes) ───
|
|
115
|
+
|
|
116
|
+
function cloneState(obj: Record<string, unknown>): Record<string, unknown> {
|
|
117
|
+
return JSON.parse(JSON.stringify(obj));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Shared Room Runtime Base ───
|
|
121
|
+
|
|
122
|
+
export class RoomRuntimeBaseDO extends DurableObject<RoomDOEnv> {
|
|
123
|
+
protected readonly config: EdgeBaseConfig;
|
|
124
|
+
|
|
125
|
+
// ─── Room identification ───
|
|
126
|
+
protected namespace: string | null = null;
|
|
127
|
+
protected roomId: string | null = null;
|
|
128
|
+
protected namespaceConfig: RoomNamespaceConfig | null = null;
|
|
129
|
+
|
|
130
|
+
// ─── 3 state areas ───
|
|
131
|
+
private sharedState: Record<string, unknown> = {};
|
|
132
|
+
private sharedVersion = 0;
|
|
133
|
+
private playerStates = new Map<string, Record<string, unknown>>(); // userId → state
|
|
134
|
+
private playerVersions = new Map<string, number>(); // userId → version
|
|
135
|
+
private serverState: Record<string, unknown> = {};
|
|
136
|
+
|
|
137
|
+
// ─── Player tracking ───
|
|
138
|
+
private players = new Map<string, PlayerInfo>(); // connectionId → PlayerInfo
|
|
139
|
+
private userToConnections = new Map<string, Set<string>>(); // userId → Set<connectionId>
|
|
140
|
+
|
|
141
|
+
// ─── Lifecycle ───
|
|
142
|
+
private roomCreated = false;
|
|
143
|
+
|
|
144
|
+
// ─── State persistence (replaces RESYNC) ───
|
|
145
|
+
private dirty = false;
|
|
146
|
+
private saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
147
|
+
private stateRecoveryNeeded = false;
|
|
148
|
+
|
|
149
|
+
// ─── WebSocket metadata cache ───
|
|
150
|
+
private _metaCache = new Map<WebSocket, RoomWSMeta>();
|
|
151
|
+
|
|
152
|
+
// ─── Auth timeout tracking ───
|
|
153
|
+
private pendingAuth = new Map<string, ReturnType<typeof setTimeout>>();
|
|
154
|
+
|
|
155
|
+
// ─── Delta batching (shared state) ───
|
|
156
|
+
private pendingSharedDelta: Record<string, unknown> | null = null;
|
|
157
|
+
private sharedDeltaBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
158
|
+
|
|
159
|
+
// ─── Rate limiting (per connection, token bucket) ───
|
|
160
|
+
private rateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
|
161
|
+
|
|
162
|
+
// ─── Reconnect timers ───
|
|
163
|
+
private disconnectTimers = new Map<string, ReturnType<typeof setTimeout>>(); // userId → timer
|
|
164
|
+
|
|
165
|
+
// ─── Named Timers (alarm multiplexer) ───
|
|
166
|
+
private _timers = new Map<string, { fireAt: number; data?: unknown }>();
|
|
167
|
+
private _emptyRoomCleanupAt: number | null = null;
|
|
168
|
+
private _stateTTLAlarmAt: number | null = null;
|
|
169
|
+
|
|
170
|
+
// ─── Room Metadata (queryable via HTTP without joining) ───
|
|
171
|
+
private _metadata: Record<string, unknown> = {};
|
|
172
|
+
|
|
173
|
+
constructor(ctx: DurableObjectState, env: RoomDOEnv) {
|
|
174
|
+
super(ctx, env);
|
|
175
|
+
this.config = this.parseConfig(env);
|
|
176
|
+
|
|
177
|
+
// Detect hibernation wake-up
|
|
178
|
+
if (ctx.getWebSockets().length > 0) {
|
|
179
|
+
this.stateRecoveryNeeded = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── HTTP Fetch Handler ───
|
|
184
|
+
|
|
185
|
+
async fetch(request: Request): Promise<Response> {
|
|
186
|
+
const url = new URL(request.url);
|
|
187
|
+
if (url.pathname === '/websocket') {
|
|
188
|
+
return this.handleWebSocketUpgrade(request);
|
|
189
|
+
}
|
|
190
|
+
if (url.pathname === '/metadata' && request.method === 'GET') {
|
|
191
|
+
return this.handleGetMetadata(url);
|
|
192
|
+
}
|
|
193
|
+
if (url.pathname === '/internal/stats' && request.method === 'GET') {
|
|
194
|
+
return this.handleGetStats();
|
|
195
|
+
}
|
|
196
|
+
return new Response('Not found', { status: 404 });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Stats HTTP Handler (for admin monitoring) ───
|
|
200
|
+
|
|
201
|
+
private handleGetStats(): Response {
|
|
202
|
+
const snapshot = this.collectRoomMonitoringSnapshot();
|
|
203
|
+
return new Response(JSON.stringify({
|
|
204
|
+
subsystem: 'rooms',
|
|
205
|
+
activeConnections: snapshot?.activeConnections ?? 0,
|
|
206
|
+
authenticatedConnections: snapshot?.authenticatedConnections ?? 0,
|
|
207
|
+
channels: snapshot ? 1 : 0,
|
|
208
|
+
channelDetails: snapshot
|
|
209
|
+
? [{ channel: snapshot.room, subscribers: snapshot.activeConnections }]
|
|
210
|
+
: [],
|
|
211
|
+
}), { headers: { 'Content-Type': 'application/json' } });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private resolveRoomMonitoringRoom(): string | null {
|
|
215
|
+
if (this.namespace && this.roomId) {
|
|
216
|
+
return `${this.namespace}::${this.roomId}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
220
|
+
this.getWSMeta(ws);
|
|
221
|
+
if (this.namespace && this.roomId) {
|
|
222
|
+
return `${this.namespace}::${this.roomId}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private collectRoomMonitoringSnapshot(excludeWs?: WebSocket): RoomMonitoringSnapshot | null {
|
|
230
|
+
const room = this.resolveRoomMonitoringRoom();
|
|
231
|
+
if (!room) return null;
|
|
232
|
+
|
|
233
|
+
let activeConnections = 0;
|
|
234
|
+
let authenticatedConnections = 0;
|
|
235
|
+
|
|
236
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
237
|
+
if (excludeWs && ws === excludeWs) continue;
|
|
238
|
+
activeConnections++;
|
|
239
|
+
const meta = this.getWSMeta(ws);
|
|
240
|
+
if (meta?.authenticated) authenticatedConnections++;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
room,
|
|
245
|
+
activeConnections,
|
|
246
|
+
authenticatedConnections,
|
|
247
|
+
updatedAt: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private syncRoomMonitoringSnapshot(excludeWs?: WebSocket): void {
|
|
252
|
+
if (!this.env.KV) return;
|
|
253
|
+
|
|
254
|
+
const snapshot = this.collectRoomMonitoringSnapshot(excludeWs);
|
|
255
|
+
const fallbackRoom = this.resolveRoomMonitoringRoom();
|
|
256
|
+
const snapshotToPersist = snapshot ?? (
|
|
257
|
+
fallbackRoom
|
|
258
|
+
? {
|
|
259
|
+
room: fallbackRoom,
|
|
260
|
+
activeConnections: 0,
|
|
261
|
+
authenticatedConnections: 0,
|
|
262
|
+
updatedAt: new Date().toISOString(),
|
|
263
|
+
}
|
|
264
|
+
: null
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (!snapshotToPersist) return;
|
|
268
|
+
this.ctx.waitUntil(persistRoomMonitoringSnapshot(this.env.KV, snapshotToPersist));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Metadata HTTP Handler ───
|
|
272
|
+
|
|
273
|
+
private async handleGetMetadata(url: URL): Promise<Response> {
|
|
274
|
+
// Resolve namespace if not set (DO may have been cold-started via HTTP)
|
|
275
|
+
const roomFullName = url.searchParams.get('room');
|
|
276
|
+
if (roomFullName && !this.namespace) {
|
|
277
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
278
|
+
if (separatorIdx >= 0) {
|
|
279
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
280
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
281
|
+
} else {
|
|
282
|
+
this.namespace = roomFullName;
|
|
283
|
+
this.roomId = roomFullName;
|
|
284
|
+
}
|
|
285
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Metadata may not be in memory if DO was evicted/hibernated
|
|
289
|
+
if (Object.keys(this._metadata).length === 0) {
|
|
290
|
+
const saved = await this.ctx.storage.get('roomMetadata') as Record<string, unknown> | undefined;
|
|
291
|
+
if (saved) this._metadata = saved;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return new Response(JSON.stringify(this._metadata), {
|
|
295
|
+
headers: { 'Content-Type': 'application/json' },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── WebSocket Upgrade (Hibernation API) ───
|
|
300
|
+
|
|
301
|
+
private handleWebSocketUpgrade(request: Request): Response {
|
|
302
|
+
const url = new URL(request.url);
|
|
303
|
+
const roomFullName = url.searchParams.get('room');
|
|
304
|
+
|
|
305
|
+
if (roomFullName) {
|
|
306
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
307
|
+
if (separatorIdx >= 0) {
|
|
308
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
309
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
310
|
+
} else {
|
|
311
|
+
this.namespace = roomFullName;
|
|
312
|
+
this.roomId = roomFullName;
|
|
313
|
+
}
|
|
314
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Check max players
|
|
318
|
+
const maxPlayers = this.namespaceConfig?.maxPlayers ?? DEFAULT_MAX_PLAYERS;
|
|
319
|
+
if (this.players.size >= maxPlayers) {
|
|
320
|
+
return new Response(JSON.stringify({
|
|
321
|
+
type: 'error',
|
|
322
|
+
code: 'ROOM_FULL',
|
|
323
|
+
message: `Room is full (${maxPlayers} max)`,
|
|
324
|
+
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const pair = new WebSocketPair();
|
|
328
|
+
const [client, server] = Object.values(pair);
|
|
329
|
+
|
|
330
|
+
const connectionId = crypto.randomUUID();
|
|
331
|
+
const meta: RoomWSMeta = {
|
|
332
|
+
authenticated: false,
|
|
333
|
+
connectionId,
|
|
334
|
+
ip: request.headers.get('CF-Connecting-IP')
|
|
335
|
+
|| request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
|
|
336
|
+
|| undefined,
|
|
337
|
+
userAgent: request.headers.get('User-Agent') || undefined,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Accept with Hibernation API
|
|
341
|
+
const tags = [
|
|
342
|
+
`conn:${connectionId}`,
|
|
343
|
+
`room:${roomFullName || ''}`,
|
|
344
|
+
];
|
|
345
|
+
if (meta.ip) {
|
|
346
|
+
tags.push(`ip:${encodeURIComponent(meta.ip)}`);
|
|
347
|
+
}
|
|
348
|
+
this.ctx.acceptWebSocket(server, tags);
|
|
349
|
+
this._metaCache.set(server, meta);
|
|
350
|
+
this.syncRoomMonitoringSnapshot();
|
|
351
|
+
|
|
352
|
+
// Set auth timeout
|
|
353
|
+
const authTimeoutMs = resolveDbLiveAuthTimeoutMs(this.config);
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
const currentMeta = this.getWSMeta(server);
|
|
356
|
+
if (currentMeta && !currentMeta.authenticated) {
|
|
357
|
+
try {
|
|
358
|
+
this.safeSend(server, {
|
|
359
|
+
type: 'error',
|
|
360
|
+
code: 'AUTH_TIMEOUT',
|
|
361
|
+
message: `Authentication required within ${authTimeoutMs}ms`,
|
|
362
|
+
});
|
|
363
|
+
server.close(4001, 'Authentication timeout');
|
|
364
|
+
} catch {
|
|
365
|
+
// WebSocket already closed by client
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.pendingAuth.delete(connectionId);
|
|
369
|
+
}, authTimeoutMs);
|
|
370
|
+
this.pendingAuth.set(connectionId, timer);
|
|
371
|
+
|
|
372
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Hibernation API Callbacks ───
|
|
376
|
+
|
|
377
|
+
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
378
|
+
if (typeof message !== 'string') return;
|
|
379
|
+
|
|
380
|
+
let msg: Record<string, unknown>;
|
|
381
|
+
try {
|
|
382
|
+
msg = JSON.parse(message);
|
|
383
|
+
} catch {
|
|
384
|
+
this.safeSend(ws, { type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON' });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const meta = this.getWSMeta(ws);
|
|
389
|
+
if (!meta) {
|
|
390
|
+
ws.close(4000, 'No metadata');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const type = msg.type as string;
|
|
395
|
+
|
|
396
|
+
// Auth must be first
|
|
397
|
+
if (type === 'auth') {
|
|
398
|
+
await this.handleAuth(ws, meta, msg.token as string);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Join — must be authenticated
|
|
403
|
+
if (type === 'join') {
|
|
404
|
+
await this.handleJoin(ws, meta, msg);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Everything else requires authentication
|
|
409
|
+
if (!meta.authenticated) {
|
|
410
|
+
this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
switch (type) {
|
|
415
|
+
case 'leave':
|
|
416
|
+
await this.handleExplicitLeave(ws, meta);
|
|
417
|
+
break;
|
|
418
|
+
case 'send':
|
|
419
|
+
// Rate limiting (token bucket)
|
|
420
|
+
if (!this.checkRateLimit(meta.connectionId)) {
|
|
421
|
+
this.safeSend(ws, {
|
|
422
|
+
type: 'action_error',
|
|
423
|
+
actionType: msg.actionType as string,
|
|
424
|
+
message: 'Rate limited',
|
|
425
|
+
requestId: msg.requestId,
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await this.handleSend(ws, meta, msg);
|
|
430
|
+
break;
|
|
431
|
+
case 'ping':
|
|
432
|
+
this.safeSend(ws, { type: 'pong' });
|
|
433
|
+
break;
|
|
434
|
+
default:
|
|
435
|
+
this.safeSend(ws, { type: 'error', code: 'UNKNOWN_TYPE', message: `Unknown message type: ${type}` });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
webSocketClose(ws: WebSocket, code: number, _reason: string): void {
|
|
440
|
+
const meta = this.getWSMeta(ws);
|
|
441
|
+
if (meta) {
|
|
442
|
+
const kicked = code === 4004;
|
|
443
|
+
const explicitLeave = code === ROOM_CLIENT_LEAVE_CLOSE_CODE;
|
|
444
|
+
this.handleDisconnect(meta, kicked, explicitLeave);
|
|
445
|
+
this._metaCache.delete(ws);
|
|
446
|
+
const timer = this.pendingAuth.get(meta.connectionId);
|
|
447
|
+
if (timer) {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
this.pendingAuth.delete(meta.connectionId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
this.syncRoomMonitoringSnapshot(ws);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
webSocketError(ws: WebSocket, _error: unknown): void {
|
|
456
|
+
const meta = this.getWSMeta(ws);
|
|
457
|
+
if (meta) {
|
|
458
|
+
this.handleDisconnect(meta);
|
|
459
|
+
this._metaCache.delete(ws);
|
|
460
|
+
}
|
|
461
|
+
this.syncRoomMonitoringSnapshot(ws);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ─── Alarm Multiplexer ───
|
|
465
|
+
// Single DO alarm is shared among: named timers, empty room cleanup, state TTL.
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Recalculate and set the single DO alarm to the earliest pending event.
|
|
469
|
+
*/
|
|
470
|
+
private _scheduleNextAlarm(): void {
|
|
471
|
+
let earliest = Infinity;
|
|
472
|
+
|
|
473
|
+
for (const timer of this._timers.values()) {
|
|
474
|
+
if (timer.fireAt < earliest) earliest = timer.fireAt;
|
|
475
|
+
}
|
|
476
|
+
if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt < earliest) {
|
|
477
|
+
earliest = this._emptyRoomCleanupAt;
|
|
478
|
+
}
|
|
479
|
+
if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt < earliest) {
|
|
480
|
+
earliest = this._stateTTLAlarmAt;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (earliest < Infinity) {
|
|
484
|
+
this.ctx.storage.setAlarm(earliest);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async alarm(): Promise<void> {
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
|
|
491
|
+
// 1. Fire expired named timers
|
|
492
|
+
const expiredTimers: Array<{ name: string; data?: unknown }> = [];
|
|
493
|
+
for (const [name, timer] of this._timers) {
|
|
494
|
+
if (timer.fireAt <= now) {
|
|
495
|
+
expiredTimers.push({ name, data: timer.data });
|
|
496
|
+
this._timers.delete(name);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
for (const { name, data } of expiredTimers) {
|
|
501
|
+
const handler = getRoomTimerHandlers(this.namespaceConfig ?? undefined)?.[name];
|
|
502
|
+
if (handler) {
|
|
503
|
+
try {
|
|
504
|
+
const roomApi = this.buildRoomServerAPI();
|
|
505
|
+
const ctx = this.buildHandlerContext();
|
|
506
|
+
await handler(roomApi, ctx, data);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error(`[Room] onTimer['${name}'] error: ${err instanceof Error ? err.message : String(err)}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (expiredTimers.length > 0) {
|
|
514
|
+
this.dirty = true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 2. Empty room cleanup
|
|
518
|
+
if (this._emptyRoomCleanupAt !== null && this._emptyRoomCleanupAt <= now) {
|
|
519
|
+
this._emptyRoomCleanupAt = null;
|
|
520
|
+
if (this.players.size === 0) {
|
|
521
|
+
if (Object.keys(this.sharedState).length > 0 || this.playerStates.size > 0 || Object.keys(this.serverState).length > 0) {
|
|
522
|
+
// Phase 1: Clear all in-memory state
|
|
523
|
+
this.sharedState = {};
|
|
524
|
+
this.sharedVersion = 0;
|
|
525
|
+
this.playerStates.clear();
|
|
526
|
+
this.playerVersions.clear();
|
|
527
|
+
this.serverState = {};
|
|
528
|
+
this.roomCreated = false;
|
|
529
|
+
this._timers.clear();
|
|
530
|
+
this._metadata = {};
|
|
531
|
+
this.pendingSharedDelta = null;
|
|
532
|
+
if (this.sharedDeltaBatchTimer) {
|
|
533
|
+
clearTimeout(this.sharedDeltaBatchTimer);
|
|
534
|
+
this.sharedDeltaBatchTimer = null;
|
|
535
|
+
}
|
|
536
|
+
this.stopSaveTimer();
|
|
537
|
+
// Clean up persisted state
|
|
538
|
+
await this.ctx.storage.delete('roomState');
|
|
539
|
+
await this.ctx.storage.delete('roomTimers');
|
|
540
|
+
await this.ctx.storage.delete('roomMetadata');
|
|
541
|
+
// Phase 2: Schedule idleTimeout alarm
|
|
542
|
+
this._stateTTLAlarmAt = Date.now() + DEFAULT_IDLE_TIMEOUT_SEC * 1000;
|
|
543
|
+
} else {
|
|
544
|
+
// TTL safety net alarm: room is empty and state already cleared
|
|
545
|
+
await this.ctx.storage.delete('roomState');
|
|
546
|
+
await this.ctx.storage.delete('roomTimers');
|
|
547
|
+
await this.ctx.storage.delete('roomMetadata');
|
|
548
|
+
this._stateTTLAlarmAt = null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 3. State TTL safety net
|
|
554
|
+
if (this._stateTTLAlarmAt !== null && this._stateTTLAlarmAt <= now) {
|
|
555
|
+
this._stateTTLAlarmAt = null;
|
|
556
|
+
if (this.players.size === 0) {
|
|
557
|
+
await this.ctx.storage.delete('roomState');
|
|
558
|
+
await this.ctx.storage.delete('roomTimers');
|
|
559
|
+
await this.ctx.storage.delete('roomMetadata');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 4. Reschedule for next pending event
|
|
564
|
+
this._scheduleNextAlarm();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ─── Auth Handler ───
|
|
568
|
+
|
|
569
|
+
private async handleAuth(ws: WebSocket, meta: RoomWSMeta, token: string): Promise<void> {
|
|
570
|
+
const isReAuth = meta.authenticated;
|
|
571
|
+
|
|
572
|
+
if (!token) {
|
|
573
|
+
this.safeSend(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Token required' });
|
|
574
|
+
ws.close(4002, 'Authentication failed');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const secret = this.env.JWT_USER_SECRET;
|
|
579
|
+
if (!secret) {
|
|
580
|
+
this.safeSend(ws, { type: 'error', code: 'SERVER_ERROR', message: 'JWT secret not configured' });
|
|
581
|
+
ws.close(4003, 'Server configuration error');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const headers = new Headers();
|
|
587
|
+
if (meta.ip) headers.set('CF-Connecting-IP', meta.ip);
|
|
588
|
+
if (meta.userAgent) headers.set('User-Agent', meta.userAgent);
|
|
589
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
590
|
+
const auth = await resolveAuthContextFromToken(
|
|
591
|
+
this.env,
|
|
592
|
+
token,
|
|
593
|
+
new Request('http://internal/api/room/auth', { headers }),
|
|
594
|
+
);
|
|
595
|
+
meta.authenticated = true;
|
|
596
|
+
meta.userId = auth.id;
|
|
597
|
+
meta.role = auth.role;
|
|
598
|
+
meta.auth = {
|
|
599
|
+
id: auth.id,
|
|
600
|
+
role: auth.role,
|
|
601
|
+
email: auth.email ?? undefined,
|
|
602
|
+
isAnonymous: auth.isAnonymous,
|
|
603
|
+
custom: auth.custom ?? undefined,
|
|
604
|
+
meta: auth.meta,
|
|
605
|
+
};
|
|
606
|
+
this.setWSMeta(ws, meta);
|
|
607
|
+
|
|
608
|
+
// Clear auth timeout
|
|
609
|
+
const timer = this.pendingAuth.get(meta.connectionId);
|
|
610
|
+
if (timer) {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
this.pendingAuth.delete(meta.connectionId);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Register player (only on first auth)
|
|
616
|
+
if (!isReAuth && meta.userId) {
|
|
617
|
+
// Cancel disconnect timer if this user is reconnecting
|
|
618
|
+
const existingTimer = this.disconnectTimers.get(meta.userId);
|
|
619
|
+
if (existingTimer) {
|
|
620
|
+
clearTimeout(existingTimer);
|
|
621
|
+
this.disconnectTimers.delete(meta.userId);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
this.addPlayer(meta.connectionId, meta.userId);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Send auth response
|
|
628
|
+
this.safeSend(ws, {
|
|
629
|
+
type: isReAuth ? 'auth_refreshed' : 'auth_success',
|
|
630
|
+
userId: auth.id,
|
|
631
|
+
connectionId: meta.connectionId,
|
|
632
|
+
});
|
|
633
|
+
this.syncRoomMonitoringSnapshot();
|
|
634
|
+
|
|
635
|
+
// Recover state from storage if needed (after hibernation wake-up)
|
|
636
|
+
if (this.stateRecoveryNeeded) {
|
|
637
|
+
await this.recoverFromStorage();
|
|
638
|
+
this.stateRecoveryNeeded = false;
|
|
639
|
+
}
|
|
640
|
+
// Note: full sync is sent during handleJoin(), not here
|
|
641
|
+
} catch {
|
|
642
|
+
if (isReAuth) {
|
|
643
|
+
this.safeSend(ws, { type: 'error', code: 'AUTH_REFRESH_FAILED', message: 'Token refresh failed' });
|
|
644
|
+
} else {
|
|
645
|
+
this.safeSend(ws, { type: 'error', code: 'AUTH_FAILED', message: 'Invalid or expired token' });
|
|
646
|
+
ws.close(4002, 'Authentication failed');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ─── Join Handler ───
|
|
652
|
+
|
|
653
|
+
protected async handleJoin(
|
|
654
|
+
ws: WebSocket,
|
|
655
|
+
meta: RoomWSMeta,
|
|
656
|
+
msg: Record<string, unknown>,
|
|
657
|
+
): Promise<void> {
|
|
658
|
+
if (!meta.authenticated || !meta.userId) {
|
|
659
|
+
this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const joinAccess = this.namespaceConfig?.access?.join;
|
|
664
|
+
if (this.roomId && this.namespaceConfig && !joinAccess) {
|
|
665
|
+
if (this.config.release && !isRoomOperationPublic(this.namespaceConfig, 'join')) {
|
|
666
|
+
this.safeSend(ws, {
|
|
667
|
+
type: 'error',
|
|
668
|
+
code: 'JOIN_DENIED',
|
|
669
|
+
message: 'Room join requires access.join or public.join in release mode',
|
|
670
|
+
});
|
|
671
|
+
ws.close(4003, 'Join denied');
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (!this.config.release && this.namespace) {
|
|
675
|
+
const warningKey = `${this.namespace}:join`;
|
|
676
|
+
if (!roomFallbackWarnings.has(warningKey)) {
|
|
677
|
+
roomFallbackWarnings.add(warningKey);
|
|
678
|
+
console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.join or public.join to make this explicit.`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (joinAccess && this.roomId) {
|
|
683
|
+
try {
|
|
684
|
+
const allowed = await Promise.resolve(joinAccess(this.buildAuthFromMeta(meta), this.roomId));
|
|
685
|
+
if (!allowed) {
|
|
686
|
+
this.safeSend(ws, {
|
|
687
|
+
type: 'error',
|
|
688
|
+
code: 'JOIN_DENIED',
|
|
689
|
+
message: 'Denied by room join access rule',
|
|
690
|
+
});
|
|
691
|
+
ws.close(4003, 'Join denied');
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
} catch {
|
|
695
|
+
this.safeSend(ws, {
|
|
696
|
+
type: 'error',
|
|
697
|
+
code: 'JOIN_DENIED',
|
|
698
|
+
message: 'Denied by room join access rule',
|
|
699
|
+
});
|
|
700
|
+
ws.close(4003, 'Join denied');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Lifecycle: onCreate (first time only)
|
|
706
|
+
if (!this.roomCreated) {
|
|
707
|
+
this.roomCreated = true;
|
|
708
|
+
const onCreate = getRoomLifecycleHandlers(this.namespaceConfig ?? undefined)?.onCreate;
|
|
709
|
+
if (onCreate) {
|
|
710
|
+
try {
|
|
711
|
+
const roomApi = this.buildRoomServerAPI();
|
|
712
|
+
const ctx = this.buildHandlerContext();
|
|
713
|
+
await onCreate(roomApi, ctx);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error(`[Room] onCreate error: ${err instanceof Error ? err.message : String(err)}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Start periodic state persistence
|
|
719
|
+
this.startSaveTimer();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Lifecycle: onJoin (throw to reject)
|
|
723
|
+
const onJoin = getRoomLifecycleHandlers(this.namespaceConfig ?? undefined)?.onJoin;
|
|
724
|
+
if (onJoin) {
|
|
725
|
+
try {
|
|
726
|
+
const sender = this.buildSender(meta);
|
|
727
|
+
const roomApi = this.buildRoomServerAPI();
|
|
728
|
+
const ctx = this.buildHandlerContext();
|
|
729
|
+
await onJoin(sender, roomApi, ctx);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
this.safeSend(ws, {
|
|
732
|
+
type: 'error',
|
|
733
|
+
code: 'JOIN_DENIED',
|
|
734
|
+
message: err instanceof Error ? err.message : 'Join denied',
|
|
735
|
+
});
|
|
736
|
+
ws.close(4003, 'Join denied');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Eviction recovery (DO was evicted, state empty, client has state)
|
|
742
|
+
const lastSharedState = msg.lastSharedState as Record<string, unknown> | undefined;
|
|
743
|
+
const lastSharedVersion = (msg.lastSharedVersion as number) ?? 0;
|
|
744
|
+
const lastPlayerState = msg.lastPlayerState as Record<string, unknown> | undefined;
|
|
745
|
+
const lastPlayerVersion = (msg.lastPlayerVersion as number) ?? 0;
|
|
746
|
+
|
|
747
|
+
if (
|
|
748
|
+
Object.keys(this.sharedState).length === 0 &&
|
|
749
|
+
this.sharedVersion === 0 &&
|
|
750
|
+
lastSharedState &&
|
|
751
|
+
Object.keys(lastSharedState).length > 0 &&
|
|
752
|
+
lastSharedVersion > 0
|
|
753
|
+
) {
|
|
754
|
+
this.sharedState = lastSharedState;
|
|
755
|
+
this.sharedVersion = lastSharedVersion;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Restore player state if provided (e.g. after reconnect)
|
|
759
|
+
if (lastPlayerState && Object.keys(lastPlayerState).length > 0 && lastPlayerVersion > 0) {
|
|
760
|
+
const currentVer = this.playerVersions.get(meta.userId) ?? 0;
|
|
761
|
+
if (currentVer === 0) {
|
|
762
|
+
this.playerStates.set(meta.userId, lastPlayerState);
|
|
763
|
+
this.playerVersions.set(meta.userId, lastPlayerVersion);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Initialize player state if not exists
|
|
768
|
+
if (!this.playerStates.has(meta.userId)) {
|
|
769
|
+
this.playerStates.set(meta.userId, {});
|
|
770
|
+
this.playerVersions.set(meta.userId, 0);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Flush any pending deltas from onJoin so other clients receive
|
|
774
|
+
// the state change immediately (not after batch timer)
|
|
775
|
+
this.flushSharedDelta();
|
|
776
|
+
|
|
777
|
+
// Send full sync to this client
|
|
778
|
+
this.safeSend(ws, {
|
|
779
|
+
type: 'sync',
|
|
780
|
+
sharedState: this.sharedState,
|
|
781
|
+
sharedVersion: this.sharedVersion,
|
|
782
|
+
playerState: this.playerStates.get(meta.userId) ?? {},
|
|
783
|
+
playerVersion: this.playerVersions.get(meta.userId) ?? 0,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ─── Send Handler (replaces setState/patchState/sendAction) ───
|
|
788
|
+
|
|
789
|
+
private async handleSend(
|
|
790
|
+
ws: WebSocket,
|
|
791
|
+
meta: RoomWSMeta,
|
|
792
|
+
msg: Record<string, unknown>,
|
|
793
|
+
): Promise<void> {
|
|
794
|
+
const actionType = msg.actionType as string | undefined;
|
|
795
|
+
const payload = msg.payload;
|
|
796
|
+
const requestId = msg.requestId as string | undefined;
|
|
797
|
+
|
|
798
|
+
if (!actionType || typeof actionType !== 'string') {
|
|
799
|
+
this.safeSend(ws, {
|
|
800
|
+
type: 'action_error',
|
|
801
|
+
actionType: actionType ?? '',
|
|
802
|
+
message: 'actionType is required',
|
|
803
|
+
requestId,
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (!meta.userId) {
|
|
809
|
+
this.safeSend(ws, {
|
|
810
|
+
type: 'action_error',
|
|
811
|
+
actionType,
|
|
812
|
+
message: 'User not authenticated',
|
|
813
|
+
requestId,
|
|
814
|
+
});
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const actionAccess = this.namespaceConfig?.access?.action;
|
|
819
|
+
if (this.roomId && this.namespaceConfig && !actionAccess) {
|
|
820
|
+
if (this.config.release && !isRoomOperationPublic(this.namespaceConfig, 'action')) {
|
|
821
|
+
this.safeSend(ws, {
|
|
822
|
+
type: 'action_error',
|
|
823
|
+
actionType,
|
|
824
|
+
message: 'Room action requires access.action or public.action in release mode',
|
|
825
|
+
requestId,
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (!this.config.release && this.namespace) {
|
|
830
|
+
const warningKey = `${this.namespace}:action`;
|
|
831
|
+
if (!roomFallbackWarnings.has(warningKey)) {
|
|
832
|
+
roomFallbackWarnings.add(warningKey);
|
|
833
|
+
console.warn(`[Room] ${warningKey} is using development-mode allow-by-default. Add rooms.${this.namespace}.access.action or public.action to make this explicit.`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (actionAccess && this.roomId) {
|
|
838
|
+
try {
|
|
839
|
+
const allowed = await Promise.resolve(actionAccess(
|
|
840
|
+
this.buildAuthFromMeta(meta),
|
|
841
|
+
this.roomId,
|
|
842
|
+
actionType,
|
|
843
|
+
payload,
|
|
844
|
+
));
|
|
845
|
+
if (!allowed) {
|
|
846
|
+
this.safeSend(ws, {
|
|
847
|
+
type: 'action_error',
|
|
848
|
+
actionType,
|
|
849
|
+
message: 'Denied by room action access rule',
|
|
850
|
+
requestId,
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
} catch {
|
|
855
|
+
this.safeSend(ws, {
|
|
856
|
+
type: 'action_error',
|
|
857
|
+
actionType,
|
|
858
|
+
message: 'Denied by room action access rule',
|
|
859
|
+
requestId,
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Resolve handler from config
|
|
866
|
+
const handler = getRoomActionHandlers(this.namespaceConfig ?? undefined)?.[actionType];
|
|
867
|
+
if (!handler) {
|
|
868
|
+
this.safeSend(ws, {
|
|
869
|
+
type: 'action_error',
|
|
870
|
+
actionType,
|
|
871
|
+
message: `No handler for action '${actionType}'`,
|
|
872
|
+
requestId,
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const sender = this.buildSender(meta);
|
|
878
|
+
const roomApi = this.buildRoomServerAPI();
|
|
879
|
+
const ctx = this.buildHandlerContext();
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
const result = await Promise.race([
|
|
883
|
+
handler(payload, roomApi, sender, ctx),
|
|
884
|
+
new Promise<never>((_, reject) =>
|
|
885
|
+
setTimeout(() => reject(new Error('Action timeout')), ACTION_TIMEOUT_MS),
|
|
886
|
+
),
|
|
887
|
+
]);
|
|
888
|
+
|
|
889
|
+
// Flush any pending shared delta immediately so clients receive
|
|
890
|
+
// state changes in the same round-trip as the action_result
|
|
891
|
+
this.flushSharedDelta();
|
|
892
|
+
|
|
893
|
+
this.safeSend(ws, {
|
|
894
|
+
type: 'action_result',
|
|
895
|
+
actionType,
|
|
896
|
+
result: result ?? null,
|
|
897
|
+
requestId,
|
|
898
|
+
});
|
|
899
|
+
} catch (err) {
|
|
900
|
+
this.flushSharedDelta();
|
|
901
|
+
|
|
902
|
+
this.safeSend(ws, {
|
|
903
|
+
type: 'action_error',
|
|
904
|
+
actionType,
|
|
905
|
+
message: err instanceof Error ? err.message : 'Action execution failed',
|
|
906
|
+
requestId,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
protected async handleExplicitLeave(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
|
|
912
|
+
const player = this.players.get(meta.connectionId);
|
|
913
|
+
if (!player) {
|
|
914
|
+
try {
|
|
915
|
+
ws.close(ROOM_CLIENT_LEAVE_CLOSE_CODE, 'Client left room');
|
|
916
|
+
} catch {
|
|
917
|
+
// Socket already closed.
|
|
918
|
+
}
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
this.players.delete(meta.connectionId);
|
|
923
|
+
const conns = this.userToConnections.get(player.userId);
|
|
924
|
+
if (conns) {
|
|
925
|
+
conns.delete(meta.connectionId);
|
|
926
|
+
if (conns.size === 0) {
|
|
927
|
+
this.userToConnections.delete(player.userId);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const existing = this.disconnectTimers.get(player.userId);
|
|
932
|
+
if (existing) {
|
|
933
|
+
clearTimeout(existing);
|
|
934
|
+
this.disconnectTimers.delete(player.userId);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const remainingConns = this.userToConnections.get(player.userId);
|
|
938
|
+
if (!remainingConns || remainingConns.size === 0) {
|
|
939
|
+
await this.finalizePlayerLeave(player.userId, meta.connectionId, 'leave');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (this.players.size === 0 && this.disconnectTimers.size === 0) {
|
|
943
|
+
this.scheduleEmptyRoomCleanup();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
ws.close(ROOM_CLIENT_LEAVE_CLOSE_CODE, 'Client left room');
|
|
948
|
+
} catch {
|
|
949
|
+
// Socket already closed.
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── RoomServerAPI Implementation ───
|
|
954
|
+
|
|
955
|
+
protected buildRoomServerAPI(): RoomServerAPI {
|
|
956
|
+
return {
|
|
957
|
+
getSharedState: (): Record<string, unknown> => {
|
|
958
|
+
return cloneState(this.sharedState);
|
|
959
|
+
},
|
|
960
|
+
|
|
961
|
+
setSharedState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
|
|
962
|
+
const oldState = cloneState(this.sharedState);
|
|
963
|
+
const prevVersion = this.sharedVersion;
|
|
964
|
+
this.sharedState = updater(cloneState(this.sharedState));
|
|
965
|
+
this.sharedVersion++;
|
|
966
|
+
this.dirty = true;
|
|
967
|
+
try {
|
|
968
|
+
this.checkStateSizeLimit();
|
|
969
|
+
} catch (err) {
|
|
970
|
+
// Revert mutation
|
|
971
|
+
this.sharedState = oldState;
|
|
972
|
+
this.sharedVersion = prevVersion;
|
|
973
|
+
this.dirty = false;
|
|
974
|
+
throw err;
|
|
975
|
+
}
|
|
976
|
+
const delta = computeDelta(oldState, this.sharedState);
|
|
977
|
+
if (delta) {
|
|
978
|
+
this.queueSharedDelta(delta);
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
|
|
982
|
+
player: (userId: string): Record<string, unknown> => {
|
|
983
|
+
return cloneState(this.playerStates.get(userId) ?? {});
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
players: (): Array<[string, Record<string, unknown>]> => {
|
|
987
|
+
return Array.from(this.playerStates.entries()).map(
|
|
988
|
+
([uid, state]) => [uid, cloneState(state)] as [string, Record<string, unknown>],
|
|
989
|
+
);
|
|
990
|
+
},
|
|
991
|
+
|
|
992
|
+
setPlayerState: (userId: string, updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
|
|
993
|
+
const oldState = cloneState(this.playerStates.get(userId) ?? {});
|
|
994
|
+
const hadPrevState = this.playerStates.has(userId);
|
|
995
|
+
const newState = updater(cloneState(this.playerStates.get(userId) ?? {}));
|
|
996
|
+
this.playerStates.set(userId, newState);
|
|
997
|
+
const prevVer = this.playerVersions.get(userId) ?? 0;
|
|
998
|
+
const ver = prevVer + 1;
|
|
999
|
+
this.playerVersions.set(userId, ver);
|
|
1000
|
+
this.dirty = true;
|
|
1001
|
+
try {
|
|
1002
|
+
this.checkStateSizeLimit();
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
// Revert mutation
|
|
1005
|
+
if (hadPrevState) {
|
|
1006
|
+
this.playerStates.set(userId, oldState);
|
|
1007
|
+
} else {
|
|
1008
|
+
this.playerStates.delete(userId);
|
|
1009
|
+
}
|
|
1010
|
+
this.playerVersions.set(userId, prevVer);
|
|
1011
|
+
this.dirty = false;
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
const delta = computeDelta(oldState, newState);
|
|
1015
|
+
if (delta) {
|
|
1016
|
+
this.sendPlayerDelta(userId, delta, ver);
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
|
|
1020
|
+
getServerState: (): Record<string, unknown> => {
|
|
1021
|
+
return cloneState(this.serverState);
|
|
1022
|
+
},
|
|
1023
|
+
|
|
1024
|
+
setServerState: (updater: (s: Record<string, unknown>) => Record<string, unknown>): void => {
|
|
1025
|
+
const oldState = this.serverState;
|
|
1026
|
+
this.serverState = updater(cloneState(this.serverState));
|
|
1027
|
+
this.dirty = true;
|
|
1028
|
+
try {
|
|
1029
|
+
this.checkStateSizeLimit();
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
// Revert mutation
|
|
1032
|
+
this.serverState = oldState;
|
|
1033
|
+
this.dirty = false;
|
|
1034
|
+
throw err;
|
|
1035
|
+
}
|
|
1036
|
+
// No broadcast — server-only state
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
sendMessage: (type: string, data?: unknown, options?: { exclude?: string[] }): void => {
|
|
1040
|
+
this.broadcastToAuthenticated(
|
|
1041
|
+
{
|
|
1042
|
+
type: 'message',
|
|
1043
|
+
messageType: type,
|
|
1044
|
+
data: data ?? {},
|
|
1045
|
+
},
|
|
1046
|
+
undefined,
|
|
1047
|
+
options?.exclude,
|
|
1048
|
+
);
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
sendMessageTo: (userId: string, type: string, data?: unknown): void => {
|
|
1052
|
+
this.sendMessageToUser(userId, {
|
|
1053
|
+
type: 'message',
|
|
1054
|
+
messageType: type,
|
|
1055
|
+
data: data ?? {},
|
|
1056
|
+
});
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
kick: async (userId: string): Promise<void> => {
|
|
1060
|
+
await this.kickPlayer(userId);
|
|
1061
|
+
},
|
|
1062
|
+
|
|
1063
|
+
saveState: async (): Promise<void> => {
|
|
1064
|
+
await this.persistState();
|
|
1065
|
+
},
|
|
1066
|
+
|
|
1067
|
+
setTimer: (name: string, ms: number, data?: unknown): void => {
|
|
1068
|
+
if (ms < 0) throw new Error('Timer delay must be non-negative');
|
|
1069
|
+
if (!getRoomTimerHandlers(this.namespaceConfig ?? undefined)?.[name]) {
|
|
1070
|
+
throw new Error(`No onTimer handler for '${name}'`);
|
|
1071
|
+
}
|
|
1072
|
+
this._timers.set(name, { fireAt: Date.now() + ms, data });
|
|
1073
|
+
this.dirty = true;
|
|
1074
|
+
this._scheduleNextAlarm();
|
|
1075
|
+
},
|
|
1076
|
+
|
|
1077
|
+
clearTimer: (name: string): void => {
|
|
1078
|
+
this._timers.delete(name);
|
|
1079
|
+
this._scheduleNextAlarm();
|
|
1080
|
+
},
|
|
1081
|
+
|
|
1082
|
+
setMetadata: (data: Record<string, unknown>): void => {
|
|
1083
|
+
this._metadata = data;
|
|
1084
|
+
void this.ctx.storage.put('roomMetadata', data);
|
|
1085
|
+
},
|
|
1086
|
+
|
|
1087
|
+
getMetadata: (): Record<string, unknown> => {
|
|
1088
|
+
return cloneState(this._metadata);
|
|
1089
|
+
},
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ─── Handler Context Builder ───
|
|
1094
|
+
|
|
1095
|
+
protected buildHandlerContext(): RoomHandlerContext {
|
|
1096
|
+
const ctx = buildFunctionContext({
|
|
1097
|
+
request: new Request('http://internal/room/action'),
|
|
1098
|
+
auth: null,
|
|
1099
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1100
|
+
databaseNamespace: this.env.DATABASE as any,
|
|
1101
|
+
authNamespace: this.env.AUTH as any,
|
|
1102
|
+
d1Database: this.env.AUTH_DB as any,
|
|
1103
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
1104
|
+
config: this.config,
|
|
1105
|
+
env: this.env as never,
|
|
1106
|
+
executionCtx: this.ctx as never,
|
|
1107
|
+
serviceKey: resolveRootServiceKey(this.config, this.env as never),
|
|
1108
|
+
// Room handlers run inside a DO and should always talk to DB DOs directly.
|
|
1109
|
+
preferDirectDoDb: true,
|
|
1110
|
+
});
|
|
1111
|
+
return {
|
|
1112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1113
|
+
admin: ctx.admin as any,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ─── Sender Builder ───
|
|
1118
|
+
|
|
1119
|
+
protected buildSender(meta: RoomWSMeta): RoomSender {
|
|
1120
|
+
return {
|
|
1121
|
+
userId: meta.userId!,
|
|
1122
|
+
connectionId: meta.connectionId,
|
|
1123
|
+
role: meta.role,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
protected buildAuthFromMeta(meta: RoomWSMeta): SharedAuthContext {
|
|
1128
|
+
return meta.auth ?? {
|
|
1129
|
+
id: meta.userId ?? '',
|
|
1130
|
+
role: meta.role,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ─── State Persistence (DO Storage) ───
|
|
1135
|
+
|
|
1136
|
+
private startSaveTimer(): void {
|
|
1137
|
+
if (this.saveTimer) return;
|
|
1138
|
+
const interval = this.namespaceConfig?.stateSaveInterval ?? DEFAULT_STATE_SAVE_INTERVAL_MS;
|
|
1139
|
+
this.saveTimer = setInterval(async () => {
|
|
1140
|
+
if (this.dirty) {
|
|
1141
|
+
await this.persistState();
|
|
1142
|
+
}
|
|
1143
|
+
}, interval);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private stopSaveTimer(): void {
|
|
1147
|
+
if (this.saveTimer) {
|
|
1148
|
+
clearInterval(this.saveTimer);
|
|
1149
|
+
this.saveTimer = null;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private async persistState(): Promise<void> {
|
|
1154
|
+
await this.ctx.storage.put('roomState', {
|
|
1155
|
+
sharedState: this.sharedState,
|
|
1156
|
+
playerStates: Object.fromEntries(this.playerStates),
|
|
1157
|
+
serverState: this.serverState,
|
|
1158
|
+
sharedVersion: this.sharedVersion,
|
|
1159
|
+
playerVersions: Object.fromEntries(this.playerVersions),
|
|
1160
|
+
savedAt: Date.now(),
|
|
1161
|
+
});
|
|
1162
|
+
// Persist named timers
|
|
1163
|
+
if (this._timers.size > 0) {
|
|
1164
|
+
await this.ctx.storage.put('roomTimers', Object.fromEntries(this._timers));
|
|
1165
|
+
} else {
|
|
1166
|
+
await this.ctx.storage.delete('roomTimers');
|
|
1167
|
+
}
|
|
1168
|
+
this.dirty = false;
|
|
1169
|
+
// Set TTL alarm as safety net for orphaned storage cleanup
|
|
1170
|
+
const ttl = this.namespaceConfig?.stateTTL ?? DEFAULT_STATE_TTL_MS;
|
|
1171
|
+
this._stateTTLAlarmAt = Date.now() + ttl;
|
|
1172
|
+
this._scheduleNextAlarm();
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
private async recoverFromStorage(): Promise<void> {
|
|
1176
|
+
const saved = await this.ctx.storage.get('roomState') as Record<string, unknown> | undefined;
|
|
1177
|
+
const ttl = this.namespaceConfig?.stateTTL ?? DEFAULT_STATE_TTL_MS;
|
|
1178
|
+
|
|
1179
|
+
if (saved && typeof saved.savedAt === 'number' && (Date.now() - saved.savedAt) < ttl) {
|
|
1180
|
+
// TTL valid — recover all 3 state areas
|
|
1181
|
+
this.sharedState = (saved.sharedState as Record<string, unknown>) ?? {};
|
|
1182
|
+
this.serverState = (saved.serverState as Record<string, unknown>) ?? {};
|
|
1183
|
+
this.sharedVersion = (saved.sharedVersion as number) ?? 0;
|
|
1184
|
+
|
|
1185
|
+
const playerStatesObj = (saved.playerStates as Record<string, Record<string, unknown>>) ?? {};
|
|
1186
|
+
this.playerStates = new Map(Object.entries(playerStatesObj));
|
|
1187
|
+
|
|
1188
|
+
const playerVersionsObj = (saved.playerVersions as Record<string, number>) ?? {};
|
|
1189
|
+
this.playerVersions = new Map(Object.entries(playerVersionsObj));
|
|
1190
|
+
} else {
|
|
1191
|
+
// TTL expired — discard and start fresh
|
|
1192
|
+
await this.ctx.storage.delete('roomState');
|
|
1193
|
+
await this.ctx.storage.delete('roomTimers');
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Recover named timers
|
|
1197
|
+
const savedTimers = await this.ctx.storage.get('roomTimers') as Record<string, { fireAt: number; data?: unknown }> | undefined;
|
|
1198
|
+
if (savedTimers) {
|
|
1199
|
+
this._timers = new Map(Object.entries(savedTimers));
|
|
1200
|
+
this._scheduleNextAlarm();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Recover metadata
|
|
1204
|
+
const savedMeta = await this.ctx.storage.get('roomMetadata') as Record<string, unknown> | undefined;
|
|
1205
|
+
if (savedMeta) {
|
|
1206
|
+
this._metadata = savedMeta;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
this.startSaveTimer();
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// ─── Delta Broadcasting ───
|
|
1213
|
+
|
|
1214
|
+
/** Queue shared state delta (batched, broadcast to all) */
|
|
1215
|
+
private queueSharedDelta(delta: Record<string, unknown>): void {
|
|
1216
|
+
if (!this.pendingSharedDelta) {
|
|
1217
|
+
this.pendingSharedDelta = {};
|
|
1218
|
+
}
|
|
1219
|
+
Object.assign(this.pendingSharedDelta, delta);
|
|
1220
|
+
|
|
1221
|
+
if (!this.sharedDeltaBatchTimer) {
|
|
1222
|
+
const batchMs = DEFAULT_DELTA_BATCH_MS;
|
|
1223
|
+
this.sharedDeltaBatchTimer = setTimeout(() => {
|
|
1224
|
+
this.flushSharedDelta();
|
|
1225
|
+
}, batchMs);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private flushSharedDelta(): void {
|
|
1230
|
+
if (!this.pendingSharedDelta) return;
|
|
1231
|
+
|
|
1232
|
+
// Cancel batch timer if still pending
|
|
1233
|
+
if (this.sharedDeltaBatchTimer) {
|
|
1234
|
+
clearTimeout(this.sharedDeltaBatchTimer);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
this.broadcastToAuthenticated({
|
|
1238
|
+
type: 'shared_delta',
|
|
1239
|
+
delta: this.pendingSharedDelta,
|
|
1240
|
+
version: this.sharedVersion,
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
this.pendingSharedDelta = null;
|
|
1244
|
+
this.sharedDeltaBatchTimer = null;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** Send player state delta directly (unicast, no batching) */
|
|
1248
|
+
private sendPlayerDelta(userId: string, delta: Record<string, unknown>, version: number): void {
|
|
1249
|
+
const msg = JSON.stringify({
|
|
1250
|
+
type: 'player_delta',
|
|
1251
|
+
delta,
|
|
1252
|
+
version,
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
// Find WebSocket(s) for this userId
|
|
1256
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1257
|
+
const meta = this.getWSMeta(ws);
|
|
1258
|
+
if (meta?.authenticated && meta.userId === userId) {
|
|
1259
|
+
this.safeSendRaw(ws, msg);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ─── Send Message To User (unicast) ───
|
|
1265
|
+
|
|
1266
|
+
protected sendMessageToUser(userId: string, msg: Record<string, unknown>): void {
|
|
1267
|
+
const json = JSON.stringify(msg);
|
|
1268
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1269
|
+
const meta = this.getWSMeta(ws);
|
|
1270
|
+
if (meta?.authenticated && meta.userId === userId) {
|
|
1271
|
+
this.safeSendRaw(ws, json);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ─── Kick Player ───
|
|
1277
|
+
|
|
1278
|
+
protected async kickPlayer(userId: string): Promise<void> {
|
|
1279
|
+
// Collect all connection IDs for this user before closing
|
|
1280
|
+
const connectionsToClose: Array<{ ws: WebSocket; connectionId: string }> = [];
|
|
1281
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1282
|
+
const meta = this.getWSMeta(ws);
|
|
1283
|
+
if (meta?.userId === userId) {
|
|
1284
|
+
connectionsToClose.push({ ws, connectionId: meta.connectionId });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Remove player and finalize leave BEFORE closing WS
|
|
1289
|
+
// This ensures onLeave fires synchronously and delta is queued
|
|
1290
|
+
for (const { connectionId } of connectionsToClose) {
|
|
1291
|
+
this.players.delete(connectionId);
|
|
1292
|
+
}
|
|
1293
|
+
const conns = this.userToConnections.get(userId);
|
|
1294
|
+
if (conns) {
|
|
1295
|
+
for (const { connectionId } of connectionsToClose) {
|
|
1296
|
+
conns.delete(connectionId);
|
|
1297
|
+
}
|
|
1298
|
+
if (conns.size === 0) {
|
|
1299
|
+
this.userToConnections.delete(userId);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// Cancel any existing reconnect timer
|
|
1303
|
+
const existingTimer = this.disconnectTimers.get(userId);
|
|
1304
|
+
if (existingTimer) {
|
|
1305
|
+
clearTimeout(existingTimer);
|
|
1306
|
+
this.disconnectTimers.delete(userId);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Fire onLeave with 'kicked' reason
|
|
1310
|
+
const firstConn = connectionsToClose[0];
|
|
1311
|
+
if (firstConn) {
|
|
1312
|
+
await this.finalizePlayerLeave(userId, firstConn.connectionId, 'kicked');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Now close WebSockets (webSocketClose will find no player and skip)
|
|
1316
|
+
for (const { ws } of connectionsToClose) {
|
|
1317
|
+
try {
|
|
1318
|
+
this.safeSend(ws, { type: 'kicked' });
|
|
1319
|
+
ws.close(4004, 'Kicked');
|
|
1320
|
+
} catch {
|
|
1321
|
+
// Already closed
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ─── Player Management ───
|
|
1327
|
+
|
|
1328
|
+
protected addPlayer(connectionId: string, userId: string): void {
|
|
1329
|
+
this.players.set(connectionId, {
|
|
1330
|
+
userId,
|
|
1331
|
+
connectionId,
|
|
1332
|
+
joinedAt: Date.now(),
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
// Track userId → connectionIds
|
|
1336
|
+
let conns = this.userToConnections.get(userId);
|
|
1337
|
+
if (!conns) {
|
|
1338
|
+
conns = new Set();
|
|
1339
|
+
this.userToConnections.set(userId, conns);
|
|
1340
|
+
}
|
|
1341
|
+
conns.add(connectionId);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
protected async handleDisconnect(meta: RoomWSMeta, kicked = false, explicitLeave = false): Promise<void> {
|
|
1345
|
+
const player = this.players.get(meta.connectionId);
|
|
1346
|
+
if (!player) return;
|
|
1347
|
+
|
|
1348
|
+
// Remove this connection
|
|
1349
|
+
this.players.delete(meta.connectionId);
|
|
1350
|
+
const conns = this.userToConnections.get(player.userId);
|
|
1351
|
+
if (conns) {
|
|
1352
|
+
conns.delete(meta.connectionId);
|
|
1353
|
+
if (conns.size === 0) {
|
|
1354
|
+
this.userToConnections.delete(player.userId);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Check if user has any remaining connections
|
|
1359
|
+
const remainingConns = this.userToConnections.get(player.userId);
|
|
1360
|
+
if (!remainingConns || remainingConns.size === 0) {
|
|
1361
|
+
if (kicked) {
|
|
1362
|
+
// Kicked — immediate leave, no reconnect timer
|
|
1363
|
+
// Cancel any existing reconnect timer for this user
|
|
1364
|
+
const existing = this.disconnectTimers.get(player.userId);
|
|
1365
|
+
if (existing) {
|
|
1366
|
+
clearTimeout(existing);
|
|
1367
|
+
this.disconnectTimers.delete(player.userId);
|
|
1368
|
+
}
|
|
1369
|
+
await this.finalizePlayerLeave(player.userId, meta.connectionId, 'kicked');
|
|
1370
|
+
} else if (explicitLeave) {
|
|
1371
|
+
const existing = this.disconnectTimers.get(player.userId);
|
|
1372
|
+
if (existing) {
|
|
1373
|
+
clearTimeout(existing);
|
|
1374
|
+
this.disconnectTimers.delete(player.userId);
|
|
1375
|
+
}
|
|
1376
|
+
await this.finalizePlayerLeave(player.userId, meta.connectionId, 'leave');
|
|
1377
|
+
} else {
|
|
1378
|
+
// Normal disconnect — start reconnect timer
|
|
1379
|
+
const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT_MS;
|
|
1380
|
+
|
|
1381
|
+
if (reconnectTimeout > 0) {
|
|
1382
|
+
const timer = setTimeout(async () => {
|
|
1383
|
+
this.disconnectTimers.delete(player.userId);
|
|
1384
|
+
await this.finalizePlayerLeave(player.userId, meta.connectionId, 'disconnect');
|
|
1385
|
+
}, reconnectTimeout);
|
|
1386
|
+
this.disconnectTimers.set(player.userId, timer);
|
|
1387
|
+
} else {
|
|
1388
|
+
// Immediate leave
|
|
1389
|
+
await this.finalizePlayerLeave(player.userId, meta.connectionId, 'disconnect');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Schedule empty room cleanup if no players remain
|
|
1395
|
+
if (this.players.size === 0 && this.disconnectTimers.size === 0) {
|
|
1396
|
+
this.scheduleEmptyRoomCleanup();
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Finalize player removal: onLeave callback + cleanup */
|
|
1401
|
+
protected async finalizePlayerLeave(userId: string, connectionId: string, reason: 'leave' | 'disconnect' | 'kicked'): Promise<void> {
|
|
1402
|
+
// Call onLeave
|
|
1403
|
+
const onLeave = getRoomLifecycleHandlers(this.namespaceConfig ?? undefined)?.onLeave;
|
|
1404
|
+
if (onLeave) {
|
|
1405
|
+
try {
|
|
1406
|
+
const sender: RoomSender = { userId, connectionId };
|
|
1407
|
+
const roomApi = this.buildRoomServerAPI();
|
|
1408
|
+
const ctx = this.buildHandlerContext();
|
|
1409
|
+
await onLeave(sender, roomApi, ctx, reason);
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
console.error(`[Room] onLeave error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Clean up player state
|
|
1416
|
+
this.playerStates.delete(userId);
|
|
1417
|
+
this.playerVersions.delete(userId);
|
|
1418
|
+
|
|
1419
|
+
// Check if room is empty
|
|
1420
|
+
if (this.players.size === 0 && this.disconnectTimers.size === 0) {
|
|
1421
|
+
await this.handleRoomEmpty();
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** Handle room becoming completely empty */
|
|
1426
|
+
private async handleRoomEmpty(): Promise<void> {
|
|
1427
|
+
// Call onDestroy
|
|
1428
|
+
const onDestroy = getRoomLifecycleHandlers(this.namespaceConfig ?? undefined)?.onDestroy;
|
|
1429
|
+
if (onDestroy) {
|
|
1430
|
+
try {
|
|
1431
|
+
const roomApi = this.buildRoomServerAPI();
|
|
1432
|
+
const ctx = this.buildHandlerContext();
|
|
1433
|
+
await onDestroy(roomApi, ctx);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
console.error(`[Room] onDestroy error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Clean up state persistence
|
|
1440
|
+
this.stopSaveTimer();
|
|
1441
|
+
this._timers.clear();
|
|
1442
|
+
this._metadata = {};
|
|
1443
|
+
await this.ctx.storage.delete('roomState');
|
|
1444
|
+
await this.ctx.storage.delete('roomTimers');
|
|
1445
|
+
await this.ctx.storage.delete('roomMetadata');
|
|
1446
|
+
|
|
1447
|
+
this.scheduleEmptyRoomCleanup();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private getPlayersArray(): Array<{ userId: string; connectionId: string }> {
|
|
1451
|
+
return Array.from(this.players.values()).map(p => ({
|
|
1452
|
+
userId: p.userId,
|
|
1453
|
+
connectionId: p.connectionId,
|
|
1454
|
+
}));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
protected safeSend(ws: WebSocket, msg: Record<string, unknown>): void {
|
|
1458
|
+
this.safeSendRaw(ws, JSON.stringify(msg));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
protected safeSendRaw(ws: WebSocket, msg: string): void {
|
|
1462
|
+
try {
|
|
1463
|
+
ws.send(msg);
|
|
1464
|
+
} catch {
|
|
1465
|
+
// Socket may already be closed while async work is finishing.
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// ─── Broadcast Helpers ───
|
|
1470
|
+
|
|
1471
|
+
private broadcastToAll(msg: Record<string, unknown>): void {
|
|
1472
|
+
const json = JSON.stringify(msg);
|
|
1473
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1474
|
+
this.safeSendRaw(ws, json);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
protected broadcastToAuthenticated(
|
|
1479
|
+
msg: Record<string, unknown>,
|
|
1480
|
+
excludeConnectionId?: string,
|
|
1481
|
+
excludeUserIds?: string[],
|
|
1482
|
+
): void {
|
|
1483
|
+
const json = JSON.stringify(msg);
|
|
1484
|
+
const excludeSet = excludeUserIds?.length ? new Set(excludeUserIds) : null;
|
|
1485
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1486
|
+
const meta = this.getWSMeta(ws);
|
|
1487
|
+
if (
|
|
1488
|
+
meta?.authenticated &&
|
|
1489
|
+
meta.connectionId !== excludeConnectionId &&
|
|
1490
|
+
(!excludeSet || !excludeSet.has(meta.userId!))
|
|
1491
|
+
) {
|
|
1492
|
+
this.safeSendRaw(ws, json);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// ─── State Size Enforcement ───
|
|
1498
|
+
|
|
1499
|
+
private getTotalStateSize(): number {
|
|
1500
|
+
let size = JSON.stringify(this.sharedState).length;
|
|
1501
|
+
for (const state of this.playerStates.values()) {
|
|
1502
|
+
size += JSON.stringify(state).length;
|
|
1503
|
+
}
|
|
1504
|
+
size += JSON.stringify(this.serverState).length;
|
|
1505
|
+
return size;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
private checkStateSizeLimit(): void {
|
|
1509
|
+
const limit = this.namespaceConfig?.maxStateSize ?? DEFAULT_MAX_STATE_SIZE;
|
|
1510
|
+
const size = this.getTotalStateSize();
|
|
1511
|
+
if (size > limit) {
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
`Room state size (${size} bytes) exceeds maxStateSize limit (${limit} bytes)`,
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ─── Rate Limiting (Token Bucket) ───
|
|
1519
|
+
|
|
1520
|
+
protected checkRateLimit(connectionId: string): boolean {
|
|
1521
|
+
const now = Date.now();
|
|
1522
|
+
const maxActions = this.namespaceConfig?.rateLimit?.actions ?? DEFAULT_RATE_LIMIT_ACTIONS;
|
|
1523
|
+
let bucket = this.rateBuckets.get(connectionId);
|
|
1524
|
+
|
|
1525
|
+
if (!bucket) {
|
|
1526
|
+
bucket = { tokens: maxActions, lastRefill: now };
|
|
1527
|
+
this.rateBuckets.set(connectionId, bucket);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Refill tokens (1 token per 1000/maxActions ms)
|
|
1531
|
+
const elapsed = now - bucket.lastRefill;
|
|
1532
|
+
const refill = (elapsed / 1000) * maxActions;
|
|
1533
|
+
bucket.tokens = Math.min(maxActions, bucket.tokens + refill);
|
|
1534
|
+
bucket.lastRefill = now;
|
|
1535
|
+
|
|
1536
|
+
if (bucket.tokens >= 1) {
|
|
1537
|
+
bucket.tokens -= 1;
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ─── Empty Room Cleanup ───
|
|
1544
|
+
|
|
1545
|
+
private scheduleEmptyRoomCleanup(): void {
|
|
1546
|
+
this._emptyRoomCleanupAt = Date.now() + EMPTY_ROOM_CLEANUP_DELAY_MS;
|
|
1547
|
+
this._scheduleNextAlarm();
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ─── WebSocket Metadata (Hibernation API) ───
|
|
1551
|
+
|
|
1552
|
+
protected getWSMeta(ws: WebSocket): RoomWSMeta | null {
|
|
1553
|
+
const cached = this._metaCache.get(ws);
|
|
1554
|
+
if (cached) return cached;
|
|
1555
|
+
|
|
1556
|
+
// After hibernation wake-up: rebuild from tags
|
|
1557
|
+
try {
|
|
1558
|
+
const tags = this.ctx.getTags(ws);
|
|
1559
|
+
if (tags.length === 0) return null;
|
|
1560
|
+
|
|
1561
|
+
const connTag = tags.find(t => t.startsWith('conn:'));
|
|
1562
|
+
const connectionId = connTag ? connTag.substring(5) : tags[0];
|
|
1563
|
+
const ipTag = tags.find(t => t.startsWith('ip:'));
|
|
1564
|
+
const ip = ipTag ? decodeURIComponent(ipTag.substring(3)) : undefined;
|
|
1565
|
+
|
|
1566
|
+
// Recover room name if lost due to hibernation
|
|
1567
|
+
if (!this.namespace) {
|
|
1568
|
+
const roomTag = tags.find(t => t.startsWith('room:'));
|
|
1569
|
+
if (roomTag) {
|
|
1570
|
+
const roomFullName = roomTag.substring(5);
|
|
1571
|
+
const separatorIdx = roomFullName.indexOf('::');
|
|
1572
|
+
if (separatorIdx >= 0) {
|
|
1573
|
+
this.namespace = roomFullName.substring(0, separatorIdx);
|
|
1574
|
+
this.roomId = roomFullName.substring(separatorIdx + 2);
|
|
1575
|
+
} else {
|
|
1576
|
+
this.namespace = roomFullName;
|
|
1577
|
+
this.roomId = roomFullName;
|
|
1578
|
+
}
|
|
1579
|
+
this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const meta: RoomWSMeta = {
|
|
1584
|
+
authenticated: false, // Must re-auth after hibernation
|
|
1585
|
+
connectionId,
|
|
1586
|
+
ip,
|
|
1587
|
+
};
|
|
1588
|
+
this._metaCache.set(ws, meta);
|
|
1589
|
+
return meta;
|
|
1590
|
+
} catch {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
protected setWSMeta(ws: WebSocket, meta: RoomWSMeta): void {
|
|
1596
|
+
this._metaCache.set(ws, meta);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// ─── Config ───
|
|
1600
|
+
|
|
1601
|
+
private parseConfig(env: RoomDOEnv): EdgeBaseConfig {
|
|
1602
|
+
return getGlobalConfig(env);
|
|
1603
|
+
}
|
|
1604
|
+
}
|