@homie-rocks/studio 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/check.mjs +8 -1
  8. package/lib/cloudflare.mjs +49 -22
  9. package/lib/dev.mjs +6 -8
  10. package/lib/local-database-start.mjs +6 -0
  11. package/lib/mcp-tools.mjs +3 -3
  12. package/lib/port-check.mjs +3 -2
  13. package/lib/scaffold.mjs +2 -0
  14. package/lib/template-history.json +46 -1
  15. package/lib/worker-build.mjs +4 -1
  16. package/lib/worker-config.mjs +7 -0
  17. package/netplay/NETPLAY.md +5 -1
  18. package/netplay/netplay.ts +24 -9
  19. package/package.json +1 -1
  20. package/rules/core.ts +9 -0
  21. package/rules/host.ts +40 -11
  22. package/rules/view.ts +1 -0
  23. package/tools/TOOLS.md +6 -1
  24. package/worker/background-queue.mjs +1 -0
  25. package/worker/build-id.mjs +1 -1
  26. package/worker/custom-routes.mjs +16 -0
  27. package/worker/gate.mjs +94 -23
  28. package/worker/idle-chat.mjs +56 -0
  29. package/worker/index.mjs +2 -0
  30. package/worker/lazy-objects.mjs +3 -0
  31. package/worker/lobby-peak.mjs +44 -0
  32. package/worker/lobby-shards.mjs +43 -0
  33. package/worker/lobby-store.mjs +35 -0
  34. package/worker/lobby-summary.mjs +28 -0
  35. package/worker/lobby.mjs +86 -18
  36. package/worker/lounge-store.mjs +2 -2
  37. package/worker/match-router.mjs +74 -0
  38. package/worker/mcp-room.mjs +2 -1
  39. package/worker/mcp-tools.mjs +4 -2
  40. package/worker/office.mjs +2 -1
  41. package/worker/operational-schema.mjs +2 -0
  42. package/worker/pages.mjs +1 -1
  43. package/worker/relay-channel.mjs +119 -0
  44. package/worker/request-db.mjs +4 -3
  45. package/worker/room-chat.mjs +2 -1
  46. package/worker/room-sessions.mjs +65 -0
  47. package/worker/room.mjs +16 -9
  48. package/worker/route-auth.mjs +22 -0
  49. package/worker/routes.mjs +7 -4
  50. package/worker/runtime-support.mjs +5 -3
  51. package/worker/session-codec.mjs +24 -0
  52. package/worker/stats.mjs +2 -1
  53. package/worker/table.mjs +277 -41
  54. package/worker/version.mjs +1 -1
  55. package/worker/watch-sockets.mjs +75 -0
@@ -0,0 +1,75 @@
1
+ /** The public watch socket has no simulation state. Keep its authenticated
2
+ * request in DO storage and a small attachment on the socket; listeners are
3
+ * rebuilt after hibernation without trusting anything supplied by the client.
4
+ */
5
+ import {isQa} from './stats.mjs';
6
+ export class WatchSockets {
7
+ constructor(ctx) {
8
+ this.ctx = ctx;
9
+ this.listeners = new Map();
10
+ if (ctx.setWebSocketAutoResponse && typeof WebSocketRequestResponsePair !== 'undefined') {
11
+ ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('homie-ping', 'homie-pong'));
12
+ }
13
+ }
14
+ transport(socket) {
15
+ const listeners = new Map();
16
+ this.listeners.set(socket, listeners);
17
+ return {
18
+ accept() {},
19
+ lastPing: () => this.ctx.getWebSocketAutoResponseTimestamp?.(socket)?.getTime?.() ?? 0,
20
+ remember: clientId => socket.serializeAttachment({...socket.deserializeAttachment(),clientId}),
21
+ watchState: socket.deserializeAttachment()?.watchState,
22
+ rememberWatch: watchState => socket.serializeAttachment({...socket.deserializeAttachment(),watchState}),
23
+ rememberGate: gate => socket.serializeAttachment({...socket.deserializeAttachment(),gate}),
24
+ send: text => socket.send(text),
25
+ close: (code, reason) => socket.close(code, reason),
26
+ addEventListener: (event, fn) => listeners.set(event, fn),
27
+ };
28
+ }
29
+ async accept(socket, request) {
30
+ const id = crypto.randomUUID();
31
+ const ip = request.headers.get('cf-connecting-ip');
32
+ // A digest retains per-address admission limits without persisting raw addresses.
33
+ const address = ip ? [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip)))].map(n => n.toString(16).padStart(2, '0')).join('') : null;
34
+ await this.ctx.storage.put('watch:' + id, {url: request.url, headers: [...(address ? [['cf-connecting-ip', address]] : []),...(isQa(request)?[['user-agent','homie-house-qa']]:[])]});
35
+ socket.serializeAttachment({v: 1, id});
36
+ this.ctx.acceptWebSocket(socket, [new URL(request.url).pathname==='/__watch'?'watch':'net']);
37
+ return Object.assign(this.transport(socket), {address});
38
+ }
39
+ async prune() {
40
+ const live=new Set((this.ctx.getWebSockets?.()??[]).filter(socket=>socket.readyState!==3).map(socket=>'watch:'+socket.deserializeAttachment()?.id));
41
+ for(const key of (await this.ctx.storage.list?.({prefix:'watch:'})??new Map()).keys())if(!live.has(key))await this.ctx.storage.delete(key);
42
+ }
43
+ async restore(connect) {
44
+ const sockets = this.ctx.getWebSockets?.() ?? [];
45
+ const requests = await this.ctx.storage.list?.({prefix: 'watch:'}) ?? new Map();
46
+ const live = new Set(sockets.map(socket => 'watch:' + socket.deserializeAttachment()?.id));
47
+ for (const key of requests.keys()) if (!live.has(key)) await this.ctx.storage.delete(key);
48
+ for (const socket of sockets) {
49
+ const attachment = socket.deserializeAttachment();
50
+ const request = attachment?.v === 1 && requests.get('watch:' + attachment.id);
51
+ if (!request) { socket.close(1011, 'watch state missing'); continue; }
52
+ // Migrate older request records without retaining their raw user agent.
53
+ const headers=new Headers(request.headers);
54
+ const ip=headers.get('cf-connecting-ip');
55
+ if(ip&&!/^[a-f0-9]{64}$/.test(ip))headers.set('cf-connecting-ip',[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(ip)))].map(n=>n.toString(16).padStart(2,'0')).join(''));
56
+ const minimal=[...(headers.has('cf-connecting-ip')?[['cf-connecting-ip',headers.get('cf-connecting-ip')]]:[]),...(isQa({headers})?[['user-agent','homie-house-qa']]:[])];
57
+ if(JSON.stringify(request.headers)!==JSON.stringify(minimal)){request.headers=minimal;await this.ctx.storage.put('watch:'+attachment.id,request);}
58
+ try {
59
+ const response = await connect(new Request(request.url, {headers: request.headers}), this.transport(socket),attachment);
60
+ if (response != null) { socket.close(1012, 'room restore rejected'); await this.close(socket, null, false); }
61
+ } catch { socket.close(1011, 'room restore failed'); await this.close(socket, null, false); }
62
+ }
63
+ }
64
+ message(socket, data) { this.listeners.get(socket)?.get('message')?.({data}); }
65
+ async close(socket, error = null, reply = true) {
66
+ if (reply) { try { socket.close(1000, 'closed'); } catch { /* already closed */ } }
67
+ const listeners = this.listeners.get(socket);
68
+ this.listeners.delete(socket);
69
+ try { listeners?.get(error ? 'error' : 'close')?.(error ? {error} : {}); }
70
+ finally {
71
+ const attachment = socket.deserializeAttachment();
72
+ if (attachment?.v === 1) await this.ctx.storage.delete('watch:' + attachment.id);
73
+ }
74
+ }
75
+ }