@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.
Files changed (309) hide show
  1. package/admin-build/.gitkeep +0 -0
  2. package/admin-build/_app/env.js +1 -0
  3. package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
  4. package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
  5. package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
  6. package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
  7. package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
  8. package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
  9. package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
  10. package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
  11. package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
  12. package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
  13. package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
  14. package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
  15. package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
  16. package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
  17. package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
  18. package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
  19. package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
  20. package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
  21. package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
  22. package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
  23. package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
  24. package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
  25. package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
  26. package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
  27. package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
  28. package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
  29. package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
  30. package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
  31. package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
  32. package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
  33. package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
  34. package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
  35. package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
  36. package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
  37. package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
  38. package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
  39. package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
  40. package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
  41. package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
  42. package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
  43. package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
  44. package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
  45. package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
  46. package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
  47. package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
  48. package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
  49. package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
  50. package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
  51. package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
  52. package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
  53. package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
  54. package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
  55. package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
  56. package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
  57. package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
  58. package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
  59. package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
  60. package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
  61. package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
  62. package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
  63. package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
  64. package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
  65. package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
  66. package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
  67. package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
  68. package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
  69. package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
  70. package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
  71. package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
  72. package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
  73. package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
  74. package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
  75. package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
  76. package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
  77. package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
  78. package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
  79. package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
  80. package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
  81. package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
  82. package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
  83. package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
  84. package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
  85. package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
  86. package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
  87. package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
  88. package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
  89. package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
  90. package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
  91. package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
  92. package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
  93. package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
  94. package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
  95. package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
  96. package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
  97. package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
  98. package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
  99. package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
  100. package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
  101. package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
  102. package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
  103. package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
  104. package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
  105. package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
  106. package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
  107. package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
  108. package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
  109. package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
  110. package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
  111. package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
  112. package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
  113. package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
  114. package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
  115. package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
  116. package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
  117. package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
  118. package/admin-build/_app/version.json +1 -0
  119. package/admin-build/favicon.svg +26 -0
  120. package/admin-build/index.html +45 -0
  121. package/openapi.json +19543 -0
  122. package/package.json +66 -0
  123. package/src/__tests__/admin-assets.test.ts +55 -0
  124. package/src/__tests__/admin-data-routes.test.ts +488 -0
  125. package/src/__tests__/admin-db-target.test.ts +103 -0
  126. package/src/__tests__/admin-routing.test.ts +31 -0
  127. package/src/__tests__/admin-user-management.test.ts +311 -0
  128. package/src/__tests__/analytics-query.test.ts +75 -0
  129. package/src/__tests__/auth-d1.test.ts +749 -0
  130. package/src/__tests__/auth-db-adapter.test.ts +73 -0
  131. package/src/__tests__/auth-jwt.test.ts +440 -0
  132. package/src/__tests__/auth-oauth.test.ts +389 -0
  133. package/src/__tests__/auth-password.test.ts +367 -0
  134. package/src/__tests__/auth-redirect.test.ts +87 -0
  135. package/src/__tests__/backup-restore.test.ts +711 -0
  136. package/src/__tests__/broadcast.test.ts +128 -0
  137. package/src/__tests__/cli.test.ts +178 -0
  138. package/src/__tests__/cloudflare-realtime.test.ts +113 -0
  139. package/src/__tests__/config.test.ts +469 -0
  140. package/src/__tests__/cors.test.ts +154 -0
  141. package/src/__tests__/cron.test.ts +302 -0
  142. package/src/__tests__/d1-handler.test.ts +402 -0
  143. package/src/__tests__/d1-sql.test.ts +120 -0
  144. package/src/__tests__/database-live-config.test.ts +42 -0
  145. package/src/__tests__/database-live-emitter.test.ts +56 -0
  146. package/src/__tests__/database-live-filters.test.ts +63 -0
  147. package/src/__tests__/database-live-route.test.ts +113 -0
  148. package/src/__tests__/db-sql.test.ts +163 -0
  149. package/src/__tests__/do-lifecycle.test.ts +263 -0
  150. package/src/__tests__/do-router.test.ts +729 -0
  151. package/src/__tests__/email-provider.test.ts +128 -0
  152. package/src/__tests__/email-templates.test.ts +528 -0
  153. package/src/__tests__/error-format.test.ts +250 -0
  154. package/src/__tests__/field-ops.test.ts +242 -0
  155. package/src/__tests__/functions-context.test.ts +334 -0
  156. package/src/__tests__/functions-d1-proxy.test.ts +229 -0
  157. package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
  158. package/src/__tests__/functions-route.test.ts +139 -0
  159. package/src/__tests__/internal-request.test.ts +77 -0
  160. package/src/__tests__/log-writer.test.ts +44 -0
  161. package/src/__tests__/logger.test.ts +58 -0
  162. package/src/__tests__/meta-admin-proxy.test.ts +48 -0
  163. package/src/__tests__/meta-export-coverage.test.ts +191 -0
  164. package/src/__tests__/meta-route-registration.test.ts +47 -0
  165. package/src/__tests__/namespace-dump.test.ts +28 -0
  166. package/src/__tests__/oauth-providers.test.ts +337 -0
  167. package/src/__tests__/openapi-coverage.test.ts +144 -0
  168. package/src/__tests__/pagination.test.ts +59 -0
  169. package/src/__tests__/password-policy.test.ts +191 -0
  170. package/src/__tests__/plugin-migrations.test.ts +379 -0
  171. package/src/__tests__/postgres-batch-compat.test.ts +133 -0
  172. package/src/__tests__/postgres-dialect.test.ts +328 -0
  173. package/src/__tests__/postgres-executor.test.ts +79 -0
  174. package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
  175. package/src/__tests__/postgres-schema-init.test.ts +105 -0
  176. package/src/__tests__/postgres-table-utils.test.ts +107 -0
  177. package/src/__tests__/presence.test.ts +199 -0
  178. package/src/__tests__/provider.test.ts +550 -0
  179. package/src/__tests__/public-user-profile.test.ts +339 -0
  180. package/src/__tests__/push-handlers.test.ts +179 -0
  181. package/src/__tests__/push-provider.test.ts +80 -0
  182. package/src/__tests__/push-token.test.ts +418 -0
  183. package/src/__tests__/query.test.ts +771 -0
  184. package/src/__tests__/rate-limit.test.ts +260 -0
  185. package/src/__tests__/room-access-policy.test.ts +101 -0
  186. package/src/__tests__/room-handler-context.test.ts +130 -0
  187. package/src/__tests__/room-monitoring.test.ts +138 -0
  188. package/src/__tests__/room-runtime-routing.test.ts +222 -0
  189. package/src/__tests__/room.test.ts +254 -0
  190. package/src/__tests__/route-parser.test.ts +490 -0
  191. package/src/__tests__/rules.test.ts +234 -0
  192. package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
  193. package/src/__tests__/scheduled.test.ts +80 -0
  194. package/src/__tests__/schema.test.ts +1273 -0
  195. package/src/__tests__/security-hardening.test.ts +312 -0
  196. package/src/__tests__/server.unit.test.ts +333 -0
  197. package/src/__tests__/service-key-db-proxy.test.ts +650 -0
  198. package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
  199. package/src/__tests__/service-key.test.ts +757 -0
  200. package/src/__tests__/smoke-skip-report.test.ts +72 -0
  201. package/src/__tests__/sms-provider.test.ts +39 -0
  202. package/src/__tests__/sql-route.test.ts +218 -0
  203. package/src/__tests__/storage-hook-context.test.ts +115 -0
  204. package/src/__tests__/totp.test.ts +200 -0
  205. package/src/__tests__/uuid.test.ts +144 -0
  206. package/src/__tests__/validation.test.ts +773 -0
  207. package/src/__tests__/websocket-pending.test.ts +163 -0
  208. package/src/_functions-registry.ts +51 -0
  209. package/src/bench-entry.ts +9 -0
  210. package/src/cloudflare-test.d.ts +1 -0
  211. package/src/durable-objects/auth-do.ts +49 -0
  212. package/src/durable-objects/database-do.ts +2240 -0
  213. package/src/durable-objects/database-live-do.ts +949 -0
  214. package/src/durable-objects/logs-do.ts +1200 -0
  215. package/src/durable-objects/room-runtime-base.ts +1604 -0
  216. package/src/durable-objects/rooms-do.ts +2191 -0
  217. package/src/generated-config.ts +6 -0
  218. package/src/index.ts +382 -0
  219. package/src/lib/admin-assets.ts +54 -0
  220. package/src/lib/admin-db-target.ts +301 -0
  221. package/src/lib/admin-routing.ts +35 -0
  222. package/src/lib/admin-user-management.ts +464 -0
  223. package/src/lib/analytics-adapter.ts +103 -0
  224. package/src/lib/analytics-query.ts +579 -0
  225. package/src/lib/auth-d1-service.ts +1193 -0
  226. package/src/lib/auth-d1.ts +1056 -0
  227. package/src/lib/auth-db-adapter.ts +289 -0
  228. package/src/lib/auth-redirect.ts +116 -0
  229. package/src/lib/cidr.ts +115 -0
  230. package/src/lib/client-ip.ts +51 -0
  231. package/src/lib/cloudflare-realtime.ts +251 -0
  232. package/src/lib/control-db.ts +36 -0
  233. package/src/lib/cron.ts +163 -0
  234. package/src/lib/d1-handler.ts +1425 -0
  235. package/src/lib/d1-schema-init.ts +255 -0
  236. package/src/lib/d1-sql.ts +33 -0
  237. package/src/lib/database-live-config.ts +24 -0
  238. package/src/lib/database-live-emitter.ts +111 -0
  239. package/src/lib/db-sql.ts +66 -0
  240. package/src/lib/do-retry.ts +36 -0
  241. package/src/lib/do-router.ts +270 -0
  242. package/src/lib/do-sql.ts +73 -0
  243. package/src/lib/email-provider.ts +379 -0
  244. package/src/lib/email-templates.ts +285 -0
  245. package/src/lib/email-translations.ts +422 -0
  246. package/src/lib/errors.ts +151 -0
  247. package/src/lib/functions.ts +2091 -0
  248. package/src/lib/hono.ts +56 -0
  249. package/src/lib/internal-request.ts +56 -0
  250. package/src/lib/jwt.ts +354 -0
  251. package/src/lib/log-writer.ts +272 -0
  252. package/src/lib/namespace-dump.ts +125 -0
  253. package/src/lib/oauth-providers.ts +1225 -0
  254. package/src/lib/op-parser.ts +99 -0
  255. package/src/lib/openapi.ts +146 -0
  256. package/src/lib/pagination.ts +19 -0
  257. package/src/lib/password-policy.ts +102 -0
  258. package/src/lib/password.ts +145 -0
  259. package/src/lib/plugin-migrations.ts +612 -0
  260. package/src/lib/postgres-executor.ts +203 -0
  261. package/src/lib/postgres-handler.ts +1102 -0
  262. package/src/lib/postgres-schema-init.ts +341 -0
  263. package/src/lib/postgres-table-utils.ts +87 -0
  264. package/src/lib/public-user-profile.ts +187 -0
  265. package/src/lib/push-provider.ts +409 -0
  266. package/src/lib/push-token.ts +294 -0
  267. package/src/lib/query-engine.ts +768 -0
  268. package/src/lib/room-monitoring.ts +97 -0
  269. package/src/lib/room-runtime.ts +14 -0
  270. package/src/lib/route-parser.ts +434 -0
  271. package/src/lib/schema.ts +538 -0
  272. package/src/lib/schemas.ts +152 -0
  273. package/src/lib/service-key.ts +419 -0
  274. package/src/lib/sms-provider.ts +230 -0
  275. package/src/lib/startup-config.ts +99 -0
  276. package/src/lib/totp.ts +242 -0
  277. package/src/lib/uuid.ts +87 -0
  278. package/src/lib/validation.ts +205 -0
  279. package/src/lib/version.ts +2 -0
  280. package/src/lib/websocket-pending.ts +40 -0
  281. package/src/middleware/auth.ts +169 -0
  282. package/src/middleware/captcha-verify.ts +217 -0
  283. package/src/middleware/cors.ts +159 -0
  284. package/src/middleware/error-handler.ts +54 -0
  285. package/src/middleware/internal-guard.ts +26 -0
  286. package/src/middleware/logger.ts +126 -0
  287. package/src/middleware/rate-limit.ts +283 -0
  288. package/src/middleware/rules.ts +475 -0
  289. package/src/routes/admin-auth.ts +447 -0
  290. package/src/routes/admin.ts +3501 -0
  291. package/src/routes/analytics-api.ts +290 -0
  292. package/src/routes/auth.ts +4222 -0
  293. package/src/routes/backup.ts +1466 -0
  294. package/src/routes/config.ts +53 -0
  295. package/src/routes/d1.ts +109 -0
  296. package/src/routes/database-live.ts +281 -0
  297. package/src/routes/functions.ts +155 -0
  298. package/src/routes/health.ts +32 -0
  299. package/src/routes/kv.ts +167 -0
  300. package/src/routes/oauth.ts +1055 -0
  301. package/src/routes/push.ts +1465 -0
  302. package/src/routes/room.ts +639 -0
  303. package/src/routes/schema-endpoint.ts +76 -0
  304. package/src/routes/sql.ts +176 -0
  305. package/src/routes/storage.ts +1674 -0
  306. package/src/routes/tables.ts +699 -0
  307. package/src/routes/users.ts +21 -0
  308. package/src/routes/vectorize.ts +372 -0
  309. 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
+ }