@gavin-hu/my-pi-agent 0.19.0 → 0.21.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/README.md +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request/response handshake each caller runs over `pi.events`.
|
|
3
|
+
*
|
|
4
|
+
* Pi loads every extension with its own module cache, so one extension cannot call
|
|
5
|
+
* another: `plan` is asked to enter plan mode, `session-bus` to join the team, and
|
|
6
|
+
* `serve` to hand a page a prefix — each on a channel, each with a payload of its own.
|
|
7
|
+
* What they share is not the question but the answer's two guarantees: it settles
|
|
8
|
+
* exactly once, and a caller with no listener is told so rather than left waiting.
|
|
9
|
+
*
|
|
10
|
+
* The refusal stays with the caller: this module never names an extension, a tool, or
|
|
11
|
+
* a sentence. Value-only, as `lib/` must be: the state is the caller's bus and one
|
|
12
|
+
* closure.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a listener answers with: the value, or the promise of one it has not got yet.
|
|
19
|
+
*
|
|
20
|
+
* An owner may have to bind a socket, start a bus, or read a repository before it
|
|
21
|
+
* knows the answer, and the *claim* is the answer, not the result — so it hands back
|
|
22
|
+
* the promise and stays claimed while it works, rather than leaving the caller to
|
|
23
|
+
* guess whether anyone is there.
|
|
24
|
+
*/
|
|
25
|
+
export type BusAnswer<T> = T | Promise<T>;
|
|
26
|
+
|
|
27
|
+
/** One question for one channel. */
|
|
28
|
+
export interface BusRequest<T, P> {
|
|
29
|
+
/** The `pi.events` channel the owner answers on. */
|
|
30
|
+
channel: string;
|
|
31
|
+
/**
|
|
32
|
+
* The object it reads, with `respond` given beside it: the helper supplies the one
|
|
33
|
+
* answer a request has, so no listener can answer twice and no caller can settle
|
|
34
|
+
* its own request.
|
|
35
|
+
*/
|
|
36
|
+
payload(respond: (answer: BusAnswer<T>) => void): P;
|
|
37
|
+
/**
|
|
38
|
+
* The answer when no listener claimed the request — the sentence that names the
|
|
39
|
+
* extension which is not loaded. Settled on the microtask after the emit, because
|
|
40
|
+
* a listener claims synchronously or not at all.
|
|
41
|
+
*/
|
|
42
|
+
unclaimed(): T;
|
|
43
|
+
/**
|
|
44
|
+
* The answer when a listener claimed the request and then failed; defaults to
|
|
45
|
+
* {@link unclaimed}, which reads the same to a caller that cannot tell why.
|
|
46
|
+
*/
|
|
47
|
+
failed?(error: unknown): T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ask `request`'s channel for its answer, and settle once.
|
|
52
|
+
*
|
|
53
|
+
* Resolves the claimant's answer — including one it is still producing — or
|
|
54
|
+
* {@link BusRequest.unclaimed} when nobody claimed the request, so a session running
|
|
55
|
+
* without that extension reports a sentence instead of hanging.
|
|
56
|
+
*/
|
|
57
|
+
export function requestOverBus<T, P>(pi: Pick<ExtensionAPI, "events">, request: BusRequest<T, P>): Promise<T> {
|
|
58
|
+
return new Promise<T>((resolve) => {
|
|
59
|
+
let claimed = false;
|
|
60
|
+
let settled = false;
|
|
61
|
+
const settle = (answer: T): void => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
resolve(answer);
|
|
65
|
+
};
|
|
66
|
+
const respond = (answer: BusAnswer<T>): void => {
|
|
67
|
+
if (claimed) return;
|
|
68
|
+
claimed = true;
|
|
69
|
+
// A claim that is still binding answers later, so its failure needs one place to
|
|
70
|
+
// become the sentence the caller reports rather than an unhandled rejection.
|
|
71
|
+
Promise.resolve(answer).then(settle, (error: unknown) => {
|
|
72
|
+
const failed = request.failed;
|
|
73
|
+
settle(failed === undefined ? request.unclaimed() : failed(error));
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
pi.events.emit(request.channel, request.payload(respond));
|
|
77
|
+
// A listener claims synchronously or not at all, so a request nothing claimed is one
|
|
78
|
+
// no extension is listening for.
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (!claimed) settle(request.unclaimed());
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
package/lib/format.ts
CHANGED
|
@@ -53,6 +53,28 @@ export function formatDuration(ms: number): string {
|
|
|
53
53
|
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A coarse age, the way a reader says it: `now`, `3m`, `2h`, `5d`.
|
|
58
|
+
*
|
|
59
|
+
* One rule for both surfaces that print an age — a card's offer in the transcript, and
|
|
60
|
+
* the same card on the page — so one fact cannot read `2h ago` in a tool result and `3h`
|
|
61
|
+
* on the page. Written as concatenation because the page's bundle carries this function's
|
|
62
|
+
* *source* (`FORMAT_AGE_JS`): a backtick or an interpolation in it would end the string it
|
|
63
|
+
* is spliced into.
|
|
64
|
+
*/
|
|
65
|
+
export function formatAge(now: number, at: number): string {
|
|
66
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
67
|
+
if (seconds < 45) return "now";
|
|
68
|
+
const minutes = Math.round(seconds / 60);
|
|
69
|
+
if (minutes < 60) return minutes + "m";
|
|
70
|
+
const hours = Math.round(minutes / 60);
|
|
71
|
+
if (hours < 24) return hours + "h";
|
|
72
|
+
return Math.round(hours / 24) + "d";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** {@link formatAge} as the page's bundle receives it. */
|
|
76
|
+
export const FORMAT_AGE_JS = String(formatAge);
|
|
77
|
+
|
|
56
78
|
/** `1 tool`, `3 tools`: a tool count with the right word. */
|
|
57
79
|
export function formatToolCount(count: number): string {
|
|
58
80
|
const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
|
package/lib/http/index.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: the
|
|
3
3
|
* response shape, the header policy, the request-envelope helpers and the
|
|
4
|
-
* guards built on them, the one server entry,
|
|
4
|
+
* guards built on them, the one server entry, the socket seam behind it, and the
|
|
5
|
+
* mount contract a page uses to claim a prefix on the session's one port.
|
|
5
6
|
*
|
|
6
|
-
* Import from here rather than a leaf module, so the layer reads as one kit.
|
|
7
|
-
* extension imports {@link
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Import from here rather than a leaf module, so the layer reads as one kit. The
|
|
8
|
+
* `serve` extension imports {@link serveHttp} and never `./server.ts`; every other
|
|
9
|
+
* extension imports {@link requestMount} and never binds a socket at all — a
|
|
10
|
+
* hand-built `node:http` server beside this one is how the close path and the idle
|
|
11
|
+
* grace start to differ again. The same rule covers the guards: `plan` and `board`
|
|
12
|
+
* both serve a page that can write, so the `Host`, origin, and JSON rules are
|
|
11
13
|
* {@link envelopeRefusal} and {@link requireJsonBody} rather than two copies, and
|
|
12
14
|
* the credential a page carries is {@link mintToken} / {@link tokenMatches}.
|
|
13
15
|
*
|
|
14
16
|
* Value-only, like the rest of `lib/`: the only state is what a returned closure
|
|
15
|
-
* carries (`createCloser`, `
|
|
17
|
+
* carries (`createCloser`, `serveHttp`).
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export * from "./guard.ts";
|
|
19
21
|
export * from "./headers.ts";
|
|
22
|
+
export * from "./mount.ts";
|
|
23
|
+
export * from "./port.ts";
|
|
20
24
|
export * from "./response.ts";
|
|
21
25
|
export * from "./router.ts";
|
|
22
26
|
export * from "./serve.ts";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract a page and the session's one server agree on.
|
|
3
|
+
*
|
|
4
|
+
* A session has one port and `serve` owns it, so a page cannot bind: it *mounts* a
|
|
5
|
+
* prefix and is handed back where it answers. Pi loads every extension with an
|
|
6
|
+
* isolated module cache and forbids one extension importing another, so the request
|
|
7
|
+
* travels on `pi.events` as an in-process request/response, on the handshake in
|
|
8
|
+
* `lib/bus-request.ts`.
|
|
9
|
+
*
|
|
10
|
+
* The handshake is claimed, not awaited: a mount binds, and `server.listen` cannot
|
|
11
|
+
* answer synchronously, so `respond` takes the *pending* outcome and only a request
|
|
12
|
+
* no listener claimed settles as "no server". That distinction is the difference
|
|
13
|
+
* between a page that starts and a page that is told the extension is missing.
|
|
14
|
+
*
|
|
15
|
+
* Value-only, as `lib/` must be: the state is the caller's bus and its closures.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { NetworkInterfaceInfo } from "node:os";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { type BusAnswer, requestOverBus } from "../bus-request.ts";
|
|
21
|
+
import { messageOf } from "../errors.ts";
|
|
22
|
+
import type { IdleGrace } from "./sink.ts";
|
|
23
|
+
import type { LoopbackHandler } from "./serve.ts";
|
|
24
|
+
|
|
25
|
+
/** EventBus channel on which a page asks the session's server for a prefix. */
|
|
26
|
+
export const HTTP_MOUNT_CHANNEL = "my-pi-agent/http/mount";
|
|
27
|
+
|
|
28
|
+
/** The refusal a page shows when nothing owns the session's socket. */
|
|
29
|
+
export const NO_SERVER_REASON = "the serve extension is not loaded, so this session has no port";
|
|
30
|
+
|
|
31
|
+
/** One page's request for a prefix on the session's server. */
|
|
32
|
+
export interface MountSpec {
|
|
33
|
+
/**
|
|
34
|
+
* The Pi session asking. The server is per session, so a mount from another one —
|
|
35
|
+
* a WeChat peer, a second session in this process — never lands on this socket.
|
|
36
|
+
*/
|
|
37
|
+
session: string;
|
|
38
|
+
/** The prefix it answers under, e.g. `/board`; `""` claims the whole origin. */
|
|
39
|
+
prefix: string;
|
|
40
|
+
handler: LoopbackHandler;
|
|
41
|
+
/** Largest request body accepted here; absent, a route sees `""`. */
|
|
42
|
+
maxBodyBytes?: number;
|
|
43
|
+
/** Grace after this page's last reader leaves; omitted, it waits for `release()`. */
|
|
44
|
+
idle?: IdleGrace;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A prefix this page now serves. */
|
|
48
|
+
export interface HttpMount {
|
|
49
|
+
/** The prefix, normalized: `/board`. */
|
|
50
|
+
base: string;
|
|
51
|
+
/** `http://127.0.0.1:<port>`, the session's one origin. */
|
|
52
|
+
origin: string;
|
|
53
|
+
/** The port the session's server bound. */
|
|
54
|
+
port: number;
|
|
55
|
+
/**
|
|
56
|
+
* Free the prefix. Idempotent; the socket closes with its last mount, so a page
|
|
57
|
+
* that is done does not hold the session's port.
|
|
58
|
+
*/
|
|
59
|
+
release(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Serve this same page on one LAN address, for a review shared with a teammate.
|
|
62
|
+
* `undefined` when the machine has no address to offer or the bind failed, so the
|
|
63
|
+
* caller keeps the sentence that explains it.
|
|
64
|
+
*/
|
|
65
|
+
share(): Promise<SharedListener | undefined>;
|
|
66
|
+
/** Stop serving on the LAN address; the loopback mount keeps answering. */
|
|
67
|
+
unshare(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The LAN address a shared page answers on. */
|
|
71
|
+
export interface SharedListener {
|
|
72
|
+
/** The address a link names, e.g. `192.168.1.24`. */
|
|
73
|
+
address: string;
|
|
74
|
+
/** `http://<address>:<port>`, which may differ from the loopback port. */
|
|
75
|
+
origin: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The machine's interfaces, as `os.networkInterfaces()` reports them. */
|
|
79
|
+
export type NetworkInterfaces = Record<string, NetworkInterfaceInfo[] | undefined>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The addresses a teammate could reach this machine on: one per non-internal IPv4
|
|
83
|
+
* interface, ordered so a link is stable between calls.
|
|
84
|
+
*
|
|
85
|
+
* This is what a shared page is offered, never `0.0.0.0`: a review opens exactly
|
|
86
|
+
* one address, so the link the owner sends is the whole surface. Link-local
|
|
87
|
+
* (`169.254.*`) addresses are left out because they reach nothing.
|
|
88
|
+
*/
|
|
89
|
+
export function lanAddresses(interfaces: NetworkInterfaces): string[] {
|
|
90
|
+
const addresses = new Set<string>();
|
|
91
|
+
for (const entries of Object.values(interfaces)) {
|
|
92
|
+
for (const entry of entries ?? []) {
|
|
93
|
+
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
94
|
+
if (entry.address === "0.0.0.0" || entry.address.startsWith("169.254.")) continue;
|
|
95
|
+
addresses.add(entry.address);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...addresses].sort(byAddress);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Numeric IPv4 comparison, so `…1.9` sorts before `…1.24`. */
|
|
102
|
+
function byAddress(a: string, b: string): number {
|
|
103
|
+
const left = a.split(".").map(Number);
|
|
104
|
+
const right = b.split(".").map(Number);
|
|
105
|
+
for (let index = 0; index < 4; index++) {
|
|
106
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
107
|
+
if (difference !== 0) return difference;
|
|
108
|
+
}
|
|
109
|
+
return a.localeCompare(b);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What a mount request answers with: the page's place, or the reason it has none. */
|
|
113
|
+
export type MountOutcome = { kind: "mounted"; mount: HttpMount } | { kind: "refused"; reason: string };
|
|
114
|
+
|
|
115
|
+
/** The question, as it travels on the bus. */
|
|
116
|
+
export interface MountRequest {
|
|
117
|
+
spec: MountSpec;
|
|
118
|
+
/**
|
|
119
|
+
* Called once, by the owner, **synchronously** with the bind it just started. A
|
|
120
|
+
* request nobody claims is a session without a server.
|
|
121
|
+
*/
|
|
122
|
+
respond(pending: BusAnswer<MountOutcome>): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How a page asks for its prefix; the seam a caller substitutes in a test. */
|
|
126
|
+
export type MountSource = (spec: MountSpec) => Promise<MountOutcome>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ask the session's server for `spec`'s prefix.
|
|
130
|
+
*
|
|
131
|
+
* Resolves `refused` with {@link NO_SERVER_REASON} when no listener claims the
|
|
132
|
+
* request, so a session running without `serve` fails with a sentence instead of
|
|
133
|
+
* hanging or binding a rival socket.
|
|
134
|
+
*/
|
|
135
|
+
export function requestMount(pi: Pick<ExtensionAPI, "events">, spec: MountSpec): Promise<MountOutcome> {
|
|
136
|
+
return requestOverBus<MountOutcome, MountRequest>(pi, {
|
|
137
|
+
channel: HTTP_MOUNT_CHANNEL,
|
|
138
|
+
payload: (respond) => ({ spec, respond }),
|
|
139
|
+
unclaimed: () => ({ kind: "refused", reason: NO_SERVER_REASON }),
|
|
140
|
+
failed: (error) => ({ kind: "refused", reason: messageOf(error) }),
|
|
141
|
+
});
|
|
142
|
+
}
|
package/lib/http/port.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session port band the loopback pages share.
|
|
3
|
+
*
|
|
4
|
+
* `serve` draws the session's one port from it, so every page that session serves
|
|
5
|
+
* clusters where a person's bookmarks already point. The band is one product decision,
|
|
6
|
+
* stated here once: a number repeated in each extension's config is several chances to
|
|
7
|
+
* move one of them, and nothing would notice until a bookmark landed outside the range.
|
|
8
|
+
*
|
|
9
|
+
* A pick is a guess, not a reservation. `serveHttp` binds it and falls back to whatever
|
|
10
|
+
* free port the OS gives, so a port another process holds costs a retry rather than a
|
|
11
|
+
* start.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomInt } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
/** Base of the per-session port range. */
|
|
17
|
+
export const PORT_BASE = 4780;
|
|
18
|
+
|
|
19
|
+
/** Number of ports in the per-session range. */
|
|
20
|
+
export const PORT_RANGE = 1000;
|
|
21
|
+
|
|
22
|
+
/** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
|
|
23
|
+
export function randomPort(): number {
|
|
24
|
+
return PORT_BASE + randomInt(PORT_RANGE);
|
|
25
|
+
}
|
package/lib/http/serve.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* close.
|
|
2
|
+
* The one loopback server a session serves its pages on: bind once, dispatch by
|
|
3
|
+
* mount, stream, idle, close.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* A session has one port, so the socket cannot belong to a page. A page *mounts* a
|
|
6
|
+
* path prefix on it — `/board`, `/files`, `/plan/<id>` — and the server owns
|
|
7
|
+
* everything a request does not decide: the longest-prefix dispatch, the body cap
|
|
8
|
+
* of whichever mount was reached, `HEAD` of a stream, the event-stream head, the
|
|
9
|
+
* port retry, and a close that is safe to call twice. A mount's handler is handed
|
|
10
|
+
* the request target *relative to its own prefix*, so a router keeps matching the
|
|
11
|
+
* paths it always matched.
|
|
10
12
|
*
|
|
11
|
-
* The
|
|
12
|
-
* a
|
|
13
|
-
*
|
|
13
|
+
* The idle grace is per mount, not per socket: one page going quiet must not close
|
|
14
|
+
* another's, so a mount carries its own open-stream registry and release, and the
|
|
15
|
+
* socket closes when its last mount leaves.
|
|
16
|
+
*
|
|
17
|
+
* The `Host` rule stays with each router: the allowlist is per extension, and for a
|
|
18
|
+
* shared review it grows while the server is up, so it cannot be passed in once.
|
|
14
19
|
*
|
|
15
20
|
* Value-only: the only state is what a returned closure carries.
|
|
16
21
|
*/
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
import type { ArmTimer } from "../timer.ts";
|
|
26
31
|
import { jsonHeaders } from "./headers.ts";
|
|
27
32
|
import { type ServeResponse, textResponse } from "./response.ts";
|
|
33
|
+
import { parseRequestUrl } from "./router.ts";
|
|
28
34
|
import { bindHost, createCloser, openEventStream, respond } from "./server.ts";
|
|
29
35
|
import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "./sink.ts";
|
|
30
36
|
|
|
@@ -32,7 +38,7 @@ import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "
|
|
|
32
38
|
export interface LoopbackRequest {
|
|
33
39
|
/** Upper-cased method, e.g. `GET`. */
|
|
34
40
|
method: string;
|
|
35
|
-
/** The request target, path and query. */
|
|
41
|
+
/** The request target, path and query, relative to the mount's prefix. */
|
|
36
42
|
url: string;
|
|
37
43
|
/** Headers exactly as the socket carried them; the router applies its `Host` rule. */
|
|
38
44
|
headers: IncomingHttpHeaders;
|
|
@@ -57,35 +63,67 @@ export interface LoopbackReply {
|
|
|
57
63
|
/** One request in, one reply out. A throw is answered as a plain 500. */
|
|
58
64
|
export type LoopbackHandler = (request: LoopbackRequest) => Promise<LoopbackReply> | LoopbackReply;
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
ports: readonly number[];
|
|
66
|
+
/** One page's claim on a path prefix. */
|
|
67
|
+
export interface ServedMount {
|
|
68
|
+
/** The prefix it answers under: `/board`, or `""` for the whole origin. */
|
|
69
|
+
prefix: string;
|
|
65
70
|
handler: LoopbackHandler;
|
|
66
|
-
/** Largest body accepted; absent, no body is read and a route sees `""`. */
|
|
71
|
+
/** Largest body accepted here; absent, no body is read and a route sees `""`. */
|
|
67
72
|
maxBodyBytes?: number;
|
|
68
|
-
/** Grace after
|
|
73
|
+
/** Grace after this mount's last stream leaves; omitted, it waits for `release()`. */
|
|
69
74
|
idle?: IdleGrace;
|
|
70
|
-
/** The timer
|
|
75
|
+
/** The timer this mount's idle grace is armed on. */
|
|
71
76
|
timer?: ArmTimer;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
/** A
|
|
75
|
-
export interface
|
|
79
|
+
/** A prefix served on a running server. */
|
|
80
|
+
export interface Mount {
|
|
81
|
+
/** The normalized prefix, e.g. `/board`. */
|
|
82
|
+
base: string;
|
|
83
|
+
/** The port the server bound. */
|
|
84
|
+
port: number;
|
|
85
|
+
/** `http://<host>:<port>`, the address this mount is reached on. */
|
|
86
|
+
origin: string;
|
|
87
|
+
/**
|
|
88
|
+
* End this mount's streams and free its prefix. Idempotent, and the server closes
|
|
89
|
+
* with its last mount.
|
|
90
|
+
*/
|
|
91
|
+
release(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ServeOptions {
|
|
95
|
+
/** The address to bind; defaults to `127.0.0.1`. */
|
|
96
|
+
host?: string;
|
|
97
|
+
/** Ports tried in order, so a remembered port can fall back to an OS-chosen one. */
|
|
98
|
+
ports: readonly number[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A bound server, before any page has mounted on it. */
|
|
102
|
+
export interface ServedServer {
|
|
76
103
|
/** The port it actually bound. */
|
|
77
104
|
port: number;
|
|
78
105
|
/** `http://<host>:<port>`, the address the caller prints. */
|
|
79
106
|
origin: string;
|
|
80
107
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
* Add one prefix. `undefined` means the prefix is already taken, which a caller
|
|
109
|
+
* reports rather than lets two pages answer as one.
|
|
110
|
+
*/
|
|
111
|
+
mount(spec: ServedMount): Mount | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* End every mount's streams and close the socket. Idempotent, and what the last
|
|
114
|
+
* mount's release does on its own.
|
|
85
115
|
*/
|
|
86
116
|
close(): Promise<void>;
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/** One prefix as the server holds it: its spec, its streams, and its mount handle. */
|
|
120
|
+
interface Route {
|
|
121
|
+
spec: ServedMount;
|
|
122
|
+
base: string;
|
|
123
|
+
sinks: SinkRegistry;
|
|
124
|
+
mount: Mount;
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
/** The one refusal this layer answers itself: a body it will not read. */
|
|
90
128
|
const BODY_TOO_LARGE: ServeResponse = {
|
|
91
129
|
status: 413,
|
|
@@ -99,6 +137,10 @@ const STREAM_WITHOUT_SINK: ServeResponse = textResponse(500, "Internal error");
|
|
|
99
137
|
/** A handler that threw, or a socket that failed after its head was written. */
|
|
100
138
|
const INTERNAL_ERROR: ServeResponse = textResponse(500, "Internal error");
|
|
101
139
|
|
|
140
|
+
/** A prefix no mount claims, and a target no URL parser accepts. */
|
|
141
|
+
const NOT_FOUND: ServeResponse = textResponse(404, "Nothing is served at this path.");
|
|
142
|
+
const MALFORMED: ServeResponse = textResponse(400, "Malformed request URL.");
|
|
143
|
+
|
|
102
144
|
/**
|
|
103
145
|
* Bind the first port that answers, falling back in order. Rejects with the last
|
|
104
146
|
* bind error, so a caller can still name the reason (`EADDRINUSE` and friends).
|
|
@@ -115,6 +157,31 @@ async function bindFirst(server: Server, host: string, ports: readonly number[])
|
|
|
115
157
|
throw failure instanceof Error ? failure : new Error(`could not bind ${host}`);
|
|
116
158
|
}
|
|
117
159
|
|
|
160
|
+
/** One prefix, spelled the way dispatch compares it: no trailing slash, a leading one. */
|
|
161
|
+
function normalizeBase(prefix: string): string {
|
|
162
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
163
|
+
if (trimmed === "" || trimmed === "/") return "";
|
|
164
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether `base` is a prefix of `pathname` at a segment boundary; `""` covers everything. */
|
|
168
|
+
function prefixCovers(base: string, pathname: string): boolean {
|
|
169
|
+
return base === "" || pathname === base || pathname.startsWith(`${base}/`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The mount a path belongs to: the longest prefix that covers it, so `/plan/a` wins
|
|
174
|
+
* over a bare `/plan` and a root mount is the fallback rather than the rival.
|
|
175
|
+
*/
|
|
176
|
+
function routeFor(routes: Map<string, Route>, pathname: string): Route | undefined {
|
|
177
|
+
let best: Route | undefined;
|
|
178
|
+
for (const route of routes.values()) {
|
|
179
|
+
if (!prefixCovers(route.base, pathname)) continue;
|
|
180
|
+
if (best === undefined || route.base.length > best.base.length) best = route;
|
|
181
|
+
}
|
|
182
|
+
return best;
|
|
183
|
+
}
|
|
184
|
+
|
|
118
185
|
/**
|
|
119
186
|
* Read a request body, stopping at `limit`. An oversize body is reported rather
|
|
120
187
|
* than destroyed, so it can still be answered 413 over the open socket instead of
|
|
@@ -157,22 +224,45 @@ function once(fn: (() => void) | undefined): (() => void) | undefined {
|
|
|
157
224
|
};
|
|
158
225
|
}
|
|
159
226
|
|
|
160
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Route one request: pick the mount, read, dispatch, then answer, stream, or refuse.
|
|
229
|
+
*
|
|
230
|
+
* A request that names a prefix without its slash is redirected to the slashed form,
|
|
231
|
+
* because a page's own links are relative to its directory and `/board` is not one.
|
|
232
|
+
*/
|
|
161
233
|
async function answer(
|
|
162
234
|
request: IncomingMessage,
|
|
163
235
|
response: ServerResponse,
|
|
164
|
-
|
|
165
|
-
|
|
236
|
+
routes: Map<string, Route>,
|
|
237
|
+
redirect: (to: string) => ServeResponse,
|
|
166
238
|
): Promise<void> {
|
|
167
239
|
const method = request.method ?? "GET";
|
|
168
|
-
const
|
|
240
|
+
const target = request.url ?? "/";
|
|
241
|
+
const parsed = parseRequestUrl(target);
|
|
242
|
+
if (!parsed) {
|
|
243
|
+
respond(request, response, MALFORMED);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const route = routeFor(routes, parsed.pathname);
|
|
247
|
+
if (route === undefined) {
|
|
248
|
+
respond(request, response, NOT_FOUND);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (route.base !== "" && parsed.pathname === route.base) {
|
|
252
|
+
respond(request, response, redirect(`${route.base}/${parsed.search.toString() === "" ? "" : `?${parsed.search}`}`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const rest = target.slice(route.base.length);
|
|
256
|
+
const url = rest === "" ? "/" : rest.startsWith("?") ? `/${rest}` : rest;
|
|
257
|
+
const limit = route.spec.maxBodyBytes;
|
|
258
|
+
const body = limit === undefined ? "" : await readBody(request, limit);
|
|
169
259
|
if (body === undefined) {
|
|
170
260
|
respond(request, response, BODY_TOO_LARGE);
|
|
171
261
|
return;
|
|
172
262
|
}
|
|
173
263
|
let reply: LoopbackReply;
|
|
174
264
|
try {
|
|
175
|
-
reply = await
|
|
265
|
+
reply = await route.spec.handler({ method, url, headers: request.headers, body });
|
|
176
266
|
} catch {
|
|
177
267
|
reply = { result: INTERNAL_ERROR };
|
|
178
268
|
}
|
|
@@ -186,7 +276,7 @@ async function answer(
|
|
|
186
276
|
// the client would otherwise wait on frames it asked not to get.
|
|
187
277
|
if (reply.openSink !== undefined && method.toUpperCase() !== "HEAD") {
|
|
188
278
|
openEventStream(response, reply.result);
|
|
189
|
-
const sink = sinks.open(response);
|
|
279
|
+
const sink = route.sinks.open(response);
|
|
190
280
|
const detach = reply.openSink(sink);
|
|
191
281
|
let closed = false;
|
|
192
282
|
const finish = (): void => {
|
|
@@ -205,30 +295,67 @@ async function answer(
|
|
|
205
295
|
}
|
|
206
296
|
|
|
207
297
|
/** Start a server on the first port that binds, and close it idempotently. */
|
|
208
|
-
export async function
|
|
298
|
+
export async function serveHttp(options: ServeOptions): Promise<ServedServer> {
|
|
209
299
|
const host = options.host ?? "127.0.0.1";
|
|
210
|
-
const
|
|
300
|
+
const routes = new Map<string, Route>();
|
|
301
|
+
let closed = false;
|
|
302
|
+
let closeServer: (() => Promise<void>) | undefined;
|
|
303
|
+
|
|
304
|
+
const close = async (): Promise<void> => {
|
|
305
|
+
if (closed) return;
|
|
306
|
+
closed = true;
|
|
307
|
+
// Streams end first: a connected page must not hold the close open.
|
|
308
|
+
const open = [...routes.values()];
|
|
309
|
+
routes.clear();
|
|
310
|
+
for (const route of open) route.sinks.endAll();
|
|
311
|
+
await closeServer?.();
|
|
312
|
+
};
|
|
313
|
+
|
|
211
314
|
const server = createServer((request, response) => {
|
|
212
315
|
// A page that walks away mid-frame must not take the session down.
|
|
213
316
|
response.on("error", () => {});
|
|
214
|
-
void answer(request, response,
|
|
317
|
+
void answer(request, response, routes, (to) => ({
|
|
318
|
+
status: 301,
|
|
319
|
+
headers: { ...jsonHeaders(), Location: to },
|
|
320
|
+
body: "",
|
|
321
|
+
})).catch(() => {
|
|
215
322
|
// A route that failed after its head was written can only be cut off; ending
|
|
216
323
|
// the socket beats a client waiting on a reply that will not come.
|
|
217
324
|
if (!response.writableEnded) response.end();
|
|
218
325
|
});
|
|
219
326
|
});
|
|
220
327
|
const port = await bindFirst(server, host, options.ports);
|
|
221
|
-
|
|
222
|
-
|
|
328
|
+
closeServer = createCloser(server);
|
|
329
|
+
const origin = `http://${host}:${port}`;
|
|
330
|
+
|
|
223
331
|
return {
|
|
224
332
|
port,
|
|
225
|
-
origin
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
333
|
+
origin,
|
|
334
|
+
mount(spec) {
|
|
335
|
+
const base = normalizeBase(spec.prefix);
|
|
336
|
+
if (routes.has(base)) return undefined;
|
|
337
|
+
const sinks = createSinks({ idle: spec.idle, timer: spec.timer });
|
|
338
|
+
const route: Route = {
|
|
339
|
+
spec: { ...spec, prefix: base },
|
|
340
|
+
base,
|
|
341
|
+
sinks,
|
|
342
|
+
mount: {
|
|
343
|
+
base,
|
|
344
|
+
port,
|
|
345
|
+
origin,
|
|
346
|
+
release: async () => {
|
|
347
|
+
if (!routes.delete(base)) return;
|
|
348
|
+
sinks.endAll();
|
|
349
|
+
if (routes.size === 0) await close();
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
routes.set(base, route);
|
|
354
|
+
// A mount that asked for a first grace arms it here, so a browser that never
|
|
355
|
+
// came up cannot hold the prefix for the rest of the session.
|
|
356
|
+
sinks.start();
|
|
357
|
+
return route.mount;
|
|
232
358
|
},
|
|
359
|
+
close,
|
|
233
360
|
};
|
|
234
361
|
}
|
package/lib/http/server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The socket primitives `
|
|
3
|
-
*
|
|
2
|
+
* The socket primitives `serveHttp` is built from: bind, write a response out, and
|
|
3
|
+
* close.
|
|
4
4
|
*
|
|
5
5
|
* Internal to the layer: an extension starts a server through
|
|
6
|
-
* `
|
|
6
|
+
* `serveHttp`, which composes these, so this module is reached from `lib/http`
|
|
7
7
|
* and its own suite — not from the barrel. Everything here is behavior a caller
|
|
8
8
|
* cannot get right by accident: a bind on an address the caller names (loopback
|
|
9
9
|
* unless it asked for more), an accept-level `error` listener so a transient
|