@standardagents/builder 0.41.8 → 0.41.9

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.
@@ -1,6 +1,6 @@
1
1
  import { i as AuthContext, b as ThreadEnv, v as Agent, x as ThreadMetadata, z as ToolResult, M as Message, n as ClientToolRequest, o as ClientToolResponse, V as ProxyStreamReceiver, W as ProxyStreamFrame, p as ClientStreamRequest, X as ProxyStreamTarget, q as ClientStreamResponse, Y as ProxyStreamOpenResult, H as FileRecord, Z as SkillStorageScope, F as FlowState, N as FileStats, K as GrepResult, J as AttachmentRef, U as MessageContent, G as Env, w as ThreadInstance } from './index-ChHAVogf.js';
2
2
  import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
3
- import { CodeExecutionOptions, CodeExecutionResult, SubagentRegistryEntry, InjectMessageInput, QueueMessageInput, CredentialGrant, SkillMetadata, SkillFileInput, ModelDefinition as ModelDefinition$1, ToolArgs, PromptTextPart, PromptEnvPart, VariableDefinition, SubpromptConfig as SubpromptConfig$1, PromptToolConfig as PromptToolConfig$1, ReasoningConfig, HumanSide as HumanSide$1, SubagentRelationship as SubagentRelationship$1, ParentHookRequirement, LLMProviderInterface, ContentPart, TextPart, ImagePart, FilePart, ThreadState, NamespaceContext, DefinitionLoader } from '@standardagents/spec';
3
+ import { CodeExecutionOptions, CodeExecutionResult, SubagentRegistryEntry, InjectMessageInput, QueueMessageInput, UserRoomConnection, CredentialGrant, SkillMetadata, SkillFileInput, ModelDefinition as ModelDefinition$1, ToolArgs, PromptTextPart, PromptEnvPart, VariableDefinition, SubpromptConfig as SubpromptConfig$1, PromptToolConfig as PromptToolConfig$1, ReasoningConfig, HumanSide as HumanSide$1, SubagentRelationship as SubagentRelationship$1, ParentHookRequirement, LLMProviderInterface, ContentPart, TextPart, ImagePart, FilePart, ThreadState, NamespaceContext, DefinitionLoader } from '@standardagents/spec';
4
4
  import { N as NamespacedRegistry } from './types-Dhd0M369.js';
5
5
 
6
6
  /**
@@ -1892,12 +1892,20 @@ interface HubClientInfo {
1892
1892
  clientId: string | null;
1893
1893
  clientName: string | null;
1894
1894
  clientKind: string | null;
1895
+ /** Unix milliseconds when the socket attached; restored from the attachment. */
1896
+ since?: number;
1895
1897
  }
1896
1898
  declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1897
1899
  /** Persistent client (daemon) sockets, restored across hibernation. */
1898
1900
  private clientSockets;
1899
1901
  private clientInfo;
1900
1902
  private migrated;
1903
+ /** Inbound frame rate and outbound byte budget per room socket. */
1904
+ private roomLimits;
1905
+ /** Detach notices held for the presence grace window, by client id. */
1906
+ private pendingDetach;
1907
+ /** The project's room definition, loaded once per instance. */
1908
+ private roomDefinition;
1901
1909
  constructor(ctx: DurableObjectState, env: DurableUserHubEnv);
1902
1910
  private ensureMigrated;
1903
1911
  /**
@@ -1920,6 +1928,32 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1920
1928
  * opened separately and only while a thread is active.
1921
1929
  */
1922
1930
  fetch(request: Request): Promise<Response>;
1931
+ private connectionOf;
1932
+ /** Close every other socket presenting `clientId`; true when one existed. */
1933
+ private supersedeClient;
1934
+ private forgetSocket;
1935
+ private liveSocketFor;
1936
+ /** Live room connections, one per client id, oldest attachment first. */
1937
+ listRoomClients(): UserRoomConnection[];
1938
+ /** Deliver one frame to the socket carrying `clientId`, within its send budget. */
1939
+ sendToRoomClient(clientId: string, frame: unknown): boolean;
1940
+ private sendRoomFrame;
1941
+ private limitsFor;
1942
+ /** Load the project's room definition from the users registry, once. */
1943
+ private loadRoom;
1944
+ /** Whether `key` is room-owned state, exempt from `user_value_changed`. */
1945
+ private roomOwnsKey;
1946
+ /**
1947
+ * Presence runs inside the serialization chain, after any execution the
1948
+ * event raced with, so a room's presence write never interleaves with a
1949
+ * mutation. A reconnect inside the grace window is not a change.
1950
+ */
1951
+ private observeAttach;
1952
+ private observeDetach;
1953
+ private dispatchRoomPresence;
1954
+ /** The hub is one object per user; its name is the user id. */
1955
+ private roomUserId;
1956
+ private dispatchRoomFrame;
1923
1957
  /** Idle sockets answer heartbeats at the edge without waking the object.
1924
1958
  * Registered at accept time rather than in the constructor so a hub that
1925
1959
  * never accepts a socket stays fully evictable (this also keeps
@@ -1929,8 +1963,9 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1929
1963
  private streamSockets;
1930
1964
  private acceptUserStream;
1931
1965
  /**
1932
- * Deliver an event frame to every socket attached to this user's stream.
1933
- * At-most-once, live-only (spec: Users §4) — no history, no acks.
1966
+ * Deliver an event frame to every socket attached to this user's stream
1967
+ * and to every room socket. At-most-once, live-only (spec: Users §4) —
1968
+ * no history, no acks.
1934
1969
  */
1935
1970
  broadcastUserEvent(userId: string, event: string, data?: unknown): void;
1936
1971
  /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
@@ -1959,7 +1994,7 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1959
1994
  /** Perform a privileged fetch with the grant's credential injected. */
1960
1995
  private grantFetch;
1961
1996
  private handleGrantFetch;
1962
- webSocketMessage(ws: WebSocket, _message: string | ArrayBuffer): Promise<void>;
1997
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
1963
1998
  webSocketError(ws: WebSocket): Promise<void>;
1964
1999
  /** Whether a persistent client (daemon) is connected right now. */
1965
2000
  hasClient(): Promise<boolean>;
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { m as AgentBuilderEnv, n as AgentBuilderEvent, A as AgentDefinition, ah as BroadcastOptions, C as CodeExecutionBridge, h as DurableAgentBuilder, D as DurableThread, l as DurableUserHub, af as FlowStateSdk, ai as FlowStateWithSdk, G as HandlerSide, H as HubClientInfo, E as HumanSide, aq as ImageContextConfig, ae as InjectMessageOptions, as as LLMProviderInterface, M as ModelDefinition, aL as NamespaceResolutionError, w as PromptContent, s as PromptDefinition, y as PromptIncludePart, x as PromptPart, F as PromptSide, t as PromptToolConfig, P as Provider, at as ProviderContentPart, aw as ProviderFilePart, av as ProviderImagePart, ar as ProviderRegistry, au as ProviderTextPart, B as SessionToolBinding, z as SessionToolConfig, I as SideConfig, J as SubagentRelationship, u as SubagentToolConfig, S as SubpromptConfig, o as ThreadEventPayload, p as ThreadRegistryEntry, T as ThreadWatchdog, j as ThreadWatchdogState, v as ToolConfig, U as UpdateThreadParams, q as User, k as WatchdogCheckin, W as WatchdogProbeResult, f as applyRuntimeCors, am as buildImageDescription, c as canAccessThread, a9 as cat, aJ as createNamespaceContext, Q as emitThreadEvent, ag as enhanceFlowState, a0 as exists, ad as find, R as forceTurn, ap as generateImageDescription, a7 as getFileStats, N as getMessages, al as getMessagesToSummarize, az as getShortName, a8 as getThumbnail, ak as getUnsummarizedImageAttachments, aG as getVisibleAgentNames, aI as getVisibleModelNames, aH as getVisiblePromptNames, aF as getVisibleToolNames, ac as grep, aj as hasImageAttachments, aa as head, L as injectMessage, d as isAdminOnlyApiRoute, i as isHostedInstance, b as isPublicApiRoute, aK as isQualifiedName, e as isSameOriginBrowserRequest, a as isTrustedLocalDevelopmentRequest, Z as linkFile, a2 as mkdir, a5 as move, ao as optimizeImageContext, ax as parseQualifiedName, ay as qualifyName, K as queueTool, _ as readFile, a3 as readdir, O as reloadHistory, a6 as rename, an as replaceImagesWithDescriptions, aD as resolveAgent, aE as resolveHook, aC as resolveModel, aB as resolvePrompt, aA as resolveTool, a4 as rmdir, r as runtimeCorsHeaders, g as runtimePreflightResponse, $ as stat, ab as tail, a1 as unlink, V as updateThread, X as writeFile, Y as writeImage } from './resolver-BMZAzKD3.js';
1
+ export { m as AgentBuilderEnv, n as AgentBuilderEvent, A as AgentDefinition, ah as BroadcastOptions, C as CodeExecutionBridge, h as DurableAgentBuilder, D as DurableThread, l as DurableUserHub, af as FlowStateSdk, ai as FlowStateWithSdk, G as HandlerSide, H as HubClientInfo, E as HumanSide, aq as ImageContextConfig, ae as InjectMessageOptions, as as LLMProviderInterface, M as ModelDefinition, aL as NamespaceResolutionError, w as PromptContent, s as PromptDefinition, y as PromptIncludePart, x as PromptPart, F as PromptSide, t as PromptToolConfig, P as Provider, at as ProviderContentPart, aw as ProviderFilePart, av as ProviderImagePart, ar as ProviderRegistry, au as ProviderTextPart, B as SessionToolBinding, z as SessionToolConfig, I as SideConfig, J as SubagentRelationship, u as SubagentToolConfig, S as SubpromptConfig, o as ThreadEventPayload, p as ThreadRegistryEntry, T as ThreadWatchdog, j as ThreadWatchdogState, v as ToolConfig, U as UpdateThreadParams, q as User, k as WatchdogCheckin, W as WatchdogProbeResult, f as applyRuntimeCors, am as buildImageDescription, c as canAccessThread, a9 as cat, aJ as createNamespaceContext, Q as emitThreadEvent, ag as enhanceFlowState, a0 as exists, ad as find, R as forceTurn, ap as generateImageDescription, a7 as getFileStats, N as getMessages, al as getMessagesToSummarize, az as getShortName, a8 as getThumbnail, ak as getUnsummarizedImageAttachments, aG as getVisibleAgentNames, aI as getVisibleModelNames, aH as getVisiblePromptNames, aF as getVisibleToolNames, ac as grep, aj as hasImageAttachments, aa as head, L as injectMessage, d as isAdminOnlyApiRoute, i as isHostedInstance, b as isPublicApiRoute, aK as isQualifiedName, e as isSameOriginBrowserRequest, a as isTrustedLocalDevelopmentRequest, Z as linkFile, a2 as mkdir, a5 as move, ao as optimizeImageContext, ax as parseQualifiedName, ay as qualifyName, K as queueTool, _ as readFile, a3 as readdir, O as reloadHistory, a6 as rename, an as replaceImagesWithDescriptions, aD as resolveAgent, aE as resolveHook, aC as resolveModel, aB as resolvePrompt, aA as resolveTool, a4 as rmdir, r as runtimeCorsHeaders, g as runtimePreflightResponse, $ as stat, ab as tail, a1 as unlink, V as updateThread, X as writeFile, Y as writeImage } from './resolver-DWuGxI4w.js';
2
2
  export { v as Agent, J as AttachmentRef, i as AuthContext, A as AuthUser, e as BuilderThreadEndpointHandler, C as CLIENT_STREAM_CHUNK_BYTES, t as ClientDuplex, u as ClientForwarding, p as ClientStreamRequest, q as ClientStreamResponse, n as ClientToolRequest, o as ClientToolResponse, B as Controller, a as ControllerContext, G as Env, H as FileRecord, N as FileStats, D as FlowResult, F as FlowState, K as GrepResult, P as ImageContentPart, I as ImageMetadata, L as LLMResponse, M as Message, U as MessageContent, Q as MultimodalContent, R as RequestContext, S as StorageBackend, E as TelemetryEvent, O as TextContentPart, T as ThreadEndpointContext, b as ThreadEnv, w as ThreadInstance, x as ThreadMetadata, y as ToolCall, z as ToolResult, f as authenticate, j as callClient, k as callClientStream, c as createThreadEndpointHandler, m as decodeStreamChunk, d as defineController, l as encodeStreamChunk, h as endpointCallerFromAuth, g as requireAdmin, r as requireAuth, s as supportsClientForwarding } from './index-ChHAVogf.js';
3
3
  export { AgentType, DefineHandlerOptions, DefinitionLoader, GlobalNamespaceContext, HANDLER_SYMBOL, HandlerDefinition, HookExecutionContext, HookExecutionSource, HookSignatures, ImageContent, ModelCapabilities, ModelProvider, NamespaceContext, PackageSignature, PackedExports, PackedMeta, PackedMetadata, PackedNamespaceContext, ParentHookRequirement, ParentHookStage, PromptInput, PromptTextPart, ProviderAssistantMessage, ProviderError, ProviderErrorCode, ProviderFactory, ProviderFactoryConfig, ProviderFinishReason, ProviderGeneratedImage, ProviderMessage, ProviderMessageContent, ModelCapabilities as ProviderModelCapabilities, ProviderReasoningDetail, ProviderRequest, ProviderResponse, ProviderStreamChunk, ProviderSystemMessage, ProviderTool, ProviderToolCallPart, ProviderToolMessage, ProviderToolResultContent, ProviderUsage, ProviderUserMessage, ReasoningConfig, StructuredPrompt, TextContent, ThreadHookName, Tool, ToolArgs, ToolArgsNode, ToolArgsRawShape, ToolContent, belongsToPackage, defineAgent, defineHandler, defineHook, defineModel, definePrompt, defineProvider, defineTool, isHandler, isPacked, isVisibleInNamespace, mapReasoningLevel, providerConst, providerEnv, providerValue } from '@standardagents/spec';
4
4
  export { N as NamespacedRegistry } from './types-Dhd0M369.js';
package/dist/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getProviderModelPricing } from '@standardagents/provider-pricing';
2
2
  import { jsonPathReader } from '@formkit/jsonreader';
3
- import { ProviderError, mapReasoningLevel, validateAgentDefinition, THREAD_STREAM_REASONING_PARAM, isUserEndpoint } from '@standardagents/spec';
3
+ import { ProviderError, mapReasoningLevel, validateAgentDefinition, THREAD_STREAM_REASONING_PARAM, isUserRoom, isUserEndpoint } from '@standardagents/spec';
4
4
  export { HANDLER_SYMBOL, ProviderError, belongsToPackage, defineAgent, defineHandler, defineHook, defineModel, definePrompt, defineProvider, defineTool, isHandler, isPacked, isVisibleInNamespace, mapReasoningLevel, providerConst, providerEnv, providerValue } from '@standardagents/spec';
5
5
  import 'virtual:@standardagents-providers';
6
6
  import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
@@ -30547,6 +30547,12 @@ var UserStateImpl = class {
30547
30547
  emit(event, data) {
30548
30548
  this.host.broadcastUserEvent(this.userId, event, data);
30549
30549
  }
30550
+ async clients() {
30551
+ return this.host.listRoomClients(this.userId);
30552
+ }
30553
+ async send(clientId, frame) {
30554
+ return this.host.sendToRoomClient(this.userId, clientId, frame);
30555
+ }
30550
30556
  async listThreads(options) {
30551
30557
  const { threads, total } = await this.singleton().listThreads({
30552
30558
  user_id: this.userId,
@@ -30617,6 +30623,69 @@ function createExecutionChain() {
30617
30623
  // src/durable-objects/DurableUserHub.ts
30618
30624
  init_bridge_owner();
30619
30625
 
30626
+ // src/users/room.ts
30627
+ var ROOM_FRAME_MAX_BYTES = 1024 * 1024;
30628
+ var ROOM_FRAME_RATE_PER_SECOND = 200;
30629
+ var ROOM_FRAME_BURST = 400;
30630
+ var ROOM_SEND_BUDGET_BYTES = 8 * 1024 * 1024;
30631
+ var ROOM_SEND_BUDGET_WINDOW_MS = 1e4;
30632
+ var ROOM_PRESENCE_GRACE_MS = 3e4;
30633
+ var ROOM_CLOSE_SUPERSEDED = 4001;
30634
+ var ROOM_CLOSE_TOO_LARGE = 1009;
30635
+ var ROOM_CLOSE_TRY_AGAIN = 1013;
30636
+ function parseRoomFrame(message) {
30637
+ const size = typeof message === "string" ? message.length : message.byteLength;
30638
+ if (size > ROOM_FRAME_MAX_BYTES) return "too_large";
30639
+ if (typeof message !== "string") return null;
30640
+ let parsed;
30641
+ try {
30642
+ parsed = JSON.parse(message);
30643
+ } catch {
30644
+ return null;
30645
+ }
30646
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
30647
+ const frame = parsed;
30648
+ if (frame.type !== "room" || typeof frame.id !== "string" || frame.id.length === 0 || frame.id.length > 256) {
30649
+ return null;
30650
+ }
30651
+ return frame;
30652
+ }
30653
+ var RoomRateLimiter = class {
30654
+ constructor(ratePerSecond = ROOM_FRAME_RATE_PER_SECOND, burst = ROOM_FRAME_BURST, now = Date.now()) {
30655
+ this.ratePerSecond = ratePerSecond;
30656
+ this.burst = burst;
30657
+ this.tokens = burst;
30658
+ this.last = now;
30659
+ }
30660
+ tokens;
30661
+ last;
30662
+ take(now = Date.now()) {
30663
+ const elapsed = Math.max(0, now - this.last) / 1e3;
30664
+ this.tokens = Math.min(this.burst, this.tokens + elapsed * this.ratePerSecond);
30665
+ this.last = now;
30666
+ if (this.tokens < 1) return false;
30667
+ this.tokens -= 1;
30668
+ return true;
30669
+ }
30670
+ };
30671
+ var RoomSendBudget = class {
30672
+ constructor(budgetBytes = ROOM_SEND_BUDGET_BYTES, windowMs = ROOM_SEND_BUDGET_WINDOW_MS, now = Date.now()) {
30673
+ this.budgetBytes = budgetBytes;
30674
+ this.windowMs = windowMs;
30675
+ this.windowStart = now;
30676
+ }
30677
+ spent = 0;
30678
+ windowStart;
30679
+ charge(bytes, now = Date.now()) {
30680
+ if (now - this.windowStart >= this.windowMs) {
30681
+ this.windowStart = now;
30682
+ this.spent = 0;
30683
+ }
30684
+ this.spent += bytes;
30685
+ return this.spent <= this.budgetBytes;
30686
+ }
30687
+ };
30688
+
30620
30689
  // src/users/grants.ts
30621
30690
  var INSTANCE_GRANTS_MAX_BYTES = 64 * 1024;
30622
30691
  var INSTANCE_GRANTS_MAX_COUNT = 64;
@@ -30772,6 +30841,12 @@ var DurableUserHub = class extends DurableObject {
30772
30841
  clientSockets = /* @__PURE__ */ new Set();
30773
30842
  clientInfo = /* @__PURE__ */ new Map();
30774
30843
  migrated = false;
30844
+ /** Inbound frame rate and outbound byte budget per room socket. */
30845
+ roomLimits = /* @__PURE__ */ new Map();
30846
+ /** Detach notices held for the presence grace window, by client id. */
30847
+ pendingDetach = /* @__PURE__ */ new Map();
30848
+ /** The project's room definition, loaded once per instance. */
30849
+ roomDefinition = null;
30775
30850
  constructor(ctx, env) {
30776
30851
  super(ctx, env);
30777
30852
  ctx.blockConcurrencyWhile(async () => {
@@ -30784,7 +30859,8 @@ var DurableUserHub = class extends DurableObject {
30784
30859
  this.clientInfo.set(ws, {
30785
30860
  clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
30786
30861
  clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
30787
- clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
30862
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null,
30863
+ since: typeof attachment.since === "number" ? attachment.since : void 0
30788
30864
  });
30789
30865
  }
30790
30866
  }
@@ -30857,20 +30933,188 @@ var DurableUserHub = class extends DurableObject {
30857
30933
  if (request.headers.get("Upgrade") === "websocket") {
30858
30934
  const pair = new WebSocketPair();
30859
30935
  const [client, server] = [pair[0], pair[1]];
30860
- const info = parseBridgeClientParams(url).info;
30936
+ const info = { ...parseBridgeClientParams(url).info, since: Date.now() };
30861
30937
  this.ensureHeartbeatAutoResponse();
30862
30938
  this.ctx.acceptWebSocket(server);
30863
30939
  server.serializeAttachment({ type: "hub_client", ...info });
30940
+ const reconnecting = info.clientId !== null && this.supersedeClient(info.clientId, server);
30864
30941
  this.clientSockets.add(server);
30865
30942
  this.clientInfo.set(server, info);
30866
30943
  try {
30867
30944
  server.send(JSON.stringify({ type: "hub_ready" }));
30868
30945
  } catch {
30869
30946
  }
30947
+ if (info.clientId) {
30948
+ const connection = this.connectionOf(info);
30949
+ if (connection) this.observeAttach(connection, reconnecting);
30950
+ }
30870
30951
  return new Response(null, { status: 101, webSocket: client });
30871
30952
  }
30872
30953
  return new Response("Not found", { status: 404 });
30873
30954
  }
30955
+ // ─── Room connections (spec: Users rooms) ───────────────────────────────────
30956
+ connectionOf(info) {
30957
+ if (!info?.clientId) return null;
30958
+ return { clientId: info.clientId, clientKind: info.clientKind, since: info.since ?? 0 };
30959
+ }
30960
+ /** Close every other socket presenting `clientId`; true when one existed. */
30961
+ supersedeClient(clientId, keep) {
30962
+ let superseded = false;
30963
+ for (const ws of Array.from(this.clientSockets)) {
30964
+ if (ws === keep || this.clientInfo.get(ws)?.clientId !== clientId) continue;
30965
+ this.forgetSocket(ws);
30966
+ superseded = true;
30967
+ try {
30968
+ ws.send(JSON.stringify({ type: "superseded" }));
30969
+ } catch {
30970
+ }
30971
+ try {
30972
+ ws.close(ROOM_CLOSE_SUPERSEDED, "Superseded by a newer connection from this client");
30973
+ } catch {
30974
+ }
30975
+ }
30976
+ return superseded;
30977
+ }
30978
+ forgetSocket(ws) {
30979
+ this.clientSockets.delete(ws);
30980
+ this.clientInfo.delete(ws);
30981
+ this.roomLimits.delete(ws);
30982
+ }
30983
+ liveSocketFor(clientId) {
30984
+ for (const ws of this.clientSockets) {
30985
+ if (this.clientInfo.get(ws)?.clientId === clientId && ws.readyState === WebSocket.OPEN) return ws;
30986
+ }
30987
+ return null;
30988
+ }
30989
+ /** Live room connections, one per client id, oldest attachment first. */
30990
+ listRoomClients() {
30991
+ const byId = /* @__PURE__ */ new Map();
30992
+ for (const ws of this.clientSockets) {
30993
+ if (ws.readyState !== WebSocket.OPEN) continue;
30994
+ const connection = this.connectionOf(this.clientInfo.get(ws));
30995
+ if (connection) byId.set(connection.clientId, connection);
30996
+ }
30997
+ return Array.from(byId.values()).sort((a, b) => a.since - b.since);
30998
+ }
30999
+ /** Deliver one frame to the socket carrying `clientId`, within its send budget. */
31000
+ sendToRoomClient(clientId, frame) {
31001
+ const ws = this.liveSocketFor(clientId);
31002
+ if (!ws) return false;
31003
+ return this.sendRoomFrame(ws, JSON.stringify(frame));
31004
+ }
31005
+ sendRoomFrame(ws, payload) {
31006
+ const limits = this.limitsFor(ws);
31007
+ if (!limits.send.charge(payload.length)) {
31008
+ this.forgetSocket(ws);
31009
+ try {
31010
+ ws.close(ROOM_CLOSE_TRY_AGAIN, "Slow consumer: send budget exceeded");
31011
+ } catch {
31012
+ }
31013
+ return false;
31014
+ }
31015
+ try {
31016
+ ws.send(payload);
31017
+ return true;
31018
+ } catch {
31019
+ this.forgetSocket(ws);
31020
+ return false;
31021
+ }
31022
+ }
31023
+ limitsFor(ws) {
31024
+ let limits = this.roomLimits.get(ws);
31025
+ if (!limits) {
31026
+ limits = { rate: new RoomRateLimiter(), send: new RoomSendBudget() };
31027
+ this.roomLimits.set(ws, limits);
31028
+ }
31029
+ return limits;
31030
+ }
31031
+ /** Load the project's room definition from the users registry, once. */
31032
+ async loadRoom() {
31033
+ if (!this.roomDefinition) {
31034
+ this.roomDefinition = (async () => {
31035
+ const registry = this.userEndpoints();
31036
+ for (const key of Object.keys(registry)) {
31037
+ if (!/(^|\/)room$/.test(key)) continue;
31038
+ try {
31039
+ const loaded = await registry[key]();
31040
+ const candidate = loaded.default ?? loaded;
31041
+ if (isUserRoom(candidate)) return candidate;
31042
+ } catch {
31043
+ }
31044
+ }
31045
+ return null;
31046
+ })();
31047
+ }
31048
+ return this.roomDefinition;
31049
+ }
31050
+ /** Whether `key` is room-owned state, exempt from `user_value_changed`. */
31051
+ async roomOwnsKey(key) {
31052
+ const room = await this.loadRoom();
31053
+ return Boolean(room?.valuePrefix && key.startsWith(room.valuePrefix));
31054
+ }
31055
+ /**
31056
+ * Presence runs inside the serialization chain, after any execution the
31057
+ * event raced with, so a room's presence write never interleaves with a
31058
+ * mutation. A reconnect inside the grace window is not a change.
31059
+ */
31060
+ observeAttach(connection, reconnecting) {
31061
+ const pending = this.pendingDetach.get(connection.clientId);
31062
+ if (pending) {
31063
+ clearTimeout(pending);
31064
+ this.pendingDetach.delete(connection.clientId);
31065
+ return;
31066
+ }
31067
+ if (reconnecting) return;
31068
+ this.dispatchRoomPresence({ kind: "attached", connection, at: Date.now() });
31069
+ }
31070
+ observeDetach(connection) {
31071
+ if (this.liveSocketFor(connection.clientId)) return;
31072
+ const existing = this.pendingDetach.get(connection.clientId);
31073
+ if (existing) clearTimeout(existing);
31074
+ this.pendingDetach.set(
31075
+ connection.clientId,
31076
+ setTimeout(() => {
31077
+ this.pendingDetach.delete(connection.clientId);
31078
+ if (this.liveSocketFor(connection.clientId)) return;
31079
+ this.dispatchRoomPresence({ kind: "detached", connection, at: Date.now() });
31080
+ }, ROOM_PRESENCE_GRACE_MS)
31081
+ );
31082
+ }
31083
+ dispatchRoomPresence(event) {
31084
+ const run = this.executionChain.run(async () => {
31085
+ const room = await this.loadRoom();
31086
+ if (!room?.onPresence) return;
31087
+ const user = new UserStateImpl(this.roomUserId(), this.scopeHost(), { kind: "userRoom", name: "presence" });
31088
+ try {
31089
+ await room.onPresence(user, event);
31090
+ } catch (error) {
31091
+ console.error("[DurableUserHub] room presence handler failed:", error);
31092
+ }
31093
+ });
31094
+ this.ctx.waitUntil(run);
31095
+ }
31096
+ /** The hub is one object per user; its name is the user id. */
31097
+ roomUserId() {
31098
+ return this.ctx.id.name ?? this.ctx.id.toString();
31099
+ }
31100
+ async dispatchRoomFrame(ws, frame) {
31101
+ const connection = this.connectionOf(this.clientInfo.get(ws));
31102
+ if (!connection) return;
31103
+ await this.executionChain.run(async () => {
31104
+ const room = await this.loadRoom();
31105
+ if (!room) {
31106
+ this.sendRoomFrame(ws, JSON.stringify({ type: "room", id: frame.id, error: "no_room" }));
31107
+ return;
31108
+ }
31109
+ const user = new UserStateImpl(this.roomUserId(), this.scopeHost(), { kind: "userRoom", name: "frame" });
31110
+ try {
31111
+ await room.onFrame(user, connection, frame);
31112
+ } catch (error) {
31113
+ console.error("[DurableUserHub] room frame handler failed:", error);
31114
+ this.sendRoomFrame(ws, JSON.stringify({ type: "room", id: frame.id, error: "handler_failed" }));
31115
+ }
31116
+ });
31117
+ }
30874
31118
  // ─── User scope: event stream (spec: Users §4) ─────────────────────────────
30875
31119
  /** Idle sockets answer heartbeats at the edge without waking the object.
30876
31120
  * Registered at accept time rather than in the constructor so a hub that
@@ -30908,8 +31152,9 @@ var DurableUserHub = class extends DurableObject {
30908
31152
  return new Response(null, { status: 101, webSocket: client });
30909
31153
  }
30910
31154
  /**
30911
- * Deliver an event frame to every socket attached to this user's stream.
30912
- * At-most-once, live-only (spec: Users §4) — no history, no acks.
31155
+ * Deliver an event frame to every socket attached to this user's stream
31156
+ * and to every room socket. At-most-once, live-only (spec: Users §4) —
31157
+ * no history, no acks.
30913
31158
  */
30914
31159
  broadcastUserEvent(userId, event, data) {
30915
31160
  const frame = JSON.stringify({ event, data });
@@ -30921,6 +31166,10 @@ var DurableUserHub = class extends DurableObject {
30921
31166
  } catch {
30922
31167
  }
30923
31168
  }
31169
+ for (const ws of Array.from(this.clientSockets)) {
31170
+ if (ws.readyState !== WebSocket.OPEN || !this.clientInfo.get(ws)?.clientId) continue;
31171
+ this.sendRoomFrame(ws, frame);
31172
+ }
30924
31173
  }
30925
31174
  // ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
30926
31175
  /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
@@ -30931,6 +31180,8 @@ var DurableUserHub = class extends DurableObject {
30931
31180
  setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
30932
31181
  listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
30933
31182
  broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
31183
+ listRoomClients: () => this.listRoomClients(),
31184
+ sendToRoomClient: (_userId, clientId, frame) => this.sendToRoomClient(clientId, frame),
30934
31185
  grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
30935
31186
  env: this.env
30936
31187
  };
@@ -30950,6 +31201,7 @@ var DurableUserHub = class extends DurableObject {
30950
31201
  }
30951
31202
  const module = loaded;
30952
31203
  const handler = module.default ?? loaded;
31204
+ if (isUserRoom(handler)) return Response.json({ error: "Not found" }, { status: 404 });
30953
31205
  if (!isUserEndpoint(handler)) {
30954
31206
  return Response.json(
30955
31207
  { error: `${match.key} is not a defineUserEndpoint export` },
@@ -30993,6 +31245,7 @@ var DurableUserHub = class extends DurableObject {
30993
31245
  const names = Object.keys(registry);
30994
31246
  if (names.length === 0) return;
30995
31247
  const run = async () => {
31248
+ if (hookType === "user_value_changed" && await this.roomOwnsKey(String(args[0]))) return;
30996
31249
  const user = new UserStateImpl(userId, this.scopeHost(), {
30997
31250
  kind: "userHook",
30998
31251
  name: hookType
@@ -31017,8 +31270,9 @@ var DurableUserHub = class extends DurableObject {
31017
31270
  this.ctx.waitUntil(run());
31018
31271
  }
31019
31272
  async webSocketClose(ws) {
31020
- this.clientSockets.delete(ws);
31021
- this.clientInfo.delete(ws);
31273
+ const connection = this.connectionOf(this.clientInfo.get(ws));
31274
+ this.forgetSocket(ws);
31275
+ if (connection) this.observeDetach(connection);
31022
31276
  const attachment = ws.deserializeAttachment();
31023
31277
  if (attachment?.type === "user_stream") {
31024
31278
  this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
@@ -31147,7 +31401,7 @@ var DurableUserHub = class extends DurableObject {
31147
31401
  caller: body.caller ?? { kind: "unknown", name: "unknown" }
31148
31402
  });
31149
31403
  }
31150
- async webSocketMessage(ws, _message) {
31404
+ async webSocketMessage(ws, message) {
31151
31405
  const attachment = ws.deserializeAttachment();
31152
31406
  if (attachment?.type !== "hub_client") return;
31153
31407
  if (!this.clientSockets.has(ws)) {
@@ -31155,9 +31409,29 @@ var DurableUserHub = class extends DurableObject {
31155
31409
  this.clientInfo.set(ws, {
31156
31410
  clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
31157
31411
  clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
31158
- clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
31412
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null,
31413
+ since: typeof attachment.since === "number" ? attachment.since : void 0
31159
31414
  });
31160
31415
  }
31416
+ if (!this.limitsFor(ws).rate.take()) {
31417
+ this.forgetSocket(ws);
31418
+ try {
31419
+ ws.close(ROOM_CLOSE_TRY_AGAIN, "Frame rate exceeded");
31420
+ } catch {
31421
+ }
31422
+ return;
31423
+ }
31424
+ const frame = parseRoomFrame(message);
31425
+ if (frame === "too_large") {
31426
+ this.forgetSocket(ws);
31427
+ try {
31428
+ ws.close(ROOM_CLOSE_TOO_LARGE, "Frame exceeds 1 MiB");
31429
+ } catch {
31430
+ }
31431
+ return;
31432
+ }
31433
+ if (frame === null || !this.clientInfo.get(ws)?.clientId) return;
31434
+ await this.dispatchRoomFrame(ws, frame);
31161
31435
  }
31162
31436
  async webSocketError(ws) {
31163
31437
  await this.webSocketClose(ws);