Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

`<root>/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 — `<root>/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

`<root>/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. `<root>/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

Expand Down
7 changes: 5 additions & 2 deletions e2e/fixture-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);
Expand Down Expand Up @@ -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,
},
});
2 changes: 1 addition & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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,
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
"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",
"test:smoke:install": "npm --prefix e2e/registry-smoke install",
"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",
Expand Down
8 changes: 6 additions & 2 deletions packages/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion packages/docs/scripts/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion packages/docs/src/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 7 additions & 0 deletions packages/docs/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
});
65 changes: 65 additions & 0 deletions scripts/__tests__/devPorts.test.mts
Original file line number Diff line number Diff line change
@@ -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);
});
});
91 changes: 91 additions & 0 deletions scripts/devPorts.mts
Original file line number Diff line number Diff line change
@@ -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-<id>-...` 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-<id>-...`, 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;
}
Loading
Loading