@gavin-hu/my-pi-agent 0.18.0 → 0.19.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- 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 +554 -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} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- 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 +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +226 -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} +258 -162
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -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 +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- 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/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- 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 -15
- 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/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- 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/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- 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} +13 -13
- 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 +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- 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 +76 -92
- 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/{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/guard.ts +3 -5
- package/lib/README.md +8 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -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/file-browse/README.md +0 -506
- package/extensions/file-browse/server.ts +0 -141
- 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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board store: one directory of card files per repository, and the only code
|
|
3
|
+
* that writes one.
|
|
4
|
+
*
|
|
5
|
+
* A board is a directory — `<agentDir>/board/<repo-key>/cards/0004.json` — so a
|
|
6
|
+
* reader needs no index, no daemon, and no dependency; the listing *is* the
|
|
7
|
+
* index. The key comes from `lib/repo-key.ts`, so every worktree of one repository
|
|
8
|
+
* shares one board, and a directory outside git keys on itself.
|
|
9
|
+
*
|
|
10
|
+
* Two writes race safely without a lock. **Create** hard-links a temporary file
|
|
11
|
+
* onto the target name, which fails when the name is taken, so two adds cannot
|
|
12
|
+
* share an id and the loser simply retries with the next one. **Update** re-reads,
|
|
13
|
+
* re-checks the operation's preconditions, and renames over the old file, so a
|
|
14
|
+
* card that changed underneath is re-evaluated rather than overwritten: an
|
|
15
|
+
* operation says "apply when this holds", never "force this state".
|
|
16
|
+
*
|
|
17
|
+
* A write reads the whole board once, because `rules.ts` needs another card to answer
|
|
18
|
+
* three of its questions: is this dependency finished, would this link close a loop,
|
|
19
|
+
* and does anything wait on the card being dropped. The listing is the index here —
|
|
20
|
+
* a board is tens of files, so the read is cheaper than the second source of truth a
|
|
21
|
+
* cached index would be.
|
|
22
|
+
*
|
|
23
|
+
* Nothing prunes. A `done` card is history, the page shows the store's size, and
|
|
24
|
+
* `drop` is the only deletion.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { resolveEffectiveCwd } from "../env.ts";
|
|
31
|
+
import { hasErrorCode, messageOf } from "../errors.ts";
|
|
32
|
+
import { shortenPath } from "../format.ts";
|
|
33
|
+
import { repoKeyFor } from "../repo-key.ts";
|
|
34
|
+
import { type Card, CARD_STATUSES, cardRef, isCard, type Priority, priorityRank } from "./card.ts";
|
|
35
|
+
import { indexOf } from "./links.ts";
|
|
36
|
+
import { type Actor, applyOp, type CardOp, createCard } from "./rules.ts";
|
|
37
|
+
|
|
38
|
+
/** The directory under the agent directory that holds every board. */
|
|
39
|
+
export const BOARD_DIR_NAME = "board";
|
|
40
|
+
|
|
41
|
+
/** The subdirectory one board's cards live in. */
|
|
42
|
+
export const CARDS_DIR_NAME = "cards";
|
|
43
|
+
|
|
44
|
+
/** Card files are the user's work; only their owner reads them. */
|
|
45
|
+
const FILE_MODE = 0o600;
|
|
46
|
+
|
|
47
|
+
/** A board directory is one session's private state. */
|
|
48
|
+
const DIR_MODE = 0o700;
|
|
49
|
+
|
|
50
|
+
/** An in-flight temp file older than this was left by a process that died. */
|
|
51
|
+
const TEMP_STALE_MS = 60 * 60 * 1_000;
|
|
52
|
+
|
|
53
|
+
/** How many times an id collision or a changed card is retried before giving up. */
|
|
54
|
+
const ATTEMPTS = 4;
|
|
55
|
+
|
|
56
|
+
/** A card to add. The fields are already normalized by `card.ts`. */
|
|
57
|
+
export interface CreateInput {
|
|
58
|
+
title: string;
|
|
59
|
+
paths: string[];
|
|
60
|
+
note?: string;
|
|
61
|
+
/** Absent is `normal`, so an add that says nothing about urgency says the ordinary thing. */
|
|
62
|
+
priority?: Priority;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a mutation did, or why it was refused. */
|
|
66
|
+
export type MutateResult =
|
|
67
|
+
| { ok: true; card: Card; changed: boolean; dropped: boolean }
|
|
68
|
+
| { ok: false; error: string; card?: Card };
|
|
69
|
+
|
|
70
|
+
/** One repository's board, as the runtime, the router, and the tool use it. */
|
|
71
|
+
export interface BoardStore {
|
|
72
|
+
/** The board's own directory, for `where` and for a refusal that names it. */
|
|
73
|
+
readonly dir: string;
|
|
74
|
+
/** Create the directories and sweep a temp file a dead process left. */
|
|
75
|
+
ensure(): Promise<void>;
|
|
76
|
+
/** Every card, in lane order: todo, doing, blocked, done. */
|
|
77
|
+
list(): Promise<Card[]>;
|
|
78
|
+
/** One card by its unpadded id, or undefined when it is not there. */
|
|
79
|
+
read(id: string): Promise<Card | undefined>;
|
|
80
|
+
/** Add a card, allocating the next id. */
|
|
81
|
+
create(input: CreateInput, actor: Actor, now: number): Promise<Card>;
|
|
82
|
+
/** Apply one operation, re-checking it if the card changed while it was read. */
|
|
83
|
+
mutate(id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult>;
|
|
84
|
+
/** Bytes the store occupies on disk. */
|
|
85
|
+
bytes(): Promise<number>;
|
|
86
|
+
/** A short, user-facing name for the directory. */
|
|
87
|
+
where(): string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where one repository's board lives. */
|
|
91
|
+
export function boardDirFor(agentDir: string, key: string): string {
|
|
92
|
+
return join(agentDir, BOARD_DIR_NAME, key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The board for `cwd`'s repository. The one construction point. */
|
|
96
|
+
export async function openBoard(
|
|
97
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
98
|
+
cwd: string,
|
|
99
|
+
options: { agentDir?: string } = {},
|
|
100
|
+
): Promise<BoardStore> {
|
|
101
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
102
|
+
// A tool receives the session's cwd, not the worktree the user entered; the
|
|
103
|
+
// environment carries the active root, so the two are reconciled before the
|
|
104
|
+
// repository lookup — the same path `plan` and `session-bus` take.
|
|
105
|
+
const repo = await repoKeyFor(pi, resolveEffectiveCwd(cwd));
|
|
106
|
+
return createStore(boardDirFor(agentDir, repo.key));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build a store over one directory. Exported for tests and for a second backend. */
|
|
110
|
+
export function createStore(dir: string): BoardStore {
|
|
111
|
+
const cardsDir = join(dir, CARDS_DIR_NAME);
|
|
112
|
+
|
|
113
|
+
const ensure = async (): Promise<void> => {
|
|
114
|
+
await mkdir(cardsDir, { recursive: true, mode: DIR_MODE });
|
|
115
|
+
await sweepTemps(cardsDir);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const list = async (): Promise<Card[]> => {
|
|
119
|
+
const entries = await readdirSafe(cardsDir);
|
|
120
|
+
const cards: Card[] = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const card = await readCardFile(cardsDir, entry);
|
|
123
|
+
if (card) cards.push(card);
|
|
124
|
+
}
|
|
125
|
+
return sortForLane(cards);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const read = async (id: string): Promise<Card | undefined> => {
|
|
129
|
+
return readCardFile(cardsDir, fileNameFor(id));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const create = async (input: CreateInput, actor: Actor, now: number): Promise<Card> => {
|
|
133
|
+
await ensure();
|
|
134
|
+
let lastError: unknown;
|
|
135
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
136
|
+
const id = await nextCardId(cardsDir);
|
|
137
|
+
const card = createCard(id, input, actor, now);
|
|
138
|
+
const temp = tempPath(cardsDir);
|
|
139
|
+
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
140
|
+
try {
|
|
141
|
+
// The exclusive create: two adds cannot produce one card, and the
|
|
142
|
+
// loser retries with the id it just learned was taken.
|
|
143
|
+
await link(temp, join(cardsDir, fileNameFor(id)));
|
|
144
|
+
return card;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
lastError = error;
|
|
147
|
+
if (!hasErrorCode(error, "EEXIST")) throw error;
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(temp, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`The board could not allocate a card id (${messageOf(lastError)}).`);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const mutate = async (id: string, op: CardOp, actor: Actor, now: number): Promise<MutateResult> => {
|
|
156
|
+
let changedUnderneath = false;
|
|
157
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
158
|
+
const card = await read(id);
|
|
159
|
+
if (!card) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
error: changedUnderneath ? `${cardRef(id)} was dropped.` : `${cardRef(id)} does not exist.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// The preconditions are checked against the card as it is *now*, and against the
|
|
166
|
+
// board as it is now, so an operation re-applied to a card that changed is refused
|
|
167
|
+
// rather than forced.
|
|
168
|
+
const result = applyOp(card, op, actor, now, indexOf(await list()));
|
|
169
|
+
if (!result.ok) return { ok: false, error: result.error, card };
|
|
170
|
+
if (!result.changed) return { ok: true, card: result.card, changed: false, dropped: false };
|
|
171
|
+
if (op.kind === "drop") {
|
|
172
|
+
await rm(join(cardsDir, fileNameFor(id)), { force: true });
|
|
173
|
+
return { ok: true, card: result.card, changed: true, dropped: true };
|
|
174
|
+
}
|
|
175
|
+
// Last look before the rename: a writer that landed in between is the
|
|
176
|
+
// re-read the next pass applies against.
|
|
177
|
+
const current = await read(id);
|
|
178
|
+
if (!current || current.rev !== card.rev) {
|
|
179
|
+
changedUnderneath = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await writeCardFile(cardsDir, id, result.card);
|
|
183
|
+
return { ok: true, card: result.card, changed: true, dropped: false };
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: `${cardRef(id)} kept changing while it was written; read it again.`,
|
|
188
|
+
card: await read(id),
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const bytes = async (): Promise<number> => {
|
|
193
|
+
const entries = await readdirSafe(cardsDir);
|
|
194
|
+
let total = 0;
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (!CARD_FILE.test(entry)) continue;
|
|
197
|
+
const info = await stat(join(cardsDir, entry)).catch(() => undefined);
|
|
198
|
+
if (info) total += info.size;
|
|
199
|
+
}
|
|
200
|
+
return total;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return { dir, ensure, list, read, create, mutate, bytes, where: () => shortenPath(dir) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `<id>.json`, zero-padded to four digits so a directory lists in card order. */
|
|
207
|
+
function fileNameFor(id: string): string {
|
|
208
|
+
return `${id.padStart(4, "0")}.json`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The card files the reader accepts. */
|
|
212
|
+
const CARD_FILE = /^(\d{4,})\.json$/;
|
|
213
|
+
|
|
214
|
+
/** One card file, or undefined when it is not a card this build trusts. */
|
|
215
|
+
async function readCardFile(cardsDir: string, entry: string): Promise<Card | undefined> {
|
|
216
|
+
const match = CARD_FILE.exec(entry);
|
|
217
|
+
if (!match) return undefined;
|
|
218
|
+
const text = await readFile(join(cardsDir, entry), "utf-8").catch(() => undefined);
|
|
219
|
+
if (text === undefined) return undefined;
|
|
220
|
+
let parsed: unknown;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(text);
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!isCard(parsed)) return undefined;
|
|
227
|
+
// The file name is the id's canonical spelling: a padded id inside the record
|
|
228
|
+
// (or a record moved under another name) is a disagreement, not a card.
|
|
229
|
+
if (parsed.id !== String(Number(match[1]))) return undefined;
|
|
230
|
+
return parsed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `1`, `2`, `3` … as the highest existing id plus one. */
|
|
234
|
+
async function nextCardId(cardsDir: string): Promise<string> {
|
|
235
|
+
const entries = await readdirSafe(cardsDir);
|
|
236
|
+
let highest = 0;
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
const match = CARD_FILE.exec(entry);
|
|
239
|
+
if (match) highest = Math.max(highest, Number(match[1]));
|
|
240
|
+
}
|
|
241
|
+
return String(highest + 1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Write one card: a temp file in the same directory, then a rename over the old one. */
|
|
245
|
+
async function writeCardFile(cardsDir: string, id: string, card: Card): Promise<void> {
|
|
246
|
+
const temp = tempPath(cardsDir);
|
|
247
|
+
await writeFile(temp, serialize(card), { mode: FILE_MODE });
|
|
248
|
+
const target = join(cardsDir, fileNameFor(id));
|
|
249
|
+
try {
|
|
250
|
+
await rename(temp, target);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// Windows refuses a rename onto an open file; removing first is the fallback
|
|
253
|
+
// the platform needs, and the read above still bounds what can be lost.
|
|
254
|
+
if (!hasErrorCode(error, "EPERM") && !hasErrorCode(error, "EACCES") && !hasErrorCode(error, "EEXIST")) {
|
|
255
|
+
await rm(temp, { force: true });
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
await rm(target, { force: true });
|
|
259
|
+
await rename(temp, target);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One card's bytes, ending in a newline so the file reads well in an editor. */
|
|
264
|
+
function serialize(card: Card): string {
|
|
265
|
+
return `${JSON.stringify(card, null, "\t")}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A temp path in the cards directory: never matched by the reader's own pattern. */
|
|
269
|
+
function tempPath(cardsDir: string): string {
|
|
270
|
+
return join(cardsDir, `.tmp-${process.pid}-${Math.random().toString(16).slice(2, 10)}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Remove a temp file a process that died mid-write left behind. */
|
|
274
|
+
async function sweepTemps(cardsDir: string): Promise<void> {
|
|
275
|
+
const entries = await readdirSafe(cardsDir);
|
|
276
|
+
const cutoff = Date.now() - TEMP_STALE_MS;
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
if (!entry.startsWith(".tmp-")) continue;
|
|
279
|
+
const path = join(cardsDir, entry);
|
|
280
|
+
const info = await stat(path).catch(() => undefined);
|
|
281
|
+
if (info && info.mtimeMs < cutoff) await unlink(path).catch(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function readdirSafe(dir: string): Promise<string[]> {
|
|
286
|
+
return readdir(dir).catch((error: unknown) => {
|
|
287
|
+
if (hasErrorCode(error, "ENOENT")) return [];
|
|
288
|
+
throw error;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Lane order, then the lead's order inside it, then oldest first, with the id as the
|
|
294
|
+
* tie-break.
|
|
295
|
+
*
|
|
296
|
+
* Priority sorts inside a lane rather than beside it: a `high` card in Done is still
|
|
297
|
+
* history, and a lane that mixed the two would have to say so in its head.
|
|
298
|
+
*/
|
|
299
|
+
function sortForLane(cards: Card[]): Card[] {
|
|
300
|
+
const lane = (card: Card): number => CARD_STATUSES.indexOf(card.status);
|
|
301
|
+
return cards.sort((a, b) => {
|
|
302
|
+
const byLane = lane(a) - lane(b);
|
|
303
|
+
if (byLane !== 0) return byLane;
|
|
304
|
+
const byPriority = priorityRank(a) - priorityRank(b);
|
|
305
|
+
if (byPriority !== 0) return byPriority;
|
|
306
|
+
const byAge = a.updatedAt - b.updatedAt;
|
|
307
|
+
if (byAge !== 0) return byAge;
|
|
308
|
+
return Number(a.id) - Number(b.id);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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, rosterStateOf } from "../session-roster.ts";
|
|
17
|
+
import { type Card, CARD_STATUSES, type CardStatus, signatureOf } from "./card.ts";
|
|
18
|
+
import type { Actor } from "./rules.ts";
|
|
19
|
+
import { buildRound, type RoundSummary } from "./round.ts";
|
|
20
|
+
|
|
21
|
+
/** One session on the repository's bus, as the Team tab lists it. */
|
|
22
|
+
export interface ViewTeammate {
|
|
23
|
+
label: string;
|
|
24
|
+
/** `live` while its heartbeat holds, `quiet` once the record has aged out. */
|
|
25
|
+
state: "live" | "quiet";
|
|
26
|
+
/** When it last proved it was there. */
|
|
27
|
+
seen: number;
|
|
28
|
+
/** Messages waiting in its inbox. */
|
|
29
|
+
mail: number;
|
|
30
|
+
/** Whether it is listening for mail; absent means its record does not say so. */
|
|
31
|
+
listening?: boolean;
|
|
32
|
+
/** Whether this is the session the page writes as. */
|
|
33
|
+
you: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One lane and the cards in it, in the order the page draws them. */
|
|
37
|
+
export interface ViewLane {
|
|
38
|
+
status: CardStatus;
|
|
39
|
+
cards: Card[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The per-lane tallies the toolbar shows. */
|
|
43
|
+
export interface ViewCounts {
|
|
44
|
+
total: number;
|
|
45
|
+
todo: number;
|
|
46
|
+
doing: number;
|
|
47
|
+
blocked: number;
|
|
48
|
+
done: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Everything the page is allowed to know. */
|
|
52
|
+
export interface BoardView {
|
|
53
|
+
/** The session a page write is made as — the page's seat, never the browser. */
|
|
54
|
+
me: { id?: string; label: string };
|
|
55
|
+
/** The session the page is served from, when the page knows it: who is reading. */
|
|
56
|
+
session?: { id?: string; label: string };
|
|
57
|
+
lanes: ViewLane[];
|
|
58
|
+
counts: ViewCounts;
|
|
59
|
+
round: RoundSummary;
|
|
60
|
+
/**
|
|
61
|
+
* The sessions on this repository's bus, or absent when there is no bus here.
|
|
62
|
+
*
|
|
63
|
+
* Absent is not empty: a page with no roster cannot say who is around, while an
|
|
64
|
+
* empty one can say nobody is — and the tab draws those two answers differently.
|
|
65
|
+
*/
|
|
66
|
+
teammates?: ViewTeammate[];
|
|
67
|
+
/** Bytes the store occupies, so a board that is never pruned says so. */
|
|
68
|
+
bytes: number;
|
|
69
|
+
/** Changes when a card's revision, a teammate's state, or its mail does. */
|
|
70
|
+
sig: string;
|
|
71
|
+
/** The store's directory, shortened. */
|
|
72
|
+
where: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What the server read before building one snapshot. */
|
|
76
|
+
export interface ViewInput {
|
|
77
|
+
me: Actor;
|
|
78
|
+
/**
|
|
79
|
+
* The session the page is served from, when it has one.
|
|
80
|
+
*
|
|
81
|
+
* The page writes as its seat — a fixed name — so the reader's own row on the Team
|
|
82
|
+
* tab is marked from the session, not from `me`: the seat is what a card records,
|
|
83
|
+
* and the session is who is looking.
|
|
84
|
+
*/
|
|
85
|
+
session?: Actor;
|
|
86
|
+
cards: readonly Card[];
|
|
87
|
+
now: number;
|
|
88
|
+
bytes: number;
|
|
89
|
+
where: string;
|
|
90
|
+
/** The bus's records, when the page could read them at all. */
|
|
91
|
+
roster?: readonly RosterSession[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build the page's snapshot. */
|
|
95
|
+
export function buildView(input: ViewInput): BoardView {
|
|
96
|
+
const lanes = CARD_STATUSES.map((status) => ({
|
|
97
|
+
status,
|
|
98
|
+
cards: input.cards.filter((card) => card.status === status),
|
|
99
|
+
}));
|
|
100
|
+
const reader = input.session ?? input.me;
|
|
101
|
+
const teammates = input.roster === undefined ? undefined : teammatesOf(input.roster, reader.label, input.now);
|
|
102
|
+
return {
|
|
103
|
+
me: input.me.id === undefined ? { label: input.me.label } : { id: input.me.id, label: input.me.label },
|
|
104
|
+
...(input.session === undefined ? {} : { session: input.session }),
|
|
105
|
+
lanes,
|
|
106
|
+
counts: countOf(lanes),
|
|
107
|
+
round: buildRound(input.cards, input.now),
|
|
108
|
+
...(teammates === undefined ? {} : { teammates }),
|
|
109
|
+
bytes: input.bytes,
|
|
110
|
+
// The roster enters the signature at its coarse facts only: a heartbeat every ten
|
|
111
|
+
// seconds is not news, while a join, a quiet and a letter are.
|
|
112
|
+
sig: signatureOf(input.cards) + rosterSignature(teammates),
|
|
113
|
+
where: input.where,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The roster as the page shows it, ordered so a repaint never reorders it. */
|
|
118
|
+
function teammatesOf(roster: readonly RosterSession[], meLabel: string, now: number): ViewTeammate[] {
|
|
119
|
+
return roster
|
|
120
|
+
.map((session) => ({
|
|
121
|
+
label: session.label,
|
|
122
|
+
state: rosterStateOf(session, now),
|
|
123
|
+
seen: session.lastSeen,
|
|
124
|
+
mail: session.mail,
|
|
125
|
+
// A record that does not say is not a promise either way, so the fact is left out
|
|
126
|
+
// rather than drawn as "not listening".
|
|
127
|
+
...(session.watching === undefined ? {} : { listening: session.watching.armed }),
|
|
128
|
+
you: session.label.toLowerCase() === meLabel.toLowerCase(),
|
|
129
|
+
}))
|
|
130
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What a frame must know about the roster; `seen` is left out on purpose. */
|
|
134
|
+
function rosterSignature(teammates: readonly ViewTeammate[] | undefined): string {
|
|
135
|
+
if (teammates === undefined) return "";
|
|
136
|
+
return (
|
|
137
|
+
"|" +
|
|
138
|
+
teammates.map((teammate) => `${teammate.label}:${teammate.state}:${teammate.mail}:${teammate.listening}`).join(",")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The tallies, summed from the lanes so the two can never disagree. */
|
|
143
|
+
function countOf(lanes: readonly ViewLane[]): ViewCounts {
|
|
144
|
+
const counts: ViewCounts = { total: 0, todo: 0, doing: 0, blocked: 0, done: 0 };
|
|
145
|
+
for (const lane of lanes) {
|
|
146
|
+
counts[lane.status] = lane.cards.length;
|
|
147
|
+
counts.total += lane.cards.length;
|
|
148
|
+
}
|
|
149
|
+
return counts;
|
|
150
|
+
}
|
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
|
@@ -166,3 +166,16 @@ export function shortenPath(path: string, home: string = homedir()): string {
|
|
|
166
166
|
if (normalized === root) return "~";
|
|
167
167
|
return normalized.startsWith(`${root}/`) ? `~${normalized.slice(root.length)}` : normalized;
|
|
168
168
|
}
|
|
169
|
+
|
|
170
|
+
/** A compact byte size for a listing or a report: `68 B`, `12.4 kB`, `1.5 MB`. */
|
|
171
|
+
export function formatSize(bytes: number): string {
|
|
172
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
173
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
174
|
+
let value = bytes;
|
|
175
|
+
let index = -1;
|
|
176
|
+
do {
|
|
177
|
+
value /= 1024;
|
|
178
|
+
index++;
|
|
179
|
+
} while (value >= 1024 && index < units.length - 1);
|
|
180
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[index]}`;
|
|
181
|
+
}
|
|
@@ -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";
|