@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,2191 @@
1
+ import {
2
+ getRoomHooks,
3
+ type AuthContext as SharedAuthContext,
4
+ type RoomMemberInfo,
5
+ type RoomSender,
6
+ type RoomServerAPI,
7
+ } from '@edge-base/shared';
8
+ import {
9
+ createCloudflareRealtimeClient,
10
+ type CloudflareRealtimeCloseTracksRequest,
11
+ type CloudflareRealtimeNewSessionRequest,
12
+ type CloudflareRealtimeNewSessionResponse,
13
+ type CloudflareRealtimeRenegotiateRequest,
14
+ type CloudflareRealtimeTracksRequest,
15
+ type CloudflareRealtimeTracksResponse,
16
+ } from '../lib/cloudflare-realtime.js';
17
+ import { resolveAuthContextFromToken } from '../middleware/auth.js';
18
+ import type { Env } from '../types.js';
19
+ import { RoomRuntimeBaseDO, type RoomWSMeta } from './room-runtime-base.js';
20
+
21
+ /**
22
+ * Parallel `rooms` runtime entrypoint.
23
+ *
24
+ * It currently preserves legacy Room behavior while giving rollout routing a
25
+ * separate Durable Object class to target. New room-runtime capabilities can
26
+ * evolve here without mutating the legacy RoomDO class in place.
27
+ */
28
+
29
+ interface SignalMessage {
30
+ type: 'signal';
31
+ event: string;
32
+ payload?: unknown;
33
+ memberId?: string | null;
34
+ includeSelf?: boolean;
35
+ requestId?: string;
36
+ }
37
+
38
+ interface MemberStateMessage {
39
+ type: 'member_state';
40
+ state?: Record<string, unknown>;
41
+ requestId?: string;
42
+ }
43
+
44
+ interface MemberStateClearMessage {
45
+ type: 'member_state_clear';
46
+ requestId?: string;
47
+ }
48
+
49
+ interface AdminMessage {
50
+ type: 'admin';
51
+ operation: string;
52
+ memberId?: string | null;
53
+ payload?: Record<string, unknown>;
54
+ requestId?: string;
55
+ }
56
+
57
+ type MediaKind = 'audio' | 'video' | 'screen';
58
+
59
+ interface MediaMessage {
60
+ type: 'media';
61
+ operation: 'publish' | 'unpublish' | 'mute' | 'device';
62
+ kind?: MediaKind;
63
+ payload?: Record<string, unknown>;
64
+ requestId?: string;
65
+ }
66
+
67
+ interface SignalFrameMeta {
68
+ memberId: string | null;
69
+ userId: string | null;
70
+ connectionId: string | null;
71
+ sentAt: number;
72
+ serverSent: boolean;
73
+ }
74
+
75
+ interface RoomMemberMediaKindState {
76
+ published: boolean;
77
+ muted: boolean;
78
+ trackId?: string;
79
+ deviceId?: string;
80
+ publishedAt?: number;
81
+ adminDisabled?: boolean;
82
+ providerSessionId?: string;
83
+ }
84
+
85
+ interface RoomMemberMediaState {
86
+ audio?: RoomMemberMediaKindState;
87
+ video?: RoomMemberMediaKindState;
88
+ screen?: RoomMemberMediaKindState;
89
+ }
90
+
91
+ interface RoomMemberPresence {
92
+ memberId: string;
93
+ userId: string;
94
+ joinedAt: number;
95
+ connectionIds: Set<string>;
96
+ state: Record<string, unknown>;
97
+ }
98
+
99
+ interface RoomMemberRealtimeSession {
100
+ sessionId: string;
101
+ connectionId?: string;
102
+ createdAt: number;
103
+ updatedAt: number;
104
+ }
105
+
106
+ type RoomMemberSnapshot = RoomMemberInfo & { state: Record<string, unknown> };
107
+ type RoomMemberLeaveReason = 'leave' | 'timeout' | 'kicked';
108
+
109
+ const SYSTEM_SIGNAL_SENDER: RoomSender = {
110
+ userId: 'system',
111
+ connectionId: 'server',
112
+ };
113
+
114
+ const DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS = 30000;
115
+ const SIGNAL_DENIED = Symbol('rooms.signal.denied');
116
+ const MEDIA_DENIED = Symbol('rooms.media.denied');
117
+ const WEBSOCKET_OPEN = 1;
118
+
119
+ function computeStateDelta(
120
+ previous: Record<string, unknown>,
121
+ next: Record<string, unknown>,
122
+ ): Record<string, unknown> | null {
123
+ const delta: Record<string, unknown> = {};
124
+ let hasChanges = false;
125
+
126
+ for (const key of Object.keys(next)) {
127
+ if (JSON.stringify(previous[key]) !== JSON.stringify(next[key])) {
128
+ delta[key] = next[key];
129
+ hasChanges = true;
130
+ }
131
+ }
132
+ for (const key of Object.keys(previous)) {
133
+ if (!(key in next)) {
134
+ delta[key] = null;
135
+ hasChanges = true;
136
+ }
137
+ }
138
+
139
+ return hasChanges ? delta : null;
140
+ }
141
+
142
+ export class RoomsDO extends RoomRuntimeBaseDO {
143
+ private readonly joinedConnectionIds = new Set<string>();
144
+ private readonly members = new Map<string, RoomMemberPresence>();
145
+ private readonly blockedMembers = new Set<string>();
146
+ private readonly memberRoles = new Map<string, string>();
147
+ private readonly memberMediaStates = new Map<string, RoomMemberMediaState>();
148
+ private readonly memberRealtimeSessions = new Map<string, RoomMemberRealtimeSession>();
149
+
150
+ override async fetch(request: Request): Promise<Response> {
151
+ const url = new URL(request.url);
152
+
153
+ if (url.pathname === '/media/realtime/session') {
154
+ if (request.method === 'POST') return this.handleRealtimeSessionCreate(request, url);
155
+ if (request.method === 'GET') return this.handleRealtimeSessionGet(request, url);
156
+ return this.jsonResponse(405, { code: 405, message: 'Method not allowed' });
157
+ }
158
+
159
+ if (url.pathname === '/media/realtime/turn' && request.method === 'POST') {
160
+ return this.handleRealtimeTurn(request, url);
161
+ }
162
+
163
+ if (url.pathname === '/media/realtime/tracks/new' && request.method === 'POST') {
164
+ return this.handleRealtimeTracksNew(request, url);
165
+ }
166
+
167
+ if (url.pathname === '/media/realtime/renegotiate' && request.method === 'PUT') {
168
+ return this.handleRealtimeRenegotiate(request, url);
169
+ }
170
+
171
+ if (url.pathname === '/media/realtime/tracks/close' && request.method === 'PUT') {
172
+ return this.handleRealtimeTracksClose(request, url);
173
+ }
174
+
175
+ return super.fetch(request);
176
+ }
177
+
178
+ private async handleRealtimeSessionCreate(request: Request, url: URL): Promise<Response> {
179
+ try {
180
+ const body = await this.readJsonBody<{
181
+ connectionId?: string;
182
+ correlationId?: string;
183
+ thirdparty?: boolean;
184
+ sessionDescription?: CloudflareRealtimeNewSessionRequest['sessionDescription'];
185
+ }>(request);
186
+ const { memberId, connectionId } = await this.authenticateRealtimeRequest(
187
+ request,
188
+ url,
189
+ typeof body.connectionId === 'string' ? body.connectionId : undefined,
190
+ );
191
+
192
+ if (this.hasPublishedTracks(memberId)) {
193
+ return this.jsonResponse(409, {
194
+ code: 409,
195
+ message: 'Unpublish existing room media before replacing the active realtime session.',
196
+ });
197
+ }
198
+
199
+ const client = this.buildRealtimeClient();
200
+ const response = await client.createSession(
201
+ {
202
+ sessionDescription: body.sessionDescription,
203
+ },
204
+ {
205
+ thirdparty: body.thirdparty === true,
206
+ correlationId:
207
+ typeof body.correlationId === 'string' && body.correlationId.trim()
208
+ ? body.correlationId.trim()
209
+ : `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}::${memberId}`,
210
+ },
211
+ );
212
+
213
+ this.memberRealtimeSessions.set(memberId, {
214
+ sessionId: response.sessionId,
215
+ connectionId,
216
+ createdAt: Date.now(),
217
+ updatedAt: Date.now(),
218
+ });
219
+
220
+ return this.jsonResponse<CloudflareRealtimeNewSessionResponse & {
221
+ connectionId: string;
222
+ reused: false;
223
+ }>(200, {
224
+ ...response,
225
+ connectionId,
226
+ reused: false,
227
+ });
228
+ } catch (err) {
229
+ return this.jsonResponse(400, {
230
+ code: 400,
231
+ message: err instanceof Error ? err.message : 'Failed to create realtime session',
232
+ });
233
+ }
234
+ }
235
+
236
+ private async handleRealtimeSessionGet(request: Request, url: URL): Promise<Response> {
237
+ try {
238
+ const requestedConnectionId = url.searchParams.get('connectionId') ?? undefined;
239
+ const { memberId } = await this.authenticateRealtimeRequest(request, url, requestedConnectionId);
240
+ const session = this.memberRealtimeSessions.get(memberId);
241
+ if (!session) {
242
+ return this.jsonResponse(404, {
243
+ code: 404,
244
+ message: 'No active realtime session for this room member.',
245
+ });
246
+ }
247
+ return this.jsonResponse(200, session);
248
+ } catch (err) {
249
+ return this.jsonResponse(400, {
250
+ code: 400,
251
+ message: err instanceof Error ? err.message : 'Failed to read realtime session',
252
+ });
253
+ }
254
+ }
255
+
256
+ private async handleRealtimeTurn(request: Request, url: URL): Promise<Response> {
257
+ try {
258
+ const body = await this.readJsonBody<{ ttl?: number }>(request);
259
+ await this.authenticateRealtimeRequest(request, url);
260
+ const client = this.buildRealtimeClient();
261
+ const ttl = typeof body.ttl === 'number' && Number.isFinite(body.ttl) && body.ttl > 0
262
+ ? Math.floor(body.ttl)
263
+ : 3600;
264
+ const response = await client.generateIceServers(ttl);
265
+ return this.jsonResponse(200, response);
266
+ } catch (err) {
267
+ return this.jsonResponse(400, {
268
+ code: 400,
269
+ message: err instanceof Error ? err.message : 'Failed to generate ICE servers',
270
+ });
271
+ }
272
+ }
273
+
274
+ private async handleRealtimeTracksNew(request: Request, url: URL): Promise<Response> {
275
+ try {
276
+ const body = await this.readJsonBody<CloudflareRealtimeTracksRequest & {
277
+ sessionId?: string;
278
+ connectionId?: string;
279
+ publish?: {
280
+ kind?: MediaKind;
281
+ trackId?: string;
282
+ deviceId?: string;
283
+ muted?: boolean;
284
+ };
285
+ }>(request);
286
+
287
+ const { memberId, meta } = await this.authenticateRealtimeRequest(
288
+ request,
289
+ url,
290
+ typeof body.connectionId === 'string' ? body.connectionId : undefined,
291
+ );
292
+
293
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
294
+ if (!sessionId) {
295
+ throw new Error('sessionId is required');
296
+ }
297
+ this.assertRealtimeSessionOwnership(memberId, sessionId);
298
+
299
+ if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
300
+ throw new Error('tracks is required');
301
+ }
302
+
303
+ const response = await this.buildRealtimeClient().addTracks(sessionId, {
304
+ sessionDescription: body.sessionDescription,
305
+ tracks: body.tracks,
306
+ autoDiscover: body.autoDiscover === true,
307
+ });
308
+ this.assertRealtimeTracksResponseSuccess(response);
309
+
310
+ const publishPayload = body.publish;
311
+ const publishKind = publishPayload?.kind;
312
+ if (publishKind) {
313
+ if (!(await this.canPublishMedia(meta, publishKind, publishPayload ?? {}))) {
314
+ throw new Error('Denied by room media publish access rule');
315
+ }
316
+ const localTrackName = publishPayload.trackId?.trim()
317
+ || body.tracks.find((track) => track.location === 'local')?.trackName?.trim()
318
+ || response.tracks?.find((track) => track.location === 'local')?.trackName?.trim();
319
+ await this.publishMedia(meta, publishKind, {
320
+ trackId: localTrackName,
321
+ deviceId: publishPayload.deviceId,
322
+ muted: publishPayload.muted,
323
+ providerSessionId: sessionId,
324
+ });
325
+ }
326
+
327
+ const session = this.memberRealtimeSessions.get(memberId);
328
+ if (session) {
329
+ session.updatedAt = Date.now();
330
+ }
331
+
332
+ return this.jsonResponse(200, response);
333
+ } catch (err) {
334
+ return this.jsonResponse(400, {
335
+ code: 400,
336
+ message: err instanceof Error ? err.message : 'Failed to add realtime tracks',
337
+ });
338
+ }
339
+ }
340
+
341
+ private async handleRealtimeRenegotiate(request: Request, url: URL): Promise<Response> {
342
+ try {
343
+ const body = await this.readJsonBody<CloudflareRealtimeRenegotiateRequest & {
344
+ sessionId?: string;
345
+ connectionId?: string;
346
+ }>(request);
347
+ const { memberId } = await this.authenticateRealtimeRequest(
348
+ request,
349
+ url,
350
+ typeof body.connectionId === 'string' ? body.connectionId : undefined,
351
+ );
352
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
353
+ if (!sessionId) throw new Error('sessionId is required');
354
+ this.assertRealtimeSessionOwnership(memberId, sessionId);
355
+ if (!body.sessionDescription) {
356
+ throw new Error('sessionDescription is required');
357
+ }
358
+
359
+ const response = await this.buildRealtimeClient().renegotiate(sessionId, {
360
+ sessionDescription: body.sessionDescription,
361
+ });
362
+ this.assertRealtimeTracksResponseSuccess(response);
363
+
364
+ const session = this.memberRealtimeSessions.get(memberId);
365
+ if (session) {
366
+ session.updatedAt = Date.now();
367
+ }
368
+ return this.jsonResponse(200, response);
369
+ } catch (err) {
370
+ return this.jsonResponse(400, {
371
+ code: 400,
372
+ message: err instanceof Error ? err.message : 'Failed to renegotiate realtime session',
373
+ });
374
+ }
375
+ }
376
+
377
+ private async handleRealtimeTracksClose(request: Request, url: URL): Promise<Response> {
378
+ try {
379
+ const body = await this.readJsonBody<CloudflareRealtimeCloseTracksRequest & {
380
+ sessionId?: string;
381
+ connectionId?: string;
382
+ unpublish?: { kind?: MediaKind };
383
+ }>(request);
384
+ const { memberId } = await this.authenticateRealtimeRequest(
385
+ request,
386
+ url,
387
+ typeof body.connectionId === 'string' ? body.connectionId : undefined,
388
+ );
389
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
390
+ if (!sessionId) throw new Error('sessionId is required');
391
+ this.assertRealtimeSessionOwnership(memberId, sessionId);
392
+ if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
393
+ throw new Error('tracks is required');
394
+ }
395
+
396
+ const response = await this.buildRealtimeClient().closeTracks(sessionId, {
397
+ sessionDescription: body.sessionDescription,
398
+ tracks: body.tracks,
399
+ force: body.force === true,
400
+ });
401
+ this.assertRealtimeTracksResponseSuccess(response);
402
+
403
+ const unpublishKind = body.unpublish?.kind;
404
+ if (unpublishKind) {
405
+ await this.unpublishMedia(memberId, unpublishKind);
406
+ }
407
+
408
+ const session = this.memberRealtimeSessions.get(memberId);
409
+ if (session) {
410
+ session.updatedAt = Date.now();
411
+ }
412
+ return this.jsonResponse(200, response);
413
+ } catch (err) {
414
+ return this.jsonResponse(400, {
415
+ code: 400,
416
+ message: err instanceof Error ? err.message : 'Failed to close realtime tracks',
417
+ });
418
+ }
419
+ }
420
+
421
+ private buildRealtimeClient() {
422
+ return createCloudflareRealtimeClient(this.env as unknown as Env);
423
+ }
424
+
425
+ private async authenticateRealtimeRequest(
426
+ request: Request,
427
+ url: URL,
428
+ requestedConnectionId?: string,
429
+ ): Promise<{ memberId: string; connectionId: string; meta: RoomWSMeta }> {
430
+ this.hydrateRoomFromUrl(url);
431
+
432
+ const token = this.extractBearerToken(request);
433
+ if (!token) {
434
+ throw new Error('Authentication required');
435
+ }
436
+
437
+ const auth = await resolveAuthContextFromToken(this.env, token, request);
438
+ const memberId = auth.id;
439
+ const member = this.members.get(memberId);
440
+ if (!member || member.connectionIds.size === 0) {
441
+ throw new Error('Join the room WebSocket before using realtime media');
442
+ }
443
+
444
+ const connectionId = requestedConnectionId?.trim()
445
+ || (member.connectionIds.values().next().value as string | undefined);
446
+ if (!connectionId) {
447
+ throw new Error('No active room connection for this member');
448
+ }
449
+ if (!member.connectionIds.has(connectionId)) {
450
+ throw new Error('connectionId does not belong to the authenticated room member');
451
+ }
452
+
453
+ const existingMeta = this.findConnectionMeta(connectionId);
454
+ const meta: RoomWSMeta = existingMeta
455
+ ? {
456
+ ...existingMeta,
457
+ authenticated: true,
458
+ userId: memberId,
459
+ role: auth.role,
460
+ auth,
461
+ }
462
+ : {
463
+ authenticated: true,
464
+ userId: memberId,
465
+ role: auth.role,
466
+ auth,
467
+ connectionId,
468
+ ip: request.headers.get('CF-Connecting-IP')
469
+ || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
470
+ || undefined,
471
+ userAgent: request.headers.get('User-Agent') || undefined,
472
+ };
473
+
474
+ return { memberId, connectionId, meta };
475
+ }
476
+
477
+ private assertRealtimeSessionOwnership(memberId: string, sessionId: string): RoomMemberRealtimeSession {
478
+ const session = this.memberRealtimeSessions.get(memberId);
479
+ if (!session || session.sessionId !== sessionId) {
480
+ throw new Error('Realtime session is not owned by the authenticated room member');
481
+ }
482
+ return session;
483
+ }
484
+
485
+ private assertRealtimeTracksResponseSuccess(response: CloudflareRealtimeTracksResponse): void {
486
+ if (response.errorCode) {
487
+ throw new Error(response.errorDescription || response.errorCode);
488
+ }
489
+ const trackFailure = response.tracks?.find((track) => track.errorCode);
490
+ if (trackFailure?.errorCode) {
491
+ throw new Error(trackFailure.errorDescription || trackFailure.errorCode);
492
+ }
493
+ }
494
+
495
+ private hasPublishedTracks(memberId: string): boolean {
496
+ const state = this.memberMediaStates.get(memberId);
497
+ return !!state?.audio?.published || !!state?.video?.published || !!state?.screen?.published;
498
+ }
499
+
500
+ private hydrateRoomFromUrl(url: URL): void {
501
+ const roomFullName = url.searchParams.get('room');
502
+ if (!roomFullName || this.namespace) {
503
+ return;
504
+ }
505
+
506
+ const separatorIdx = roomFullName.indexOf('::');
507
+ if (separatorIdx >= 0) {
508
+ this.namespace = roomFullName.substring(0, separatorIdx);
509
+ this.roomId = roomFullName.substring(separatorIdx + 2);
510
+ } else {
511
+ this.namespace = roomFullName;
512
+ this.roomId = roomFullName;
513
+ }
514
+ this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
515
+ }
516
+
517
+ private extractBearerToken(request: Request): string | null {
518
+ const header = request.headers.get('Authorization');
519
+ if (!header) return null;
520
+ const match = header.match(/^Bearer\s+(.+)$/i);
521
+ return match?.[1]?.trim() ?? null;
522
+ }
523
+
524
+ private async readJsonBody<T>(request: Request): Promise<T> {
525
+ if (request.method === 'GET' || request.method === 'HEAD') {
526
+ return {} as T;
527
+ }
528
+ try {
529
+ return await request.json() as T;
530
+ } catch {
531
+ return {} as T;
532
+ }
533
+ }
534
+
535
+ private jsonResponse<T>(status: number, body: T): Response {
536
+ return new Response(JSON.stringify(body), {
537
+ status,
538
+ headers: { 'Content-Type': 'application/json' },
539
+ });
540
+ }
541
+
542
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
543
+ if (typeof message !== 'string') return;
544
+
545
+ let msg: Record<string, unknown>;
546
+ try {
547
+ msg = JSON.parse(message);
548
+ } catch {
549
+ this.safeSend(ws, { type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON' });
550
+ return;
551
+ }
552
+
553
+ if (msg.type === 'signal') {
554
+ const meta = this.requireAuthenticatedMeta(ws);
555
+ if (!meta) return;
556
+
557
+ const event = typeof msg.event === 'string' ? msg.event : '';
558
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
559
+ if (!this.checkRateLimit(meta.connectionId)) {
560
+ this.safeSend(ws, {
561
+ type: 'signal_error',
562
+ event,
563
+ message: 'Rate limited',
564
+ requestId,
565
+ });
566
+ return;
567
+ }
568
+
569
+ await this.handleSignal(ws, meta, {
570
+ type: 'signal',
571
+ event,
572
+ payload: msg.payload,
573
+ memberId: typeof msg.memberId === 'string' ? msg.memberId : null,
574
+ includeSelf: msg.includeSelf === true,
575
+ requestId,
576
+ });
577
+ return;
578
+ }
579
+
580
+ if (msg.type === 'member_state') {
581
+ const meta = this.requireAuthenticatedMeta(ws);
582
+ if (!meta) return;
583
+ await this.handleMemberState(ws, meta, {
584
+ type: 'member_state',
585
+ state: this.asRecord(msg.state),
586
+ requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
587
+ });
588
+ return;
589
+ }
590
+
591
+ if (msg.type === 'member_state_clear') {
592
+ const meta = this.requireAuthenticatedMeta(ws);
593
+ if (!meta) return;
594
+ await this.handleMemberState(ws, meta, {
595
+ type: 'member_state_clear',
596
+ requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
597
+ });
598
+ return;
599
+ }
600
+
601
+ if (msg.type === 'admin') {
602
+ const meta = this.requireAuthenticatedMeta(ws);
603
+ if (!meta) return;
604
+
605
+ const operation = typeof msg.operation === 'string' ? msg.operation : '';
606
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
607
+ if (!this.checkRateLimit(meta.connectionId)) {
608
+ this.safeSend(ws, {
609
+ type: 'admin_error',
610
+ operation,
611
+ message: 'Rate limited',
612
+ requestId,
613
+ });
614
+ return;
615
+ }
616
+
617
+ await this.handleAdmin(ws, meta, {
618
+ type: 'admin',
619
+ operation,
620
+ memberId: typeof msg.memberId === 'string' ? msg.memberId : null,
621
+ payload: this.asRecord(msg.payload),
622
+ requestId,
623
+ });
624
+ return;
625
+ }
626
+
627
+ if (msg.type === 'media') {
628
+ const meta = this.requireAuthenticatedMeta(ws);
629
+ if (!meta) return;
630
+
631
+ const operation = this.normalizeMediaOperation(msg.operation);
632
+ const kind = this.normalizeMediaKind(msg.kind);
633
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
634
+ if (!this.checkRateLimit(meta.connectionId)) {
635
+ this.safeSend(ws, {
636
+ type: 'media_error',
637
+ operation: operation ?? '',
638
+ kind: kind ?? null,
639
+ message: 'Rate limited',
640
+ requestId,
641
+ });
642
+ return;
643
+ }
644
+
645
+ await this.handleMedia(ws, meta, {
646
+ type: 'media',
647
+ operation: operation ?? 'publish',
648
+ kind: kind ?? undefined,
649
+ payload: this.asRecord(msg.payload),
650
+ requestId,
651
+ });
652
+ return;
653
+ }
654
+
655
+ await super.webSocketMessage(ws, message);
656
+ }
657
+
658
+ protected override async handleJoin(
659
+ ws: WebSocket,
660
+ meta: RoomWSMeta,
661
+ msg: Record<string, unknown>,
662
+ ): Promise<void> {
663
+ const userId = meta.userId;
664
+ if (userId && this.blockedMembers.has(userId)) {
665
+ this.safeSend(ws, {
666
+ type: 'error',
667
+ code: 'JOIN_DENIED',
668
+ message: 'Blocked from this room',
669
+ });
670
+ ws.close(4003, 'Join denied');
671
+ return;
672
+ }
673
+
674
+ const existingMember = userId ? this.members.get(userId) : undefined;
675
+ const hadMember = !!existingMember;
676
+ const wasReconnecting = !!existingMember && existingMember.connectionIds.size === 0;
677
+
678
+ await super.handleJoin(ws, meta, msg);
679
+
680
+ if (!userId || !meta.authenticated || !this.isSocketOpen(ws)) {
681
+ return;
682
+ }
683
+
684
+ const member = this.ensureMember(userId);
685
+ this.joinedConnectionIds.add(meta.connectionId);
686
+ member.connectionIds.add(meta.connectionId);
687
+
688
+ if (!hadMember) {
689
+ const snapshot = this.buildMemberSnapshot(member);
690
+ await this.runMemberJoinHook(snapshot);
691
+ this.broadcastToJoined({ type: 'member_join', member: snapshot }, meta.connectionId);
692
+ }
693
+
694
+ this.broadcastMembersSync();
695
+ await this.sendMediaSyncToConnection(ws, meta);
696
+
697
+ if (wasReconnecting) {
698
+ await this.runSessionReconnectHook(this.buildSender(meta));
699
+ }
700
+ }
701
+
702
+ protected override async handleExplicitLeave(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
703
+ const userId = meta.userId;
704
+ const wasJoined = this.isJoinedConnection(meta.connectionId);
705
+
706
+ await super.handleExplicitLeave(ws, meta);
707
+
708
+ if (!userId || !wasJoined) {
709
+ return;
710
+ }
711
+
712
+ const member = this.removeMemberConnection(userId, meta.connectionId);
713
+ if (member) {
714
+ if (member.connectionIds.size === 0) {
715
+ await this.clearPublishedMedia(userId);
716
+ }
717
+ this.broadcastMembersSync();
718
+ }
719
+ }
720
+
721
+ protected override async handleDisconnect(
722
+ meta: RoomWSMeta,
723
+ kicked = false,
724
+ explicitLeave = false,
725
+ ): Promise<void> {
726
+ const userId = meta.userId;
727
+ const wasJoined = this.isJoinedConnection(meta.connectionId);
728
+
729
+ await super.handleDisconnect(meta, kicked, explicitLeave);
730
+
731
+ if (!userId || !wasJoined || explicitLeave) {
732
+ return;
733
+ }
734
+
735
+ const member = this.removeMemberConnection(userId, meta.connectionId);
736
+ if (!member) {
737
+ return;
738
+ }
739
+
740
+ if (member.connectionIds.size > 0) {
741
+ this.broadcastMembersSync();
742
+ return;
743
+ }
744
+
745
+ await this.clearPublishedMedia(userId);
746
+
747
+ const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS;
748
+ if (!kicked && reconnectTimeout > 0) {
749
+ this.broadcastMembersSync();
750
+ }
751
+ }
752
+
753
+ protected override async finalizePlayerLeave(
754
+ userId: string,
755
+ connectionId: string,
756
+ reason: 'leave' | 'disconnect' | 'kicked',
757
+ ): Promise<void> {
758
+ const member = this.members.get(userId);
759
+ const snapshot = member ? this.buildMemberSnapshot(member, connectionId) : null;
760
+
761
+ await super.finalizePlayerLeave(userId, connectionId, reason);
762
+
763
+ if (!snapshot) {
764
+ return;
765
+ }
766
+
767
+ if (reason === 'disconnect') {
768
+ await this.runSessionDisconnectTimeoutHook({
769
+ userId,
770
+ connectionId,
771
+ role: this.memberRoles.get(userId),
772
+ });
773
+ }
774
+
775
+ this.deleteMember(userId);
776
+ this.memberMediaStates.delete(userId);
777
+ this.memberRealtimeSessions.delete(userId);
778
+
779
+ const leaveReason: RoomMemberLeaveReason = reason === 'disconnect' ? 'timeout' : reason;
780
+ await this.runMemberLeaveHook(snapshot, leaveReason);
781
+ this.broadcastToJoined({
782
+ type: 'member_leave',
783
+ member: snapshot,
784
+ reason: leaveReason,
785
+ });
786
+ this.broadcastMembersSync();
787
+ }
788
+
789
+ protected override buildRoomServerAPI(): RoomServerAPI {
790
+ const roomApi = super.buildRoomServerAPI();
791
+ return {
792
+ ...roomApi,
793
+ setSharedState: (updater: (state: Record<string, unknown>) => Record<string, unknown>): void => {
794
+ const previous = roomApi.getSharedState();
795
+ roomApi.setSharedState(updater);
796
+ const next = roomApi.getSharedState();
797
+ const delta = computeStateDelta(previous, next);
798
+ if (delta) {
799
+ void this.runSharedStateHook(delta, roomApi);
800
+ }
801
+ },
802
+ sendMessage: (event: string, payload?: unknown, options?: { exclude?: string[] }): void => {
803
+ roomApi.sendMessage(event, payload, options);
804
+ void this.sendServerSignal({
805
+ event,
806
+ payload: payload ?? {},
807
+ excludeUserIds: options?.exclude,
808
+ });
809
+ },
810
+ sendMessageTo: (memberId: string, event: string, payload?: unknown): void => {
811
+ roomApi.sendMessageTo(memberId, event, payload);
812
+ void this.sendServerSignal({
813
+ event,
814
+ payload: payload ?? {},
815
+ memberId,
816
+ });
817
+ },
818
+ };
819
+ }
820
+
821
+ protected override buildSender(meta: RoomWSMeta): RoomSender {
822
+ const sender = super.buildSender(meta);
823
+ const role = meta.userId ? this.memberRoles.get(meta.userId) : undefined;
824
+ return role ? { ...sender, role } : sender;
825
+ }
826
+
827
+ protected override buildAuthFromMeta(meta: RoomWSMeta): SharedAuthContext {
828
+ const auth = super.buildAuthFromMeta(meta);
829
+ const role = meta.userId ? this.memberRoles.get(meta.userId) ?? auth.role : auth.role;
830
+ return role === auth.role ? auth : { ...auth, role };
831
+ }
832
+
833
+ private async handleAdmin(
834
+ ws: WebSocket,
835
+ meta: RoomWSMeta,
836
+ msg: AdminMessage,
837
+ ): Promise<void> {
838
+ const operation = msg.operation.trim();
839
+ const requestId = msg.requestId;
840
+ if (!operation) {
841
+ this.safeSend(ws, {
842
+ type: 'admin_error',
843
+ operation: '',
844
+ message: 'operation is required',
845
+ requestId,
846
+ });
847
+ return;
848
+ }
849
+
850
+ if (!meta.userId || !this.roomId) {
851
+ this.safeSend(ws, {
852
+ type: 'admin_error',
853
+ operation,
854
+ message: 'User not authenticated',
855
+ requestId,
856
+ });
857
+ return;
858
+ }
859
+
860
+ if (!this.isJoinedConnection(meta.connectionId)) {
861
+ this.safeSend(ws, {
862
+ type: 'admin_error',
863
+ operation,
864
+ message: 'Join the room before issuing admin commands',
865
+ requestId,
866
+ });
867
+ return;
868
+ }
869
+
870
+ const memberId = this.normalizeMemberId(msg.memberId);
871
+ if (!memberId) {
872
+ this.safeSend(ws, {
873
+ type: 'admin_error',
874
+ operation,
875
+ message: 'memberId is required',
876
+ requestId,
877
+ });
878
+ return;
879
+ }
880
+
881
+ const payload = msg.payload ?? {};
882
+ if (!(await this.canRunAdmin(meta, operation, { memberId, ...payload }))) {
883
+ this.safeSend(ws, {
884
+ type: 'admin_error',
885
+ operation,
886
+ message: 'Denied by room admin access rule',
887
+ requestId,
888
+ });
889
+ return;
890
+ }
891
+
892
+ try {
893
+ switch (operation) {
894
+ case 'kick':
895
+ await this.kickPlayer(memberId);
896
+ break;
897
+ case 'block':
898
+ this.blockedMembers.add(memberId);
899
+ await this.kickPlayer(memberId);
900
+ break;
901
+ case 'setRole': {
902
+ const role = typeof payload.role === 'string' ? payload.role.trim() : '';
903
+ if (!role) {
904
+ throw new Error('role is required');
905
+ }
906
+ this.syncMemberRole(memberId, role);
907
+ this.broadcastMembersSync();
908
+ break;
909
+ }
910
+ case 'mute':
911
+ await this.applyMuteChange(memberId, 'audio', true);
912
+ break;
913
+ case 'disableVideo':
914
+ await this.applyAdminUnpublish(memberId, 'video');
915
+ break;
916
+ case 'stopScreenShare':
917
+ await this.applyAdminUnpublish(memberId, 'screen');
918
+ break;
919
+ default:
920
+ throw new Error(`Unsupported admin operation '${operation}'`);
921
+ }
922
+
923
+ this.safeSend(ws, {
924
+ type: 'admin_result',
925
+ operation,
926
+ memberId,
927
+ requestId,
928
+ result: { ok: true },
929
+ });
930
+ } catch (err) {
931
+ this.safeSend(ws, {
932
+ type: 'admin_error',
933
+ operation,
934
+ memberId,
935
+ requestId,
936
+ message: err instanceof Error ? err.message : 'Admin operation failed',
937
+ });
938
+ }
939
+ }
940
+
941
+ private async handleMedia(
942
+ ws: WebSocket,
943
+ meta: RoomWSMeta,
944
+ msg: MediaMessage,
945
+ ): Promise<void> {
946
+ const operation = msg.operation;
947
+ const kind = msg.kind;
948
+ const requestId = msg.requestId;
949
+ if (!operation || !kind) {
950
+ this.safeSend(ws, {
951
+ type: 'media_error',
952
+ operation: operation ?? '',
953
+ kind: kind ?? null,
954
+ message: 'operation and kind are required',
955
+ requestId,
956
+ });
957
+ return;
958
+ }
959
+
960
+ if (!meta.userId || !this.roomId) {
961
+ this.safeSend(ws, {
962
+ type: 'media_error',
963
+ operation,
964
+ kind,
965
+ message: 'User not authenticated',
966
+ requestId,
967
+ });
968
+ return;
969
+ }
970
+
971
+ if (!this.isJoinedConnection(meta.connectionId)) {
972
+ this.safeSend(ws, {
973
+ type: 'media_error',
974
+ operation,
975
+ kind,
976
+ message: 'Join the room before using media controls',
977
+ requestId,
978
+ });
979
+ return;
980
+ }
981
+
982
+ try {
983
+ const payload = msg.payload ?? {};
984
+ if (operation === 'publish') {
985
+ if (!(await this.canPublishMedia(meta, kind, payload))) {
986
+ throw new Error('Denied by room media publish access rule');
987
+ }
988
+ } else if (!(await this.canControlMedia(meta, operation, { kind, ...payload }))) {
989
+ throw new Error('Denied by room media control access rule');
990
+ }
991
+
992
+ switch (operation) {
993
+ case 'publish':
994
+ await this.publishMedia(meta, kind, payload);
995
+ break;
996
+ case 'unpublish':
997
+ await this.unpublishMedia(meta.userId, kind);
998
+ break;
999
+ case 'mute': {
1000
+ const muted = payload.muted === true;
1001
+ await this.applyMuteChange(meta.userId, kind, muted);
1002
+ break;
1003
+ }
1004
+ case 'device':
1005
+ await this.applyDeviceChange(meta.userId, kind, payload);
1006
+ break;
1007
+ default:
1008
+ throw new Error(`Unsupported media operation '${operation}'`);
1009
+ }
1010
+
1011
+ this.safeSend(ws, {
1012
+ type: 'media_result',
1013
+ operation,
1014
+ kind,
1015
+ requestId,
1016
+ result: { ok: true },
1017
+ });
1018
+ } catch (err) {
1019
+ this.safeSend(ws, {
1020
+ type: 'media_error',
1021
+ operation,
1022
+ kind,
1023
+ requestId,
1024
+ message: err instanceof Error ? err.message : 'Media operation failed',
1025
+ });
1026
+ }
1027
+ }
1028
+
1029
+ private async handleSignal(
1030
+ ws: WebSocket,
1031
+ meta: RoomWSMeta,
1032
+ msg: SignalMessage,
1033
+ ): Promise<void> {
1034
+ const event = typeof msg.event === 'string' ? msg.event.trim() : '';
1035
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1036
+
1037
+ if (!event) {
1038
+ this.safeSend(ws, {
1039
+ type: 'signal_error',
1040
+ event: '',
1041
+ message: 'event is required',
1042
+ requestId,
1043
+ });
1044
+ return;
1045
+ }
1046
+
1047
+ if (!meta.userId || !this.roomId) {
1048
+ this.safeSend(ws, {
1049
+ type: 'signal_error',
1050
+ event,
1051
+ message: 'User not authenticated',
1052
+ requestId,
1053
+ });
1054
+ return;
1055
+ }
1056
+
1057
+ if (!this.isJoinedConnection(meta.connectionId)) {
1058
+ this.safeSend(ws, {
1059
+ type: 'signal_error',
1060
+ event,
1061
+ message: 'Join the room before sending signals',
1062
+ requestId,
1063
+ });
1064
+ return;
1065
+ }
1066
+
1067
+ if (!(await this.canSendSignal(meta, event, msg.payload))) {
1068
+ this.safeSend(ws, {
1069
+ type: 'signal_error',
1070
+ event,
1071
+ message: 'Denied by room signal access rule',
1072
+ requestId,
1073
+ });
1074
+ return;
1075
+ }
1076
+
1077
+ const sender = this.buildSender(meta);
1078
+ const roomApi = this.buildRoomServerAPI();
1079
+ const signalCtx = {
1080
+ event,
1081
+ payload: msg.payload,
1082
+ sender,
1083
+ roomApi,
1084
+ memberId: this.normalizeMemberId(msg.memberId),
1085
+ includeSelf: msg.includeSelf === true,
1086
+ meta: this.buildSignalFrameMeta(sender, false),
1087
+ };
1088
+ const transformedPayload = await this.applySignalBeforeSend(signalCtx);
1089
+ if (transformedPayload === SIGNAL_DENIED) {
1090
+ this.safeSend(ws, {
1091
+ type: 'signal_error',
1092
+ event,
1093
+ message: 'Rejected by room signal hook',
1094
+ requestId,
1095
+ });
1096
+ return;
1097
+ }
1098
+
1099
+ this.deliverSignal(
1100
+ {
1101
+ type: 'signal',
1102
+ event,
1103
+ payload: transformedPayload,
1104
+ meta: signalCtx.meta,
1105
+ },
1106
+ {
1107
+ memberId: signalCtx.memberId,
1108
+ includeSelf: signalCtx.includeSelf,
1109
+ senderConnectionId: meta.connectionId,
1110
+ },
1111
+ );
1112
+
1113
+ this.safeSend(ws, {
1114
+ type: 'signal_sent',
1115
+ event,
1116
+ memberId: signalCtx.memberId,
1117
+ requestId,
1118
+ });
1119
+
1120
+ await this.runSignalOnSend(event, transformedPayload, sender, roomApi);
1121
+ }
1122
+
1123
+ private async handleMemberState(
1124
+ ws: WebSocket,
1125
+ meta: RoomWSMeta,
1126
+ msg: MemberStateMessage | MemberStateClearMessage,
1127
+ ): Promise<void> {
1128
+ if (!meta.userId) {
1129
+ this.safeSend(ws, {
1130
+ type: 'member_state_error',
1131
+ message: 'User not authenticated',
1132
+ requestId: msg.requestId,
1133
+ });
1134
+ return;
1135
+ }
1136
+
1137
+ if (!this.isJoinedConnection(meta.connectionId)) {
1138
+ this.safeSend(ws, {
1139
+ type: 'member_state_error',
1140
+ message: 'Join the room before updating member state',
1141
+ requestId: msg.requestId,
1142
+ });
1143
+ return;
1144
+ }
1145
+
1146
+ const member = this.ensureMember(meta.userId);
1147
+ if (msg.type === 'member_state') {
1148
+ if (!msg.state) {
1149
+ this.safeSend(ws, {
1150
+ type: 'member_state_error',
1151
+ message: 'state must be an object',
1152
+ requestId: msg.requestId,
1153
+ });
1154
+ return;
1155
+ }
1156
+
1157
+ member.state = {
1158
+ ...member.state,
1159
+ ...msg.state,
1160
+ };
1161
+ } else {
1162
+ member.state = {};
1163
+ }
1164
+
1165
+ const snapshot = this.buildMemberSnapshot(member);
1166
+ const state = { ...member.state };
1167
+ this.broadcastToJoined({
1168
+ type: 'member_state',
1169
+ member: snapshot,
1170
+ state,
1171
+ requestId: msg.requestId,
1172
+ });
1173
+ this.broadcastMembersSync();
1174
+ await this.runMemberStateHook(snapshot, state);
1175
+ }
1176
+
1177
+ private async sendServerSignal(input: {
1178
+ event: string;
1179
+ payload: unknown;
1180
+ memberId?: string;
1181
+ excludeUserIds?: string[];
1182
+ }): Promise<void> {
1183
+ const event = input.event.trim();
1184
+ if (!event) return;
1185
+
1186
+ const roomApi = this.buildRoomServerAPI();
1187
+ const signalCtx = {
1188
+ event,
1189
+ payload: input.payload,
1190
+ sender: SYSTEM_SIGNAL_SENDER,
1191
+ roomApi,
1192
+ memberId: this.normalizeMemberId(input.memberId),
1193
+ includeSelf: true,
1194
+ meta: this.buildSignalFrameMeta(SYSTEM_SIGNAL_SENDER, true),
1195
+ };
1196
+ const transformedPayload = await this.applySignalBeforeSend(signalCtx);
1197
+ if (transformedPayload === SIGNAL_DENIED) return;
1198
+
1199
+ this.deliverSignal(
1200
+ {
1201
+ type: 'signal',
1202
+ event,
1203
+ payload: transformedPayload,
1204
+ meta: signalCtx.meta,
1205
+ },
1206
+ {
1207
+ memberId: signalCtx.memberId,
1208
+ includeSelf: true,
1209
+ excludeUserIds: input.excludeUserIds,
1210
+ },
1211
+ );
1212
+
1213
+ await this.runSignalOnSend(event, transformedPayload, SYSTEM_SIGNAL_SENDER, roomApi);
1214
+ }
1215
+
1216
+ private async canSendSignal(
1217
+ meta: RoomWSMeta,
1218
+ event: string,
1219
+ payload: unknown,
1220
+ ): Promise<boolean> {
1221
+ if (!this.namespaceConfig?.access?.signal || !this.roomId) {
1222
+ return !this.config.release;
1223
+ }
1224
+
1225
+ try {
1226
+ return await Promise.resolve(
1227
+ this.namespaceConfig.access.signal(
1228
+ this.buildAuthFromMeta(meta),
1229
+ this.roomId,
1230
+ event,
1231
+ payload,
1232
+ ),
1233
+ );
1234
+ } catch {
1235
+ return false;
1236
+ }
1237
+ }
1238
+
1239
+ private async applySignalBeforeSend(signal: {
1240
+ event: string;
1241
+ payload: unknown;
1242
+ sender: RoomSender;
1243
+ roomApi: RoomServerAPI;
1244
+ memberId?: string;
1245
+ includeSelf: boolean;
1246
+ meta: SignalFrameMeta;
1247
+ }): Promise<unknown | typeof SIGNAL_DENIED> {
1248
+ const beforeSend = getRoomHooks(this.namespaceConfig ?? undefined)?.signals?.beforeSend;
1249
+ if (!beforeSend) return signal.payload;
1250
+
1251
+ const ctx = this.buildHandlerContext();
1252
+ const result = await Promise.resolve(
1253
+ beforeSend(signal.event, signal.payload, signal.sender, signal.roomApi, ctx),
1254
+ );
1255
+ if (result === false) return SIGNAL_DENIED;
1256
+ return result === undefined ? signal.payload : result;
1257
+ }
1258
+
1259
+ private async runSignalOnSend(
1260
+ event: string,
1261
+ payload: unknown,
1262
+ sender: RoomSender,
1263
+ roomApi: RoomServerAPI,
1264
+ ): Promise<void> {
1265
+ const onSend = getRoomHooks(this.namespaceConfig ?? undefined)?.signals?.onSend;
1266
+ if (!onSend) return;
1267
+
1268
+ try {
1269
+ const ctx = this.buildHandlerContext();
1270
+ await Promise.resolve(onSend(event, payload, sender, roomApi, ctx));
1271
+ } catch (err) {
1272
+ console.error(`[Rooms] signal.onSend error: ${err instanceof Error ? err.message : String(err)}`);
1273
+ }
1274
+ }
1275
+
1276
+ private async runMemberJoinHook(member: RoomMemberSnapshot): Promise<void> {
1277
+ const onJoin = getRoomHooks(this.namespaceConfig ?? undefined)?.members?.onJoin;
1278
+ if (!onJoin) return;
1279
+
1280
+ try {
1281
+ const roomApi = this.buildRoomServerAPI();
1282
+ const ctx = this.buildHandlerContext();
1283
+ await Promise.resolve(onJoin(member, roomApi, ctx));
1284
+ } catch (err) {
1285
+ console.error(`[Rooms] members.onJoin error: ${err instanceof Error ? err.message : String(err)}`);
1286
+ }
1287
+ }
1288
+
1289
+ private async runMemberLeaveHook(
1290
+ member: RoomMemberSnapshot,
1291
+ reason: RoomMemberLeaveReason,
1292
+ ): Promise<void> {
1293
+ const onLeave = getRoomHooks(this.namespaceConfig ?? undefined)?.members?.onLeave;
1294
+ if (!onLeave) return;
1295
+
1296
+ try {
1297
+ const roomApi = this.buildRoomServerAPI();
1298
+ const ctx = this.buildHandlerContext();
1299
+ await Promise.resolve(onLeave(member, roomApi, ctx, reason));
1300
+ } catch (err) {
1301
+ console.error(`[Rooms] members.onLeave error: ${err instanceof Error ? err.message : String(err)}`);
1302
+ }
1303
+ }
1304
+
1305
+ private async runMemberStateHook(
1306
+ member: RoomMemberSnapshot,
1307
+ state: Record<string, unknown>,
1308
+ ): Promise<void> {
1309
+ const onStateChange = getRoomHooks(this.namespaceConfig ?? undefined)?.members?.onStateChange;
1310
+ if (!onStateChange) return;
1311
+
1312
+ try {
1313
+ const roomApi = this.buildRoomServerAPI();
1314
+ const ctx = this.buildHandlerContext();
1315
+ await Promise.resolve(onStateChange(member, state, roomApi, ctx));
1316
+ } catch (err) {
1317
+ console.error(`[Rooms] members.onStateChange error: ${err instanceof Error ? err.message : String(err)}`);
1318
+ }
1319
+ }
1320
+
1321
+ private async runSharedStateHook(
1322
+ delta: Record<string, unknown>,
1323
+ roomApi: RoomServerAPI,
1324
+ ): Promise<void> {
1325
+ const onStateChange = getRoomHooks(this.namespaceConfig ?? undefined)?.state?.onStateChange;
1326
+ if (!onStateChange) return;
1327
+
1328
+ try {
1329
+ const ctx = this.buildHandlerContext();
1330
+ await Promise.resolve(onStateChange(delta, roomApi, ctx));
1331
+ } catch (err) {
1332
+ console.error(`[Rooms] state.onStateChange error: ${err instanceof Error ? err.message : String(err)}`);
1333
+ }
1334
+ }
1335
+
1336
+ private async runSessionReconnectHook(sender: RoomSender): Promise<void> {
1337
+ const onReconnect = getRoomHooks(this.namespaceConfig ?? undefined)?.session?.onReconnect;
1338
+ if (!onReconnect) return;
1339
+
1340
+ try {
1341
+ const roomApi = this.buildRoomServerAPI();
1342
+ const ctx = this.buildHandlerContext();
1343
+ await Promise.resolve(onReconnect(sender, roomApi, ctx));
1344
+ } catch (err) {
1345
+ console.error(`[Rooms] session.onReconnect error: ${err instanceof Error ? err.message : String(err)}`);
1346
+ }
1347
+ }
1348
+
1349
+ private async runSessionDisconnectTimeoutHook(sender: RoomSender): Promise<void> {
1350
+ const onDisconnectTimeout = getRoomHooks(this.namespaceConfig ?? undefined)?.session?.onDisconnectTimeout;
1351
+ if (!onDisconnectTimeout) return;
1352
+
1353
+ try {
1354
+ const roomApi = this.buildRoomServerAPI();
1355
+ const ctx = this.buildHandlerContext();
1356
+ await Promise.resolve(onDisconnectTimeout(sender, roomApi, ctx));
1357
+ } catch (err) {
1358
+ console.error(`[Rooms] session.onDisconnectTimeout error: ${err instanceof Error ? err.message : String(err)}`);
1359
+ }
1360
+ }
1361
+
1362
+ private async canRunAdmin(
1363
+ meta: RoomWSMeta,
1364
+ operation: string,
1365
+ payload: Record<string, unknown>,
1366
+ ): Promise<boolean> {
1367
+ const adminAccess = this.namespaceConfig?.access?.admin;
1368
+ if (!adminAccess || !this.roomId) {
1369
+ return !this.config.release;
1370
+ }
1371
+
1372
+ try {
1373
+ return await Promise.resolve(
1374
+ adminAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload),
1375
+ );
1376
+ } catch {
1377
+ return false;
1378
+ }
1379
+ }
1380
+
1381
+ private async canPublishMedia(
1382
+ meta: RoomWSMeta,
1383
+ kind: MediaKind,
1384
+ payload: Record<string, unknown>,
1385
+ ): Promise<boolean> {
1386
+ const publishAccess = this.namespaceConfig?.access?.media?.publish;
1387
+ if (!publishAccess || !this.roomId) {
1388
+ return !this.config.release;
1389
+ }
1390
+
1391
+ try {
1392
+ return await Promise.resolve(
1393
+ publishAccess(this.buildAuthFromMeta(meta), this.roomId, kind, payload),
1394
+ );
1395
+ } catch {
1396
+ return false;
1397
+ }
1398
+ }
1399
+
1400
+ private async canControlMedia(
1401
+ meta: RoomWSMeta,
1402
+ operation: MediaMessage['operation'],
1403
+ payload: Record<string, unknown>,
1404
+ ): Promise<boolean> {
1405
+ const controlAccess = this.namespaceConfig?.access?.media?.control;
1406
+ if (!controlAccess || !this.roomId) {
1407
+ return !this.config.release;
1408
+ }
1409
+
1410
+ try {
1411
+ return await Promise.resolve(
1412
+ controlAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload),
1413
+ );
1414
+ } catch {
1415
+ return false;
1416
+ }
1417
+ }
1418
+
1419
+ private async canSubscribeToMedia(
1420
+ meta: RoomWSMeta,
1421
+ payload: Record<string, unknown>,
1422
+ ): Promise<boolean> {
1423
+ if (meta.userId && payload.memberId === meta.userId) {
1424
+ return true;
1425
+ }
1426
+
1427
+ const subscribeAccess = this.namespaceConfig?.access?.media?.subscribe;
1428
+ if (!subscribeAccess || !this.roomId) {
1429
+ return !this.config.release;
1430
+ }
1431
+
1432
+ try {
1433
+ return await Promise.resolve(
1434
+ subscribeAccess(this.buildAuthFromMeta(meta), this.roomId, payload),
1435
+ );
1436
+ } catch {
1437
+ return false;
1438
+ }
1439
+ }
1440
+
1441
+ private ensureMemberMediaState(memberId: string): RoomMemberMediaState {
1442
+ let mediaState = this.memberMediaStates.get(memberId);
1443
+ if (!mediaState) {
1444
+ mediaState = {};
1445
+ this.memberMediaStates.set(memberId, mediaState);
1446
+ }
1447
+ return mediaState;
1448
+ }
1449
+
1450
+ private getKindState(memberId: string, kind: MediaKind): RoomMemberMediaKindState {
1451
+ const mediaState = this.ensureMemberMediaState(memberId);
1452
+ mediaState[kind] ??= {
1453
+ published: false,
1454
+ muted: false,
1455
+ };
1456
+ return mediaState[kind]!;
1457
+ }
1458
+
1459
+ private pruneMediaKindState(memberId: string, kind: MediaKind): void {
1460
+ const mediaState = this.memberMediaStates.get(memberId);
1461
+ const kindState = mediaState?.[kind];
1462
+ if (!mediaState || !kindState) {
1463
+ return;
1464
+ }
1465
+
1466
+ if (
1467
+ !kindState.published &&
1468
+ !kindState.muted &&
1469
+ !kindState.trackId &&
1470
+ !kindState.deviceId &&
1471
+ !kindState.publishedAt &&
1472
+ !kindState.adminDisabled &&
1473
+ !kindState.providerSessionId
1474
+ ) {
1475
+ delete mediaState[kind];
1476
+ }
1477
+
1478
+ if (!mediaState.audio && !mediaState.video && !mediaState.screen) {
1479
+ this.memberMediaStates.delete(memberId);
1480
+ }
1481
+ }
1482
+
1483
+ private buildMediaStateSnapshot(memberId: string): RoomMemberMediaState {
1484
+ const mediaState = this.memberMediaStates.get(memberId);
1485
+ if (!mediaState) {
1486
+ return {};
1487
+ }
1488
+
1489
+ const snapshot: RoomMemberMediaState = {};
1490
+ for (const kind of ['audio', 'video', 'screen'] as const) {
1491
+ const kindState = mediaState[kind];
1492
+ if (kindState) {
1493
+ snapshot[kind] = { ...kindState };
1494
+ }
1495
+ }
1496
+ return snapshot;
1497
+ }
1498
+
1499
+ private buildMediaTrackFrame(memberId: string, kind: MediaKind): {
1500
+ kind: MediaKind;
1501
+ trackId?: string;
1502
+ deviceId?: string;
1503
+ muted: boolean;
1504
+ publishedAt?: number;
1505
+ adminDisabled?: boolean;
1506
+ providerSessionId?: string;
1507
+ } | null {
1508
+ const kindState = this.memberMediaStates.get(memberId)?.[kind];
1509
+ if (!kindState?.published) {
1510
+ return null;
1511
+ }
1512
+
1513
+ return {
1514
+ kind,
1515
+ trackId: kindState.trackId,
1516
+ deviceId: kindState.deviceId,
1517
+ muted: kindState.muted,
1518
+ publishedAt: kindState.publishedAt,
1519
+ adminDisabled: kindState.adminDisabled,
1520
+ providerSessionId: kindState.providerSessionId,
1521
+ };
1522
+ }
1523
+
1524
+ private listPublishedTracks(memberId: string): Array<{
1525
+ kind: MediaKind;
1526
+ trackId?: string;
1527
+ deviceId?: string;
1528
+ muted: boolean;
1529
+ publishedAt?: number;
1530
+ adminDisabled?: boolean;
1531
+ providerSessionId?: string;
1532
+ }> {
1533
+ const tracks: Array<{
1534
+ kind: MediaKind;
1535
+ trackId?: string;
1536
+ deviceId?: string;
1537
+ muted: boolean;
1538
+ publishedAt?: number;
1539
+ adminDisabled?: boolean;
1540
+ providerSessionId?: string;
1541
+ }> = [];
1542
+ for (const kind of ['audio', 'video', 'screen'] as const) {
1543
+ const track = this.buildMediaTrackFrame(memberId, kind);
1544
+ if (track) {
1545
+ tracks.push(track);
1546
+ }
1547
+ }
1548
+ return tracks;
1549
+ }
1550
+
1551
+ private buildMemberSender(memberId: string): RoomSender {
1552
+ const member = this.members.get(memberId);
1553
+ const info = member
1554
+ ? this.buildMemberInfo(member)
1555
+ : { memberId, userId: memberId, connectionId: undefined, connectionCount: 0, role: this.memberRoles.get(memberId) };
1556
+
1557
+ return {
1558
+ userId: info.userId,
1559
+ connectionId: info.connectionId ?? 'server',
1560
+ role: info.role,
1561
+ };
1562
+ }
1563
+
1564
+ private async publishMedia(
1565
+ meta: RoomWSMeta,
1566
+ kind: MediaKind,
1567
+ payload: Record<string, unknown>,
1568
+ ): Promise<void> {
1569
+ if (!meta.userId) {
1570
+ throw new Error('User not authenticated');
1571
+ }
1572
+
1573
+ const member = this.members.get(meta.userId);
1574
+ if (!member) {
1575
+ throw new Error('Member is not joined');
1576
+ }
1577
+
1578
+ const sender = this.buildSender(meta);
1579
+ const roomApi = this.buildRoomServerAPI();
1580
+ const beforePublish = await this.applyMediaBeforePublish(kind, sender, roomApi);
1581
+ if (beforePublish === MEDIA_DENIED) {
1582
+ throw new Error('Rejected by room media hook');
1583
+ }
1584
+
1585
+ const nextPayload = beforePublish && typeof beforePublish === 'object' && !Array.isArray(beforePublish)
1586
+ ? { ...payload, ...(beforePublish as Record<string, unknown>) }
1587
+ : payload;
1588
+ const previousTrack = this.buildMediaTrackFrame(meta.userId, kind);
1589
+ const kindState = this.getKindState(meta.userId, kind);
1590
+ const trackId = typeof nextPayload.trackId === 'string' && nextPayload.trackId.trim()
1591
+ ? nextPayload.trackId.trim()
1592
+ : kindState.trackId ?? `${kind}-${crypto.randomUUID()}`;
1593
+ const deviceId = typeof nextPayload.deviceId === 'string' && nextPayload.deviceId.trim()
1594
+ ? nextPayload.deviceId.trim()
1595
+ : kindState.deviceId;
1596
+ const providerSessionId =
1597
+ typeof nextPayload.providerSessionId === 'string' && nextPayload.providerSessionId.trim()
1598
+ ? nextPayload.providerSessionId.trim()
1599
+ : kindState.providerSessionId;
1600
+
1601
+ kindState.published = true;
1602
+ kindState.muted = nextPayload.muted === true ? true : kindState.muted;
1603
+ kindState.trackId = trackId;
1604
+ kindState.deviceId = deviceId;
1605
+ kindState.publishedAt = Date.now();
1606
+ kindState.adminDisabled = false;
1607
+ kindState.providerSessionId = providerSessionId;
1608
+
1609
+ if (previousTrack && previousTrack.trackId !== trackId) {
1610
+ await this.broadcastMediaTrackRemoved(meta.userId, kind, previousTrack);
1611
+ }
1612
+
1613
+ await this.broadcastMediaTrack(meta.userId, kind);
1614
+ await this.broadcastMediaState(meta.userId);
1615
+ await this.runMediaPublishedHook(kind, sender, roomApi);
1616
+ }
1617
+
1618
+ private async unpublishMedia(memberId: string, kind: MediaKind): Promise<void> {
1619
+ const mediaState = this.memberMediaStates.get(memberId);
1620
+ const kindState = mediaState?.[kind];
1621
+ if (!kindState) {
1622
+ return;
1623
+ }
1624
+
1625
+ const previousTrack = this.buildMediaTrackFrame(memberId, kind);
1626
+ if (!kindState.published && !previousTrack) {
1627
+ kindState.trackId = undefined;
1628
+ kindState.publishedAt = undefined;
1629
+ kindState.adminDisabled = false;
1630
+ kindState.providerSessionId = undefined;
1631
+ this.pruneMediaKindState(memberId, kind);
1632
+ return;
1633
+ }
1634
+
1635
+ kindState.published = false;
1636
+ kindState.trackId = undefined;
1637
+ kindState.publishedAt = undefined;
1638
+ kindState.adminDisabled = false;
1639
+ kindState.providerSessionId = undefined;
1640
+ this.pruneMediaKindState(memberId, kind);
1641
+
1642
+ if (previousTrack) {
1643
+ await this.broadcastMediaTrackRemoved(memberId, kind, previousTrack);
1644
+ await this.broadcastMediaState(memberId);
1645
+ await this.runMediaUnpublishedHook(kind, this.buildMemberSender(memberId), this.buildRoomServerAPI());
1646
+ }
1647
+ }
1648
+
1649
+ private async applyMuteChange(
1650
+ memberId: string,
1651
+ kind: MediaKind,
1652
+ muted: boolean,
1653
+ ): Promise<void> {
1654
+ if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1655
+ throw new Error('Unknown member');
1656
+ }
1657
+
1658
+ const kindState = this.getKindState(memberId, kind);
1659
+ if (kindState.muted === muted) {
1660
+ return;
1661
+ }
1662
+
1663
+ kindState.muted = muted;
1664
+ this.pruneMediaKindState(memberId, kind);
1665
+ await this.broadcastMediaState(memberId);
1666
+ await this.runMediaMuteChangeHook(
1667
+ kind,
1668
+ this.buildMemberSender(memberId),
1669
+ muted,
1670
+ this.buildRoomServerAPI(),
1671
+ );
1672
+ }
1673
+
1674
+ private async applyDeviceChange(
1675
+ memberId: string,
1676
+ kind: MediaKind,
1677
+ payload: Record<string, unknown>,
1678
+ ): Promise<void> {
1679
+ if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1680
+ throw new Error('Unknown member');
1681
+ }
1682
+
1683
+ const deviceId = typeof payload.deviceId === 'string' ? payload.deviceId.trim() : '';
1684
+ if (!deviceId) {
1685
+ throw new Error('deviceId is required');
1686
+ }
1687
+
1688
+ const kindState = this.getKindState(memberId, kind);
1689
+ if (kindState.deviceId === deviceId) {
1690
+ return;
1691
+ }
1692
+
1693
+ kindState.deviceId = deviceId;
1694
+ await this.broadcastMediaState(memberId);
1695
+ await this.broadcastMediaDevice(memberId, kind, deviceId);
1696
+ }
1697
+
1698
+ private async applyAdminUnpublish(memberId: string, kind: MediaKind): Promise<void> {
1699
+ const kindState = this.getKindState(memberId, kind);
1700
+ kindState.adminDisabled = true;
1701
+ await this.unpublishMedia(memberId, kind);
1702
+ }
1703
+
1704
+ private async clearPublishedMedia(memberId: string): Promise<void> {
1705
+ for (const kind of ['audio', 'video', 'screen'] as const) {
1706
+ await this.unpublishMedia(memberId, kind);
1707
+ }
1708
+ }
1709
+
1710
+ private async applyMediaBeforePublish(
1711
+ kind: MediaKind,
1712
+ sender: RoomSender,
1713
+ roomApi: RoomServerAPI,
1714
+ ): Promise<unknown | typeof MEDIA_DENIED> {
1715
+ const beforePublish = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.beforePublish;
1716
+ if (!beforePublish) {
1717
+ return undefined;
1718
+ }
1719
+
1720
+ const ctx = this.buildHandlerContext();
1721
+ const result = await Promise.resolve(beforePublish(kind, sender, roomApi, ctx));
1722
+ if (result === false) {
1723
+ return MEDIA_DENIED;
1724
+ }
1725
+ return result;
1726
+ }
1727
+
1728
+ private async runMediaPublishedHook(
1729
+ kind: MediaKind,
1730
+ sender: RoomSender,
1731
+ roomApi: RoomServerAPI,
1732
+ ): Promise<void> {
1733
+ const onPublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onPublished;
1734
+ if (!onPublished) return;
1735
+
1736
+ try {
1737
+ const ctx = this.buildHandlerContext();
1738
+ await Promise.resolve(onPublished(kind, sender, roomApi, ctx));
1739
+ } catch (err) {
1740
+ console.error(`[Rooms] media.onPublished error: ${err instanceof Error ? err.message : String(err)}`);
1741
+ }
1742
+ }
1743
+
1744
+ private async runMediaUnpublishedHook(
1745
+ kind: MediaKind,
1746
+ sender: RoomSender,
1747
+ roomApi: RoomServerAPI,
1748
+ ): Promise<void> {
1749
+ const onUnpublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onUnpublished;
1750
+ if (!onUnpublished) return;
1751
+
1752
+ try {
1753
+ const ctx = this.buildHandlerContext();
1754
+ await Promise.resolve(onUnpublished(kind, sender, roomApi, ctx));
1755
+ } catch (err) {
1756
+ console.error(`[Rooms] media.onUnpublished error: ${err instanceof Error ? err.message : String(err)}`);
1757
+ }
1758
+ }
1759
+
1760
+ private async runMediaMuteChangeHook(
1761
+ kind: MediaKind,
1762
+ sender: RoomSender,
1763
+ muted: boolean,
1764
+ roomApi: RoomServerAPI,
1765
+ ): Promise<void> {
1766
+ const onMuteChange = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onMuteChange;
1767
+ if (!onMuteChange) return;
1768
+
1769
+ try {
1770
+ const ctx = this.buildHandlerContext();
1771
+ await Promise.resolve(onMuteChange(kind, sender, muted, roomApi, ctx));
1772
+ } catch (err) {
1773
+ console.error(`[Rooms] media.onMuteChange error: ${err instanceof Error ? err.message : String(err)}`);
1774
+ }
1775
+ }
1776
+
1777
+ private async broadcastMediaTrack(memberId: string, kind: MediaKind): Promise<void> {
1778
+ const member = this.members.get(memberId);
1779
+ const track = this.buildMediaTrackFrame(memberId, kind);
1780
+ if (!member || !track) {
1781
+ return;
1782
+ }
1783
+
1784
+ await this.broadcastMediaFrame(
1785
+ {
1786
+ type: 'media_track',
1787
+ member: this.buildMemberInfo(member),
1788
+ track,
1789
+ },
1790
+ {
1791
+ event: 'track',
1792
+ memberId,
1793
+ kind,
1794
+ track,
1795
+ },
1796
+ );
1797
+ }
1798
+
1799
+ private async broadcastMediaTrackRemoved(
1800
+ memberId: string,
1801
+ kind: MediaKind,
1802
+ track?: {
1803
+ kind: MediaKind;
1804
+ trackId?: string;
1805
+ deviceId?: string;
1806
+ muted: boolean;
1807
+ publishedAt?: number;
1808
+ adminDisabled?: boolean;
1809
+ } | null,
1810
+ ): Promise<void> {
1811
+ const member = this.members.get(memberId);
1812
+ if (!member) {
1813
+ return;
1814
+ }
1815
+
1816
+ await this.broadcastMediaFrame(
1817
+ {
1818
+ type: 'media_track_removed',
1819
+ member: this.buildMemberInfo(member),
1820
+ track: track ?? { kind },
1821
+ },
1822
+ {
1823
+ event: 'track_removed',
1824
+ memberId,
1825
+ kind,
1826
+ track: track ?? { kind },
1827
+ },
1828
+ );
1829
+ }
1830
+
1831
+ private async broadcastMediaState(memberId: string): Promise<void> {
1832
+ const member = this.members.get(memberId);
1833
+ if (!member) {
1834
+ return;
1835
+ }
1836
+
1837
+ const state = this.buildMediaStateSnapshot(memberId);
1838
+ await this.broadcastMediaFrame(
1839
+ {
1840
+ type: 'media_state',
1841
+ member: this.buildMemberInfo(member),
1842
+ state,
1843
+ },
1844
+ {
1845
+ event: 'state',
1846
+ memberId,
1847
+ state,
1848
+ },
1849
+ );
1850
+ }
1851
+
1852
+ private async broadcastMediaDevice(
1853
+ memberId: string,
1854
+ kind: MediaKind,
1855
+ deviceId: string,
1856
+ ): Promise<void> {
1857
+ const member = this.members.get(memberId);
1858
+ if (!member) {
1859
+ return;
1860
+ }
1861
+
1862
+ await this.broadcastMediaFrame(
1863
+ {
1864
+ type: 'media_device',
1865
+ member: this.buildMemberInfo(member),
1866
+ kind,
1867
+ deviceId,
1868
+ },
1869
+ {
1870
+ event: 'device',
1871
+ memberId,
1872
+ kind,
1873
+ deviceId,
1874
+ },
1875
+ );
1876
+ }
1877
+
1878
+ private async broadcastMediaFrame(
1879
+ frame: Record<string, unknown>,
1880
+ payload: Record<string, unknown>,
1881
+ ): Promise<void> {
1882
+ const json = JSON.stringify(frame);
1883
+ for (const ws of this.ctx.getWebSockets()) {
1884
+ const meta = this.getWSMeta(ws);
1885
+ if (!meta?.authenticated || !this.joinedConnectionIds.has(meta.connectionId)) {
1886
+ continue;
1887
+ }
1888
+ if (!(await this.canSubscribeToMedia(meta, payload))) {
1889
+ continue;
1890
+ }
1891
+ this.safeSendRaw(ws, json);
1892
+ }
1893
+ }
1894
+
1895
+ private async sendMediaSyncToConnection(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
1896
+ if (!this.isSocketOpen(ws) || !meta.userId) {
1897
+ return;
1898
+ }
1899
+
1900
+ const members: Array<{
1901
+ member: RoomMemberInfo;
1902
+ state: RoomMemberMediaState;
1903
+ tracks: Array<{
1904
+ kind: MediaKind;
1905
+ trackId?: string;
1906
+ deviceId?: string;
1907
+ muted: boolean;
1908
+ publishedAt?: number;
1909
+ adminDisabled?: boolean;
1910
+ }>;
1911
+ }> = [];
1912
+
1913
+ for (const member of this.listMembers()) {
1914
+ const state = this.buildMediaStateSnapshot(member.memberId);
1915
+ const tracks = this.listPublishedTracks(member.memberId);
1916
+ if (Object.keys(state).length === 0 && tracks.length === 0) {
1917
+ continue;
1918
+ }
1919
+ if (!(await this.canSubscribeToMedia(meta, {
1920
+ event: 'sync',
1921
+ memberId: member.memberId,
1922
+ state,
1923
+ tracks,
1924
+ }))) {
1925
+ continue;
1926
+ }
1927
+ members.push({ member, state, tracks });
1928
+ }
1929
+
1930
+ this.safeSend(ws, {
1931
+ type: 'media_sync',
1932
+ members,
1933
+ });
1934
+ }
1935
+
1936
+ private normalizeMediaOperation(operation: unknown): MediaMessage['operation'] | null {
1937
+ if (typeof operation !== 'string') {
1938
+ return null;
1939
+ }
1940
+ switch (operation.trim()) {
1941
+ case 'publish':
1942
+ case 'unpublish':
1943
+ case 'mute':
1944
+ case 'device':
1945
+ return operation.trim() as MediaMessage['operation'];
1946
+ default:
1947
+ return null;
1948
+ }
1949
+ }
1950
+
1951
+ private normalizeMediaKind(kind: unknown): MediaKind | null {
1952
+ if (typeof kind !== 'string') {
1953
+ return null;
1954
+ }
1955
+ switch (kind.trim()) {
1956
+ case 'audio':
1957
+ case 'video':
1958
+ case 'screen':
1959
+ return kind.trim() as MediaKind;
1960
+ default:
1961
+ return null;
1962
+ }
1963
+ }
1964
+
1965
+ private deliverSignal(
1966
+ frame: {
1967
+ type: 'signal';
1968
+ event: string;
1969
+ payload: unknown;
1970
+ meta: SignalFrameMeta;
1971
+ },
1972
+ options: {
1973
+ memberId?: string;
1974
+ includeSelf: boolean;
1975
+ senderConnectionId?: string;
1976
+ excludeUserIds?: string[];
1977
+ },
1978
+ ): void {
1979
+ if (options.memberId) {
1980
+ this.sendSignalToMember(options.memberId, frame);
1981
+ return;
1982
+ }
1983
+
1984
+ this.broadcastToJoined(
1985
+ frame,
1986
+ options.includeSelf ? undefined : options.senderConnectionId,
1987
+ options.excludeUserIds,
1988
+ );
1989
+ }
1990
+
1991
+ private sendSignalToMember(
1992
+ memberId: string,
1993
+ frame: {
1994
+ type: 'signal';
1995
+ event: string;
1996
+ payload: unknown;
1997
+ meta: SignalFrameMeta;
1998
+ },
1999
+ ): void {
2000
+ const member = this.members.get(memberId);
2001
+ if (!member || member.connectionIds.size === 0) {
2002
+ return;
2003
+ }
2004
+
2005
+ const json = JSON.stringify(frame);
2006
+ for (const ws of this.ctx.getWebSockets()) {
2007
+ const meta = this.getWSMeta(ws);
2008
+ if (meta?.authenticated && member.connectionIds.has(meta.connectionId)) {
2009
+ this.safeSendRaw(ws, json);
2010
+ }
2011
+ }
2012
+ }
2013
+
2014
+ private broadcastMembersSync(): void {
2015
+ this.broadcastToJoined({
2016
+ type: 'members_sync',
2017
+ members: this.listMembers(),
2018
+ });
2019
+ }
2020
+
2021
+ private broadcastToJoined(
2022
+ msg: Record<string, unknown>,
2023
+ excludeConnectionId?: string,
2024
+ excludeUserIds?: string[],
2025
+ ): void {
2026
+ if (this.joinedConnectionIds.size === 0) {
2027
+ return;
2028
+ }
2029
+
2030
+ const json = JSON.stringify(msg);
2031
+ const excludeSet = excludeUserIds?.length ? new Set(excludeUserIds) : null;
2032
+ for (const ws of this.ctx.getWebSockets()) {
2033
+ const meta = this.getWSMeta(ws);
2034
+ if (!meta?.authenticated || !this.joinedConnectionIds.has(meta.connectionId)) {
2035
+ continue;
2036
+ }
2037
+ if (excludeConnectionId && meta.connectionId === excludeConnectionId) {
2038
+ continue;
2039
+ }
2040
+ if (excludeSet && meta.userId && excludeSet.has(meta.userId)) {
2041
+ continue;
2042
+ }
2043
+ this.safeSendRaw(ws, json);
2044
+ }
2045
+ }
2046
+
2047
+ private ensureMember(userId: string): RoomMemberPresence {
2048
+ let member = this.members.get(userId);
2049
+ if (!member) {
2050
+ member = {
2051
+ memberId: userId,
2052
+ userId,
2053
+ joinedAt: Date.now(),
2054
+ connectionIds: new Set<string>(),
2055
+ state: {},
2056
+ };
2057
+ this.members.set(userId, member);
2058
+ }
2059
+ return member;
2060
+ }
2061
+
2062
+ private removeMemberConnection(userId: string, connectionId: string): RoomMemberPresence | null {
2063
+ this.joinedConnectionIds.delete(connectionId);
2064
+ const member = this.members.get(userId);
2065
+ if (!member) {
2066
+ return null;
2067
+ }
2068
+
2069
+ member.connectionIds.delete(connectionId);
2070
+ return member;
2071
+ }
2072
+
2073
+ private deleteMember(userId: string): void {
2074
+ const member = this.members.get(userId);
2075
+ if (!member) {
2076
+ return;
2077
+ }
2078
+
2079
+ for (const connectionId of member.connectionIds) {
2080
+ this.joinedConnectionIds.delete(connectionId);
2081
+ }
2082
+ this.members.delete(userId);
2083
+ }
2084
+
2085
+ private listMembers(): RoomMemberSnapshot[] {
2086
+ return Array.from(this.members.values())
2087
+ .sort((left, right) => left.joinedAt - right.joinedAt || left.memberId.localeCompare(right.memberId))
2088
+ .map((member) => this.buildMemberSnapshot(member));
2089
+ }
2090
+
2091
+ private buildMemberSnapshot(
2092
+ member: RoomMemberPresence,
2093
+ fallbackConnectionId?: string,
2094
+ ): RoomMemberSnapshot {
2095
+ return {
2096
+ ...this.buildMemberInfo(member, fallbackConnectionId),
2097
+ state: { ...member.state },
2098
+ };
2099
+ }
2100
+
2101
+ private buildMemberInfo(
2102
+ member: RoomMemberPresence,
2103
+ fallbackConnectionId?: string,
2104
+ ): RoomMemberInfo {
2105
+ const activeConnectionId = member.connectionIds.values().next().value as string | undefined;
2106
+ const connectionId = activeConnectionId ?? fallbackConnectionId;
2107
+ const meta = connectionId ? this.findConnectionMeta(connectionId) : null;
2108
+ const role = this.memberRoles.get(member.memberId) ?? meta?.role;
2109
+
2110
+ return {
2111
+ memberId: member.memberId,
2112
+ userId: member.userId,
2113
+ connectionId,
2114
+ connectionCount: member.connectionIds.size,
2115
+ role,
2116
+ };
2117
+ }
2118
+
2119
+ private findConnectionMeta(connectionId: string): RoomWSMeta | null {
2120
+ for (const ws of this.ctx.getWebSockets()) {
2121
+ const meta = this.getWSMeta(ws);
2122
+ if (meta?.connectionId === connectionId) {
2123
+ return meta;
2124
+ }
2125
+ }
2126
+ return null;
2127
+ }
2128
+
2129
+ private buildSignalFrameMeta(sender: RoomSender, serverSent: boolean): SignalFrameMeta {
2130
+ return {
2131
+ memberId: serverSent ? null : sender.userId,
2132
+ userId: serverSent ? null : sender.userId,
2133
+ connectionId: serverSent ? null : sender.connectionId,
2134
+ sentAt: Date.now(),
2135
+ serverSent,
2136
+ };
2137
+ }
2138
+
2139
+ private normalizeMemberId(memberId: string | null | undefined): string | undefined {
2140
+ if (typeof memberId !== 'string') return undefined;
2141
+ const trimmed = memberId.trim();
2142
+ return trimmed.length > 0 ? trimmed : undefined;
2143
+ }
2144
+
2145
+ private asRecord(value: unknown): Record<string, unknown> | undefined {
2146
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2147
+ return undefined;
2148
+ }
2149
+ return value as Record<string, unknown>;
2150
+ }
2151
+
2152
+ private requireAuthenticatedMeta(ws: WebSocket): RoomWSMeta | null {
2153
+ const meta = this.getWSMeta(ws);
2154
+ if (!meta) {
2155
+ ws.close(4000, 'No metadata');
2156
+ return null;
2157
+ }
2158
+
2159
+ if (!meta.authenticated) {
2160
+ this.safeSend(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Authenticate first' });
2161
+ return null;
2162
+ }
2163
+
2164
+ return meta;
2165
+ }
2166
+
2167
+ private isJoinedConnection(connectionId: string): boolean {
2168
+ return this.joinedConnectionIds.has(connectionId);
2169
+ }
2170
+
2171
+ private isSocketOpen(ws: WebSocket): boolean {
2172
+ return ws.readyState === WEBSOCKET_OPEN;
2173
+ }
2174
+
2175
+ private syncMemberRole(memberId: string, role: string): void {
2176
+ this.memberRoles.set(memberId, role);
2177
+
2178
+ for (const ws of this.ctx.getWebSockets()) {
2179
+ const meta = this.getWSMeta(ws);
2180
+ if (!meta || meta.userId !== memberId) {
2181
+ continue;
2182
+ }
2183
+
2184
+ meta.role = role;
2185
+ if (meta.auth) {
2186
+ meta.auth = { ...meta.auth, role };
2187
+ }
2188
+ this.setWSMeta(ws, meta);
2189
+ }
2190
+ }
2191
+ }