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.
- package/CHANGELOG.md +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +7 -3
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +10 -0
- package/src/index.ts +65 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- 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
|
|
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, "
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/core/domain/ports.ts
CHANGED
|
@@ -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
|
-
/**
|
|
22
|
-
|
|
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 };
|
package/src/core/events.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
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 —
|
|
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
|
-
#
|
|
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
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
162
|
-
|
|
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
|
}
|