pi-roundtable 0.7.19 → 0.9.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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -12,7 +12,7 @@ import type { CommandGuard, InteractionModule } from "./interaction-module.ts";
12
12
 
13
13
  export const STOP_BUTTON_ID = "roundtable:stop";
14
14
 
15
- /** The message a long turn shows: a stop button only the owner may press. */
15
+ /** The message a long turn shows: a stop button only an owner may press. */
16
16
  export function stopPanel() {
17
17
  return {
18
18
  content: messages().stopNote,
@@ -29,13 +29,13 @@ export function stopPanel() {
29
29
  }
30
30
 
31
31
  export interface StopButtonOptions {
32
- guard: Pick<CommandGuard, "isOwner">;
32
+ guard: Pick<CommandGuard, "allows">;
33
33
  /** Stops the running turn of the channel the button was pressed in, through its claim. */
34
34
  conversations: Pick<ConversationPort, "stop">;
35
35
  }
36
36
 
37
37
  /**
38
- * Answers a press of the stop button: only the owner stops the channel's turn, and the answer,
38
+ * Answers a press of the stop button: only an owner stops the channel's turn, and the answer,
39
39
  * visible only to whoever pressed, says whether one was running.
40
40
  */
41
41
  export function stopButtonModule(
@@ -46,7 +46,7 @@ export function stopButtonModule(
46
46
  handle: async (interaction: Interaction) => {
47
47
  if (!interaction.isButton() || interaction.customId !== STOP_BUTTON_ID)
48
48
  return false;
49
- const owner = options.guard.isOwner(interaction);
49
+ const owner = await options.guard.allows(interaction);
50
50
  const stopped =
51
51
  owner &&
52
52
  options.conversations.stop(
@@ -26,6 +26,16 @@ export interface PendingConfirmation {
26
26
  selectionId: string;
27
27
  heldAt: Date;
28
28
  calls: HeldCall[];
29
+ /**
30
+ * The speaker whose turn held the calls, by their id on the surface. Besides the owners, only
31
+ * they may approve them, at a tier that holds them; without one, the owners alone may.
32
+ */
33
+ speakerId?: string;
34
+ /**
35
+ * The principal of that speaker, who approves them on any of their identities; absent for
36
+ * calls held before 0.9, which are matched by `speakerId`.
37
+ */
38
+ principalId?: string;
29
39
  }
30
40
 
31
41
  /** A file produced for a reply; data is raw bytes, not a path or base64. */
@@ -29,3 +29,8 @@ export class DelegationError extends Error {
29
29
  export class AgentError extends Error {
30
30
  override name = "AgentError";
31
31
  }
32
+
33
+ /** A principal, identity link, or role change that cannot be made; its message names what to fix. */
34
+ export class IdentityError extends Error {
35
+ override name = "IdentityError";
36
+ }
@@ -3,9 +3,15 @@ import type { Speaker } from "../speakers.ts";
3
3
  import type { TurnAttachments } from "./attachment.ts";
4
4
  import type { ChannelKey } from "./conversation.ts";
5
5
  import type { InterimPosts } from "./interim.ts";
6
+ import type { TurnProgress } from "./progress.ts";
6
7
 
7
8
  export type { AgentTurnScope };
8
9
 
10
+ /** Who a turn's conversation belongs to. */
11
+ export type TurnConversation =
12
+ | { visibility: "private"; principalId: string }
13
+ | { visibility: "shared" };
14
+
9
15
  export interface TurnRequest {
10
16
  /** Where the turn's message arrived and its attachments are saved. */
11
17
  channel: ChannelKey;
@@ -18,8 +24,18 @@ export interface TurnRequest {
18
24
  confirmed?: boolean;
19
25
  /** Set for a turn of an agent-server agent. */
20
26
  agent?: AgentTurnScope;
21
- /** The person the turn is for: who wrote the message, or who set up the work. */
22
- speaker?: Speaker;
27
+ /**
28
+ * The person the turn is for: who wrote the message, or who set up the work; the turn runs at
29
+ * their tier. Required: a turn without one is refused, never run as the owner's. A plugin that
30
+ * starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
31
+ */
32
+ speaker: Speaker;
33
+ /**
34
+ * Who the conversation belongs to, as the host records it: `private` to one principal, or
35
+ * `shared` by whoever its claim admits, which it is taken to be when absent. A private
36
+ * conversation's prompts are its person's alone; a shared one's escalate to the owners.
37
+ */
38
+ conversation?: TurnConversation;
23
39
  /**
24
40
  * The conversation's kind, fixed when its session is made: the string a claim's `startFresh`
25
41
  * returns, which picks the persona of a non-agent conversation. "owner" when absent.
@@ -38,6 +54,11 @@ export interface TurnRequest {
38
54
  * hands the channel its reply goes to. Absent = only the final reply is posted.
39
55
  */
40
56
  interim?: InterimPosts;
57
+ /**
58
+ * Hears what the turn writes and which tools it runs, as it goes, until the turn ends. A runtime
59
+ * without live progress never calls it.
60
+ */
61
+ progress?: (event: TurnProgress) => void;
41
62
  }
42
63
 
43
64
  /** The judge's question and answer shapes. */
@@ -48,6 +69,16 @@ export type {
48
69
  YesNoQuestion,
49
70
  } from "../contract/providers.ts";
50
71
 
72
+ /** Sends a person a notice through their own channel; resolves false when they have none. */
73
+ export interface Notifier {
74
+ notify(principalId: string, text: string): Promise<boolean>;
75
+ }
76
+
77
+ /**
78
+ * Sends the primary owner a direct message.
79
+ * @deprecated Since 0.9 there may be more than one owner: reach a person with a `Notifier`, such as
80
+ * `context.directChannels`. Goes away in 1.0.
81
+ */
51
82
  export interface OwnerNotifier {
52
83
  notifyOwner(text: string): Promise<void>;
53
84
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What a running turn reports as it goes, for a surface that shows it live. Text arrives in
3
+ * chunks joined over a short interval; a tool is named with a short one-line preview of its
4
+ * arguments, never their full text, and ends with whether it succeeded. The final reply still
5
+ * arrives through `sendReply`.
6
+ */
7
+ export type TurnProgress =
8
+ | { type: "text"; delta: string }
9
+ | { type: "tool_start"; id: string; tool: string; preview?: string }
10
+ | { type: "tool_end"; id: string; tool: string; ok: boolean };
@@ -50,6 +50,7 @@ export class EventBus {
50
50
  readonly sink: EventSink = {
51
51
  turnStarted: (turn) => void this.deliver("turnStarted", turn),
52
52
  turnEnded: (turn) => void this.deliver("turnEnded", turn),
53
+ turnProgress: (event) => void this.deliver("turnProgress", event),
53
54
  changed: () => void this.deliver("changed"),
54
55
  };
55
56
  }
package/src/core/host.ts CHANGED
@@ -7,6 +7,7 @@ import { MigrationError, NotLinkedError, PluginError } from "./errors.ts";
7
7
  import { EventBus } from "./events.ts";
8
8
  import { HttpListeners, type ListenerConfig } from "./http/listeners.ts";
9
9
  import { type Locale, setLocale } from "./i18n/index.ts";
10
+ import { contactsOf } from "./identity/identity-view.ts";
10
11
  import type { JudgeModel } from "./judging/model-judge.ts";
11
12
  import type { Logger } from "./log.ts";
12
13
  import type {
@@ -32,7 +33,7 @@ import {
32
33
  conversationTurns,
33
34
  } from "./routing/conversation-turns.ts";
34
35
  import { surfacePort } from "./routing/surface-port.ts";
35
- import { AGENTS } from "./services.ts";
36
+ import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
36
37
  import { setTimeZone } from "./time.ts";
37
38
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
38
39
 
@@ -154,6 +155,7 @@ export class Roundtable {
154
155
  #listeners: HttpListeners | undefined;
155
156
  #pool: SQL | undefined;
156
157
  readonly #queue = new ChannelQueue();
158
+ readonly #warned = new Set<string>();
157
159
  readonly #tiers: ToolTierTable;
158
160
  readonly #events: EventBus;
159
161
  #services: ServiceRegistry | undefined;
@@ -191,26 +193,40 @@ export class Roundtable {
191
193
  return {
192
194
  handle: (message) => router().handle(message),
193
195
  background: (turn) => router().background(turn),
196
+ runsAs: (turn) => router().runsAs(turn),
194
197
  target: (name) => router().target(name),
195
198
  startFresh: (channel) => router().startFresh(channel),
196
199
  deleteConversation: (channel) => router().deleteConversation(channel),
197
200
  stop: (channel) => router().stop(channel),
198
201
  postsInPlace: (channel) => router().postsInPlace(channel),
202
+ owns: (channel) => router().owns(channel),
203
+ takesBackground: (channel) => router().takesBackground(channel),
204
+ takesSystemReports: (channel) => router().takesSystemReports(channel),
199
205
  };
200
206
  }
201
207
 
202
208
  /** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
203
209
  #surfaces(): SurfacePort {
204
- return surfacePort(() => {
205
- if (!this.#sessions)
206
- throw new NotLinkedError(
207
- "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
208
- );
209
- return this.#registry.surfaces;
210
- });
210
+ return surfacePort(
211
+ () => {
212
+ if (!this.#sessions)
213
+ throw new NotLinkedError(
214
+ "chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
215
+ );
216
+ return this.#registry.surfaces;
217
+ },
218
+ { deprecated: (message) => this.#deprecated(message) },
219
+ );
211
220
  }
212
221
 
213
- /** Turns over the agent server's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
222
+ /** Logs a deprecation once per host. */
223
+ #deprecated(message: string): void {
224
+ if (this.#warned.has(message)) return;
225
+ this.#warned.add(message);
226
+ this.#options.logger.warn(message);
227
+ }
228
+
229
+ /** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
214
230
  #turns(): ConversationTurns {
215
231
  return conversationTurns({
216
232
  linked: () => {
@@ -222,8 +238,9 @@ export class Roundtable {
222
238
  runtime: () => {
223
239
  if (!this.#services)
224
240
  throw new NotLinkedError("the host has not started yet.");
225
- return this.#services.get(AGENTS).runtime;
241
+ return this.#services.get(RUNTIME);
226
242
  },
243
+ registry: () => this.#services?.find(CONVERSATIONS),
227
244
  surfaces: this.#surfaces(),
228
245
  events: this.#events.sink,
229
246
  selection: () => {
@@ -325,11 +342,16 @@ export class Roundtable {
325
342
  const { routes, channels } = this.#registry;
326
343
  this.#sessions = linkSessions(this.#registry);
327
344
  this.#events.link(this.#registry.handlers);
345
+ const identity = this.#services.find(IDENTITY);
328
346
  this.#router = new ChannelRouter({
329
347
  claims: channels,
348
+ ...(identity
349
+ ? { contacts: contactsOf(identity), principals: identity }
350
+ : {}),
330
351
  targets: (name) =>
331
352
  this.#registry.backgroundTargets.find((t) => t.name === name),
332
353
  queue: this.#queue,
354
+ surfaces: this.#surfaces(),
333
355
  logger,
334
356
  ...(this.#options.conversations?.forwardJoinMs === undefined
335
357
  ? {}
@@ -1,8 +1,16 @@
1
1
  import { chmodSync } from "node:fs";
2
- import type { Server } from "bun";
2
+ import type { Server, WebSocketHandler } from "bun";
3
3
  import { PluginError } from "../errors.ts";
4
4
  import type { Logger } from "../log.ts";
5
5
  import { serveUnix } from "../shared/unix-server.ts";
6
+ import {
7
+ type Connection,
8
+ DEFAULT_MAX_BUFFERED_BYTES,
9
+ DEFAULT_MAX_MESSAGE_BYTES,
10
+ isUpgrade,
11
+ SocketSet,
12
+ type WebSocketRoute,
13
+ } from "./websocket.ts";
6
14
 
7
15
  /** A handler a plugin attaches to one of the host's configured listeners. */
8
16
  export interface HttpRoute {
@@ -13,6 +21,11 @@ export interface HttpRoute {
13
21
  /** Omitted: every method, for a handler that answers the rest itself. */
14
22
  methods?: readonly string[];
15
23
  handle(request: Request): Response | Promise<Response>;
24
+ /**
25
+ * Takes the route's WebSocket upgrade requests (GET with `Upgrade: websocket`); every other
26
+ * request still reaches `handle`.
27
+ */
28
+ websocket?: WebSocketRoute;
16
29
  }
17
30
 
18
31
  /** Where a listener serves: a unix socket, reached from outside through a tunnel, or a TCP port. */
@@ -44,6 +57,37 @@ function overlaps(a: HttpRoute, b: HttpRoute): boolean {
44
57
  return matches(a, pathOf(b)) || matches(b, pathOf(a));
45
58
  }
46
59
 
60
+ /** A non-special scheme's origin, such as `chrome-extension://<id>`, which URL reports as "null". */
61
+ const OPAQUE_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/?#@:\s]+(?::\d+)?$/;
62
+
63
+ /**
64
+ * Whether `origin` is `scheme://host[:port]` exactly as a browser sends it: a special scheme's
65
+ * origin, or a non-special one in lowercase without path, query, fragment, or user.
66
+ */
67
+ const isOrigin = (origin: string) => {
68
+ const url = URL.parse(origin);
69
+ if (!url) return false;
70
+ return url.origin === "null"
71
+ ? OPAQUE_ORIGIN.test(origin)
72
+ : url.origin === origin;
73
+ };
74
+
75
+ /** Refuses a WebSocket limit that is not a positive integer, naming the route and field. */
76
+ function validateLimits(name: string, websocket: WebSocketRoute): void {
77
+ const limits: [string, number | undefined][] = [
78
+ ["maxMessageBytes", websocket.maxMessageBytes],
79
+ ["maxBufferedBytes", websocket.maxBufferedBytes],
80
+ ["maxConnections", websocket.maxConnections],
81
+ ["rate.messages", websocket.rate?.messages],
82
+ ["rate.perMs", websocket.rate?.perMs],
83
+ ];
84
+ for (const [field, value] of limits)
85
+ if (value !== undefined && !(Number.isSafeInteger(value) && value > 0))
86
+ throw new PluginError(
87
+ `route ${name} sets websocket ${field} to ${value}, which is not a positive integer`,
88
+ );
89
+ }
90
+
47
91
  /** Refuses routes on unknown listeners, reused names, and any request two routes could both take. */
48
92
  function validate(
49
93
  listeners: readonly ListenerConfig[],
@@ -53,6 +97,18 @@ function validate(
53
97
  if (ids.size !== listeners.length)
54
98
  throw new PluginError("a listener is configured twice");
55
99
  routes.forEach((route, index) => {
100
+ if (route.websocket && route.methods && !route.methods.includes("GET"))
101
+ throw new PluginError(
102
+ `route ${route.name} takes WebSockets, so its methods must include GET`,
103
+ );
104
+ if (route.websocket) validateLimits(route.name, route.websocket);
105
+ const { origins } = route.websocket ?? {};
106
+ if (origins && origins !== "any")
107
+ for (const origin of origins)
108
+ if (!isOrigin(origin))
109
+ throw new PluginError(
110
+ `route ${route.name} lists origin ${JSON.stringify(origin)}, which is not a scheme://host[:port] origin`,
111
+ );
56
112
  if (!ids.has(route.listener))
57
113
  throw new PluginError(
58
114
  `route ${route.name} needs listener ${route.listener}, which is not configured`,
@@ -71,6 +127,16 @@ function validate(
71
127
  const serverError = () =>
72
128
  new Response("Internal Server Error", { status: 500 });
73
129
 
130
+ /** The route that takes a request, when one does. */
131
+ const routeFor = (routes: readonly HttpRoute[], request: Request) => {
132
+ // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
133
+ const path = new URL(request.url).pathname;
134
+ return routes.find(
135
+ (r) =>
136
+ matches(r, path) && (!r.methods || r.methods.includes(request.method)),
137
+ );
138
+ };
139
+
74
140
  /**
75
141
  * Routes one listener's request; a request no route takes is 404, and a route that throws, or
76
142
  * whose promise rejects, is 500 with a fixed body. The failure is logged with the route's name
@@ -82,12 +148,7 @@ export async function routeRequest(
82
148
  listener: string,
83
149
  logger: Logger,
84
150
  ): Promise<Response> {
85
- // pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
86
- const path = new URL(request.url).pathname;
87
- const route = routes.find(
88
- (r) =>
89
- matches(r, path) && (!r.methods || r.methods.includes(request.method)),
90
- );
151
+ const route = routeFor(routes, request);
91
152
  if (!route) return new Response("Not found", { status: 404 });
92
153
  try {
93
154
  return await route.handle(request);
@@ -97,39 +158,87 @@ export async function routeRequest(
97
158
  }
98
159
  }
99
160
 
161
+ /** What every request on one listener is served with. */
162
+ interface ListenerContext {
163
+ listener: string;
164
+ logger: Logger;
165
+ sockets: SocketSet;
166
+ }
167
+
168
+ /**
169
+ * Routes one listener's request like `routeRequest`, except that a WebSocket upgrade for a route
170
+ * that takes them goes to its `websocket`; a failing `accept` answers and logs like a failing
171
+ * `handle`. Answers nothing once the socket is upgraded.
172
+ */
173
+ async function serveRequest(
174
+ routes: readonly HttpRoute[],
175
+ request: Request,
176
+ server: Server<Connection>,
177
+ { listener, logger, sockets }: ListenerContext,
178
+ ): Promise<Response | undefined> {
179
+ const route = routeFor(routes, request);
180
+ if (!route?.websocket || !isUpgrade(request))
181
+ return routeRequest(routes, request, listener, logger);
182
+ try {
183
+ return await sockets.upgrade(route.name, route.websocket, request, server);
184
+ } catch (err) {
185
+ logger.error({ err, route: route.name, listener }, "route failed");
186
+ return serverError();
187
+ }
188
+ }
189
+
190
+ type Fetch = (
191
+ request: Request,
192
+ server: Server<Connection>,
193
+ ) => Promise<Response | undefined>;
194
+
100
195
  /** Serves HTTP on a TCP port with the same idle setting as the unix sockets. */
101
196
  function serveTcp(
102
197
  port: number,
103
198
  hostname: string | undefined,
104
- fetch: (request: Request) => Response | Promise<Response>,
105
- ): Server<undefined> {
106
- return Bun.serve({
199
+ fetch: Fetch,
200
+ websocket: WebSocketHandler<Connection> | undefined,
201
+ ): Server<Connection> {
202
+ const options = {
107
203
  port,
108
204
  ...(hostname ? { hostname } : {}),
109
205
  idleTimeout: 0,
110
206
  fetch,
111
207
  error: serverError,
112
- });
208
+ ...(websocket ? { websocket } : {}),
209
+ };
210
+ // SAFETY: a listener without WebSocket routes passes no handler and never upgrades.
211
+ return Bun.serve(
212
+ options as Parameters<typeof Bun.serve<Connection>>[0],
213
+ ) as Server<Connection>;
113
214
  }
114
215
 
216
+ /** How long a stop waits for WebSocket routes' handlers to settle before it returns anyway. */
217
+ const CLOSE_TIMEOUT_MS = 5_000;
218
+
115
219
  /** The host's HTTP listeners, each serving only the routes attached to it. */
116
- // pi-lens-ignore: large-class — three members: validation in the constructor, start, and stop
220
+ // pi-lens-ignore: large-class — four members: validation in the constructor, start, the WebSocket handler, and stop
117
221
  export class HttpListeners {
118
222
  readonly #listeners: readonly ListenerConfig[];
119
223
  readonly #routes: readonly HttpRoute[];
120
224
  readonly #logger: Logger;
121
- #servers: Server<undefined>[] = [];
225
+ readonly #sockets: SocketSet;
226
+ readonly #closeTimeoutMs: number;
227
+ #servers: Pick<Server<unknown>, "stop">[] = [];
122
228
 
123
- /** Validates every route before any socket opens. */
229
+ /** Validates every route before any socket opens; `closeTimeoutMs` bounds how long a stop waits on WebSocket handlers. */
124
230
  constructor(
125
231
  listeners: readonly ListenerConfig[],
126
232
  routes: readonly HttpRoute[],
127
233
  logger: Logger,
234
+ options: { closeTimeoutMs?: number } = {},
128
235
  ) {
129
236
  validate(listeners, routes);
130
237
  this.#logger = logger;
131
238
  this.#listeners = listeners;
132
239
  this.#routes = routes;
240
+ this.#sockets = new SocketSet(logger);
241
+ this.#closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
133
242
  }
134
243
 
135
244
  /** Opens every listener; when one fails, the ones already open close again before the error is thrown. */
@@ -139,28 +248,72 @@ export class HttpListeners {
139
248
  const routes = this.#routes.filter(
140
249
  (route) => route.listener === listener.id,
141
250
  );
142
- const handle = (request: Request) =>
143
- routeRequest(routes, request, listener.id, this.#logger);
251
+ const handle: Fetch = (request, server) =>
252
+ serveRequest(routes, request, server, {
253
+ listener: listener.id,
254
+ logger: this.#logger,
255
+ sockets: this.#sockets,
256
+ });
257
+ const websocket = this.#websocketHandler(listener.id, routes);
144
258
  if ("socketPath" in listener) {
145
259
  this.#servers.push(
146
- serveUnix(listener.socketPath, handle, { error: serverError }),
260
+ websocket
261
+ ? serveUnix(listener.socketPath, handle, {
262
+ error: serverError,
263
+ websocket,
264
+ })
265
+ : serveUnix(
266
+ listener.socketPath,
267
+ (request) =>
268
+ routeRequest(routes, request, listener.id, this.#logger),
269
+ { error: serverError },
270
+ ),
147
271
  );
148
272
  chmodSync(listener.socketPath, listener.mode ?? 0o660);
149
273
  } else {
150
274
  this.#servers.push(
151
- serveTcp(listener.port, listener.hostname, handle),
275
+ serveTcp(listener.port, listener.hostname, handle, websocket),
152
276
  );
153
277
  }
154
278
  }
155
279
  } catch (error) {
156
- this.stop();
280
+ void this.stop();
157
281
  throw error;
158
282
  }
159
283
  }
160
284
 
161
- /** Stops accepting at once and cuts open connections, such as event streams, without waiting on them. */
162
- stop(): void {
285
+ /** The listener's WebSocket handler, sized for its largest route limits; none without WebSocket routes. */
286
+ #websocketHandler(
287
+ listener: string,
288
+ routes: readonly HttpRoute[],
289
+ ): WebSocketHandler<Connection> | undefined {
290
+ const websockets = routes.flatMap((route) =>
291
+ route.websocket ? [route.websocket] : [],
292
+ );
293
+ if (websockets.length === 0) return undefined;
294
+ return this.#sockets.handler(listener, {
295
+ maxMessageBytes: Math.max(
296
+ ...websockets.map(
297
+ (w) => w.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
298
+ ),
299
+ ),
300
+ maxBufferedBytes: Math.max(
301
+ ...websockets.map(
302
+ (w) => w.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
303
+ ),
304
+ ),
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Stops accepting at once and cuts open connections, such as event streams, without waiting on
310
+ * them. Open WebSockets get close code 1001 first, and the stop resolves once their routes'
311
+ * handlers have settled, or after the close timeout.
312
+ */
313
+ async stop(): Promise<void> {
314
+ this.#sockets.closeAll();
163
315
  for (const server of this.#servers) void server.stop(true);
164
316
  this.#servers = [];
317
+ await this.#sockets.drain(this.#closeTimeoutMs);
165
318
  }
166
319
  }