@standardagents/builder 0.41.8 → 0.41.10

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,22 @@ 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, and its
1908
+ * registry key, which names room executions for grant policies. */
1909
+ private roomDefinition;
1910
+ private roomKey;
1901
1911
  constructor(ctx: DurableObjectState, env: DurableUserHubEnv);
1902
1912
  private ensureMigrated;
1903
1913
  /**
@@ -1920,6 +1930,32 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1920
1930
  * opened separately and only while a thread is active.
1921
1931
  */
1922
1932
  fetch(request: Request): Promise<Response>;
1933
+ private connectionOf;
1934
+ /** Close every other socket presenting `clientId`; true when one existed. */
1935
+ private supersedeClient;
1936
+ private forgetSocket;
1937
+ private liveSocketFor;
1938
+ /** Live room connections, one per client id, oldest attachment first. */
1939
+ listRoomClients(): UserRoomConnection[];
1940
+ /** Deliver one frame to the socket carrying `clientId`, within its send budget. */
1941
+ sendToRoomClient(clientId: string, frame: unknown): boolean;
1942
+ private sendRoomFrame;
1943
+ private limitsFor;
1944
+ /** Load the project's room definition from the users registry, once. */
1945
+ private loadRoom;
1946
+ /** Whether `key` is room-owned state, exempt from `user_value_changed`. */
1947
+ private roomOwnsKey;
1948
+ /**
1949
+ * Presence runs inside the serialization chain, after any execution the
1950
+ * event raced with, so a room's presence write never interleaves with a
1951
+ * mutation. A reconnect inside the grace window is not a change.
1952
+ */
1953
+ private observeAttach;
1954
+ private observeDetach;
1955
+ private dispatchRoomPresence;
1956
+ /** The hub is one object per user; its name is the user id. */
1957
+ private roomUserId;
1958
+ private dispatchRoomFrame;
1923
1959
  /** Idle sockets answer heartbeats at the edge without waking the object.
1924
1960
  * Registered at accept time rather than in the constructor so a hub that
1925
1961
  * never accepts a socket stays fully evictable (this also keeps
@@ -1929,8 +1965,9 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1929
1965
  private streamSockets;
1930
1966
  private acceptUserStream;
1931
1967
  /**
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.
1968
+ * Deliver an event frame to every socket attached to this user's stream
1969
+ * and to every room socket. At-most-once, live-only (spec: Users §4) —
1970
+ * no history, no acks.
1934
1971
  */
1935
1972
  broadcastUserEvent(userId: string, event: string, data?: unknown): void;
1936
1973
  /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
@@ -1959,7 +1996,7 @@ declare class DurableUserHub extends DurableObject<DurableUserHubEnv> {
1959
1996
  /** Perform a privileged fetch with the grant's credential injected. */
1960
1997
  private grantFetch;
1961
1998
  private handleGrantFetch;
1962
- webSocketMessage(ws: WebSocket, _message: string | ArrayBuffer): Promise<void>;
1999
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
1963
2000
  webSocketError(ws: WebSocket): Promise<void>;
1964
2001
  /** Whether a persistent client (daemon) is connected right now. */
1965
2002
  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-kvVdxct2.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;
@@ -30739,6 +30808,9 @@ function callerAllowed(allow, caller) {
30739
30808
  endpoint: allow.endpoints,
30740
30809
  userEndpoint: allow.userEndpoints,
30741
30810
  userHook: allow.userEndpoints,
30811
+ // A room execution is policy-named by its registry key; a policy may
30812
+ // list it under `userRooms` or alongside the endpoints it serves.
30813
+ userRoom: allow.userRooms ?? allow.userEndpoints,
30742
30814
  agent: allow.agents
30743
30815
  };
30744
30816
  const list = lists[caller.kind];
@@ -30772,6 +30844,14 @@ var DurableUserHub = class extends DurableObject {
30772
30844
  clientSockets = /* @__PURE__ */ new Set();
30773
30845
  clientInfo = /* @__PURE__ */ new Map();
30774
30846
  migrated = false;
30847
+ /** Inbound frame rate and outbound byte budget per room socket. */
30848
+ roomLimits = /* @__PURE__ */ new Map();
30849
+ /** Detach notices held for the presence grace window, by client id. */
30850
+ pendingDetach = /* @__PURE__ */ new Map();
30851
+ /** The project's room definition, loaded once per instance, and its
30852
+ * registry key, which names room executions for grant policies. */
30853
+ roomDefinition = null;
30854
+ roomKey = null;
30775
30855
  constructor(ctx, env) {
30776
30856
  super(ctx, env);
30777
30857
  ctx.blockConcurrencyWhile(async () => {
@@ -30784,7 +30864,8 @@ var DurableUserHub = class extends DurableObject {
30784
30864
  this.clientInfo.set(ws, {
30785
30865
  clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
30786
30866
  clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
30787
- clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
30867
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null,
30868
+ since: typeof attachment.since === "number" ? attachment.since : void 0
30788
30869
  });
30789
30870
  }
30790
30871
  }
@@ -30857,20 +30938,191 @@ var DurableUserHub = class extends DurableObject {
30857
30938
  if (request.headers.get("Upgrade") === "websocket") {
30858
30939
  const pair = new WebSocketPair();
30859
30940
  const [client, server] = [pair[0], pair[1]];
30860
- const info = parseBridgeClientParams(url).info;
30941
+ const info = { ...parseBridgeClientParams(url).info, since: Date.now() };
30861
30942
  this.ensureHeartbeatAutoResponse();
30862
30943
  this.ctx.acceptWebSocket(server);
30863
30944
  server.serializeAttachment({ type: "hub_client", ...info });
30945
+ const reconnecting = info.clientId !== null && this.supersedeClient(info.clientId, server);
30864
30946
  this.clientSockets.add(server);
30865
30947
  this.clientInfo.set(server, info);
30866
30948
  try {
30867
30949
  server.send(JSON.stringify({ type: "hub_ready" }));
30868
30950
  } catch {
30869
30951
  }
30952
+ if (info.clientId) {
30953
+ const connection = this.connectionOf(info);
30954
+ if (connection) this.observeAttach(connection, reconnecting);
30955
+ }
30870
30956
  return new Response(null, { status: 101, webSocket: client });
30871
30957
  }
30872
30958
  return new Response("Not found", { status: 404 });
30873
30959
  }
30960
+ // ─── Room connections (spec: Users rooms) ───────────────────────────────────
30961
+ connectionOf(info) {
30962
+ if (!info?.clientId) return null;
30963
+ return { clientId: info.clientId, clientKind: info.clientKind, since: info.since ?? 0 };
30964
+ }
30965
+ /** Close every other socket presenting `clientId`; true when one existed. */
30966
+ supersedeClient(clientId, keep) {
30967
+ let superseded = false;
30968
+ for (const ws of Array.from(this.clientSockets)) {
30969
+ if (ws === keep || this.clientInfo.get(ws)?.clientId !== clientId) continue;
30970
+ this.forgetSocket(ws);
30971
+ superseded = true;
30972
+ try {
30973
+ ws.send(JSON.stringify({ type: "superseded" }));
30974
+ } catch {
30975
+ }
30976
+ try {
30977
+ ws.close(ROOM_CLOSE_SUPERSEDED, "Superseded by a newer connection from this client");
30978
+ } catch {
30979
+ }
30980
+ }
30981
+ return superseded;
30982
+ }
30983
+ forgetSocket(ws) {
30984
+ this.clientSockets.delete(ws);
30985
+ this.clientInfo.delete(ws);
30986
+ this.roomLimits.delete(ws);
30987
+ }
30988
+ liveSocketFor(clientId) {
30989
+ for (const ws of this.clientSockets) {
30990
+ if (this.clientInfo.get(ws)?.clientId === clientId && ws.readyState === WebSocket.OPEN) return ws;
30991
+ }
30992
+ return null;
30993
+ }
30994
+ /** Live room connections, one per client id, oldest attachment first. */
30995
+ listRoomClients() {
30996
+ const byId = /* @__PURE__ */ new Map();
30997
+ for (const ws of this.clientSockets) {
30998
+ if (ws.readyState !== WebSocket.OPEN) continue;
30999
+ const connection = this.connectionOf(this.clientInfo.get(ws));
31000
+ if (connection) byId.set(connection.clientId, connection);
31001
+ }
31002
+ return Array.from(byId.values()).sort((a, b) => a.since - b.since);
31003
+ }
31004
+ /** Deliver one frame to the socket carrying `clientId`, within its send budget. */
31005
+ sendToRoomClient(clientId, frame) {
31006
+ const ws = this.liveSocketFor(clientId);
31007
+ if (!ws) return false;
31008
+ return this.sendRoomFrame(ws, JSON.stringify(frame));
31009
+ }
31010
+ sendRoomFrame(ws, payload) {
31011
+ const limits = this.limitsFor(ws);
31012
+ if (!limits.send.charge(payload.length)) {
31013
+ this.forgetSocket(ws);
31014
+ try {
31015
+ ws.close(ROOM_CLOSE_TRY_AGAIN, "Slow consumer: send budget exceeded");
31016
+ } catch {
31017
+ }
31018
+ return false;
31019
+ }
31020
+ try {
31021
+ ws.send(payload);
31022
+ return true;
31023
+ } catch {
31024
+ this.forgetSocket(ws);
31025
+ return false;
31026
+ }
31027
+ }
31028
+ limitsFor(ws) {
31029
+ let limits = this.roomLimits.get(ws);
31030
+ if (!limits) {
31031
+ limits = { rate: new RoomRateLimiter(), send: new RoomSendBudget() };
31032
+ this.roomLimits.set(ws, limits);
31033
+ }
31034
+ return limits;
31035
+ }
31036
+ /** Load the project's room definition from the users registry, once. */
31037
+ async loadRoom() {
31038
+ if (!this.roomDefinition) {
31039
+ this.roomDefinition = (async () => {
31040
+ const registry = this.userEndpoints();
31041
+ for (const key of Object.keys(registry)) {
31042
+ if (!/(^|\/)room$/.test(key)) continue;
31043
+ try {
31044
+ const loaded = await registry[key]();
31045
+ const candidate = loaded.default ?? loaded;
31046
+ if (isUserRoom(candidate)) {
31047
+ this.roomKey = key;
31048
+ return candidate;
31049
+ }
31050
+ } catch {
31051
+ }
31052
+ }
31053
+ return null;
31054
+ })();
31055
+ }
31056
+ return this.roomDefinition;
31057
+ }
31058
+ /** Whether `key` is room-owned state, exempt from `user_value_changed`. */
31059
+ async roomOwnsKey(key) {
31060
+ const room = await this.loadRoom();
31061
+ return Boolean(room?.valuePrefix && key.startsWith(room.valuePrefix));
31062
+ }
31063
+ /**
31064
+ * Presence runs inside the serialization chain, after any execution the
31065
+ * event raced with, so a room's presence write never interleaves with a
31066
+ * mutation. A reconnect inside the grace window is not a change.
31067
+ */
31068
+ observeAttach(connection, reconnecting) {
31069
+ const pending = this.pendingDetach.get(connection.clientId);
31070
+ if (pending) {
31071
+ clearTimeout(pending);
31072
+ this.pendingDetach.delete(connection.clientId);
31073
+ return;
31074
+ }
31075
+ if (reconnecting) return;
31076
+ this.dispatchRoomPresence({ kind: "attached", connection, at: Date.now() });
31077
+ }
31078
+ observeDetach(connection) {
31079
+ if (this.liveSocketFor(connection.clientId)) return;
31080
+ const existing = this.pendingDetach.get(connection.clientId);
31081
+ if (existing) clearTimeout(existing);
31082
+ this.pendingDetach.set(
31083
+ connection.clientId,
31084
+ setTimeout(() => {
31085
+ this.pendingDetach.delete(connection.clientId);
31086
+ if (this.liveSocketFor(connection.clientId)) return;
31087
+ this.dispatchRoomPresence({ kind: "detached", connection, at: Date.now() });
31088
+ }, ROOM_PRESENCE_GRACE_MS)
31089
+ );
31090
+ }
31091
+ dispatchRoomPresence(event) {
31092
+ const run = this.executionChain.run(async () => {
31093
+ const room = await this.loadRoom();
31094
+ if (!room?.onPresence) return;
31095
+ const user = new UserStateImpl(this.roomUserId(), this.scopeHost(), { kind: "userRoom", name: this.roomKey ?? "room" });
31096
+ try {
31097
+ await room.onPresence(user, event);
31098
+ } catch (error) {
31099
+ console.error("[DurableUserHub] room presence handler failed:", error);
31100
+ }
31101
+ });
31102
+ this.ctx.waitUntil(run);
31103
+ }
31104
+ /** The hub is one object per user; its name is the user id. */
31105
+ roomUserId() {
31106
+ return this.ctx.id.name ?? this.ctx.id.toString();
31107
+ }
31108
+ async dispatchRoomFrame(ws, frame) {
31109
+ const connection = this.connectionOf(this.clientInfo.get(ws));
31110
+ if (!connection) return;
31111
+ await this.executionChain.run(async () => {
31112
+ const room = await this.loadRoom();
31113
+ if (!room) {
31114
+ this.sendRoomFrame(ws, JSON.stringify({ type: "room", id: frame.id, error: "no_room" }));
31115
+ return;
31116
+ }
31117
+ const user = new UserStateImpl(this.roomUserId(), this.scopeHost(), { kind: "userRoom", name: this.roomKey ?? "room" });
31118
+ try {
31119
+ await room.onFrame(user, connection, frame);
31120
+ } catch (error) {
31121
+ console.error("[DurableUserHub] room frame handler failed:", error);
31122
+ this.sendRoomFrame(ws, JSON.stringify({ type: "room", id: frame.id, error: "handler_failed" }));
31123
+ }
31124
+ });
31125
+ }
30874
31126
  // ─── User scope: event stream (spec: Users §4) ─────────────────────────────
30875
31127
  /** Idle sockets answer heartbeats at the edge without waking the object.
30876
31128
  * Registered at accept time rather than in the constructor so a hub that
@@ -30908,8 +31160,9 @@ var DurableUserHub = class extends DurableObject {
30908
31160
  return new Response(null, { status: 101, webSocket: client });
30909
31161
  }
30910
31162
  /**
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.
31163
+ * Deliver an event frame to every socket attached to this user's stream
31164
+ * and to every room socket. At-most-once, live-only (spec: Users §4) —
31165
+ * no history, no acks.
30913
31166
  */
30914
31167
  broadcastUserEvent(userId, event, data) {
30915
31168
  const frame = JSON.stringify({ event, data });
@@ -30921,6 +31174,10 @@ var DurableUserHub = class extends DurableObject {
30921
31174
  } catch {
30922
31175
  }
30923
31176
  }
31177
+ for (const ws of Array.from(this.clientSockets)) {
31178
+ if (ws.readyState !== WebSocket.OPEN || !this.clientInfo.get(ws)?.clientId) continue;
31179
+ this.sendRoomFrame(ws, frame);
31180
+ }
30924
31181
  }
30925
31182
  // ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
30926
31183
  /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
@@ -30931,6 +31188,8 @@ var DurableUserHub = class extends DurableObject {
30931
31188
  setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
30932
31189
  listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
30933
31190
  broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
31191
+ listRoomClients: () => this.listRoomClients(),
31192
+ sendToRoomClient: (_userId, clientId, frame) => this.sendToRoomClient(clientId, frame),
30934
31193
  grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
30935
31194
  env: this.env
30936
31195
  };
@@ -30950,6 +31209,7 @@ var DurableUserHub = class extends DurableObject {
30950
31209
  }
30951
31210
  const module = loaded;
30952
31211
  const handler = module.default ?? loaded;
31212
+ if (isUserRoom(handler)) return Response.json({ error: "Not found" }, { status: 404 });
30953
31213
  if (!isUserEndpoint(handler)) {
30954
31214
  return Response.json(
30955
31215
  { error: `${match.key} is not a defineUserEndpoint export` },
@@ -30993,6 +31253,7 @@ var DurableUserHub = class extends DurableObject {
30993
31253
  const names = Object.keys(registry);
30994
31254
  if (names.length === 0) return;
30995
31255
  const run = async () => {
31256
+ if (hookType === "user_value_changed" && await this.roomOwnsKey(String(args[0]))) return;
30996
31257
  const user = new UserStateImpl(userId, this.scopeHost(), {
30997
31258
  kind: "userHook",
30998
31259
  name: hookType
@@ -31017,8 +31278,9 @@ var DurableUserHub = class extends DurableObject {
31017
31278
  this.ctx.waitUntil(run());
31018
31279
  }
31019
31280
  async webSocketClose(ws) {
31020
- this.clientSockets.delete(ws);
31021
- this.clientInfo.delete(ws);
31281
+ const connection = this.connectionOf(this.clientInfo.get(ws));
31282
+ this.forgetSocket(ws);
31283
+ if (connection) this.observeDetach(connection);
31022
31284
  const attachment = ws.deserializeAttachment();
31023
31285
  if (attachment?.type === "user_stream") {
31024
31286
  this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
@@ -31147,7 +31409,7 @@ var DurableUserHub = class extends DurableObject {
31147
31409
  caller: body.caller ?? { kind: "unknown", name: "unknown" }
31148
31410
  });
31149
31411
  }
31150
- async webSocketMessage(ws, _message) {
31412
+ async webSocketMessage(ws, message) {
31151
31413
  const attachment = ws.deserializeAttachment();
31152
31414
  if (attachment?.type !== "hub_client") return;
31153
31415
  if (!this.clientSockets.has(ws)) {
@@ -31155,9 +31417,29 @@ var DurableUserHub = class extends DurableObject {
31155
31417
  this.clientInfo.set(ws, {
31156
31418
  clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
31157
31419
  clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
31158
- clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
31420
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null,
31421
+ since: typeof attachment.since === "number" ? attachment.since : void 0
31159
31422
  });
31160
31423
  }
31424
+ if (!this.limitsFor(ws).rate.take()) {
31425
+ this.forgetSocket(ws);
31426
+ try {
31427
+ ws.close(ROOM_CLOSE_TRY_AGAIN, "Frame rate exceeded");
31428
+ } catch {
31429
+ }
31430
+ return;
31431
+ }
31432
+ const frame = parseRoomFrame(message);
31433
+ if (frame === "too_large") {
31434
+ this.forgetSocket(ws);
31435
+ try {
31436
+ ws.close(ROOM_CLOSE_TOO_LARGE, "Frame exceeds 1 MiB");
31437
+ } catch {
31438
+ }
31439
+ return;
31440
+ }
31441
+ if (frame === null || !this.clientInfo.get(ws)?.clientId) return;
31442
+ await this.dispatchRoomFrame(ws, frame);
31161
31443
  }
31162
31444
  async webSocketError(ws) {
31163
31445
  await this.webSocketClose(ws);