@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 +66 -52
- package/README.zh-CN.md +56 -46
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +507 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +561 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +298 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +254 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
- package/extensions/board/commands.ts +105 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +170 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +388 -0
- package/extensions/board/result.ts +108 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +90 -0
- package/extensions/board/seat.ts +28 -0
- package/extensions/board/snapshot.ts +42 -0
- package/extensions/board/tools.ts +444 -0
- package/extensions/board/transport/router.ts +91 -0
- package/extensions/board/transport/server.ts +138 -0
- package/extensions/board/types.ts +121 -0
- package/extensions/board/view/client.ts +51 -0
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +125 -0
- package/extensions/board/view/live.ts +231 -0
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +118 -0
- package/extensions/board/view/render.ts +519 -0
- package/extensions/board/view/styles.ts +228 -0
- package/extensions/board/view/team.ts +327 -0
- 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 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +67 -7
- 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-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +75 -43
- 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/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/check/commands.ts +2 -1
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +12 -0
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/job/runtime.ts +4 -4
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +185 -52
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +23 -5
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +27 -34
- package/extensions/plan/policy.ts +30 -17
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +52 -11
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +20 -95
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +15 -8
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -8
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +3 -58
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +11 -5
- package/extensions/session-bus/runtime.ts +220 -267
- package/extensions/session-bus/store.ts +55 -7
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +5 -5
- package/extensions/session-bus/types.ts +23 -14
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +145 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +119 -0
- package/extensions/team/index.ts +34 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +267 -0
- package/extensions/team/types.ts +65 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +106 -251
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- 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 +18 -290
- 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 +11 -8
- package/lib/board/card.ts +479 -0
- package/lib/board/links.ts +141 -0
- package/lib/board/page-pointer.ts +310 -0
- package/lib/board/round-text.ts +81 -0
- package/lib/board/round.ts +94 -0
- package/lib/board/rules.ts +437 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +497 -0
- package/lib/board/view.ts +234 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +35 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +237 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +135 -0
- package/lib/http/token.ts +38 -0
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +25 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +201 -0
- package/lib/session-tool.ts +184 -0
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +38 -2
- package/lib/tui/glyphs.ts +3 -2
- package/lib/web/README.md +57 -4
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/components.ts +5 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/files.ts +0 -564
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/job/process.ts +0 -35
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The snapshot: the one shape the page knows, built on the server.
|
|
3
|
+
*
|
|
4
|
+
* The browser is a dumb renderer over this record, so the lanes, the counts, the
|
|
5
|
+
* rollup, and the store's size are decided in one place and a client cannot invent
|
|
6
|
+
* a state the model surface would not report. `sig` is what makes a push cheap:
|
|
7
|
+
* the page is re-sent only when a card's revision moved.
|
|
8
|
+
*
|
|
9
|
+
* `where` is the store's own path, shortened, so a reader can tell which directory
|
|
10
|
+
* a shared board is filed under — and so nothing here needs a "remote" variant that
|
|
11
|
+
* no backend produces.
|
|
12
|
+
*
|
|
13
|
+
* Pure: the caller supplies the cards, the clock, and the sizes it read.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type RosterSession, type RosterState, rosterStateOf } from "../session-roster.ts";
|
|
17
|
+
import {
|
|
18
|
+
type Card,
|
|
19
|
+
CARD_STATUSES,
|
|
20
|
+
type CardStatus,
|
|
21
|
+
laneSince,
|
|
22
|
+
priorityOf,
|
|
23
|
+
type Priority,
|
|
24
|
+
signatureOf,
|
|
25
|
+
} from "./card.ts";
|
|
26
|
+
import { blockersOf, indexOf, lanesText, openDependentsOf } from "./links.ts";
|
|
27
|
+
import type { Actor } from "./rules.ts";
|
|
28
|
+
import { buildRound, type RoundSummary } from "./round.ts";
|
|
29
|
+
|
|
30
|
+
/** One session on the repository's bus, as the Team tab lists it. */
|
|
31
|
+
export interface ViewTeammate {
|
|
32
|
+
label: string;
|
|
33
|
+
/** `live` while its heartbeat holds, `quiet` once the record has aged out, `gone` once its process is not running. */
|
|
34
|
+
state: RosterState;
|
|
35
|
+
/** When it last proved it was there. */
|
|
36
|
+
seen: number;
|
|
37
|
+
/** Messages waiting in its inbox. */
|
|
38
|
+
mail: number;
|
|
39
|
+
/** Whether it is listening for mail; absent means its record does not say so. */
|
|
40
|
+
listening?: boolean;
|
|
41
|
+
/** Whether this is the session the page writes as. */
|
|
42
|
+
you: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One card as the page meets it: the stored card, plus the two answers it would
|
|
47
|
+
* otherwise compute for itself.
|
|
48
|
+
*
|
|
49
|
+
* Both are derived from the board, not stored on the card, so a client that did the
|
|
50
|
+
* same work would be a second copy of a rule — and the copies drift, which is how the
|
|
51
|
+
* page came to disagree with the tool about what a card waits for.
|
|
52
|
+
*/
|
|
53
|
+
export interface ViewCard extends Card {
|
|
54
|
+
/** The priority it acts on: a card that never set one is ordinary. */
|
|
55
|
+
priority: Priority;
|
|
56
|
+
/**
|
|
57
|
+
* The unfinished cards it waits for, as `#2 (doing)`; absent when nothing blocks it.
|
|
58
|
+
*
|
|
59
|
+
* `undefined` is the whole fact — a waiting card is one with a `waits` — because a
|
|
60
|
+
* boolean beside it would be one more thing that can disagree with the string.
|
|
61
|
+
*/
|
|
62
|
+
waits?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The unfinished cards waiting on it, as `#9 (todo)`; absent when none do.
|
|
65
|
+
*
|
|
66
|
+
* The other side of `waits`: what leaving this card undone holds up. Derived here
|
|
67
|
+
* rather than in the page's script for the same reason, and filtered to the unfinished
|
|
68
|
+
* for the same reason a finished blocker does not count.
|
|
69
|
+
*/
|
|
70
|
+
dependents?: string;
|
|
71
|
+
/**
|
|
72
|
+
* When the card entered the lane it is in now, or absent when its history does not say.
|
|
73
|
+
*
|
|
74
|
+
* A timestamp rather than a phrase: the page already formats ages, and a wording
|
|
75
|
+
* decided here would be a second copy of that rule. Absent is the honest answer for a
|
|
76
|
+
* card written before the log existed, so the page draws no age rather than guessing
|
|
77
|
+
* one from `updatedAt`, which moves on every write.
|
|
78
|
+
*/
|
|
79
|
+
since?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One lane and the cards in it, in the order the page draws them. */
|
|
83
|
+
export interface ViewLane {
|
|
84
|
+
status: CardStatus;
|
|
85
|
+
cards: ViewCard[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The per-lane tallies the toolbar shows. */
|
|
89
|
+
export interface ViewCounts {
|
|
90
|
+
total: number;
|
|
91
|
+
todo: number;
|
|
92
|
+
doing: number;
|
|
93
|
+
blocked: number;
|
|
94
|
+
done: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Everything the page is allowed to know. */
|
|
98
|
+
export interface BoardView {
|
|
99
|
+
/** The session a page write is made as — the page's seat, never the browser. */
|
|
100
|
+
me: { id?: string; label: string };
|
|
101
|
+
/** The session the page is served from, when the page knows it: who is reading. */
|
|
102
|
+
session?: { id?: string; label: string };
|
|
103
|
+
lanes: ViewLane[];
|
|
104
|
+
counts: ViewCounts;
|
|
105
|
+
round: RoundSummary<ViewCard>;
|
|
106
|
+
/**
|
|
107
|
+
* The sessions on this repository's bus, or absent when there is no bus here.
|
|
108
|
+
*
|
|
109
|
+
* Absent is not empty: a page with no roster cannot say who is around, while an
|
|
110
|
+
* empty one can say nobody is — and the tab draws those two answers differently.
|
|
111
|
+
*/
|
|
112
|
+
teammates?: ViewTeammate[];
|
|
113
|
+
/** Bytes the store occupies, so a board that is never pruned says so. */
|
|
114
|
+
bytes: number;
|
|
115
|
+
/**
|
|
116
|
+
* Card files the reader refused, so the footer can say the board is not empty.
|
|
117
|
+
*
|
|
118
|
+
* Zero is the ordinary answer and draws nothing; a count above it is the one fact
|
|
119
|
+
* that separates "no work here" from "this build cannot read your work".
|
|
120
|
+
*/
|
|
121
|
+
skipped: number;
|
|
122
|
+
/** Changes when a card's revision, a teammate's state, or its mail does. */
|
|
123
|
+
sig: string;
|
|
124
|
+
/** The store's directory, shortened. */
|
|
125
|
+
where: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What the server read before building one snapshot. */
|
|
129
|
+
export interface ViewInput {
|
|
130
|
+
me: Actor;
|
|
131
|
+
/**
|
|
132
|
+
* The session the page is served from, when it has one.
|
|
133
|
+
*
|
|
134
|
+
* The page writes as its seat — a fixed name — so the reader's own row on the Team
|
|
135
|
+
* tab is marked from the session, not from `me`: the seat is what a card records,
|
|
136
|
+
* and the session is who is looking.
|
|
137
|
+
*/
|
|
138
|
+
session?: Actor;
|
|
139
|
+
cards: readonly Card[];
|
|
140
|
+
now: number;
|
|
141
|
+
bytes: number;
|
|
142
|
+
/** Card files the reader refused; absent reads as none, which is the ordinary board. */
|
|
143
|
+
skipped?: number;
|
|
144
|
+
where: string;
|
|
145
|
+
/** The bus's records, when the page could read them at all. */
|
|
146
|
+
roster?: readonly RosterSession[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the page's snapshot. */
|
|
150
|
+
export function buildView(input: ViewInput): BoardView {
|
|
151
|
+
// One index for the whole snapshot: a card's lane and its blockers are read from the
|
|
152
|
+
// same board, so a dependency and its dependent cannot be told two different stories.
|
|
153
|
+
const index = indexOf(input.cards);
|
|
154
|
+
// One mapped list feeds both halves of the snapshot: the rollup's groups carry the same
|
|
155
|
+
// card the lane does, so a client reading `round.unassigned` is not handed a card
|
|
156
|
+
// missing the facts every other card has.
|
|
157
|
+
const cards = input.cards.map((card) => viewCardOf(card, index));
|
|
158
|
+
const lanes = CARD_STATUSES.map((status) => ({
|
|
159
|
+
status,
|
|
160
|
+
cards: cards.filter((card) => card.status === status),
|
|
161
|
+
}));
|
|
162
|
+
const reader = input.session ?? input.me;
|
|
163
|
+
const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
|
|
164
|
+
return {
|
|
165
|
+
me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
|
|
166
|
+
...(input.session === undefined ? {} : { session: input.session }),
|
|
167
|
+
lanes,
|
|
168
|
+
counts: countOf(lanes),
|
|
169
|
+
round: buildRound(cards, input.now),
|
|
170
|
+
...(teammates === undefined ? {} : { teammates }),
|
|
171
|
+
bytes: input.bytes,
|
|
172
|
+
skipped: input.skipped ?? 0,
|
|
173
|
+
// The roster enters the signature at its coarse facts only: a heartbeat every ten
|
|
174
|
+
// seconds is not news, while a join, a quiet and a letter are. A file that became
|
|
175
|
+
// readable, or unreadable, is news too: the footer counts it, and a frame that is
|
|
176
|
+
// not pushed leaves the page saying the board holds less than it does.
|
|
177
|
+
sig: signatureOf(input.cards) + rosterSignature(teammates) + `|skipped:${input.skipped ?? 0}`,
|
|
178
|
+
where: input.where,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One card for the page: what it carries, plus what it acts on.
|
|
184
|
+
*
|
|
185
|
+
* The card is copied rather than annotated — the caller's cards are the store's, and a
|
|
186
|
+
* derived field written onto one would outlive the read that made it.
|
|
187
|
+
*/
|
|
188
|
+
function viewCardOf(card: Card, index: ReadonlyMap<string, Card>): ViewCard {
|
|
189
|
+
const waits = lanesText(blockersOf(card, index));
|
|
190
|
+
const dependents = lanesText(openDependentsOf(card.id, index));
|
|
191
|
+
const since = laneSince(card);
|
|
192
|
+
return {
|
|
193
|
+
...card,
|
|
194
|
+
priority: priorityOf(card),
|
|
195
|
+
...(waits === "" ? {} : { waits }),
|
|
196
|
+
...(dependents === "" ? {} : { dependents }),
|
|
197
|
+
...(since === undefined ? {} : { since }),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The roster as the page shows it, ordered so a repaint never reorders it. */
|
|
202
|
+
function teammatesOf(roster: readonly RosterSession[], meLabel: string, now: number): ViewTeammate[] {
|
|
203
|
+
return roster
|
|
204
|
+
.map((session) => ({
|
|
205
|
+
label: session.label,
|
|
206
|
+
state: rosterStateOf(session, now),
|
|
207
|
+
seen: session.lastSeen,
|
|
208
|
+
mail: session.mail,
|
|
209
|
+
// A record that does not say is not a promise either way, so the fact is left out
|
|
210
|
+
// rather than drawn as "not listening".
|
|
211
|
+
...(session.watching === undefined ? {} : { listening: session.watching.armed }),
|
|
212
|
+
you: session.label.toLowerCase() === meLabel.toLowerCase(),
|
|
213
|
+
}))
|
|
214
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What a frame must know about the roster; `seen` is left out on purpose. */
|
|
218
|
+
function rosterSignature(teammates: readonly ViewTeammate[] | undefined): string {
|
|
219
|
+
if (teammates === undefined) return "";
|
|
220
|
+
return (
|
|
221
|
+
"|" +
|
|
222
|
+
teammates.map((teammate) => `${teammate.label}:${teammate.state}:${teammate.mail}:${teammate.listening}`).join(",")
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The tallies, summed from the lanes so the two can never disagree. */
|
|
227
|
+
function countOf(lanes: readonly ViewLane[]): ViewCounts {
|
|
228
|
+
const counts: ViewCounts = { total: 0, todo: 0, doing: 0, blocked: 0, done: 0 };
|
|
229
|
+
for (const lane of lanes) {
|
|
230
|
+
counts[lane.status] = lane.cards.length;
|
|
231
|
+
counts.total += lane.cards.length;
|
|
232
|
+
}
|
|
233
|
+
return counts;
|
|
234
|
+
}
|
package/lib/errors.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Two helpers for an unknown thrown value: what to say about it, and whether it is the
|
|
3
|
+
* failure a caller was waiting for.
|
|
3
4
|
*
|
|
4
|
-
* Extensions report a rejected promise, a failed child process, and a parser
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Extensions report a rejected promise, a failed child process, and a parser error the
|
|
6
|
+
* same way: an `Error` yields its message, and anything else is stringified. A `code` —
|
|
7
|
+
* `EEXIST`, `EPERM` — is what a filesystem write branches on, and reading it off an
|
|
8
|
+
* unknown value is the same boundary, so it lives here too. Kept value-only so every
|
|
9
|
+
* caller has one definition.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
/** A user-facing message for an unknown thrown value. */
|
|
10
13
|
export function messageOf(error: unknown): string {
|
|
11
14
|
return error instanceof Error ? error.message : String(error);
|
|
12
15
|
}
|
|
16
|
+
|
|
17
|
+
/** Whether an unknown thrown value carries one errno code, such as `EEXIST`. */
|
|
18
|
+
export function hasErrorCode(error: unknown, code: string): boolean {
|
|
19
|
+
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === code;
|
|
20
|
+
}
|
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;
|
|
@@ -166,3 +188,16 @@ export function shortenPath(path: string, home: string = homedir()): string {
|
|
|
166
188
|
if (normalized === root) return "~";
|
|
167
189
|
return normalized.startsWith(`${root}/`) ? `~${normalized.slice(root.length)}` : normalized;
|
|
168
190
|
}
|
|
191
|
+
|
|
192
|
+
/** A compact byte size for a listing or a report: `68 B`, `12.4 kB`, `1.5 MB`. */
|
|
193
|
+
export function formatSize(bytes: number): string {
|
|
194
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
195
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
196
|
+
let value = bytes;
|
|
197
|
+
let index = -1;
|
|
198
|
+
do {
|
|
199
|
+
value /= 1024;
|
|
200
|
+
index++;
|
|
201
|
+
} while (value >= 1024 && index < units.length - 1);
|
|
202
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[index]}`;
|
|
203
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request guards both loopback servers apply before a route runs: the method
|
|
3
|
+
* allowlist, the `Host` allowlist, the cross-origin refusal, and — for a server
|
|
4
|
+
* that takes JSON — the content-type check.
|
|
5
|
+
*
|
|
6
|
+
* These were the `plan` review server's own three helpers until a second
|
|
7
|
+
* mutating page needed them. A guard is a policy, and two copies of a policy are
|
|
8
|
+
* how a `Host` rule or an origin rule drifts apart, so the sentences the client
|
|
9
|
+
* reads and the order they are checked in live here once.
|
|
10
|
+
*
|
|
11
|
+
* Each guard answers `undefined` when the request may proceed and the response to
|
|
12
|
+
* send instead when it may not, so a router reads as a chain of refusals.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { jsonHeaders } from "./headers.ts";
|
|
16
|
+
import { hostHeaderName, isAllowedHostHeader } from "./router.ts";
|
|
17
|
+
import { jsonError, type ServeResponse } from "./response.ts";
|
|
18
|
+
|
|
19
|
+
/** The methods a loopback server answers unless it says otherwise. */
|
|
20
|
+
export const PAGE_METHODS: readonly string[] = ["GET", "HEAD", "POST"];
|
|
21
|
+
|
|
22
|
+
/** The 405 for a route that only accepts POST, or nothing when the method is right. */
|
|
23
|
+
export function postOnly(method: string, what: string): ServeResponse | undefined {
|
|
24
|
+
if (method.toUpperCase() === "POST") return undefined;
|
|
25
|
+
return jsonError(405, `Use POST to ${what}.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The 403 for a state-changing request another site made, or nothing when the
|
|
30
|
+
* caller may proceed.
|
|
31
|
+
*
|
|
32
|
+
* A browser sends `Origin` on every state-changing request and `Sec-Fetch-Site`
|
|
33
|
+
* with its own classification; a request with neither is a script or a test,
|
|
34
|
+
* which has no ambient credential to abuse. A present `Origin` must name the host
|
|
35
|
+
* the request was addressed to, so a page served by another site cannot drive
|
|
36
|
+
* this one through a victim's browser.
|
|
37
|
+
*/
|
|
38
|
+
export function checkOrigin(headers: Record<string, string | string[] | undefined>): ServeResponse | undefined {
|
|
39
|
+
const site = headers["sec-fetch-site"];
|
|
40
|
+
if (typeof site === "string" && site !== "" && site !== "same-origin" && site !== "none") {
|
|
41
|
+
return jsonError(403, "Cross-origin request refused.");
|
|
42
|
+
}
|
|
43
|
+
const origin = headers.origin;
|
|
44
|
+
if (origin === undefined) return undefined;
|
|
45
|
+
if (typeof origin !== "string" || origin === "") return jsonError(403, "Unexpected Origin header.");
|
|
46
|
+
let parsed: URL;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(origin);
|
|
49
|
+
} catch {
|
|
50
|
+
return jsonError(403, "Unexpected Origin header.");
|
|
51
|
+
}
|
|
52
|
+
const host = headers.host;
|
|
53
|
+
if (typeof host !== "string" || hostHeaderName(parsed.host) !== hostHeaderName(host)) {
|
|
54
|
+
return jsonError(403, "Cross-origin request refused.");
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The 415 for a state-changing body that is not JSON, or nothing when it is.
|
|
61
|
+
*
|
|
62
|
+
* A server that only reads JSON refuses anything else early: an HTML form post
|
|
63
|
+
* and a `text/plain` body cannot reach the parser, so the request cannot be
|
|
64
|
+
* mounted cross-origin by a form or a `<script>` tag.
|
|
65
|
+
*/
|
|
66
|
+
export function requireJsonBody(contentType: string | string[] | undefined): ServeResponse | undefined {
|
|
67
|
+
const value = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
68
|
+
if (typeof value === "string" && /^application\/json\s*(;|$)/i.test(value.trim())) return undefined;
|
|
69
|
+
return jsonError(415, "The request body must be JSON.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a server answers, and to which host names. */
|
|
73
|
+
export interface EnvelopeOptions {
|
|
74
|
+
/** The raw request method. */
|
|
75
|
+
method: string;
|
|
76
|
+
/** The raw request headers. */
|
|
77
|
+
headers: Record<string, string | string[] | undefined>;
|
|
78
|
+
/** The host names this server answers to now. */
|
|
79
|
+
hosts: readonly string[];
|
|
80
|
+
/** The methods it serves; `PAGE_METHODS` unless a route set needs more. */
|
|
81
|
+
allowed?: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The 405 or 403 a request deserves before any route runs, or nothing when the
|
|
86
|
+
* envelope is acceptable.
|
|
87
|
+
*
|
|
88
|
+
* `Host` fails closed: a missing or non-string header is refused too, not just a
|
|
89
|
+
* foreign one, so the DNS-rebinding guard cannot be skipped by omitting it. Every
|
|
90
|
+
* method that is not a read is also checked for a foreign origin.
|
|
91
|
+
*/
|
|
92
|
+
export function envelopeRefusal(options: EnvelopeOptions): ServeResponse | undefined {
|
|
93
|
+
const allowed = options.allowed ?? PAGE_METHODS;
|
|
94
|
+
const upper = options.method.toUpperCase();
|
|
95
|
+
if (!allowed.includes(upper)) {
|
|
96
|
+
return {
|
|
97
|
+
...jsonError(405, `Only ${joinMethods(allowed)} are served.`),
|
|
98
|
+
headers: { ...jsonHeaders(), Allow: allowed.join(", ") },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (!isAllowedHostHeader(options.headers, { hosts: options.hosts, required: true })) {
|
|
102
|
+
return jsonError(403, "Unexpected Host header.");
|
|
103
|
+
}
|
|
104
|
+
const reads = upper === "GET" || upper === "HEAD";
|
|
105
|
+
return reads ? undefined : checkOrigin(options.headers);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `GET, HEAD, and POST` — the list as a sentence a person reads. */
|
|
109
|
+
function joinMethods(methods: readonly string[]): string {
|
|
110
|
+
if (methods.length <= 1) return methods.join("");
|
|
111
|
+
return `${methods.slice(0, -1).join(", ")}, and ${methods.at(-1)}`;
|
|
112
|
+
}
|
package/lib/http/index.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The loopback HTTP layer shared by `file-browse` and `
|
|
3
|
-
* shape, the header policy, the request-envelope helpers
|
|
2
|
+
* The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: the
|
|
3
|
+
* response shape, the header policy, the request-envelope helpers and the
|
|
4
|
+
* guards built on them, the one server entry, and the socket seam behind it.
|
|
5
|
+
*
|
|
6
|
+
* Import from here rather than a leaf module, so the layer reads as one kit. An
|
|
7
|
+
* extension imports {@link serveLoopback} and never `./server.ts`: a hand-built
|
|
8
|
+
* `node:http` server beside this one is how the close path and the idle grace
|
|
9
|
+
* start to differ again. The same rule covers the guards: `plan` and `board` both
|
|
10
|
+
* serve a page that can write, so the `Host`, origin, and JSON rules are
|
|
11
|
+
* {@link envelopeRefusal} and {@link requireJsonBody} rather than two copies, and
|
|
12
|
+
* the credential a page carries is {@link mintToken} / {@link tokenMatches}.
|
|
4
13
|
*
|
|
5
|
-
* Import from here rather than a leaf module, so the layer reads as one kit.
|
|
6
14
|
* Value-only, like the rest of `lib/`: the only state is what a returned closure
|
|
7
|
-
* carries (`createCloser`).
|
|
15
|
+
* carries (`createCloser`, `serveLoopback`).
|
|
8
16
|
*/
|
|
9
17
|
|
|
18
|
+
export * from "./guard.ts";
|
|
10
19
|
export * from "./headers.ts";
|
|
11
20
|
export * from "./response.ts";
|
|
12
21
|
export * from "./router.ts";
|
|
13
|
-
export * from "./
|
|
22
|
+
export * from "./serve.ts";
|
|
23
|
+
export * from "./sink.ts";
|
|
24
|
+
export * from "./token.ts";
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One loopback server per served surface: bind, accept, route, stream, idle,
|
|
3
|
+
* close.
|
|
4
|
+
*
|
|
5
|
+
* Both browser surfaces build the same socket around `lib/http`'s pieces, so the
|
|
6
|
+
* socket lives here: an extension supplies a handler that turns one request into
|
|
7
|
+
* one reply, and this module owns everything the request does not decide — the
|
|
8
|
+
* body cap, `HEAD` of a stream, the event-stream head, the port retry, the idle
|
|
9
|
+
* grace, and a close that is safe to call twice.
|
|
10
|
+
*
|
|
11
|
+
* The `Host` rule stays with each router: the allowlist is per extension, and for
|
|
12
|
+
* a shared review it grows while the server is up, so it cannot be passed in
|
|
13
|
+
* once.
|
|
14
|
+
*
|
|
15
|
+
* Value-only: the only state is what a returned closure carries.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createServer,
|
|
20
|
+
type IncomingHttpHeaders,
|
|
21
|
+
type IncomingMessage,
|
|
22
|
+
type Server,
|
|
23
|
+
type ServerResponse,
|
|
24
|
+
} from "node:http";
|
|
25
|
+
import type { ArmTimer } from "../timer.ts";
|
|
26
|
+
import { jsonHeaders } from "./headers.ts";
|
|
27
|
+
import { type ServeResponse, textResponse } from "./response.ts";
|
|
28
|
+
import { bindHost, createCloser, openEventStream, respond } from "./server.ts";
|
|
29
|
+
import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "./sink.ts";
|
|
30
|
+
|
|
31
|
+
/** One request as a route sees it: the envelope, and a body already capped. */
|
|
32
|
+
export interface LoopbackRequest {
|
|
33
|
+
/** Upper-cased method, e.g. `GET`. */
|
|
34
|
+
method: string;
|
|
35
|
+
/** The request target, path and query. */
|
|
36
|
+
url: string;
|
|
37
|
+
/** Headers exactly as the socket carried them; the router applies its `Host` rule. */
|
|
38
|
+
headers: IncomingHttpHeaders;
|
|
39
|
+
/** The body, read up to the cap; `""` for every method when no cap was set. */
|
|
40
|
+
body: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a route answers with. */
|
|
44
|
+
export interface LoopbackReply {
|
|
45
|
+
/** The response to write. A `stream` one needs `openSink` to go with it. */
|
|
46
|
+
result: ServeResponse;
|
|
47
|
+
/**
|
|
48
|
+
* Runs once, on whichever comes first of the response reaching the wire and the
|
|
49
|
+
* socket closing — the point a route may settle a session without a client
|
|
50
|
+
* seeing a dropped connection.
|
|
51
|
+
*/
|
|
52
|
+
afterResponse?: () => void;
|
|
53
|
+
/** Open the reply as an event stream; the returned function detaches it. */
|
|
54
|
+
openSink?: (sink: EventSink) => () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One request in, one reply out. A throw is answered as a plain 500. */
|
|
58
|
+
export type LoopbackHandler = (request: LoopbackRequest) => Promise<LoopbackReply> | LoopbackReply;
|
|
59
|
+
|
|
60
|
+
export interface LoopbackOptions {
|
|
61
|
+
/** The address to bind; defaults to `127.0.0.1`. */
|
|
62
|
+
host?: string;
|
|
63
|
+
/** Ports tried in order, so a remembered port can fall back to an OS-chosen one. */
|
|
64
|
+
ports: readonly number[];
|
|
65
|
+
handler: LoopbackHandler;
|
|
66
|
+
/** Largest body accepted; absent, no body is read and a route sees `""`. */
|
|
67
|
+
maxBodyBytes?: number;
|
|
68
|
+
/** Grace after the last event stream leaves; omitted, the server waits for `close()`. */
|
|
69
|
+
idle?: IdleGrace;
|
|
70
|
+
/** The timer the idle grace is armed on. */
|
|
71
|
+
timer?: ArmTimer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A bound loopback server. */
|
|
75
|
+
export interface LoopbackServer {
|
|
76
|
+
/** The port it actually bound. */
|
|
77
|
+
port: number;
|
|
78
|
+
/** `http://<host>:<port>`, the address the caller prints. */
|
|
79
|
+
origin: string;
|
|
80
|
+
/**
|
|
81
|
+
* End every open stream, stop the idle grace, and close the socket. Idempotent.
|
|
82
|
+
*
|
|
83
|
+
* A last frame is the caller's to send before it calls this: closing is not a
|
|
84
|
+
* conclusion, and only the route knows what its readers were waiting for.
|
|
85
|
+
*/
|
|
86
|
+
close(): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The one refusal this layer answers itself: a body it will not read. */
|
|
90
|
+
const BODY_TOO_LARGE: ServeResponse = {
|
|
91
|
+
status: 413,
|
|
92
|
+
headers: { ...jsonHeaders(), Connection: "close" },
|
|
93
|
+
body: JSON.stringify({ error: "The request body is too large." }),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** A reply that asked to stream but offered no sink cannot be served as events. */
|
|
97
|
+
const STREAM_WITHOUT_SINK: ServeResponse = textResponse(500, "Internal error");
|
|
98
|
+
|
|
99
|
+
/** A handler that threw, or a socket that failed after its head was written. */
|
|
100
|
+
const INTERNAL_ERROR: ServeResponse = textResponse(500, "Internal error");
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bind the first port that answers, falling back in order. Rejects with the last
|
|
104
|
+
* bind error, so a caller can still name the reason (`EADDRINUSE` and friends).
|
|
105
|
+
*/
|
|
106
|
+
async function bindFirst(server: Server, host: string, ports: readonly number[]): Promise<number> {
|
|
107
|
+
let failure: unknown;
|
|
108
|
+
for (const port of ports.length === 0 ? [0] : ports) {
|
|
109
|
+
try {
|
|
110
|
+
return await bindHost(server, host, port);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
failure = error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw failure instanceof Error ? failure : new Error(`could not bind ${host}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read a request body, stopping at `limit`. An oversize body is reported rather
|
|
120
|
+
* than destroyed, so it can still be answered 413 over the open socket instead of
|
|
121
|
+
* the client seeing a dropped connection.
|
|
122
|
+
*/
|
|
123
|
+
function readBody(request: IncomingMessage, limit: number): Promise<string | undefined> {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const chunks: Buffer[] = [];
|
|
126
|
+
let size = 0;
|
|
127
|
+
let done = false;
|
|
128
|
+
const settle = (body: string | undefined): void => {
|
|
129
|
+
if (done) return;
|
|
130
|
+
done = true;
|
|
131
|
+
resolve(body);
|
|
132
|
+
};
|
|
133
|
+
request.on("data", (chunk: Buffer) => {
|
|
134
|
+
if (done) return;
|
|
135
|
+
size += chunk.length;
|
|
136
|
+
if (size > limit) {
|
|
137
|
+
chunks.length = 0;
|
|
138
|
+
request.pause();
|
|
139
|
+
settle(undefined);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
chunks.push(chunk);
|
|
143
|
+
});
|
|
144
|
+
request.on("end", () => settle(Buffer.concat(chunks).toString("utf-8")));
|
|
145
|
+
request.on("error", () => settle(undefined));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A function that runs at most once; `undefined` stays `undefined`. */
|
|
150
|
+
function once(fn: (() => void) | undefined): (() => void) | undefined {
|
|
151
|
+
if (fn === undefined) return undefined;
|
|
152
|
+
let done = false;
|
|
153
|
+
return () => {
|
|
154
|
+
if (done) return;
|
|
155
|
+
done = true;
|
|
156
|
+
fn();
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Route one request: read, dispatch, then answer, stream, or refuse. */
|
|
161
|
+
async function answer(
|
|
162
|
+
request: IncomingMessage,
|
|
163
|
+
response: ServerResponse,
|
|
164
|
+
options: LoopbackOptions,
|
|
165
|
+
sinks: SinkRegistry,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const method = request.method ?? "GET";
|
|
168
|
+
const body = options.maxBodyBytes === undefined ? "" : await readBody(request, options.maxBodyBytes);
|
|
169
|
+
if (body === undefined) {
|
|
170
|
+
respond(request, response, BODY_TOO_LARGE);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let reply: LoopbackReply;
|
|
174
|
+
try {
|
|
175
|
+
reply = await options.handler({ method, url: request.url ?? "/", headers: request.headers, body });
|
|
176
|
+
} catch {
|
|
177
|
+
reply = { result: INTERNAL_ERROR };
|
|
178
|
+
}
|
|
179
|
+
const settle = once(reply.afterResponse);
|
|
180
|
+
if (reply.result.stream === true && reply.openSink === undefined) {
|
|
181
|
+
respond(request, response, STREAM_WITHOUT_SINK, settle);
|
|
182
|
+
response.on("close", () => settle?.());
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// A `HEAD` of a stream is a plain response: there is no body to hold open, and
|
|
186
|
+
// the client would otherwise wait on frames it asked not to get.
|
|
187
|
+
if (reply.openSink !== undefined && method.toUpperCase() !== "HEAD") {
|
|
188
|
+
openEventStream(response, reply.result);
|
|
189
|
+
const sink = sinks.open(response);
|
|
190
|
+
const detach = reply.openSink(sink);
|
|
191
|
+
let closed = false;
|
|
192
|
+
const finish = (): void => {
|
|
193
|
+
if (closed) return;
|
|
194
|
+
closed = true;
|
|
195
|
+
detach();
|
|
196
|
+
sink.end();
|
|
197
|
+
settle?.();
|
|
198
|
+
};
|
|
199
|
+
request.on("close", finish);
|
|
200
|
+
response.on("close", finish);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
respond(request, response, reply.result, settle);
|
|
204
|
+
response.on("close", () => settle?.());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Start a server on the first port that binds, and close it idempotently. */
|
|
208
|
+
export async function serveLoopback(options: LoopbackOptions): Promise<LoopbackServer> {
|
|
209
|
+
const host = options.host ?? "127.0.0.1";
|
|
210
|
+
const sinks = createSinks({ idle: options.idle, timer: options.timer });
|
|
211
|
+
const server = createServer((request, response) => {
|
|
212
|
+
// A page that walks away mid-frame must not take the session down.
|
|
213
|
+
response.on("error", () => {});
|
|
214
|
+
void answer(request, response, options, sinks).catch(() => {
|
|
215
|
+
// A route that failed after its head was written can only be cut off; ending
|
|
216
|
+
// the socket beats a client waiting on a reply that will not come.
|
|
217
|
+
if (!response.writableEnded) response.end();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
const port = await bindFirst(server, host, options.ports);
|
|
221
|
+
// The socket is bound but no page may ever read it: a server that asked for a first
|
|
222
|
+
// grace arms it here, so a browser that never came up cannot hold the port all session.
|
|
223
|
+
sinks.start();
|
|
224
|
+
const closeServer = createCloser(server);
|
|
225
|
+
let closed = false;
|
|
226
|
+
return {
|
|
227
|
+
port,
|
|
228
|
+
origin: `http://${host}:${port}`,
|
|
229
|
+
close: async () => {
|
|
230
|
+
if (closed) return;
|
|
231
|
+
closed = true;
|
|
232
|
+
// Streams end first: a connected page must not hold the close open.
|
|
233
|
+
sinks.endAll();
|
|
234
|
+
await closeServer();
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|