From f897e8a6daa7649dfcd77ba46a700dc796821ffc Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Fri, 21 Aug 2026 09:46:45 +0100 Subject: [PATCH 1/3] =?UTF-8?q?build(dev-ports):=20485=20=E2=80=94=20deriv?= =?UTF-8?q?e=20every=20dev=20port=20from=20the=20branch=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 485 --- .gitignore | 3 + package.json | 3 +- scripts/__tests__/devPorts.test.mts | 65 +++++++++++++++++++++ scripts/devPorts.mts | 91 +++++++++++++++++++++++++++++ scripts/ports.mts | 39 +++++++++++++ tsconfig.base.json | 1 + tsconfig.json | 3 +- vitest.config.ts | 9 +++ 8 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/devPorts.test.mts create mode 100644 scripts/devPorts.mts create mode 100644 scripts/ports.mts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index c8cbd8c8..8ca2d3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,9 @@ typings/ # dotenv environment variables file .env +# This worktree's derived dev ports (npm run ports -- --write-env) +.env.ports + # Cypress test run Output # Editors / XDE diff --git a/package.json b/package.json index d7d90ece..29dd5b70 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "clean": "npm run clean --workspaces --if-present", "start": "npm run dev --workspace packages/docs", "dev": "npm run dev --workspace packages/docs", - "test": "npm run test --workspaces --if-present", + "test": "vitest run && npm run test --workspaces --if-present", "ci-test": "npm run ci-test --workspaces --if-present", "test:e2e:install": "npm --prefix e2e install && npx --prefix e2e playwright install --with-deps chromium", "test:e2e": "npm --prefix e2e run test", @@ -19,6 +19,7 @@ "test:smoke": "npm --prefix e2e/registry-smoke run test", "test:smoke:local": "npm run build --workspace packages/jarl-atoms --workspace packages/jarl-react && npm --prefix e2e/registry-smoke run test:cjs-nodenext", "ci-publish": "npm publish --workspace packages/jarl-atoms --workspace packages/jarl-react", + "ports": "node ./scripts/ports.mts", "notify": "node ./scripts/notify.ts", "lint": "oxlint .", "lint:fix": "oxlint . --fix", diff --git a/scripts/__tests__/devPorts.test.mts b/scripts/__tests__/devPorts.test.mts new file mode 100644 index 00000000..7e9b04a9 --- /dev/null +++ b/scripts/__tests__/devPorts.test.mts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { devPort, devPortBase, parseTaskId } from "../devPorts.mts"; + +describe("parseTaskId", () => { + it("reads the ticket id out of a task branch name", () => { + expect(parseTaskId("task-485-adopt-dev-servers-port-scheme")).toBe(485); + expect(parseTaskId("task-9-fix-typo")).toBe(9); + }); + + it("maps the default branch to the reserved standing pseudo-id", () => { + expect(parseTaskId("master")).toBe(-1); + }); + + it("falls back to the standing pseudo-id for anything unrecognised", () => { + expect(parseTaskId("beta")).toBe(-1); + expect(parseTaskId("some-stray-branch")).toBe(-1); + }); +}); + +describe("devPortBase", () => { + it("derives a ticket's block from the formula, wrapping every 1000 ids", () => { + expect(devPortBase(485)).toBe(14850); + expect(devPortBase(1485)).toBe(14850); + expect(devPortBase(0)).toBe(10000); + }); + + it("puts jarl's own master worktree on 9970-9979", () => { + expect(devPortBase(-1)).toBe(9970); + expect(devPortBase(parseTaskId("master")) + 9).toBe(9979); + }); + + it("leaves the second standing pseudo-id reserved below it", () => { + expect(devPortBase(-2)).toBe(9960); + }); + + it("moves the standing block with the project ordinal, never the ticket block", () => { + expect(devPortBase(-1, 1)).toBe(9990); + expect(devPortBase(-1, 3)).toBe(9950); + expect(devPortBase(485, 3)).toBe(14850); + }); +}); + +describe("devPort", () => { + it("adds the offset onto the derived base", () => { + expect(devPort(0, 485)).toBe(14850); + expect(devPort(6, 485)).toBe(14856); + }); + + it("throws for an offset outside a block's ten ports", () => { + expect(() => devPort(-1, 485)).toThrow(/offset/); + expect(() => devPort(10, 485)).toThrow(/offset/); + }); +}); + +describe("DEV_PORT_BASE", () => { + afterEach(() => { + delete process.env["DEV_PORT_BASE"]; + }); + + it("overrides the derivation entirely", () => { + process.env["DEV_PORT_BASE"] = "12340"; + expect(devPortBase(485)).toBe(12340); + expect(devPort(6, 485)).toBe(12346); + }); +}); diff --git a/scripts/devPorts.mts b/scripts/devPorts.mts new file mode 100644 index 00000000..639fdcf6 --- /dev/null +++ b/scripts/devPorts.mts @@ -0,0 +1,91 @@ +import { execFileSync } from "node:child_process"; + +/** + * Deterministic per-worktree dev server ports (TODOS `DEV-SERVERS.md`). + * + * Every worktree/branch is named `task--...` or `master`. Each owns a contiguous ten-port + * block, `10000 + 10 × (id mod 1000)` … `+ 9` — ticket 475 owns 14750–14759, the docs dev server + * on 14750 (`+0`). A service takes one offset inside its own worktree's block and nothing outside + * it; `devPort` throws for an offset outside 0–9. + * + * `master` is not a ticket, so it is allocated per project instead: the reserved pseudo-id `-1`, + * shifted by this project's row in `board/projects.md` before the same formula applies. jarl is + * row 2, so its `master` worktree owns 9970–9979. + */ +const PROJECT_ORDINAL = 2; + +/** The docs site's SSR dev server (`npm run dev`). */ +export const DOCS_DEV_PORT_OFFSET = 0; + +/** `vite preview` over the built docs site (`npm run docs:preview`). */ +export const DOCS_PREVIEW_PORT_OFFSET = 5; + +/** The Vite fixture app the Playwright suite drives (`e2e/fixture-app`). */ +export const E2E_FIXTURE_PORT_OFFSET = 6; + +/** + * The numeric id this worktree's block derives from: a ticket's own id for `task--...`, and + * `-1` for `master` or anything else unrecognised (a stray branch, a detached HEAD) — warning in + * the latter case, since it silently falls back to the standing `master` block. + */ +export function parseTaskId(branch: string): number { + const match = /^task-(\d+)-/.exec(branch); + if (match) return Number(match[1]); + if (branch === "master") return -1; + console.warn(`devPorts: unrecognised branch "${branch}" — falling back to this worktree's master block`); + return -1; +} + +/** + * The current git branch name for `cwd`, or null if it can't be determined (git missing, detached + * HEAD, not a git checkout, ...). `git` resolves the repo root by walking up from `cwd`, so this + * works no matter how deep inside a worktree it's called from. + */ +export function currentBranch(cwd: string = process.cwd()): string | null { + try { + const out = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out === "" || out === "HEAD" ? null : out; + } catch { + return null; + } +} + +/** This worktree's task id, derived from its current git branch name. */ +export function getTaskId(cwd: string = process.cwd()): number { + const branch = currentBranch(cwd); + if (branch === null) { + console.warn("devPorts: couldn't determine this worktree's git branch — falling back to the master block"); + return -1; + } + return parseTaskId(branch); +} + +/** + * The base port of the ten-port block a task id owns. The standing pseudo-id `master` uses + * (negative) is shifted by `ordinal` — a project's row in `board/projects.md` — first; ticket ids + * are globally unique and pass through unshifted. `ordinal` defaults to this project's own + * `PROJECT_ORDINAL`; it's a parameter so the shift itself is testable independently of jarl being + * row 2. + */ +export function devPortBase(taskId: number, ordinal: number = PROJECT_ORDINAL): number { + const override = process.env["DEV_PORT_BASE"]; + if (override !== undefined) return Number(override); + const effectiveId = taskId < 0 ? taskId - 2 * (ordinal - 1) : taskId; + return 10000 + 10 * (effectiveId % 1000); +} + +/** + * The deterministic dev port for a service given its offset within a worktree's block (e.g. + * `DOCS_DEV_PORT_OFFSET`) and a task id — pass one explicitly, or omit it to derive it from the + * current worktree's git branch via `getTaskId()`. + */ +export function devPort(offset: number, taskId: number = getTaskId()): number { + if (!Number.isInteger(offset) || offset < 0 || offset > 9) { + throw new Error(`Dev port offset ${offset} is outside a worktree's ten-port block (0-9).`); + } + return devPortBase(taskId) + offset; +} diff --git a/scripts/ports.mts b/scripts/ports.mts new file mode 100644 index 00000000..e6c17f96 --- /dev/null +++ b/scripts/ports.mts @@ -0,0 +1,39 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { + currentBranch, + devPortBase, + DOCS_DEV_PORT_OFFSET, + DOCS_PREVIEW_PORT_OFFSET, + E2E_FIXTURE_PORT_OFFSET, + getTaskId, +} from "./devPorts.mts"; + +const ENV_FILE = path.resolve(import.meta.dirname, "../.env.ports"); + +const branch = currentBranch() ?? "master"; +const base = devPortBase(getTaskId()); + +const OFFSETS = { + docs: DOCS_DEV_PORT_OFFSET, + "docs preview": DOCS_PREVIEW_PORT_OFFSET, + "e2e fixture": E2E_FIXTURE_PORT_OFFSET, +} as const; + +console.log(`${branch}: block ${base}–${base + 9}`); +for (const [service, offset] of Object.entries(OFFSETS)) { + console.log(` +${offset} ${service.padEnd(13)} ${base + offset}`); +} + +if (process.argv.includes("--write-env")) { + writeFileSync( + ENV_FILE, + [ + `PORT_DOCS=${base + DOCS_DEV_PORT_OFFSET}`, + `PORT_DOCS_PREVIEW=${base + DOCS_PREVIEW_PORT_OFFSET}`, + `PORT_E2E_FIXTURE=${base + E2E_FIXTURE_PORT_OFFSET}`, + "", + ].join("\n"), + ); + console.log(`Wrote ${ENV_FILE}`); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index fad956f6..c5e58274 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,6 +4,7 @@ "lib": ["ES2019", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "jsx": "react-jsx", "esModuleInterop": true, "allowSyntheticDefaultImports": true, diff --git a/tsconfig.json b/tsconfig.json index 030ab69e..0073b068 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "packages/jarl-react/src/**/*.tsx", "packages/docs/src/**/*.ts", "packages/docs/src/**/*.tsx", - "scripts/**/*.ts" + "scripts/**/*.ts", + "scripts/**/*.mts" ], "exclude": ["node_modules", "**/node_modules", "**/dist/**"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..37cc989d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The workspace packages run their own vitest configs (jsdom, their own aliases); this one + // covers only the repo-root tooling in scripts/. + include: ["scripts/**/__tests__/**/*.test.mts"], + }, +}); From cea00eff7a262887d948dcb108a3719382f639dd Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Fri, 21 Aug 2026 09:46:52 +0100 Subject: [PATCH 2/3] =?UTF-8?q?refactor(dev-ports):=20485=20=E2=80=94=20re?= =?UTF-8?q?ad=20every=20dev,=20preview=20and=20e2e=20server=20port=20from?= =?UTF-8?q?=20the=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 485 --- e2e/fixture-app/vite.config.ts | 7 +++++-- e2e/package.json | 2 +- e2e/playwright.config.ts | 7 +++++-- packages/docs/README.md | 8 ++++++-- packages/docs/package.json | 2 +- packages/docs/scripts/dev-server.mjs | 3 ++- packages/docs/src/prod-server.ts | 5 ++++- packages/docs/vite.config.ts | 7 +++++++ 8 files changed, 31 insertions(+), 10 deletions(-) diff --git a/e2e/fixture-app/vite.config.ts b/e2e/fixture-app/vite.config.ts index 72958d93..c80cfff2 100644 --- a/e2e/fixture-app/vite.config.ts +++ b/e2e/fixture-app/vite.config.ts @@ -3,9 +3,12 @@ import react from "@vitejs/plugin-react"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; +import { devPort, E2E_FIXTURE_PORT_OFFSET } from "../../scripts/devPorts.mts"; const dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePort = devPort(E2E_FIXTURE_PORT_OFFSET); + const atomsPackageJson = JSON.parse( readFileSync(path.resolve(dirname, "../../packages/jarl-atoms/package.json"), "utf-8"), ); @@ -42,11 +45,11 @@ export default defineConfig({ __JARL_VERSION__: JSON.stringify(atomsPackageJson.version), }, server: { - port: 4173, + port: fixturePort, strictPort: true, }, preview: { - port: 4173, + port: fixturePort, strictPort: true, }, }); diff --git a/e2e/package.json b/e2e/package.json index a63dbc54..e9c398a2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite --config fixture-app/vite.config.ts", "build": "vite build --config fixture-app/vite.config.ts", - "preview": "vite preview --config fixture-app/vite.config.ts --port 4173 --strict-port", + "preview": "vite preview --config fixture-app/vite.config.ts", "test": "playwright test", "test:headed": "playwright test --headed", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index a60e621c..b593055f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,4 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import { devPort, E2E_FIXTURE_PORT_OFFSET } from "../scripts/devPorts.mts"; + +const fixtureUrl = `http://localhost:${devPort(E2E_FIXTURE_PORT_OFFSET)}`; /** * Playwright config for ticket 57 (port e2e tests to Playwright). @@ -26,13 +29,13 @@ export default defineConfig({ retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? "github" : "list", use: { - baseURL: "http://localhost:4173", + baseURL: fixtureUrl, trace: "on-first-retry", }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: { command: "npm run build && npm run preview", - url: "http://localhost:4173", + url: fixtureUrl, reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/packages/docs/README.md b/packages/docs/README.md index 76cb8d2b..3944625d 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -12,14 +12,18 @@ Run from `packages/docs/`: ``` npm install -npm run docs:dev # custom SSR dev server (Vite in middleware mode), http://localhost:4321 +npm run docs:dev # custom SSR dev server (Vite in middleware mode), on this worktree's `+0` port npm run docs:build # produces a static, deployable build in dist/ -npm run docs:preview # serve the built dist/ output locally, to sanity-check the static build +npm run docs:preview # serve the built dist/ output locally, on this worktree's `+5` port npm run typecheck # tsc --noEmit ``` Or from the repo root: `npm run docs:build --prefix packages/docs`. +Both servers derive their port from the worktree's branch name rather than taking a fixed one — +`npm run ports` at the repo root prints this worktree's block, and the root `CLAUDE.md` has the +offset map. + `docs:build` is the command a later deploy step (ticket 60's GitHub Actions workflow) should run and then upload the contents of `dist/` as-is to static hosting - every page is prerendered to a real `.html` file (see `scripts/build.mjs`), there is no Node diff --git a/packages/docs/package.json b/packages/docs/package.json index 685dcfc9..90e906af 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -7,7 +7,7 @@ "scripts": { "docs:dev": "node scripts/dev-server.mjs", "docs:build": "node scripts/build.mjs", - "docs:preview": "vite preview --outDir dist --port 4173", + "docs:preview": "vite preview --outDir dist", "dev": "npm run docs:dev", "build": "npm run docs:build", "typecheck": "tsc --noEmit" diff --git a/packages/docs/scripts/dev-server.mjs b/packages/docs/scripts/dev-server.mjs index bafb46d6..9f548798 100644 --- a/packages/docs/scripts/dev-server.mjs +++ b/packages/docs/scripts/dev-server.mjs @@ -6,10 +6,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createServer as createViteServer } from "vite"; +import { devPort, DOCS_DEV_PORT_OFFSET } from "../../../scripts/devPorts.mts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, ".."); -const port = Number(process.env.PORT) || 4321; +const port = Number(process.env.PORT) || devPort(DOCS_DEV_PORT_OFFSET); async function main() { const vite = await createViteServer({ diff --git a/packages/docs/src/prod-server.ts b/packages/docs/src/prod-server.ts index 61081bdf..73089108 100644 --- a/packages/docs/src/prod-server.ts +++ b/packages/docs/src/prod-server.ts @@ -7,7 +7,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { render } from "./entry-server"; -const port = Number(process.env.PORT) || 3000; +const port = Number(process.env.PORT); +if (!Number.isInteger(port)) { + throw new Error("PORT must be set; the jarl-ssr unit in infra/lib/jarl-stacks.ts sets it."); +} const template = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "template.html"), "utf-8"); /** Matches `ssrPathPattern` in infra/lib/jarl-stacks.ts: CloudFront forwards the whole /ssr/* request. */ diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index 4352cc95..b890ed06 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { apiReferencePlugin } from "./scripts/apiReferencePlugin.mjs"; import { sourceLinksPlugin } from "./scripts/sourceLinksPlugin.ts"; +import { devPort, DOCS_PREVIEW_PORT_OFFSET } from "../../scripts/devPorts.mts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // The repo root two levels up (packages/docs -> packages -> repo root). The docs site @@ -30,4 +31,10 @@ export default defineConfig({ allow: [__dirname, repoRoot], }, }, + preview: { + // `npm run dev` serves this site through scripts/dev-server.mjs (Vite in middleware mode), so + // only the preview server takes a port from here. + port: devPort(DOCS_PREVIEW_PORT_OFFSET), + strictPort: true, + }, }); From 6e8d57483b85381684d6abd91f905f4a76a529ab Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Fri, 21 Aug 2026 09:46:52 +0100 Subject: [PATCH 3/3] =?UTF-8?q?docs(dev-ports):=20485=20=E2=80=94=20docume?= =?UTF-8?q?nt=20jarl's=20offset=20map=20and=20the=20binding=20port=20schem?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: 485 --- CLAUDE.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cc1a218..c8c4b63b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,11 +35,12 @@ or run against a stale build. ```bash npm install # installs and links all workspace packages npm run build # build all packages (rolldown) and the docs site (vite SSG) -npm test # run each package's tests (vitest) +npm test # run the root and per-package tests (vitest) npm run ci-test # CI test run npm run lint # oxlint across the repo npm run format # oxfmt across the repo -npm run dev / npm start # run the docs site's Vite dev server +npm run dev / npm start # run the docs site's SSR dev server (see Dev ports) +npm run ports # print this worktree's derived dev port block npm run typecheck # tsc over packages/ and scripts/ (e2e and infra typecheck separately) npm run test:e2e # Playwright suite (see test:e2e:install) npm run test:smoke # published-package smoke test (see test:smoke:install) @@ -64,9 +65,32 @@ removed with the rest of the old tooling). Each package's tests run under Vitest with jsdom (the atoms talk to `window.location`/`history` via jotai-location, and the bindings render React). -## Coding & commenting style +## Dev ports -`/TODOS/CODING-STYLE.md` is the binding coding and commenting style guide for this project — comment types and their rules, hard bans, test/config conventions. Rules live there once; don't duplicate them here. +Ports are derived from the worktree's branch name, never hardcoded — `/TODOS/DEV-SERVERS.md` +(binding) has the scheme, `scripts/devPorts.mts` is jarl's one implementation of it. `npm run ports` +prints this worktree's block; `npm run ports -- --write-env` also writes a gitignored `.env.ports` +for anything that can't import the module. + +**Offset map** — the only project-specific part: + +| offset | service | +| ------ | ------------------------------------------------------ | +| `+0` | docs site SSR dev server (`npm run dev`) | +| `+5` | docs production-build preview (`npm run docs:preview`) | +| `+6` | Playwright fixture app (`npm run test:e2e`) | + +`+1`–`+4` and `+7`–`+9` are unused. `+0` is served by `packages/docs/scripts/dev-server.mjs` (Vite +in middleware mode, so the port is the script's rather than `vite.config.ts`'s); `+5` comes from +`packages/docs/vite.config.ts`, and `+6` from `e2e/fixture-app/vite.config.ts`, which +`e2e/playwright.config.ts` dials as its `baseURL`. + +`packages/docs/src/prod-server.ts` is production rather than dev: it takes `PORT` from the +`jarl-ssr` systemd unit in `infra/lib/jarl-stacks.ts` and refuses to start without it. + +## Binding board-wide rules + +`/TODOS/CODING-STYLE.md` is the binding coding and commenting style guide for this project — comment types and their rules, hard bans, test/config conventions. `/TODOS/DEV-SERVERS.md` is the binding dev-server and port scheme (see "Dev ports" above). Rules live there once; don't duplicate them here. ## TODOs