From 20bfa769ea47c08fd3e0bcf8cfd46f87bc7d0bbf Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Mon, 27 Jul 2026 19:51:17 -0700
Subject: [PATCH 01/10] feat(explorer): ship Effort Graph visualizer with
flatbread start
Distribute the content-relation explorer as `@flatbread/explorer` so
consumers no longer need a separate Next app. When config uses
`effortGraphContent()`, `flatbread start` serves the SPA at `/` and
`--open` launches it; Apollo stays at `/graphql`.
## Summary
- Add `@flatbread/explorer` (Vite SPA + Node helpers) with a generic
graph core and an Effort Graph preset migrated from `examples/effort-viz`
- Hard-depend from `flatbread`; auto-mount on known presets with bootstrap
injection for same-origin GraphQL/SSE
- Support server-only `flatbread start` (no corunner) and static deploys
via `?endpoint=`
- Point `pnpm play:efforts` at `flatbread start --watch --open`; retire
`examples/effort-viz`; update CONTRIBUTING and Effort Graph setup skill
## Test plan
- [x] `pnpm --filter @flatbread/explorer test` (79 tests)
- [x] `pnpm --filter @flatbread/explorer typecheck`
- [x] AVA: explorerMount, openPath, liveServerEffortGraph, liveServer
- [x] Smoke: `flatbread start` serves explorer HTML at `/` and GraphQL at
`/graphql` with bootstrap injection
- [ ] CI green on the stacked PR
Co-authored-by: Cursor
Change-Id: I5b888060d859bb06b64528115ef89b098a3ae62c
---
.agents/skills/effort-graph/setup.md | 15 +
CONTRIBUTING.md | 2 +-
ava.config.js | 2 +
examples/effort-viz/.gitignore | 42 --
examples/effort-viz/README.md | 155 -----
examples/effort-viz/app/layout.tsx | 47 --
examples/effort-viz/app/page.tsx | 5 -
examples/effort-viz/flatbread.config.js | 12 -
examples/effort-viz/next.config.ts | 7 -
examples/effort-viz/package.json | 34 -
examples/effort-viz/postcss.config.mjs | 5 -
examples/effort-viz/tsconfig.json | 27 -
package.json | 8 +-
.../effort-graph/skills/effort-graph/setup.md | 15 +
packages/explorer/README.md | 45 ++
packages/explorer/index.html | 35 +
packages/explorer/package.json | 59 ++
packages/explorer/postcss.config.mjs | 5 +
packages/explorer/src/node/index.ts | 6 +
.../explorer/src/node/matchPreset.test.ts | 28 +
packages/explorer/src/node/matchPreset.ts | 29 +
packages/explorer/src/node/staticDir.test.ts | 27 +
packages/explorer/src/node/staticDir.ts | 16 +
packages/explorer/src/vite-env.d.ts | 1 +
.../src/web}/app/components/DetailDrawer.tsx | 30 +-
.../web}/app/components/EffortGraphApp.tsx | 77 +--
.../src/web}/app/components/GraphCanvas.tsx | 112 +++-
.../src/web}/app/components/Legend.tsx | 47 +-
.../web}/app/components/MarkdownSurface.tsx | 22 +-
.../web}/app/components/RelationLegend.tsx | 103 ++-
.../src/web}/app/components/TopBar.tsx | 30 +-
.../explorer/src/web}/app/globals.css | 9 +-
.../explorer/src/web}/app/hooks/useTheme.tsx | 8 +-
packages/explorer/src/web/app/main.tsx | 18 +
.../explorer/src/web/core/endpoints.test.ts | 58 ++
packages/explorer/src/web/core/endpoints.ts | 96 +++
.../explorer/src/web/core}/graphql.ts | 0
.../explorer/src/web/core}/oklch.ts | 26 +-
.../src/web/core}/physics/forces.test.ts | 33 +-
.../explorer/src/web/core}/physics/forces.ts | 19 +-
.../explorer/src/web/core}/physics/growth.ts | 0
.../explorer/src/web/core}/physics/hash.ts | 6 +-
.../explorer/src/web/core}/physics/index.ts | 0
.../src/web/core}/physics/simulation.test.ts | 37 +-
.../src/web/core}/physics/simulation.ts | 49 +-
.../explorer/src/web/core}/physics/types.ts | 0
.../web/presets/effort-graph}/glyphs.test.ts | 17 +-
.../src/web/presets/effort-graph}/glyphs.ts | 22 +-
.../presets/effort-graph}/lifecycle.test.ts | 65 +-
.../web/presets/effort-graph}/lifecycle.ts | 17 +-
.../presets/effort-graph}/normalize.test.ts | 0
.../web/presets/effort-graph}/normalize.ts | 19 +-
.../web/presets/effort-graph}/primitives.ts | 14 +-
.../web/presets/effort-graph}/query.test.ts | 0
.../src/web/presets/effort-graph}/query.ts | 4 +-
.../src/web/presets/effort-graph}/types.ts | 0
.../effort-graph}/useEffortGraphLive.test.ts | 0
.../effort-graph}/useEffortGraphLive.ts | 28 +-
packages/explorer/tsconfig.json | 17 +
packages/explorer/tsconfig.node.json | 10 +
packages/explorer/tsup.config.ts | 14 +
packages/explorer/vite.config.ts | 22 +
packages/flatbread/package.json | 1 +
packages/flatbread/src/cli/index.ts | 77 ++-
packages/flatbread/src/cli/openPath.test.ts | 19 +
packages/flatbread/src/cli/openPath.ts | 16 +
packages/flatbread/src/cli/runner.ts | 12 +
.../src/graphql/explorerMount.test.ts | 90 +++
.../flatbread/src/graphql/explorerMount.ts | 107 +++
.../flatbread/src/graphql/liveServer.test.ts | 2 +-
packages/flatbread/src/graphql/liveServer.ts | 8 +
.../src/graphql/liveServerEffortGraph.test.ts | 11 +-
pnpm-lock.yaml | 616 +++++++++++++-----
tsconfig.json | 2 +
74 files changed, 1855 insertions(+), 762 deletions(-)
delete mode 100644 examples/effort-viz/.gitignore
delete mode 100644 examples/effort-viz/README.md
delete mode 100644 examples/effort-viz/app/layout.tsx
delete mode 100644 examples/effort-viz/app/page.tsx
delete mode 100644 examples/effort-viz/flatbread.config.js
delete mode 100644 examples/effort-viz/next.config.ts
delete mode 100644 examples/effort-viz/package.json
delete mode 100644 examples/effort-viz/postcss.config.mjs
delete mode 100644 examples/effort-viz/tsconfig.json
create mode 100644 packages/explorer/README.md
create mode 100644 packages/explorer/index.html
create mode 100644 packages/explorer/package.json
create mode 100644 packages/explorer/postcss.config.mjs
create mode 100644 packages/explorer/src/node/index.ts
create mode 100644 packages/explorer/src/node/matchPreset.test.ts
create mode 100644 packages/explorer/src/node/matchPreset.ts
create mode 100644 packages/explorer/src/node/staticDir.test.ts
create mode 100644 packages/explorer/src/node/staticDir.ts
create mode 100644 packages/explorer/src/vite-env.d.ts
rename {examples/effort-viz => packages/explorer/src/web}/app/components/DetailDrawer.tsx (93%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/EffortGraphApp.tsx (70%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/GraphCanvas.tsx (94%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/Legend.tsx (88%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/MarkdownSurface.tsx (92%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/RelationLegend.tsx (82%)
rename {examples/effort-viz => packages/explorer/src/web}/app/components/TopBar.tsx (86%)
rename {examples/effort-viz => packages/explorer/src/web}/app/globals.css (95%)
rename {examples/effort-viz => packages/explorer/src/web}/app/hooks/useTheme.tsx (95%)
create mode 100644 packages/explorer/src/web/app/main.tsx
create mode 100644 packages/explorer/src/web/core/endpoints.test.ts
create mode 100644 packages/explorer/src/web/core/endpoints.ts
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/graphql.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/oklch.ts (87%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/forces.test.ts (89%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/forces.ts (96%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/growth.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/hash.ts (94%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/index.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/simulation.test.ts (91%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/simulation.ts (90%)
rename {examples/effort-viz/lib => packages/explorer/src/web/core}/physics/types.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/glyphs.test.ts (91%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/glyphs.ts (91%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/lifecycle.test.ts (82%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/lifecycle.ts (95%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/normalize.test.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/normalize.ts (93%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/primitives.ts (91%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/query.test.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/query.ts (98%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/types.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/useEffortGraphLive.test.ts (100%)
rename {examples/effort-viz/lib => packages/explorer/src/web/presets/effort-graph}/useEffortGraphLive.ts (94%)
create mode 100644 packages/explorer/tsconfig.json
create mode 100644 packages/explorer/tsconfig.node.json
create mode 100644 packages/explorer/tsup.config.ts
create mode 100644 packages/explorer/vite.config.ts
create mode 100644 packages/flatbread/src/cli/openPath.test.ts
create mode 100644 packages/flatbread/src/cli/openPath.ts
create mode 100644 packages/flatbread/src/graphql/explorerMount.test.ts
create mode 100644 packages/flatbread/src/graphql/explorerMount.ts
diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index 71a13793..d38a95ac 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -84,3 +84,18 @@ On resume, begin with `flatbread effort list --status active`, then use bounded
effort-scoped reads. Capture mutation `generation` tokens and use
`--strict-min-generation` for immediate read-after-write checks; never implement
client polling loops. Semantic changes go through `flatbread effort write`.
+
+## 4. Open the explorer (optional)
+
+With a complete `effortGraphContent()` preset in config, Flatbread serves the
+content-relation explorer automatically:
+
+```bash
+flatbread start --watch --open
+```
+
+- Explorer UI: `http://localhost:5057/`
+- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
+
+No separate app install is required; `@flatbread/explorer` ships with
+`flatbread`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9c7e08b5..c41a59bf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,7 +35,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex
- Build all packages: `pnpm build`
- **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example.
- **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias.
-- **Effort Graph viz (`examples/effort-viz`):** after `pnpm build`, run `pnpm play:efforts` (or `pnpm --filter effort-viz dev`) to dogfood `.flatbread-efforts` with live SSE updates — see that example's README.
+- **Effort Graph explorer:** after `pnpm build`, run `pnpm play:efforts` (`flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`).
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
diff --git a/ava.config.js b/ava.config.js
index cb9c7c85..531f2ef9 100644
--- a/ava.config.js
+++ b/ava.config.js
@@ -13,6 +13,8 @@ export default {
// parser/runtime guardrails.
'!packages/codegen/src/__tests__/**',
'!packages/utils/src/__tests__/**',
+ // Explorer SPA uses Node's built-in test runner (see package scripts).
+ '!packages/explorer/**',
],
extensions: {
js: true,
diff --git a/examples/effort-viz/.gitignore b/examples/effort-viz/.gitignore
deleted file mode 100644
index 20fec1a7..00000000
--- a/examples/effort-viz/.gitignore
+++ /dev/null
@@ -1,42 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/.pnp
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/versions
-
-# testing
-/coverage
-
-# next.js
-/.next/
-/out/
-
-# production
-/build
-
-# misc
-.DS_Store
-*.pem
-.flatbread-codegen-cache.json
-
-# debug
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-.pnpm-debug.log*
-
-# env files (can opt-in for committing if needed)
-.env*
-
-# vercel
-.vercel
-
-# typescript
-*.tsbuildinfo
-next-env.d.ts
diff --git a/examples/effort-viz/README.md b/examples/effort-viz/README.md
deleted file mode 100644
index d13feb10..00000000
--- a/examples/effort-viz/README.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# Effort Graph Visualization (Next.js + R3F)
-
-Next.js example that dogfoods the monorepo's Effort Graph content at
-`.flatbread-efforts`. It renders an interactive 2D force-directed graph with
-`@react-three/fiber`, subscribes to Flatbread live schema generations over SSE,
-and ships a Vercel-like light/dark UI shell.
-
-## Prerequisites
-
-From the **monorepo root**:
-
-```bash
-pnpm install
-pnpm build
-```
-
-Build workspace packages (especially `flatbread`) before starting the example.
-The dev script wraps `flatbread start --watch`, which needs compiled package
-output.
-
-## Quick start
-
-```bash
-pnpm --filter effort-viz dev
-```
-
-From the repo root you can also use the convenience alias:
-
-```bash
-pnpm play:efforts
-```
-
-Then open **[http://localhost:3000](http://localhost:3000)**.
-
-Flatbread serves GraphQL at **`http://localhost:5057/graphql`**. The app
-subscribes to **`http://localhost:5057/events`** (SSE) for schema generation
-updates.
-
-## What you can read off it
-
-Within a few seconds of opening the page you should be able to answer:
-
-- **What kinds of records are here?** Every primitive has its own hue *and* its
- own silhouette — Issues are amber diamonds, Findings blue circles, Decisions
- violet squares, Constraints green/teal slabs (flat bars), Risks red triangles,
- and each Effort is a ring whose core carries that cluster's tint.
-- **What is still live, and what got overturned?** Rejected, superseded,
- invalidated, won't-fix, deprecated, and abandoned records fade to a
- desaturated ghost with a struck-through label. Supersession is derived from
- the graph's edges rather than from frontmatter, because forward edges are the
- authoritative representation and `state` can lag behind them — a Decision
- replaced through an inline `supersedes` still records `state: accepted`, so
- reading the field alone would label retired reasoning as committed.
-- **What is blocking?** Open Issues with `kind: blocker` wear an amber warning
- outline.
-- **How much work is tracked?** The header counts primitives and lifecycle
- (`5 Efforts · 4 open Issues · 3 proposed Decisions`) rather than nodes and
- edges — roughly half the "edges" are synthesised membership spokes, so a raw
- edge count flatters the graph without informing anyone.
-
-## Encoding notes
-
-Hue belongs to the **primitive**, not the Effort. Effort membership is already
-carried by three other channels — the force layout pulls same-Effort records to
-a shared centroid, each cluster has a large labelled hub, and membership spokes
-take the cluster's tint — so spending the strongest nominal channel on it left
-record kind with nothing. Shape repeats hue as a colour-vision backstop, since
-amber/red and blue/violet partially merge under deuteranopia. Silhouettes are
-area-normalized (`lib/glyphs.ts`) so a triangle and a square read at the same
-visual weight; otherwise size would imply an importance ranking nobody
-intended.
-
-The legend derives its swatches from the same outlines and palette the canvas
-builds geometry from (`lib/glyphs.ts`, `lib/primitives.ts`), so it cannot drift
-from the render, and it only lists the relations the current generation actually
-contains.
-
-## Other features
-
-- **Live graph** — `useEffortGraphLive` opens an `EventSource` on `/events`.
- On `ready` and each `generation` event it refetches the Effort Graph query and
- updates the canvas. The status pill shows connecting / live / partial /
- disconnected / error and the current generation. **Partial** means records
- loaded but relationship fields could not be confirmed yet — retirement links
- may be missing until the next successful schema probe.
-- **Watch mode** — `flatbread start --watch` reloads content and config changes
- under `.flatbread-efforts`. Edit an Effort, Issue, or Finding file and the
- graph animates in/out without restarting Next.
-- **R3F canvas** — orthographic 2D scene with pan/zoom, cluster labels, edge
- “veins”, spawn/retract physics, and a detail drawer on record click.
-- **Keyboard** — Tab to the canvas, then arrow keys to walk records in a stable
- Effort-then-primitive order, Enter to open the drawer, Escape to close. The
- camera follows focus and each move is announced to screen readers. The canvas
- itself is still a WebGL surface, so this is a focus proxy rather than a full
- DOM mirror of the graph.
-- **Reduced motion** — `prefers-reduced-motion` settles the layout and finishes
- every growth animation before the first paint, and the camera snaps instead
- of easing.
-- **Theme** — sun/moon toggle in the top bar. The app follows
- `prefers-color-scheme` until you pick a mode, after which the choice persists
- in `localStorage` (`effort-viz-theme`) with a boot script to avoid FOUC.
-
-## Scripts
-
-| Script | Purpose |
-| --- | --- |
-| `pnpm --filter effort-viz dev` | `flatbread start --watch` + Next dev (Turbopack). GraphQL on **5057**, Next on **3000**. |
-| `pnpm play:efforts` | Same as `dev`, from the monorepo root. |
-| `pnpm --filter effort-viz build` | `flatbread start` wrapping `next build` (Flatbread must be up during the build). |
-| `pnpm --filter effort-viz start` | Production Next only (`next start`); run Flatbread separately if needed. |
-| `pnpm --filter effort-viz test` | Unit tests: physics/simulation, normalizer, lifecycle derivation, glyph invariants. |
-| `pnpm --filter effort-viz exec tsc --noEmit` | Typecheck without running dev servers. |
-
-## Configuration
-
-- `flatbread.config.js` — loads effort graph collections from
- `../../.flatbread-efforts` via `effortGraphContent()`.
-- `lib/graphql.ts` — `graphqlFetch` helper (default endpoint
- `http://localhost:5057/graphql`).
-- `lib/useEffortGraphLive.ts` — SSE subscription + GraphQL refetch loop.
-
-## Project structure
-
-- `app/` — layout, theme tokens, R3F canvas and UI chrome
-- `app/hooks/useTheme.tsx` — light/dark context + FOUC boot script
-- `app/components/` — `EffortGraphApp`, `GraphCanvas`, `TopBar`, `Legend`,
- `DetailDrawer`, `RelationLegend` (shared relation + badge metadata)
-- `lib/primitives.ts` — per-primitive label, hue, and glyph: the encoding's
- single source of truth
-- `lib/glyphs.ts` — area-normalized glyph outlines shared by the canvas and the
- legend
-- `lib/lifecycle.ts` — effective lifecycle derived from edges, plus the header
- summary
-- `lib/physics/` — force simulation, growth, and layout helpers
-- `lib/query.ts` — Effort Graph GraphQL query
-- `flatbread.config.js` — Effort Graph content preset
-
-## Troubleshooting
-
-### Empty graph or “Connecting” forever
-
-Ensure Flatbread is running on port **5057**. Use `pnpm --filter effort-viz dev`
-(or `pnpm play:efforts`), not `next dev` alone.
-
-### Typecheck / build
-
-```bash
-pnpm build
-pnpm --filter effort-viz exec tsc --noEmit
-pnpm --filter effort-viz build
-```
-
-Production build starts Flatbread briefly so Next can typecheck; you may see a
-non-fatal ESLint config warning from the root toolchain — the build still
-completes.
diff --git a/examples/effort-viz/app/layout.tsx b/examples/effort-viz/app/layout.tsx
deleted file mode 100644
index db47db10..00000000
--- a/examples/effort-viz/app/layout.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Metadata, Viewport } from 'next';
-import { Geist, Geist_Mono } from 'next/font/google';
-import './globals.css';
-import { ThemeProvider, THEME_BOOT_SCRIPT } from './hooks/useTheme';
-
-const geistSans = Geist({
- variable: '--font-geist-sans',
- subsets: ['latin'],
-});
-
-const geistMono = Geist_Mono({
- variable: '--font-geist-mono',
- subsets: ['latin'],
-});
-
-export const metadata: Metadata = {
- title: 'Effort Graph',
- description:
- "Live view of Flatbread's Effort Graph — Efforts, Issues, Findings, Decisions, Constraints, and Risks with typed relations.",
-};
-
-export const viewport: Viewport = {
- width: 'device-width',
- initialScale: 1,
- viewportFit: 'cover',
-};
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- return (
-
-
-
-
-
- {children}
-
-
- );
-}
diff --git a/examples/effort-viz/app/page.tsx b/examples/effort-viz/app/page.tsx
deleted file mode 100644
index bdd34f65..00000000
--- a/examples/effort-viz/app/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { EffortGraphApp } from './components/EffortGraphApp';
-
-export default function Home() {
- return ;
-}
diff --git a/examples/effort-viz/flatbread.config.js b/examples/effort-viz/flatbread.config.js
deleted file mode 100644
index 7267c7e1..00000000
--- a/examples/effort-viz/flatbread.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import {
- defineConfig,
- effortGraphContent,
- sourceFilesystem,
- transformerMarkdown,
-} from 'flatbread';
-
-export default defineConfig({
- source: sourceFilesystem(),
- transformer: transformerMarkdown(),
- content: effortGraphContent('../../.flatbread-efforts'),
-});
diff --git a/examples/effort-viz/next.config.ts b/examples/effort-viz/next.config.ts
deleted file mode 100644
index 5e891cf0..00000000
--- a/examples/effort-viz/next.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
diff --git a/examples/effort-viz/package.json b/examples/effort-viz/package.json
deleted file mode 100644
index 783e5f37..00000000
--- a/examples/effort-viz/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "name": "effort-viz",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "dev": "flatbread start --watch -- next dev --turbopack",
- "build": "flatbread start -- next build",
- "start": "next start",
- "typecheck": "tsc --noEmit",
- "test": "node --import tsx --test lib/physics/*.test.ts lib/*.test.ts"
- },
- "dependencies": {
- "@react-three/drei": "^10.0.6",
- "@react-three/fiber": "^9.1.2",
- "flatbread": "workspace:*",
- "next": "15.4.4",
- "react": "19.1.0",
- "react-dom": "19.1.0",
- "react-markdown": "10.1.0",
- "rehype-sanitize": "^6.0.0",
- "remark-gfm": "^4.0.1",
- "three": "^0.175.0"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4.1.11",
- "@types/node": "^20.19.10",
- "@types/react": "^19.1.9",
- "@types/react-dom": "^19.1.7",
- "@types/three": "^0.175.0",
- "tailwindcss": "^4.1.11",
- "tsx": "^4.21.0",
- "typescript": "^5.9.2"
- }
-}
diff --git a/examples/effort-viz/postcss.config.mjs b/examples/effort-viz/postcss.config.mjs
deleted file mode 100644
index ba720fe5..00000000
--- a/examples/effort-viz/postcss.config.mjs
+++ /dev/null
@@ -1,5 +0,0 @@
-const config = {
- plugins: ['@tailwindcss/postcss'],
-};
-
-export default config;
diff --git a/examples/effort-viz/tsconfig.json b/examples/effort-viz/tsconfig.json
deleted file mode 100644
index d8b93235..00000000
--- a/examples/effort-viz/tsconfig.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "preserve",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
-}
diff --git a/package.json b/package.json
index 71ad7769..50bbc8b6 100644
--- a/package.json
+++ b/package.json
@@ -27,17 +27,17 @@
"lint:fix": "pnpm lint:fix:prettier",
"lint:fix:fast": "prettier --write --plugin-search-dir=. .",
"lint:fix:prettier": "pretty-quick --staged",
- "typecheck": "pnpm --filter @flatbread/proof --filter effort-viz typecheck",
+ "typecheck": "pnpm --filter @flatbread/proof --filter @flatbread/explorer typecheck",
"play": "cd examples/nextjs && pnpm dev",
- "play:efforts": "cd examples/effort-viz && pnpm dev",
+ "play:efforts": "pnpm exec flatbread start --watch --open",
"play:build": "pnpm build && cd examples/nextjs && pnpm build",
"prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types",
"publish:ci": "esno scripts/publish.ts",
"bump": "esno scripts/bumpVersions.ts",
"test:ava": "ava",
"test:vitest": "pnpm --filter @flatbread/codegen --filter @flatbread/utils test",
- "test:examples": "pnpm --filter effort-viz test",
- "test": "pnpm build && pnpm test:ava && pnpm test:vitest && pnpm test:examples",
+ "test:explorer": "pnpm --filter @flatbread/explorer test",
+ "test": "pnpm build && pnpm test:ava && pnpm test:vitest && pnpm test:explorer",
"verify": "pnpm skills:check && pnpm skills:pack-check && pnpm lint && pnpm typecheck && pnpm build && pnpm test",
"cursor:fetch-cloud-agent": "pnpm --filter @flatbread/proof exec node scripts/fetch-cloud-agent-conversation.mjs",
"dev:test": "ava --watch --verbose",
diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md
index 71a13793..d38a95ac 100644
--- a/packages/effort-graph/skills/effort-graph/setup.md
+++ b/packages/effort-graph/skills/effort-graph/setup.md
@@ -84,3 +84,18 @@ On resume, begin with `flatbread effort list --status active`, then use bounded
effort-scoped reads. Capture mutation `generation` tokens and use
`--strict-min-generation` for immediate read-after-write checks; never implement
client polling loops. Semantic changes go through `flatbread effort write`.
+
+## 4. Open the explorer (optional)
+
+With a complete `effortGraphContent()` preset in config, Flatbread serves the
+content-relation explorer automatically:
+
+```bash
+flatbread start --watch --open
+```
+
+- Explorer UI: `http://localhost:5057/`
+- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
+
+No separate app install is required; `@flatbread/explorer` ships with
+`flatbread`.
diff --git a/packages/explorer/README.md b/packages/explorer/README.md
new file mode 100644
index 00000000..c3bf0df4
--- /dev/null
+++ b/packages/explorer/README.md
@@ -0,0 +1,45 @@
+# `@flatbread/explorer`
+
+Content-relation explorer for Flatbread. v1 ships a generic SPA shell plus an
+**Effort Graph** preset. When your config uses `effortGraphContent()`,
+`flatbread start` serves this UI at `/`.
+
+## Local DX
+
+```bash
+# flatbread.config.js includes effortGraphContent()
+flatbread start --watch --open
+# → http://localhost:5057/ explorer
+# → http://localhost:5057/graphql Apollo sandbox
+```
+
+No separate Next app is required.
+
+## Static deploy
+
+This package publishes prebuilt assets under `dist/static/`. Drop them on any
+static host and point at a reachable Flatbread GraphQL endpoint:
+
+```
+https://your-host.example/?endpoint=https://api.example.com/graphql
+```
+
+Same-origin deploys (assets served by Flatbread) need no query param.
+
+## Package surface (v1)
+
+| Export | Role |
+| ------------------------------ | ------------------------------------------ |
+| `getExplorerStaticDir()` | Absolute path to `dist/static` for Express |
+| `matchExplorerPreset(content)` | Detect Effort Graph (and later presets) |
+| `EXPLORER_BOOTSTRAP_PATH` | Bootstrap JSON path Flatbread injects |
+
+There is no public React component export in v1.
+
+## Develop in the monorepo
+
+```bash
+pnpm --filter @flatbread/explorer test
+pnpm --filter @flatbread/explorer build
+pnpm play:efforts # flatbread start --watch --open from repo root
+```
diff --git a/packages/explorer/index.html b/packages/explorer/index.html
new file mode 100644
index 00000000..50c8a3bb
--- /dev/null
+++ b/packages/explorer/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Flatbread Explorer
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/explorer/package.json b/packages/explorer/package.json
new file mode 100644
index 00000000..30aed741
--- /dev/null
+++ b/packages/explorer/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@flatbread/explorer",
+ "version": "0.1.0-alpha.0",
+ "description": "Content-relation explorer SPA for Flatbread — generic graph shell with Effort Graph as the first preset.",
+ "type": "module",
+ "scripts": {
+ "build": "pnpm build:node && pnpm build:web",
+ "build:node": "tsup",
+ "build:web": "vite build",
+ "dev": "vite",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "node --import tsx --test src/node/matchPreset.test.ts src/node/staticDir.test.ts src/web/core/endpoints.test.ts src/web/core/physics/forces.test.ts src/web/core/physics/simulation.test.ts src/web/presets/effort-graph/glyphs.test.ts src/web/presets/effort-graph/lifecycle.test.ts src/web/presets/effort-graph/normalize.test.ts src/web/presets/effort-graph/query.test.ts src/web/presets/effort-graph/useEffortGraphLive.test.ts"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/FlatbreadLabs/flatbread.git",
+ "directory": "packages/explorer"
+ },
+ "homepage": "https://github.com/FlatbreadLabs/flatbread#readme",
+ "author": "Tony Ketcham ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/FlatbreadLabs/flatbread/issues"
+ },
+ "exports": {
+ ".": "./dist/node/index.js"
+ },
+ "main": "dist/node/index.js",
+ "module": "dist/node/index.js",
+ "types": "dist/node/index.d.ts",
+ "files": [
+ "dist",
+ "*.d.ts"
+ ],
+ "dependencies": {
+ "@flatbread/effort-graph": "workspace:*"
+ },
+ "devDependencies": {
+ "@react-three/drei": "^10.0.6",
+ "@react-three/fiber": "^9.1.2",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@types/node": "^20.19.10",
+ "@types/react": "^19.1.9",
+ "@types/react-dom": "^19.1.7",
+ "@types/three": "^0.175.0",
+ "@vitejs/plugin-react": "^4.7.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-markdown": "10.1.0",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
+ "tailwindcss": "^4.1.11",
+ "three": "^0.175.0",
+ "tsup": "8.5.1",
+ "tsx": "^4.21.0",
+ "typescript": "6.0.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/packages/explorer/postcss.config.mjs b/packages/explorer/postcss.config.mjs
new file mode 100644
index 00000000..a34a3d56
--- /dev/null
+++ b/packages/explorer/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
diff --git a/packages/explorer/src/node/index.ts b/packages/explorer/src/node/index.ts
new file mode 100644
index 00000000..d75175a9
--- /dev/null
+++ b/packages/explorer/src/node/index.ts
@@ -0,0 +1,6 @@
+export { getExplorerStaticDir, EXPLORER_BOOTSTRAP_PATH } from './staticDir.js';
+export {
+ matchExplorerPreset,
+ type ExplorerPresetId,
+ type ExplorerPresetMatch,
+} from './matchPreset.js';
diff --git a/packages/explorer/src/node/matchPreset.test.ts b/packages/explorer/src/node/matchPreset.test.ts
new file mode 100644
index 00000000..86c6a3d5
--- /dev/null
+++ b/packages/explorer/src/node/matchPreset.test.ts
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { effortGraphContent } from '@flatbread/effort-graph';
+import { matchExplorerPreset } from './matchPreset.js';
+
+describe('matchExplorerPreset', () => {
+ it('matches a full effortGraphContent() preset', () => {
+ const match = matchExplorerPreset(effortGraphContent('.flatbread-efforts'));
+ assert.deepEqual(match, {
+ preset: 'effort-graph',
+ root: '.flatbread-efforts',
+ });
+ });
+
+ it('returns null for unrelated content', () => {
+ assert.equal(
+ matchExplorerPreset([{ collection: 'Post', path: 'posts' }]),
+ null
+ );
+ });
+
+ it('returns null when the Effort Graph preset is incomplete', () => {
+ const partial = effortGraphContent('.flatbread-efforts').filter(
+ (entry) => entry.collection !== 'Blob'
+ );
+ assert.equal(matchExplorerPreset(partial), null);
+ });
+});
diff --git a/packages/explorer/src/node/matchPreset.ts b/packages/explorer/src/node/matchPreset.ts
new file mode 100644
index 00000000..95a0f94a
--- /dev/null
+++ b/packages/explorer/src/node/matchPreset.ts
@@ -0,0 +1,29 @@
+import { findEffortGraphContentRoot } from '@flatbread/effort-graph';
+
+export type ExplorerPresetId = 'effort-graph';
+
+export interface ExplorerPresetMatch {
+ preset: ExplorerPresetId;
+ /** Content root for the matched preset (Effort Graph markdown tree). */
+ root: string;
+}
+
+type ContentLike = readonly {
+ collection: string;
+ path?: string;
+ refs?: Record;
+}[];
+
+/**
+ * Return the explorer preset that should mount for this Flatbread content
+ * config, or `null` when no registered preset matches.
+ */
+export function matchExplorerPreset(
+ content: ContentLike
+): ExplorerPresetMatch | null {
+ const root = findEffortGraphContentRoot(
+ content as Parameters[0]
+ );
+ if (!root) return null;
+ return { preset: 'effort-graph', root };
+}
diff --git a/packages/explorer/src/node/staticDir.test.ts b/packages/explorer/src/node/staticDir.test.ts
new file mode 100644
index 00000000..f710d1b1
--- /dev/null
+++ b/packages/explorer/src/node/staticDir.test.ts
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { describe, it } from 'node:test';
+import { getExplorerStaticDir } from './staticDir.js';
+
+describe('getExplorerStaticDir', () => {
+ it('resolves to a path ending in dist/static', () => {
+ const dir = getExplorerStaticDir();
+ assert.ok(dir.endsWith(`${path.sep}static`) || dir.endsWith('/static'));
+ assert.ok(
+ dir.includes(`${path.sep}dist${path.sep}`) || dir.includes('/dist/')
+ );
+ });
+
+ it('points at a directory that exists after build (or is creatable)', () => {
+ const dir = getExplorerStaticDir();
+ // During unit tests before build, the folder may not exist yet — only check shape.
+ assert.equal(path.basename(dir), 'static');
+ assert.equal(path.basename(path.dirname(dir)), 'dist');
+ // If built, index.html should be present.
+ const index = path.join(dir, 'index.html');
+ if (fs.existsSync(dir)) {
+ assert.ok(fs.existsSync(index), `expected ${index} after vite build`);
+ }
+ });
+});
diff --git a/packages/explorer/src/node/staticDir.ts b/packages/explorer/src/node/staticDir.ts
new file mode 100644
index 00000000..654551d6
--- /dev/null
+++ b/packages/explorer/src/node/staticDir.ts
@@ -0,0 +1,16 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** HTTP path Flatbread serves for explorer bootstrap JSON. */
+export const EXPLORER_BOOTSTRAP_PATH = '/__flatbread/explorer.json';
+
+/**
+ * Absolute path to the prebuilt SPA assets shipped in this package.
+ * Flatbread mounts these with `express.static` when a preset matches.
+ */
+export function getExplorerStaticDir(): string {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ // Works from both `src/node` (tests) and `dist/node` (published).
+ const packageRoot = path.resolve(here, '../..');
+ return path.join(packageRoot, 'dist', 'static');
+}
diff --git a/packages/explorer/src/vite-env.d.ts b/packages/explorer/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/packages/explorer/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/effort-viz/app/components/DetailDrawer.tsx b/packages/explorer/src/web/app/components/DetailDrawer.tsx
similarity index 93%
rename from examples/effort-viz/app/components/DetailDrawer.tsx
rename to packages/explorer/src/web/app/components/DetailDrawer.tsx
index 0d9a963d..31f2e801 100644
--- a/examples/effort-viz/app/components/DetailDrawer.tsx
+++ b/packages/explorer/src/web/app/components/DetailDrawer.tsx
@@ -1,10 +1,11 @@
-'use client';
-
import { useEffect, useMemo, useRef } from 'react';
-import { oklchCss, effortColor, retiredOklch } from '@/lib/oklch';
-import { PRIMITIVES, primitiveOklch } from '@/lib/primitives';
-import { effectiveLifecycle, type LifecycleIndex } from '@/lib/lifecycle';
-import type { GraphEdge, GraphNode } from '@/lib/types';
+import { oklchCss, effortColor, retiredOklch } from '@/core/oklch';
+import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives';
+import {
+ effectiveLifecycle,
+ type LifecycleIndex,
+} from '@/presets/effort-graph/lifecycle';
+import type { GraphEdge, GraphNode } from '@/presets/effort-graph/types';
import { useTheme } from '../hooks/useTheme';
import { MarkdownSurface } from './MarkdownSurface';
import {
@@ -70,7 +71,9 @@ export function DetailDrawer({
if (!nodeId) return;
headingRef.current?.focus();
return () => {
- const canvas = document.querySelector('[role="application"]');
+ const canvas = document.querySelector(
+ '[role="application"]'
+ );
// Only reclaim focus if it is still inside the panel being torn down.
if (document.activeElement?.closest('aside[aria-labelledby]')) {
canvas?.focus();
@@ -168,7 +171,10 @@ export function DetailDrawer({
>
{node.title}
-
+
{node.id}
@@ -202,8 +208,8 @@ export function DetailDrawer({
{life.state === 'invalidated'
? 'Marked wrong by a later Finding. Its own frontmatter still records the state it was in when written.'
: life.state === 'rejected'
- ? 'Rejected by a later Decision. Its own frontmatter still records the state it was in when written.'
- : 'Replaced by a later record. Its own frontmatter still records the state it was in when written.'}{' '}
+ ? 'Rejected by a later Decision. Its own frontmatter still records the state it was in when written.'
+ : 'Replaced by a later record. Its own frontmatter still records the state it was in when written.'}{' '}
Read from the graph edges, which are authoritative.
)}
@@ -353,7 +359,9 @@ function RelationRow({
{peer?.title ?? peerId}
diff --git a/examples/effort-viz/app/components/EffortGraphApp.tsx b/packages/explorer/src/web/app/components/EffortGraphApp.tsx
similarity index 70%
rename from examples/effort-viz/app/components/EffortGraphApp.tsx
rename to packages/explorer/src/web/app/components/EffortGraphApp.tsx
index 4854b7bd..6a11d464 100644
--- a/examples/effort-viz/app/components/EffortGraphApp.tsx
+++ b/packages/explorer/src/web/app/components/EffortGraphApp.tsx
@@ -1,40 +1,25 @@
-'use client';
+import { lazy, Suspense, useEffect, useMemo } from 'react';
-import dynamic from 'next/dynamic';
-import { useEffect, useMemo } from 'react';
-
-import { useEffortGraphLive } from '@/lib/useEffortGraphLive';
+import { resolveGraphqlEndpoint } from '@/core/endpoints';
+import { useEffortGraphLive } from '@/presets/effort-graph/useEffortGraphLive';
import {
buildAlivenessMap,
buildLifecycleIndex,
summarizeGraph,
-} from '@/lib/lifecycle';
-import type { GraphNode } from '@/lib/types';
+} from '@/presets/effort-graph/lifecycle';
+import type { GraphNode } from '@/presets/effort-graph/types';
import { TopBar } from './TopBar';
import { Legend } from './Legend';
import { DetailDrawer } from './DetailDrawer';
import { RELATION_META, type RelationGroupId } from './RelationLegend';
-const GraphCanvas = dynamic(() => import('./GraphCanvas'), {
- ssr: false,
- loading: () => (
-
- Booting canvas…
-
- ),
-});
+const GraphCanvas = lazy(() => import('./GraphCanvas'));
export function EffortGraphApp() {
- const {
- nodes,
- edges,
- status,
- generation,
- error,
- selectedId,
- setSelectedId,
- } = useEffortGraphLive();
+ const endpoint = resolveGraphqlEndpoint();
+ const { nodes, edges, status, generation, error, selectedId, setSelectedId } =
+ useEffortGraphLive({ endpoint });
const nodesById = useMemo(() => {
const map = new Map();
@@ -63,7 +48,7 @@ export function EffortGraphApp() {
return groups;
}, [edges]);
- const selectedNode = selectedId ? (nodesById.get(selectedId) ?? null) : null;
+ const selectedNode = selectedId ? nodesById.get(selectedId) ?? null : null;
// A selected record can vanish on a live update; don't keep a dangling id
// that would silently reopen the drawer if the same id returns.
@@ -82,12 +67,20 @@ export function EffortGraphApp() {
) : (
-
+
+ Booting canvas…
+
+ }
+ >
+
+
)}
@@ -127,20 +120,22 @@ function EmptyState({
status === 'connecting'
? ['Connecting to Flatbread', 'Waiting for the live schema on port 5057.']
: status === 'error' || status === 'disconnected'
- ? [
- "Can't reach Flatbread",
- error?.message ??
- 'Start the dev server with `pnpm play:efforts` so GraphQL is served on port 5057.',
- ]
- : [
- 'No Effort Graph records yet',
- 'Nothing found in .flatbread-efforts. Journal a record and it will grow in here.',
- ];
+ ? [
+ "Can't reach Flatbread",
+ error?.message ??
+ 'Run `flatbread start --watch --open` so GraphQL is served (default port 5057).',
+ ]
+ : [
+ 'No Effort Graph records yet',
+ 'Nothing found in the Effort Graph content root. Journal a record and it will grow in here.',
+ ];
return (
{heading}
-
{message}
+
+ {message}
+
);
}
diff --git a/examples/effort-viz/app/components/GraphCanvas.tsx b/packages/explorer/src/web/app/components/GraphCanvas.tsx
similarity index 94%
rename from examples/effort-viz/app/components/GraphCanvas.tsx
rename to packages/explorer/src/web/app/components/GraphCanvas.tsx
index 171fcfcc..09837e45 100644
--- a/examples/effort-viz/app/components/GraphCanvas.tsx
+++ b/packages/explorer/src/web/app/components/GraphCanvas.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Html, OrbitControls } from '@react-three/drei';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -23,31 +21,39 @@ import {
type SimEdge,
type SimNode,
type VeinPoint,
-} from '@/lib/physics';
+} from '@/core/physics';
import {
effortOklch,
oklchToThreeColor,
retiredOklch,
structuralOklch,
type Oklch,
-} from '@/lib/oklch';
-import { PRIMITIVES, primitiveOklch } from '@/lib/primitives';
+} from '@/core/oklch';
+import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives';
import {
CIRCLE_SEGMENTS,
GLYPH_OUTLINES,
RING_INNER_RATIO,
glyphExtent,
type GlyphId,
-} from '@/lib/glyphs';
+} from '@/presets/effort-graph/glyphs';
import {
buildAlivenessMap,
isOpenBlocker,
type Aliveness,
type EffectiveLifecycle,
-} from '@/lib/lifecycle';
-import type { GraphEdge, GraphEdgeKind, GraphNode } from '@/lib/types';
+} from '@/presets/effort-graph/lifecycle';
+import type {
+ GraphEdge,
+ GraphEdgeKind,
+ GraphNode,
+} from '@/presets/effort-graph/types';
import { useTheme, type ColorMode } from '../hooks/useTheme';
-import { RELATION_META, relationStrokeOklch, type RelationMeta } from './RelationLegend';
+import {
+ RELATION_META,
+ relationStrokeOklch,
+ type RelationMeta,
+} from './RelationLegend';
export interface GraphCanvasProps {
nodes: GraphNode[];
@@ -79,7 +85,10 @@ function toSimInputs(
return { simNodes, simEdges };
}
-function idsChanged(current: Array<{ id: string }>, previous: string[]): boolean {
+function idsChanged(
+ current: Array<{ id: string }>,
+ previous: string[]
+): boolean {
if (current.length !== previous.length) return true;
for (let i = 0; i < current.length; i++) {
if (current[i].id !== previous[i]) return true;
@@ -106,7 +115,8 @@ function glyphGeometry(glyph: GlyphId): THREE.BufferGeometry {
const shape = new THREE.Shape();
const outline = GLYPH_OUTLINES[glyph];
shape.moveTo(outline[0].x, outline[0].y);
- for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i].x, outline[i].y);
+ for (let i = 1; i < outline.length; i++)
+ shape.lineTo(outline[i].x, outline[i].y);
shape.closePath();
geometry = new THREE.ShapeGeometry(shape);
}
@@ -126,7 +136,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
const [focusedId, setFocusedId] = useState(null);
const wrapperRef = useRef(null);
const takeoverRef = useRef(null);
- if (takeoverRef.current === null) takeoverRef.current = createCameraTakeover();
+ if (takeoverRef.current === null)
+ takeoverRef.current = createCameraTakeover();
const takeover = takeoverRef.current;
/**
@@ -167,7 +178,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
if (walkOrder.length === 0) return;
const current = focusedId ?? selectedId;
const index = current ? walkOrder.findIndex((n) => n.id === current) : -1;
- const next = walkOrder[(index + delta + walkOrder.length) % walkOrder.length];
+ const next =
+ walkOrder[(index + delta + walkOrder.length) % walkOrder.length];
setFocusedId(next.id);
},
[walkOrder, focusedId, selectedId]
@@ -192,7 +204,8 @@ export default function GraphCanvas(props: GraphCanvasProps) {
break;
case 'End':
event.preventDefault();
- if (walkOrder.length > 0) setFocusedId(walkOrder[walkOrder.length - 1].id);
+ if (walkOrder.length > 0)
+ setFocusedId(walkOrder[walkOrder.length - 1].id);
break;
case 'Enter':
case ' ':
@@ -215,7 +228,9 @@ export default function GraphCanvas(props: GraphCanvasProps) {
[step, walkOrder, focusedId, selectedId, onSelect]
);
- const focusedNode = focusedId ? nodes.find((n) => n.id === focusedId) : undefined;
+ const focusedNode = focusedId
+ ? nodes.find((n) => n.id === focusedId)
+ : undefined;
const focusedLife = focusedId ? lifecycles.get(focusedId) : undefined;
return (
@@ -236,8 +251,9 @@ export default function GraphCanvas(props: GraphCanvasProps) {
className="relative h-full w-full outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
- Use the arrow keys to move between records, Enter to open a record's
- details, and Escape to close. Drag to pan and scroll to zoom.
+ Use the arrow keys to move between records, Enter to open a
+ record's details, and Escape to close. Drag to pan and scroll to
+ zoom.
{
const { simNodes, simEdges } = toSimInputs(nodes, edges);
- const settledBefore = sim.getState().nodes.some((n) => n.state === 'settled');
+ const settledBefore = sim
+ .getState()
+ .nodes.some((n) => n.state === 'settled');
sim.sync(simNodes, simEdges);
/*
* Reduced motion: settle the layout and finish every growth animation
@@ -633,7 +651,10 @@ function FitCamera({
* A wheel gesture or a drag past the tap threshold is unambiguous.
*/
function createCameraTakeover() {
- const state = { moved: false, downAt: null as { x: number; y: number } | null };
+ const state = {
+ moved: false,
+ downAt: null as { x: number; y: number } | null,
+ };
return {
get: () => state.moved,
onWheel: () => {
@@ -645,7 +666,9 @@ function createCameraTakeover() {
onPointerMove: (event: React.PointerEvent) => {
const down = state.downAt;
if (!down) return;
- if (Math.hypot(event.clientX - down.x, event.clientY - down.y) > TAP_SLOP_PX) {
+ if (
+ Math.hypot(event.clientX - down.x, event.clientY - down.y) > TAP_SLOP_PX
+ ) {
state.moved = true;
}
},
@@ -810,7 +833,9 @@ function NodeMesh({
const haloColor = useMemo(
() =>
oklchToThreeColor(
- mode === 'light' ? { l: 0.2, c: 0.01, h: 260 } : { l: 0.96, c: 0.01, h: 260 }
+ mode === 'light'
+ ? { l: 0.2, c: 0.01, h: 260 }
+ : { l: 0.96, c: 0.01, h: 260 }
),
[mode]
);
@@ -853,9 +878,12 @@ function NodeMesh({
}
});
- const handlePointerDown = useCallback((event: { clientX: number; clientY: number }) => {
- pointerStart.current = { x: event.clientX, y: event.clientY };
- }, []);
+ const handlePointerDown = useCallback(
+ (event: { clientX: number; clientY: number }) => {
+ pointerStart.current = { x: event.clientX, y: event.clientY };
+ },
+ []
+ );
/**
* Commit selection on pointer-up, and only when the pointer barely moved.
@@ -863,11 +891,18 @@ function NodeMesh({
* every time a pan happened to start over a record.
*/
const handlePointerUp = useCallback(
- (event: { clientX: number; clientY: number; stopPropagation: () => void }) => {
+ (event: {
+ clientX: number;
+ clientY: number;
+ stopPropagation: () => void;
+ }) => {
const start = pointerStart.current;
pointerStart.current = null;
if (!start) return;
- const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y);
+ const travelled = Math.hypot(
+ event.clientX - start.x,
+ event.clientY - start.y
+ );
if (travelled > TAP_SLOP_PX) return;
event.stopPropagation();
onSelect(node.id);
@@ -878,7 +913,12 @@ function NodeMesh({
return (
-
+
{blocker && !retired && }
-
+
{/*
A hub's core: fills the ring's hole so membership spokes converging on
@@ -1129,7 +1173,9 @@ function EdgeLine({
positions[i * 3 + 1] = visible[i].y;
positions[i * 3 + 2] = 0;
}
- const attr = geom.getAttribute('position') as THREE.BufferAttribute | undefined;
+ const attr = geom.getAttribute('position') as
+ | THREE.BufferAttribute
+ | undefined;
if (attr) attr.needsUpdate = true;
geom.setDrawRange(0, visible.length);
// `computeLineDistances` allocates a fresh attribute on every call, so
@@ -1143,7 +1189,8 @@ function EdgeLine({
const highlighted =
(activeId !== null && (activeId === edge.from || activeId === edge.to)) ||
- (hoveredId !== null && (hoveredId === edge.from || hoveredId === edge.to));
+ (hoveredId !== null &&
+ (hoveredId === edge.from || hoveredId === edge.to));
const baseOpacity = relationEmphasisOpacity(meta.emphasis);
const modeScale = mode === 'dark' ? 1.05 : 0.95;
@@ -1255,7 +1302,10 @@ function ClusterLabel({
return (
-
+
);
}
diff --git a/examples/effort-viz/app/components/Legend.tsx b/packages/explorer/src/web/app/components/Legend.tsx
similarity index 88%
rename from examples/effort-viz/app/components/Legend.tsx
rename to packages/explorer/src/web/app/components/Legend.tsx
index c143fa8f..a6575d1a 100644
--- a/examples/effort-viz/app/components/Legend.tsx
+++ b/packages/explorer/src/web/app/components/Legend.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { useId, useState } from 'react';
import {
@@ -8,9 +6,13 @@ import {
retiredOklch,
structuralOklch,
type ColorMode,
-} from '@/lib/oklch';
-import { PRIMITIVES, PRIMITIVE_ORDER, primitiveOklch } from '@/lib/primitives';
-import type { GraphNode } from '@/lib/types';
+} from '@/core/oklch';
+import {
+ PRIMITIVES,
+ PRIMITIVE_ORDER,
+ primitiveOklch,
+} from '@/presets/effort-graph/primitives';
+import type { GraphNode } from '@/presets/effort-graph/types';
import { useTheme } from '../hooks/useTheme';
import {
PrimitiveGlyph,
@@ -64,7 +66,11 @@ export function Legend({ efforts, presentGroups }: LegendProps) {
}`}
aria-hidden
>
-
+
@@ -97,7 +103,9 @@ export function Legend({ efforts, presentGroups }: LegendProps) {
size={13}
core={
kind === 'effort'
- ? oklchCss(structuralOklch(mode === 'light' ? 'dark' : 'light'))
+ ? oklchCss(
+ structuralOklch(mode === 'light' ? 'dark' : 'light')
+ )
: undefined
}
/>
@@ -130,9 +138,13 @@ export function Legend({ efforts, presentGroups }: LegendProps) {
mode={mode}
size={13}
retired
- tint={oklchCss(retiredOklch(primitiveOklch('decision', mode), mode))}
+ tint={oklchCss(
+ retiredOklch(primitiveOklch('decision', mode), mode)
+ )}
/>
- Retired
+
+ Retired
+
Rejected, superseded, invalidated, or won't fix.
@@ -140,7 +152,9 @@ export function Legend({ efforts, presentGroups }: LegendProps) {
Blocker
- open, gating work
+
+ open, gating work
+
@@ -193,7 +207,10 @@ export function Legend({ efforts, presentGroups }: LegendProps) {
className="size-2.5 shrink-0 rounded-full"
style={{ background: effortColor(effort.id, mode).css }}
/>
-
+
{effort.title}
@@ -224,7 +241,13 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
*/
function BlockerSample({ mode }: { mode: ColorMode }) {
return (
-
+
(
{children}
@@ -101,7 +102,10 @@ export function MarkdownSurface({
a: ({ href, children }) => {
const target = href ?? '';
const recordId =
- target && !target.startsWith('#') && !hasScheme(target) && resolveRecord
+ target &&
+ !target.startsWith('#') &&
+ !hasScheme(target) &&
+ resolveRecord
? resolveRecord(normalizeNavigateTarget(target))
: null;
@@ -127,7 +131,9 @@ export function MarkdownSurface({
: {})}
>
{children}
- {external && (opens in a new tab) }
+ {external && (
+ (opens in a new tab)
+ )}
);
},
@@ -198,7 +204,9 @@ export function MarkdownSurface({
return ;
},
strong: ({ children }) => (
- {children}
+
+ {children}
+
),
em: ({ children }) => (
{children}
diff --git a/examples/effort-viz/app/components/RelationLegend.tsx b/packages/explorer/src/web/app/components/RelationLegend.tsx
similarity index 82%
rename from examples/effort-viz/app/components/RelationLegend.tsx
rename to packages/explorer/src/web/app/components/RelationLegend.tsx
index 82680a8f..8f2b8191 100644
--- a/examples/effort-viz/app/components/RelationLegend.tsx
+++ b/packages/explorer/src/web/app/components/RelationLegend.tsx
@@ -1,10 +1,14 @@
-'use client';
-
-import type { GraphEdgeKind, GraphNodeKind } from '@/lib/types';
-import type { ColorMode } from '@/lib/oklch';
-import { oklchCss, structuralOklch } from '@/lib/oklch';
-import { PRIMITIVES, primitiveOklch } from '@/lib/primitives';
-import { RING_INNER_RATIO, glyphSvgPoints } from '@/lib/glyphs';
+import type {
+ GraphEdgeKind,
+ GraphNodeKind,
+} from '@/presets/effort-graph/types';
+import type { ColorMode } from '@/core/oklch';
+import { oklchCss, structuralOklch } from '@/core/oklch';
+import { PRIMITIVES, primitiveOklch } from '@/presets/effort-graph/primitives';
+import {
+ RING_INNER_RATIO,
+ glyphSvgPoints,
+} from '@/presets/effort-graph/glyphs';
export type RelationGroupId =
| 'lineage'
@@ -41,7 +45,8 @@ export const RELATION_META: Record = {
derives_from: {
kind: 'derives_from',
label: 'Derives from',
- description: 'Causal upstream evidence or context this record was built on.',
+ description:
+ 'Causal upstream evidence or context this record was built on.',
group: 'lineage',
directionHint: { outgoing: 'Builds on', incoming: 'Informs' },
dash: 'solid',
@@ -76,7 +81,10 @@ export const RELATION_META: Record = {
label: 'Invalidates',
description: 'Says an earlier record was wrong — not merely replaced.',
group: 'invalidation',
- directionHint: { outgoing: 'Shows this was wrong', incoming: 'Shown wrong by' },
+ directionHint: {
+ outgoing: 'Shows this was wrong',
+ incoming: 'Shown wrong by',
+ },
dash: 'dashed',
weight: 'medium',
emphasis: 'strong',
@@ -197,9 +205,13 @@ export function relationStrokeOklch(
mode: ColorMode
): { l: number; c: number; h: number } {
if (group === 'resolution' || group === 'mitigation') {
- return mode === 'light' ? { l: 0.52, c: 0.12, h: 196 } : { l: 0.72, c: 0.1, h: 196 };
+ return mode === 'light'
+ ? { l: 0.52, c: 0.12, h: 196 }
+ : { l: 0.72, c: 0.1, h: 196 };
}
- return mode === 'light' ? { l: 0.42, c: 0.015, h: 260 } : { l: 0.76, c: 0.015, h: 260 };
+ return mode === 'light'
+ ? { l: 0.42, c: 0.015, h: 260 }
+ : { l: 0.76, c: 0.015, h: 260 };
}
/**
@@ -225,12 +237,14 @@ const LIFECYCLE_BADGE: Record = {
},
'effort:abandoned': {
label: 'Abandoned',
- className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
+ className:
+ 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
},
// Issue
'issue:open': {
label: 'Open',
- className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
+ className:
+ 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
},
'issue:resolved': {
label: 'Resolved',
@@ -243,12 +257,14 @@ const LIFECYCLE_BADGE: Record = {
},
'issue:wontfix': {
label: "Won't fix",
- className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
+ className:
+ 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
},
// Decision
'decision:proposed': {
label: 'Proposed',
- className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
+ className:
+ 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
},
'decision:accepted': {
label: 'Accepted',
@@ -257,20 +273,24 @@ const LIFECYCLE_BADGE: Record = {
},
'decision:rejected': {
label: 'Rejected',
- className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
+ className:
+ 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
},
'decision:superseded': {
label: 'Superseded',
- className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
+ className:
+ 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
},
'decision:deprecated': {
label: 'Deprecated',
- className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
+ className:
+ 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
},
// Risk
'risk:open': {
label: 'Open',
- className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
+ className:
+ 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
},
'risk:mitigated': {
label: 'Mitigated',
@@ -279,25 +299,30 @@ const LIFECYCLE_BADGE: Record = {
},
'risk:realized': {
label: 'Realized',
- className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
+ className:
+ 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
},
'risk:accepted': {
label: 'Accepted risk',
- className: 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
+ className:
+ 'border-amber-500/40 bg-amber-500/12 text-amber-800 dark:text-amber-200',
},
};
/** Applies to any primitive — these come from edges, not frontmatter. */
-const EDGE_DERIVED_BADGE: Record = {
- superseded: {
- label: 'Superseded',
- className: 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
- },
- invalidated: {
- label: 'Invalidated',
- className: 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
- },
-};
+const EDGE_DERIVED_BADGE: Record =
+ {
+ superseded: {
+ label: 'Superseded',
+ className:
+ 'border-border bg-muted/12 text-muted line-through decoration-muted/60',
+ },
+ invalidated: {
+ label: 'Invalidated',
+ className:
+ 'border-rose-500/40 bg-rose-500/12 text-rose-800 dark:text-rose-200',
+ },
+ };
export function lifecycleBadge(
kind: GraphNodeKind,
@@ -327,7 +352,8 @@ function strokeDash(
): string | undefined {
if (dash === 'solid') return undefined;
const scale = weight === 'medium' ? 1.15 : weight === 'bold' ? 1.3 : 1;
- if (dash === 'dashed') return `${(5 * scale).toFixed(1)} ${(3 * scale).toFixed(1)}`;
+ if (dash === 'dashed')
+ return `${(5 * scale).toFixed(1)} ${(3 * scale).toFixed(1)}`;
return `${(1.6 * scale).toFixed(1)} ${(2.6 * scale).toFixed(1)}`;
}
@@ -375,7 +401,11 @@ export function RelationLineSample({
opacity={opacity}
/>
{meta.arrow && (
-
+
)}
);
@@ -428,7 +458,12 @@ export function PrimitiveGlyph({
strokeWidth={half * (1 - RING_INNER_RATIO)}
/>
{core && (
-
+
)}
>
) : points ? (
diff --git a/examples/effort-viz/app/components/TopBar.tsx b/packages/explorer/src/web/app/components/TopBar.tsx
similarity index 86%
rename from examples/effort-viz/app/components/TopBar.tsx
rename to packages/explorer/src/web/app/components/TopBar.tsx
index f0654644..c64a1395 100644
--- a/examples/effort-viz/app/components/TopBar.tsx
+++ b/packages/explorer/src/web/app/components/TopBar.tsx
@@ -1,11 +1,9 @@
-'use client';
-
import { useTheme } from '../hooks/useTheme';
-import type { GraphSummary } from '@/lib/lifecycle';
+import type { GraphSummary } from '@/presets/effort-graph/lifecycle';
import {
liveStatusLabel,
type LiveStatus,
-} from '@/lib/useEffortGraphLive';
+} from '@/presets/effort-graph/useEffortGraphLive';
interface TopBarProps {
status: LiveStatus;
@@ -42,7 +40,11 @@ export function TopBar({ status, generation, summary }: TopBarProps) {
<>
{' · '}
-
+
>
)}
@@ -67,7 +69,7 @@ function Count({
}) {
return (
<>
- {value} {value === 1 ? label : (plural ?? `${label}s`)}
+ {value} {value === 1 ? label : plural ?? `${label}s`}
>
);
}
@@ -83,10 +85,10 @@ function StatusPill({
status === 'live'
? 'bg-emerald-500'
: status === 'connecting'
- ? 'bg-amber-500 motion-safe:animate-pulse'
- : status === 'partial'
- ? 'bg-amber-500'
- : 'bg-red-500';
+ ? 'bg-amber-500 motion-safe:animate-pulse'
+ : status === 'partial'
+ ? 'bg-amber-500'
+ : 'bg-red-500';
return (
-
+
{liveStatusLabel(status)}
@@ -112,7 +117,8 @@ function StatusPill({
function ThemeToggle() {
const { mode, toggle } = useTheme();
- const label = mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
+ const label =
+ mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
return (
{children};
+ return (
+ {children}
+ );
}
export function useTheme(): ThemeContextValue {
diff --git a/packages/explorer/src/web/app/main.tsx b/packages/explorer/src/web/app/main.tsx
new file mode 100644
index 00000000..21c744a2
--- /dev/null
+++ b/packages/explorer/src/web/app/main.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { ThemeProvider } from './hooks/useTheme';
+import { EffortGraphApp } from './components/EffortGraphApp';
+import './globals.css';
+
+const root = document.getElementById('root');
+if (!root) {
+ throw new Error('Flatbread explorer root element #root was not found');
+}
+
+createRoot(root).render(
+
+
+
+
+
+);
diff --git a/packages/explorer/src/web/core/endpoints.test.ts b/packages/explorer/src/web/core/endpoints.test.ts
new file mode 100644
index 00000000..5fc140ce
--- /dev/null
+++ b/packages/explorer/src/web/core/endpoints.test.ts
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ normalizeGraphqlUrl,
+ resolveEventsUrl,
+ resolveGraphqlEndpoint,
+} from './endpoints.js';
+
+describe('resolveGraphqlEndpoint', () => {
+ it('prefers ?endpoint= over bootstrap', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=https://api.example.com/graphql',
+ {
+ preset: 'effort-graph',
+ graphqlPath: '/graphql',
+ eventsPath: '/events',
+ },
+ 'http://localhost:5057'
+ );
+ assert.equal(endpoint, 'https://api.example.com/graphql');
+ });
+
+ it('uses bootstrap same-origin paths', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '',
+ {
+ preset: 'effort-graph',
+ graphqlPath: '/graphql',
+ eventsPath: '/events',
+ },
+ 'http://localhost:5057'
+ );
+ assert.equal(endpoint, 'http://localhost:5057/graphql');
+ });
+
+ it('falls back to localhost when nothing is configured', () => {
+ const endpoint = resolveGraphqlEndpoint('', undefined, 'file://');
+ assert.equal(endpoint, 'http://localhost:5057/graphql');
+ });
+});
+
+describe('normalizeGraphqlUrl', () => {
+ it('appends /graphql when only an origin is given', () => {
+ assert.equal(
+ normalizeGraphqlUrl('https://api.example.com'),
+ 'https://api.example.com/graphql'
+ );
+ });
+});
+
+describe('resolveEventsUrl', () => {
+ it('derives SSE URL from the GraphQL origin', () => {
+ assert.equal(
+ resolveEventsUrl('http://localhost:5057/graphql'),
+ 'http://localhost:5057/events'
+ );
+ });
+});
diff --git a/packages/explorer/src/web/core/endpoints.ts b/packages/explorer/src/web/core/endpoints.ts
new file mode 100644
index 00000000..4730c2b9
--- /dev/null
+++ b/packages/explorer/src/web/core/endpoints.ts
@@ -0,0 +1,96 @@
+export interface ExplorerBootstrap {
+ preset: string;
+ graphqlPath: string;
+ eventsPath: string;
+}
+
+const DEFAULT_GRAPHQL_PATH = '/graphql';
+const DEFAULT_EVENTS_PATH = '/events';
+const DEFAULT_PORT = 5057;
+
+declare global {
+ interface Window {
+ __FLATBREAD_EXPLORER__?: ExplorerBootstrap;
+ }
+}
+
+/**
+ * Resolve the GraphQL HTTP endpoint for the explorer SPA.
+ *
+ * Priority: `?endpoint=` query param → injected bootstrap (same-origin) →
+ * localhost default for standalone static deploys.
+ */
+export function resolveGraphqlEndpoint(
+ search: string = typeof window !== 'undefined' ? window.location.search : '',
+ bootstrap: ExplorerBootstrap | undefined = typeof window !== 'undefined'
+ ? window.__FLATBREAD_EXPLORER__
+ : undefined,
+ locationOrigin: string = typeof window !== 'undefined'
+ ? window.location.origin
+ : `http://localhost:${DEFAULT_PORT}`
+): string {
+ const params = new URLSearchParams(search);
+ const fromQuery = params.get('endpoint');
+ if (fromQuery) {
+ return normalizeGraphqlUrl(fromQuery);
+ }
+
+ if (bootstrap?.graphqlPath) {
+ return new URL(bootstrap.graphqlPath, locationOrigin).href;
+ }
+
+ // Standalone static host with no bootstrap: talk to local Flatbread.
+ if (
+ typeof window !== 'undefined' &&
+ !window.__FLATBREAD_EXPLORER__ &&
+ locationOrigin.includes('://')
+ ) {
+ // Prefer same-origin /graphql when served by Flatbread; fall back for file:// or alien hosts.
+ try {
+ const url = new URL(locationOrigin);
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
+ return new URL(DEFAULT_GRAPHQL_PATH, locationOrigin).href;
+ }
+ } catch {
+ // fall through
+ }
+ }
+
+ return `http://localhost:${DEFAULT_PORT}${DEFAULT_GRAPHQL_PATH}`;
+}
+
+export function resolveEventsUrl(graphqlEndpoint: string): string {
+ const origin = new URL(graphqlEndpoint).origin;
+ const bootstrap =
+ typeof window !== 'undefined' ? window.__FLATBREAD_EXPLORER__ : undefined;
+ const eventsPath = bootstrap?.eventsPath ?? DEFAULT_EVENTS_PATH;
+ return new URL(eventsPath, origin).href;
+}
+
+export function normalizeGraphqlUrl(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return `http://localhost:${DEFAULT_PORT}${DEFAULT_GRAPHQL_PATH}`;
+ }
+ try {
+ const url = new URL(trimmed);
+ if (!url.pathname || url.pathname === '/') {
+ url.pathname = DEFAULT_GRAPHQL_PATH;
+ }
+ return url.href;
+ } catch {
+ // Relative path like `/graphql`
+ if (trimmed.startsWith('/')) {
+ const origin =
+ typeof window !== 'undefined'
+ ? window.location.origin
+ : `http://localhost:${DEFAULT_PORT}`;
+ return new URL(trimmed, origin).href;
+ }
+ return normalizeGraphqlUrl(`http://${trimmed}`);
+ }
+}
+
+export function graphqlOrigin(endpoint: string): string {
+ return new URL(endpoint).origin;
+}
diff --git a/examples/effort-viz/lib/graphql.ts b/packages/explorer/src/web/core/graphql.ts
similarity index 100%
rename from examples/effort-viz/lib/graphql.ts
rename to packages/explorer/src/web/core/graphql.ts
diff --git a/examples/effort-viz/lib/oklch.ts b/packages/explorer/src/web/core/oklch.ts
similarity index 87%
rename from examples/effort-viz/lib/oklch.ts
rename to packages/explorer/src/web/core/oklch.ts
index 751790e7..4f53bb22 100644
--- a/examples/effort-viz/lib/oklch.ts
+++ b/packages/explorer/src/web/core/oklch.ts
@@ -47,7 +47,9 @@ export function effortOklch(id: string, mode: ColorMode = 'light'): Oklch {
* commands attention through size and silhouette.
*/
export function structuralOklch(mode: ColorMode): Oklch {
- return mode === 'light' ? { l: 0.5, c: 0.008, h: 260 } : { l: 0.56, c: 0.008, h: 260 };
+ return mode === 'light'
+ ? { l: 0.5, c: 0.008, h: 260 }
+ : { l: 0.56, c: 0.008, h: 260 };
}
/**
@@ -64,7 +66,10 @@ export function retiredOklch(base: Oklch, mode: ColorMode): Oklch {
// Light mode gets a smaller push: white leaves much less room below a
// record's lightness than black leaves above it, so the same offset drops
// a retired glyph under the 3:1 non-text contrast floor.
- l: mode === 'light' ? Math.min(base.l + 0.06, 0.7) : Math.max(base.l - 0.14, 0.44),
+ l:
+ mode === 'light'
+ ? Math.min(base.l + 0.06, 0.7)
+ : Math.max(base.l - 0.14, 0.44),
c: base.c * 0.34,
h: base.h,
};
@@ -95,17 +100,11 @@ export function oklchToSrgb({ l, c, h }: Oklch): [number, number, number] {
const sLinear = sPrime ** 3;
const r =
- 4.0767416621 * lLinear -
- 3.3077115913 * mLinear +
- 0.2309699292 * sLinear;
+ 4.0767416621 * lLinear - 3.3077115913 * mLinear + 0.2309699292 * sLinear;
const g =
- -1.2684380046 * lLinear +
- 2.6097574011 * mLinear -
- 0.3413193965 * sLinear;
+ -1.2684380046 * lLinear + 2.6097574011 * mLinear - 0.3413193965 * sLinear;
const bChannel =
- -0.0041960863 * lLinear -
- 0.7034186147 * mLinear +
- 1.707614701 * sLinear;
+ -0.0041960863 * lLinear - 0.7034186147 * mLinear + 1.707614701 * sLinear;
return [linearToSrgb(r), linearToSrgb(g), linearToSrgb(bChannel)];
}
@@ -125,10 +124,7 @@ export function oklchToThreeColor(oklch: Oklch): number {
return Number.parseInt(hex, 16);
}
-export function effortColor(
- id: string,
- mode: ColorMode = 'light'
-): OklchColor {
+export function effortColor(id: string, mode: ColorMode = 'light'): OklchColor {
return toColor(effortOklch(id, mode));
}
diff --git a/examples/effort-viz/lib/physics/forces.test.ts b/packages/explorer/src/web/core/physics/forces.test.ts
similarity index 89%
rename from examples/effort-viz/lib/physics/forces.test.ts
rename to packages/explorer/src/web/core/physics/forces.test.ts
index 4d6d5e6d..368d5a51 100644
--- a/examples/effort-viz/lib/physics/forces.test.ts
+++ b/packages/explorer/src/web/core/physics/forces.test.ts
@@ -91,23 +91,40 @@ describe('computeClusters', () => {
const seen: number[] = [];
for (let s = 0; s < clusters.count; s++) {
- for (let k = clusters.memberStart[s]; k < clusters.memberStart[s + 1]; k++) {
+ for (
+ let k = clusters.memberStart[s];
+ k < clusters.memberStart[s + 1];
+ k++
+ ) {
const i = clusters.memberIndices[k];
assert.equal(clusters.nodeCluster[i], s);
seen.push(i);
}
}
- assert.deepEqual([...seen].sort((x, y) => x - y), [0, 1, 2, 3, 4]);
+ assert.deepEqual(
+ [...seen].sort((x, y) => x - y),
+ [0, 1, 2, 3, 4]
+ );
});
test('member counts survive the bucketing pass', () => {
// Bucketing borrows `members` as a write cursor; it must be restored or
// separation would divide its impulse by a byte offset.
- const nodes = [node('a1', 'A', 0, 0), node('a2', 'A', 1, 1), node('b1', 'B', 9, 9)];
+ const nodes = [
+ node('a1', 'A', 0, 0),
+ node('a2', 'A', 1, 1),
+ node('b1', 'B', 9, 9),
+ ];
const scratch = ensureScratch(null, nodes.length);
computeClusters(nodes, scratch);
- assert.equal(scratch.clusters.members[scratch.clusters.indexOf.get('A')!], 2);
- assert.equal(scratch.clusters.members[scratch.clusters.indexOf.get('B')!], 1);
+ assert.equal(
+ scratch.clusters.members[scratch.clusters.indexOf.get('A')!],
+ 2
+ );
+ assert.equal(
+ scratch.clusters.members[scratch.clusters.indexOf.get('B')!],
+ 1
+ );
});
});
@@ -168,7 +185,11 @@ describe('applyClusterSeparation', () => {
describe('applyClusterCohesion', () => {
test('pulls members toward their own centroid only', () => {
- const nodes = [node('a1', 'A', -10, 0), node('a2', 'A', 10, 0), node('b1', 'B', 99, 0)];
+ const nodes = [
+ node('a1', 'A', -10, 0),
+ node('a2', 'A', 10, 0),
+ node('b1', 'B', 99, 0),
+ ];
const scratch = ensureScratch(null, nodes.length);
computeClusters(nodes, scratch);
applyClusterCohesion(nodes, scratch, 1);
diff --git a/examples/effort-viz/lib/physics/forces.ts b/packages/explorer/src/web/core/physics/forces.ts
similarity index 96%
rename from examples/effort-viz/lib/physics/forces.ts
rename to packages/explorer/src/web/core/physics/forces.ts
index 904d6268..a5eece0f 100644
--- a/examples/effort-viz/lib/physics/forces.ts
+++ b/packages/explorer/src/web/core/physics/forces.ts
@@ -40,7 +40,10 @@ export interface ForceScratch {
clusters: ClusterScratch;
}
-export function ensureScratch(scratch: ForceScratch | null, n: number): ForceScratch {
+export function ensureScratch(
+ scratch: ForceScratch | null,
+ n: number
+): ForceScratch {
if (scratch && scratch.fx.length >= n) {
scratch.fx.fill(0, 0, n);
scratch.fy.fill(0, 0, n);
@@ -255,7 +258,7 @@ export function applyClusterSeparation(
// impulse that only the integrator's step clamp keeps in bounds.
let dist = Math.hypot(dx, dy);
if (dist < 0.5) {
- dx = (a - b) - 0.5;
+ dx = a - b - 0.5;
dy = 0.5;
dist = Math.hypot(dx, dy);
}
@@ -269,12 +272,20 @@ export function applyClusterSeparation(
const shareA = 1 / clusters.members[a];
const shareB = 1 / clusters.members[b];
- for (let k = clusters.memberStart[a]; k < clusters.memberStart[a + 1]; k++) {
+ for (
+ let k = clusters.memberStart[a];
+ k < clusters.memberStart[a + 1];
+ k++
+ ) {
const i = clusters.memberIndices[k];
fx[i] -= nx * shareA;
fy[i] -= ny * shareA;
}
- for (let k = clusters.memberStart[b]; k < clusters.memberStart[b + 1]; k++) {
+ for (
+ let k = clusters.memberStart[b];
+ k < clusters.memberStart[b + 1];
+ k++
+ ) {
const i = clusters.memberIndices[k];
fx[i] += nx * shareB;
fy[i] += ny * shareB;
diff --git a/examples/effort-viz/lib/physics/growth.ts b/packages/explorer/src/web/core/physics/growth.ts
similarity index 100%
rename from examples/effort-viz/lib/physics/growth.ts
rename to packages/explorer/src/web/core/physics/growth.ts
diff --git a/examples/effort-viz/lib/physics/hash.ts b/packages/explorer/src/web/core/physics/hash.ts
similarity index 94%
rename from examples/effort-viz/lib/physics/hash.ts
rename to packages/explorer/src/web/core/physics/hash.ts
index b9e779f9..b9f1c0f5 100644
--- a/examples/effort-viz/lib/physics/hash.ts
+++ b/packages/explorer/src/web/core/physics/hash.ts
@@ -38,7 +38,11 @@ export function rngFromId(id: string, seed = 0): () => number {
}
/** Uniform value in [min, max). */
-export function randRange(rand: () => number, min: number, max: number): number {
+export function randRange(
+ rand: () => number,
+ min: number,
+ max: number
+): number {
return min + (max - min) * rand();
}
diff --git a/examples/effort-viz/lib/physics/index.ts b/packages/explorer/src/web/core/physics/index.ts
similarity index 100%
rename from examples/effort-viz/lib/physics/index.ts
rename to packages/explorer/src/web/core/physics/index.ts
diff --git a/examples/effort-viz/lib/physics/simulation.test.ts b/packages/explorer/src/web/core/physics/simulation.test.ts
similarity index 91%
rename from examples/effort-viz/lib/physics/simulation.test.ts
rename to packages/explorer/src/web/core/physics/simulation.test.ts
index ccec2287..63390e86 100644
--- a/examples/effort-viz/lib/physics/simulation.test.ts
+++ b/packages/explorer/src/web/core/physics/simulation.test.ts
@@ -2,7 +2,7 @@
* Unit tests for the graph simulation engine.
*
* Uses the built-in `node:test` runner so it doesn't require a bundler.
- * Run from `examples/effort-viz`:
+ * Run from `packages/explorer`:
*
* pnpm test
*/
@@ -32,7 +32,11 @@ function makeGraph(): {
};
}
-function stepFor(sim: ReturnType, seconds: number, dt = 1 / 60) {
+function stepFor(
+ sim: ReturnType,
+ seconds: number,
+ dt = 1 / 60
+) {
const steps = Math.ceil(seconds / dt);
for (let i = 0; i < steps; i++) sim.step(dt);
}
@@ -131,7 +135,10 @@ describe('createGraphSimulation', () => {
for (const rec of state.nodes.filter((n) => n.kind === 'record')) {
const d = Math.hypot(rec.x - hub.x, rec.y - hub.y);
// Distance is bounded by parent radius * (0.6 + jitter).
- assert.ok(d <= hub.radius * (0.6 + 0.5) + 1e-6, `record ${rec.id} spawned too far (${d})`);
+ assert.ok(
+ d <= hub.radius * (0.6 + 0.5) + 1e-6,
+ `record ${rec.id} spawned too far (${d})`
+ );
}
});
@@ -171,7 +178,10 @@ describe('createGraphSimulation', () => {
);
// Small step so it retracts a bit but doesn't die.
sim.step(0.1);
- assert.equal(sim.getState().nodes.find((n) => n.id === 'rec-1')!.state, 'retracting');
+ assert.equal(
+ sim.getState().nodes.find((n) => n.id === 'rec-1')!.state,
+ 'retracting'
+ );
sim.sync(nodes, edges);
const revived = sim.getState().nodes.find((n) => n.id === 'rec-1')!;
assert.notEqual(revived.state, 'retracting');
@@ -182,7 +192,9 @@ describe('createGraphSimulation', () => {
const { nodes, edges } = makeGraph();
sim.sync(nodes, edges);
stepFor(sim, 2);
- const rec1Before = { ...sim.getState().nodes.find((n) => n.id === 'rec-1')! };
+ const rec1Before = {
+ ...sim.getState().nodes.find((n) => n.id === 'rec-1')!,
+ };
const expanded: GraphInputNode[] = [
...nodes,
@@ -199,8 +211,14 @@ describe('createGraphSimulation', () => {
sim.sync(expanded, expandedEdges);
stepFor(sim, 2);
const rec1After = sim.getState().nodes.find((n) => n.id === 'rec-1')!;
- const drift = Math.hypot(rec1After.x - rec1Before.x, rec1After.y - rec1Before.y);
- assert.ok(drift > 0, 'existing record should be nudged by the new arrivals');
+ const drift = Math.hypot(
+ rec1After.x - rec1Before.x,
+ rec1After.y - rec1Before.y
+ );
+ assert.ok(
+ drift > 0,
+ 'existing record should be nudged by the new arrivals'
+ );
});
it('is stable under many random steps (no NaN, bounded positions)', () => {
@@ -219,7 +237,10 @@ describe('createGraphSimulation', () => {
sim.sync(inputNodes, inputEdges);
for (let i = 0; i < 600; i++) sim.step(1 / 60);
for (const n of sim.getState().nodes) {
- assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y), `NaN position on ${n.id}`);
+ assert.ok(
+ Number.isFinite(n.x) && Number.isFinite(n.y),
+ `NaN position on ${n.id}`
+ );
assert.ok(Math.hypot(n.x, n.y) < 1e4, `runaway position on ${n.id}`);
}
});
diff --git a/examples/effort-viz/lib/physics/simulation.ts b/packages/explorer/src/web/core/physics/simulation.ts
similarity index 90%
rename from examples/effort-viz/lib/physics/simulation.ts
rename to packages/explorer/src/web/core/physics/simulation.ts
index 5d540a7e..26d1c3d2 100644
--- a/examples/effort-viz/lib/physics/simulation.ts
+++ b/packages/explorer/src/web/core/physics/simulation.ts
@@ -82,7 +82,9 @@ interface InternalEdge extends SimEdge {
* sim.step(delta);
* const { nodes, edges } = sim.getState();
*/
-export function createGraphSimulation(options: SimulationOptions = {}): GraphSimulation {
+export function createGraphSimulation(
+ options: SimulationOptions = {}
+): GraphSimulation {
const opts: Required = { ...DEFAULTS, ...options };
const nodes: InternalNode[] = [];
@@ -105,13 +107,20 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
for (let i = 0; i < nodes.length; i++) indexById.set(nodes[i].id, i);
}
- function spawnNode(input: GraphInputNode, parent: InternalNode | null): InternalNode {
+ function spawnNode(
+ input: GraphInputNode,
+ parent: InternalNode | null
+ ): InternalNode {
const rand = mulberry32(hashString(input.id) ^ opts.seed);
// Deterministic angle + jittered distance so reloads land nearby but not
// exactly on top of each other. `spawnJitter` scales distance in parent radii.
const angle = randAngle(rand);
const parentR = parent ? parent.radius : opts.effortHubRadius;
- const dist = randRange(rand, parentR * 0.6, parentR * (0.6 + opts.spawnJitter));
+ const dist = randRange(
+ rand,
+ parentR * 0.6,
+ parentR * (0.6 + opts.spawnJitter)
+ );
const px = parent ? parent.x : 0;
const py = parent ? parent.y : 0;
const radius = radiusFor(input);
@@ -160,7 +169,10 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
return edge;
}
- function sync(inputNodes: GraphInputNode[], inputEdges: GraphInputEdge[]): void {
+ function sync(
+ inputNodes: GraphInputNode[],
+ inputEdges: GraphInputEdge[]
+ ): void {
for (const n of nodes) n.present = false;
for (const e of edges) e.present = false;
@@ -186,7 +198,9 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
}
// Sort so effort hubs spawn before their records, giving records a real
// parent to spawn near.
- pending.sort((a, b) => Number(b.kind === 'effort') - Number(a.kind === 'effort'));
+ pending.sort(
+ (a, b) => Number(b.kind === 'effort') - Number(a.kind === 'effort')
+ );
for (const input of pending) {
let parent: InternalNode | null = null;
if (input.parentId) parent = nodesById.get(input.parentId) ?? null;
@@ -221,8 +235,10 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
}
// Anything not touched this sync starts retracting.
- for (const n of nodes) if (!n.present && n.state !== 'retracting') n.state = 'retracting';
- for (const e of edges) if (!e.present && e.state !== 'retracting') e.state = 'retracting';
+ for (const n of nodes)
+ if (!n.present && n.state !== 'retracting') n.state = 'retracting';
+ for (const e of edges)
+ if (!e.present && e.state !== 'retracting') e.state = 'retracting';
rebuildIndex();
}
@@ -271,7 +287,15 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
const from = nodesById.get(edge.from);
const to = nodesById.get(edge.to);
if (!from || !to) continue;
- buildVeinPath(from.x, from.y, to.x, to.y, edge.offsets, opts.veinNoise, edge.path);
+ buildVeinPath(
+ from.x,
+ from.y,
+ to.x,
+ to.y,
+ edge.offsets,
+ opts.veinNoise,
+ edge.path
+ );
}
}
@@ -288,7 +312,14 @@ export function createGraphSimulation(options: SimulationOptions = {}): GraphSim
scratch = ensureScratch(scratch, nodes.length);
computeClusters(nodes, scratch);
applyRepulsion(nodes, scratch, opts.repulsion, opts.restLengthPad);
- applyEdgeSprings(edges, nodes, indexById, scratch, opts.spring, opts.restLengthPad);
+ applyEdgeSprings(
+ edges,
+ nodes,
+ indexById,
+ scratch,
+ opts.spring,
+ opts.restLengthPad
+ );
applyClusterCohesion(nodes, scratch, opts.clusterCohesion);
applyClusterSeparation(scratch, opts.clusterSeparation, opts.clusterGap);
applyCentering(nodes, scratch, opts.centering, opts.centeringAspect);
diff --git a/examples/effort-viz/lib/physics/types.ts b/packages/explorer/src/web/core/physics/types.ts
similarity index 100%
rename from examples/effort-viz/lib/physics/types.ts
rename to packages/explorer/src/web/core/physics/types.ts
diff --git a/examples/effort-viz/lib/glyphs.test.ts b/packages/explorer/src/web/presets/effort-graph/glyphs.test.ts
similarity index 91%
rename from examples/effort-viz/lib/glyphs.test.ts
rename to packages/explorer/src/web/presets/effort-graph/glyphs.test.ts
index e5363347..5ddb9bb5 100644
--- a/examples/effort-viz/lib/glyphs.test.ts
+++ b/packages/explorer/src/web/presets/effort-graph/glyphs.test.ts
@@ -3,7 +3,7 @@ import test, { describe } from 'node:test';
import { GLYPH_OUTLINES, glyphExtent, glyphSvgPoints } from './glyphs';
import { PRIMITIVES, PRIMITIVE_ORDER, primitiveOklch } from './primitives';
-import { retiredOklch } from './oklch';
+import { retiredOklch } from '../../core/oklch';
function polygonArea(points: ReadonlyArray<{ x: number; y: number }>): number {
let sum = 0;
@@ -72,7 +72,10 @@ describe('primitive encoding', () => {
.map((kind) => PRIMITIVES[kind].tone!.h)
.sort((a, b) => a - b);
for (let i = 1; i < hues.length; i++) {
- assert.ok(hues[i] - hues[i - 1] >= 40, `hues ${hues[i - 1]} and ${hues[i]} too close`);
+ assert.ok(
+ hues[i] - hues[i - 1] >= 40,
+ `hues ${hues[i - 1]} and ${hues[i]} too close`
+ );
}
});
@@ -83,7 +86,11 @@ describe('primitive encoding', () => {
(kind) => primitiveOklch(kind, mode).l
)
);
- assert.equal(lightness.size, 1, `${mode} mode should use one record lightness`);
+ assert.equal(
+ lightness.size,
+ 1,
+ `${mode} mode should use one record lightness`
+ );
}
});
@@ -110,7 +117,9 @@ describe('primitive encoding', () => {
const distance = Math.abs(retired.l - (mode === 'light' ? 1 : 0.13));
assert.ok(
distance > 0.22,
- `${kind} retired lightness ${retired.l.toFixed(2)} too close to ${mode} background`
+ `${kind} retired lightness ${retired.l.toFixed(
+ 2
+ )} too close to ${mode} background`
);
assert.ok(retired.c < base.c, `${kind} retired should lose chroma`);
}
diff --git a/examples/effort-viz/lib/glyphs.ts b/packages/explorer/src/web/presets/effort-graph/glyphs.ts
similarity index 91%
rename from examples/effort-viz/lib/glyphs.ts
rename to packages/explorer/src/web/presets/effort-graph/glyphs.ts
index ca60bafa..e2bddec6 100644
--- a/examples/effort-viz/lib/glyphs.ts
+++ b/packages/explorer/src/web/presets/effort-graph/glyphs.ts
@@ -14,7 +14,13 @@
* constraints purely because of their silhouette.
*/
-export type GlyphId = 'circle' | 'diamond' | 'square' | 'slab' | 'triangle' | 'ring';
+export type GlyphId =
+ | 'circle'
+ | 'diamond'
+ | 'square'
+ | 'slab'
+ | 'triangle'
+ | 'ring';
export interface GlyphPoint {
x: number;
@@ -77,7 +83,10 @@ function rectangle(ratio: number): GlyphPoint[] {
* reason Constraint is a bar rather than the hexagon it started as, which was
* the least separable non-circle in the set.
*/
-export const GLYPH_OUTLINES: Record, GlyphPoint[]> = {
+export const GLYPH_OUTLINES: Record<
+ Exclude,
+ GlyphPoint[]
+> = {
/** Point-up triangle — the universal hazard sign. Reserved for Risk. */
triangle: normalizeArea(regularPolygon(3, QUARTER)),
/** Axis-aligned square — a block that has been laid down. Decision. */
@@ -103,11 +112,16 @@ export function glyphSvgPoints(glyph: GlyphId, size: number): string | null {
const outline = GLYPH_OUTLINES[glyph];
// Fit the widest vertex inside the box rather than assuming radius 1 — the
// area normalization pushes some outlines past the unit circle.
- const extent = Math.max(...outline.map((p) => Math.max(Math.abs(p.x), Math.abs(p.y))));
+ const extent = Math.max(
+ ...outline.map((p) => Math.max(Math.abs(p.x), Math.abs(p.y)))
+ );
const half = size / 2;
const scale = half / extent;
return outline
- .map(({ x, y }) => `${(half + x * scale).toFixed(2)},${(half - y * scale).toFixed(2)}`)
+ .map(
+ ({ x, y }) =>
+ `${(half + x * scale).toFixed(2)},${(half - y * scale).toFixed(2)}`
+ )
.join(' ');
}
diff --git a/examples/effort-viz/lib/lifecycle.test.ts b/packages/explorer/src/web/presets/effort-graph/lifecycle.test.ts
similarity index 82%
rename from examples/effort-viz/lib/lifecycle.test.ts
rename to packages/explorer/src/web/presets/effort-graph/lifecycle.test.ts
index 5617a23b..321b3a83 100644
--- a/examples/effort-viz/lib/lifecycle.test.ts
+++ b/packages/explorer/src/web/presets/effort-graph/lifecycle.test.ts
@@ -10,7 +10,9 @@ import {
} from './lifecycle';
import type { GraphEdge, GraphNode } from './types';
-function node(partial: Partial & Pick): GraphNode {
+function node(
+ partial: Partial & Pick
+): GraphNode {
return {
title: partial.id,
effortId: 'eff-1',
@@ -18,7 +20,11 @@ function node(partial: Partial & Pick): Gra
} as GraphNode;
}
-function edge(kind: GraphEdge['kind'], source: string, target: string): GraphEdge {
+function edge(
+ kind: GraphEdge['kind'],
+ source: string,
+ target: string
+): GraphEdge {
return { id: `${source}:${kind}:${target}`, kind, source, target };
}
@@ -26,8 +32,14 @@ describe('effectiveLifecycle', () => {
test('reports a superseded Decision as retired even though it says accepted', () => {
// This is the whole reason the module exists: the writer expresses
// supersession as an edge, so replaced Decisions keep `state: accepted`.
- const decision = node({ id: 'dec-old', kind: 'decision', lifecycle: 'accepted' });
- const index = buildLifecycleIndex([edge('superseded_by', 'dec-old', 'dec-new')]);
+ const decision = node({
+ id: 'dec-old',
+ kind: 'decision',
+ lifecycle: 'accepted',
+ });
+ const index = buildLifecycleIndex([
+ edge('superseded_by', 'dec-old', 'dec-new'),
+ ]);
const life = effectiveLifecycle(decision, index);
@@ -37,8 +49,14 @@ describe('effectiveLifecycle', () => {
});
test('derives supersession from the newer record’s forward edge too', () => {
- const decision = node({ id: 'dec-old', kind: 'decision', lifecycle: 'accepted' });
- const index = buildLifecycleIndex([edge('supersedes', 'dec-new', 'dec-old')]);
+ const decision = node({
+ id: 'dec-old',
+ kind: 'decision',
+ lifecycle: 'accepted',
+ });
+ const index = buildLifecycleIndex([
+ edge('supersedes', 'dec-new', 'dec-old'),
+ ]);
assert.equal(effectiveLifecycle(decision, index).aliveness, 'retired');
});
@@ -89,7 +107,11 @@ describe('effectiveLifecycle', () => {
[node({ id: 'g', kind: 'constraint' }), 'settled'],
];
for (const [record, expected] of cases) {
- assert.equal(effectiveLifecycle(record, index).aliveness, expected, record.id);
+ assert.equal(
+ effectiveLifecycle(record, index).aliveness,
+ expected,
+ record.id
+ );
}
});
@@ -109,7 +131,12 @@ describe('isOpenBlocker', () => {
kindLabel: 'blocker',
lifecycle: 'open',
});
- const gap = node({ id: 'iss-2', kind: 'issue', kindLabel: 'gap', lifecycle: 'open' });
+ const gap = node({
+ id: 'iss-2',
+ kind: 'issue',
+ kindLabel: 'gap',
+ lifecycle: 'open',
+ });
const closed = node({
id: 'iss-3',
kind: 'issue',
@@ -124,17 +151,31 @@ describe('isOpenBlocker', () => {
lifecycle: 'deferred',
});
- assert.equal(isOpenBlocker(blocker, effectiveLifecycle(blocker, index)), true);
+ assert.equal(
+ isOpenBlocker(blocker, effectiveLifecycle(blocker, index)),
+ true
+ );
assert.equal(isOpenBlocker(gap, effectiveLifecycle(gap, index)), false);
- assert.equal(isOpenBlocker(closed, effectiveLifecycle(closed, index)), false);
- assert.equal(isOpenBlocker(deferred, effectiveLifecycle(deferred, index)), false);
+ assert.equal(
+ isOpenBlocker(closed, effectiveLifecycle(closed, index)),
+ false
+ );
+ assert.equal(
+ isOpenBlocker(deferred, effectiveLifecycle(deferred, index)),
+ false
+ );
});
});
describe('summarizeGraph', () => {
test('counts primitives and lifecycle rather than nodes and edges', () => {
const nodes = [
- node({ id: 'eff-1', kind: 'effort', effortId: null, lifecycle: 'active' }),
+ node({
+ id: 'eff-1',
+ kind: 'effort',
+ effortId: null,
+ lifecycle: 'active',
+ }),
node({ id: 'iss-1', kind: 'issue', lifecycle: 'open' }),
node({ id: 'iss-2', kind: 'issue', lifecycle: 'wontfix' }),
node({ id: 'iss-3', kind: 'issue', lifecycle: 'deferred' }),
diff --git a/examples/effort-viz/lib/lifecycle.ts b/packages/explorer/src/web/presets/effort-graph/lifecycle.ts
similarity index 95%
rename from examples/effort-viz/lib/lifecycle.ts
rename to packages/explorer/src/web/presets/effort-graph/lifecycle.ts
index 3f291f34..49ad8249 100644
--- a/examples/effort-viz/lib/lifecycle.ts
+++ b/packages/explorer/src/web/presets/effort-graph/lifecycle.ts
@@ -119,10 +119,18 @@ export function effectiveLifecycle(
const declared = (node.lifecycle ?? node.status ?? node.state)?.toLowerCase();
if (index.invalidated.has(node.id)) {
- return { state: 'invalidated', aliveness: 'retired', overturnedByEdge: true };
+ return {
+ state: 'invalidated',
+ aliveness: 'retired',
+ overturnedByEdge: true,
+ };
}
if (index.superseded.has(node.id)) {
- return { state: 'superseded', aliveness: 'retired', overturnedByEdge: true };
+ return {
+ state: 'superseded',
+ aliveness: 'retired',
+ overturnedByEdge: true,
+ };
}
if (index.rejected.has(node.id) && declared !== 'rejected') {
return { state: 'rejected', aliveness: 'retired', overturnedByEdge: true };
@@ -188,7 +196,10 @@ export function summarizeGraph(
// A realized Risk is `settled` on the aliveness axis — nothing is going to
// overturn it — but it is the record a reader most needs surfaced, so it
// counts alongside open Risks rather than disappearing from every total.
- if (node.kind === 'risk' && (life?.aliveness === 'open' || life?.state === 'realized')) {
+ if (
+ node.kind === 'risk' &&
+ (life?.aliveness === 'open' || life?.state === 'realized')
+ ) {
summary.liveRisks += 1;
continue;
}
diff --git a/examples/effort-viz/lib/normalize.test.ts b/packages/explorer/src/web/presets/effort-graph/normalize.test.ts
similarity index 100%
rename from examples/effort-viz/lib/normalize.test.ts
rename to packages/explorer/src/web/presets/effort-graph/normalize.test.ts
diff --git a/examples/effort-viz/lib/normalize.ts b/packages/explorer/src/web/presets/effort-graph/normalize.ts
similarity index 93%
rename from examples/effort-viz/lib/normalize.ts
rename to packages/explorer/src/web/presets/effort-graph/normalize.ts
index fb73301a..e738dde6 100644
--- a/examples/effort-viz/lib/normalize.ts
+++ b/packages/explorer/src/web/presets/effort-graph/normalize.ts
@@ -36,7 +36,11 @@ function relationIds(value: unknown): string[] {
return value.length > 0 ? [value] : [];
}
- if (value && typeof value === 'object' && typeof (value as GraphRef).id === 'string') {
+ if (
+ value &&
+ typeof value === 'object' &&
+ typeof (value as GraphRef).id === 'string'
+ ) {
return [(value as GraphRef).id as string];
}
@@ -90,7 +94,10 @@ function lifecycleForRecord(
if (kind === 'issue' && typeof record.status === 'string') {
return record.status;
}
- if ((kind === 'decision' || kind === 'risk') && typeof record.state === 'string') {
+ if (
+ (kind === 'decision' || kind === 'risk') &&
+ typeof record.state === 'string'
+ ) {
return record.state;
}
return undefined;
@@ -139,7 +146,9 @@ function addRecordNode(
kind: GraphNodeKind,
record: RelationRecord
): void {
- const effortId = effortIdFromRef(record.effort as GraphRef | null | undefined);
+ const effortId = effortIdFromRef(
+ record.effort as GraphRef | null | undefined
+ );
const node = makeNode(kind, record, effortId);
if (!node) return;
@@ -178,7 +187,9 @@ function dropRedundantReverseEdges(edges: Map): void {
* prevent.
*/
-export function normalizeEffortGraph(data: EffortGraphQueryResult): EffortGraph {
+export function normalizeEffortGraph(
+ data: EffortGraphQueryResult
+): EffortGraph {
const nodes = new Map();
const edges = new Map();
diff --git a/examples/effort-viz/lib/primitives.ts b/packages/explorer/src/web/presets/effort-graph/primitives.ts
similarity index 91%
rename from examples/effort-viz/lib/primitives.ts
rename to packages/explorer/src/web/presets/effort-graph/primitives.ts
index 77765188..d53730f6 100644
--- a/examples/effort-viz/lib/primitives.ts
+++ b/packages/explorer/src/web/presets/effort-graph/primitives.ts
@@ -21,7 +21,7 @@
import type { GlyphId } from './glyphs';
import type { GraphNodeKind } from './types';
-import type { ColorMode, Oklch } from './oklch';
+import type { ColorMode, Oklch } from '../../core/oklch';
export interface PrimitiveMeta {
kind: GraphNodeKind;
@@ -54,7 +54,8 @@ export const PRIMITIVES: Record = {
kind: 'effort',
label: 'Effort',
plural: 'Efforts',
- description: 'The anchor for one thread of work. Every record belongs to exactly one.',
+ description:
+ 'The anchor for one thread of work. Every record belongs to exactly one.',
glyph: 'ring',
// Effort hubs are tinted per cluster, so they have no fixed hue.
tone: null,
@@ -63,7 +64,8 @@ export const PRIMITIVES: Record = {
kind: 'issue',
label: 'Issue',
plural: 'Issues',
- description: 'A tracked item needing attention — question, defect, gap, or blocker.',
+ description:
+ 'A tracked item needing attention — question, defect, gap, or blocker.',
glyph: 'diamond',
tone: { c: 0.15, h: 72 },
},
@@ -71,7 +73,8 @@ export const PRIMITIVES: Record = {
kind: 'finding',
label: 'Finding',
plural: 'Findings',
- description: 'A grounded observation about code, users, literature, or runtime.',
+ description:
+ 'A grounded observation about code, users, literature, or runtime.',
glyph: 'circle',
tone: { c: 0.15, h: 250 },
},
@@ -87,7 +90,8 @@ export const PRIMITIVES: Record = {
kind: 'constraint',
label: 'Constraint',
plural: 'Constraints',
- description: 'A sticky hard or soft boundary that limits the decision space.',
+ description:
+ 'A sticky hard or soft boundary that limits the decision space.',
glyph: 'slab',
tone: { c: 0.15, h: 152 },
},
diff --git a/examples/effort-viz/lib/query.test.ts b/packages/explorer/src/web/presets/effort-graph/query.test.ts
similarity index 100%
rename from examples/effort-viz/lib/query.test.ts
rename to packages/explorer/src/web/presets/effort-graph/query.test.ts
diff --git a/examples/effort-viz/lib/query.ts b/packages/explorer/src/web/presets/effort-graph/query.ts
similarity index 98%
rename from examples/effort-viz/lib/query.ts
rename to packages/explorer/src/web/presets/effort-graph/query.ts
index 4918423b..0feb69a7 100644
--- a/examples/effort-viz/lib/query.ts
+++ b/packages/explorer/src/web/presets/effort-graph/query.ts
@@ -104,7 +104,9 @@ const REF_SUBSELECTION = new Set([
'mitigated_by',
]);
-export function buildEffortGraphQuery(schema: SchemaProbeResult | null): string {
+export function buildEffortGraphQuery(
+ schema: SchemaProbeResult | null
+): string {
const body = COLLECTIONS.map((name) => {
const available = new Set(
(schema?.[name]?.fields ?? []).map((field) => field.name)
diff --git a/examples/effort-viz/lib/types.ts b/packages/explorer/src/web/presets/effort-graph/types.ts
similarity index 100%
rename from examples/effort-viz/lib/types.ts
rename to packages/explorer/src/web/presets/effort-graph/types.ts
diff --git a/examples/effort-viz/lib/useEffortGraphLive.test.ts b/packages/explorer/src/web/presets/effort-graph/useEffortGraphLive.test.ts
similarity index 100%
rename from examples/effort-viz/lib/useEffortGraphLive.test.ts
rename to packages/explorer/src/web/presets/effort-graph/useEffortGraphLive.test.ts
diff --git a/examples/effort-viz/lib/useEffortGraphLive.ts b/packages/explorer/src/web/presets/effort-graph/useEffortGraphLive.ts
similarity index 94%
rename from examples/effort-viz/lib/useEffortGraphLive.ts
rename to packages/explorer/src/web/presets/effort-graph/useEffortGraphLive.ts
index 4b6df4aa..ff4db3d4 100644
--- a/examples/effort-viz/lib/useEffortGraphLive.ts
+++ b/packages/explorer/src/web/presets/effort-graph/useEffortGraphLive.ts
@@ -1,7 +1,6 @@
-'use client';
-
import { useCallback, useEffect, useRef, useState } from 'react';
-import { graphqlFetch } from './graphql';
+import { resolveEventsUrl, resolveGraphqlEndpoint } from '../../core/endpoints';
+import { graphqlFetch } from '../../core/graphql';
import { normalizeEffortGraph } from './normalize';
import {
SCHEMA_PROBE_QUERY,
@@ -11,7 +10,6 @@ import {
} from './query';
import type { GraphEdge, GraphNode } from './types';
-const DEFAULT_GRAPHQL_ENDPOINT = 'http://localhost:5057/graphql';
const INITIAL_RETRY_MS = 1_000;
const MAX_RETRY_MS = 30_000;
@@ -75,9 +73,7 @@ export function shouldCommitGeneration(
nextGeneration: number,
committedGeneration: number | null
): boolean {
- return (
- committedGeneration === null || nextGeneration >= committedGeneration
- );
+ return committedGeneration === null || nextGeneration >= committedGeneration;
}
/**
@@ -128,10 +124,6 @@ export function liveStatusAfterTransportLoss(
return hasCommittedGeneration ? 'disconnected' : 'error';
}
-function graphqlOrigin(endpoint: string): string {
- return new URL(endpoint).origin;
-}
-
function parseGeneration(data: string): number | null {
try {
const parsed = JSON.parse(data) as { generation?: unknown };
@@ -144,8 +136,8 @@ function parseGeneration(data: string): number | null {
export function useEffortGraphLive(
options: UseEffortGraphLiveOptions = {}
): UseEffortGraphLiveResult {
- const endpoint = options.endpoint ?? DEFAULT_GRAPHQL_ENDPOINT;
- const eventsUrl = `${graphqlOrigin(endpoint)}/events`;
+ const endpoint = options.endpoint ?? resolveGraphqlEndpoint();
+ const eventsUrl = resolveEventsUrl(endpoint);
const [nodes, setNodes] = useState([]);
const [edges, setEdges] = useState([]);
@@ -304,11 +296,15 @@ export function useEffortGraphLive(
});
source.addEventListener('ready', (event) => {
- void maybeRefetch(parseGeneration((event as MessageEvent).data));
+ void maybeRefetch(
+ parseGeneration((event as MessageEvent).data)
+ );
});
source.addEventListener('generation', (event) => {
- void maybeRefetch(parseGeneration((event as MessageEvent).data));
+ void maybeRefetch(
+ parseGeneration((event as MessageEvent).data)
+ );
});
source.onerror = () => {
@@ -347,4 +343,4 @@ export function useEffortGraphLive(
};
}
-export { graphqlOrigin };
+export { graphqlOrigin } from '../../core/endpoints';
diff --git a/packages/explorer/tsconfig.json b/packages/explorer/tsconfig.json
new file mode 100644
index 00000000..c1a0a22d
--- /dev/null
+++ b/packages/explorer/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "ignoreDeprecations": "6.0",
+ "types": ["node"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/web/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
+ "exclude": ["dist", "node_modules", "**/*.test.ts"]
+}
diff --git a/packages/explorer/tsconfig.node.json b/packages/explorer/tsconfig.node.json
new file mode 100644
index 00000000..73f23259
--- /dev/null
+++ b/packages/explorer/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "ignoreDeprecations": "6.0",
+ "types": ["node"]
+ },
+ "include": ["src/node/**/*.ts", "tsup.config.ts"]
+}
diff --git a/packages/explorer/tsup.config.ts b/packages/explorer/tsup.config.ts
new file mode 100644
index 00000000..52f0c653
--- /dev/null
+++ b/packages/explorer/tsup.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/node/index.ts'],
+ outDir: 'dist/node',
+ format: ['esm'],
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ minify: false,
+ treeshake: true,
+ tsconfig: 'tsconfig.node.json',
+ external: ['@flatbread/effort-graph', 'node:path', 'node:url'],
+});
diff --git a/packages/explorer/vite.config.ts b/packages/explorer/vite.config.ts
new file mode 100644
index 00000000..c811390c
--- /dev/null
+++ b/packages/explorer/vite.config.ts
@@ -0,0 +1,22 @@
+import path from 'node:path';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ root: '.',
+ base: './',
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src/web'),
+ },
+ },
+ build: {
+ outDir: 'dist/static',
+ emptyOutDir: true,
+ sourcemap: true,
+ },
+ server: {
+ port: 5173,
+ },
+});
diff --git a/packages/flatbread/package.json b/packages/flatbread/package.json
index 88bd511a..93fff2a5 100644
--- a/packages/flatbread/package.json
+++ b/packages/flatbread/package.json
@@ -38,6 +38,7 @@
"@flatbread/config": "workspace:*",
"@flatbread/core": "workspace:*",
"@flatbread/effort-graph": "workspace:*",
+ "@flatbread/explorer": "workspace:*",
"@flatbread/source-filesystem": "workspace:*",
"@flatbread/transformer-markdown": "workspace:*",
"@flatbread/transformer-yaml": "workspace:*",
diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts
index 5385feea..b1231d1f 100644
--- a/packages/flatbread/src/cli/index.ts
+++ b/packages/flatbread/src/cli/index.ts
@@ -7,18 +7,23 @@ import orchestrateProcesses from './runner';
import initConfig from './initConfig';
import { createCodegenCommand } from '@flatbread/codegen';
import { registerEffortCommands } from './effort';
-
-const GRAPHQL_ENDPOINT = '/graphql';
+import {
+ EXPLORER_ENDPOINT,
+ GRAPHQL_ENDPOINT,
+ resolveCliOpenPath,
+} from './openPath';
+import { loadFlatbreadConfig } from '../utils/getSchema';
/**
- * Launch the GraphQL explorer in a browser.
+ * Open a browser tab at the given path on the Flatbread server.
*
* Yoinked from [SvelteKit's CLI](https://github.com/sveltejs/kit/blob/2c133ff5b8798c885161ed57bfb45c88fc77f516/packages/kit/src/cli.js).
- *
- * @param {number} port the port the server is running on
- * @param {boolean} https whether the server is running on https
*/
-async function launch(port: number, https: boolean): Promise {
+async function launch(
+ port: number,
+ https: boolean,
+ openPath: string
+): Promise {
const { exec } = await import('child_process');
let cmd = 'open';
if (process.platform == 'win32') {
@@ -30,9 +35,8 @@ async function launch(port: number, https: boolean): Promise {
cmd = 'xdg-open';
}
}
- exec(
- `${cmd} ${https ? 'https' : 'http'}://localhost:${port}${GRAPHQL_ENDPOINT}`
- );
+ const path = openPath.startsWith('/') ? openPath : `/${openPath}`;
+ exec(`${cmd} ${https ? 'https' : 'http'}://localhost:${port}${path}`);
}
const prog = sade('flatbread').version(version);
@@ -43,15 +47,22 @@ prog
.option('-p, --port', 'Port to run the GraphQL server', 5057)
.option('-H, --https', 'Use self-signed HTTPS certificate', false)
.option('-w, --watch', 'Hot-swap content and reload config', false)
- .option('-o, --open', 'Open the explorer in a browser tab', false)
+ .option(
+ '-o, --open',
+ 'Open the explorer (or GraphQL sandbox) in a browser tab',
+ false
+ )
.option(
'-X, --exec',
'The runner to execute the corunning script with. Defaults to your package manager (i.e. npm, pnpm, yarn)'
)
.action(async (corunner, { _, port, https, watch, open, exec }) => {
- // Combine the corunning script & the options passed to it
- const secondaryScript = `${corunner} ${_.join(' ')}`;
- // Yeet it into the all seeing eye of the universe
+ const args = Array.isArray(_) ? _ : [];
+ const secondaryScript =
+ typeof corunner === 'string' && corunner.length > 0
+ ? `${corunner} ${args.join(' ')}`.trim()
+ : '';
+
orchestrateProcesses({
corunner: secondaryScript,
flatbreadPort: port,
@@ -59,8 +70,18 @@ prog
watch,
packageManager: exec,
});
- // Say hi for good measure
- welcome({ port, https, open });
+
+ let openPath = GRAPHQL_ENDPOINT;
+ let explorer = false;
+ try {
+ const loaded = await loadFlatbreadConfig(process.cwd());
+ openPath = resolveCliOpenPath(loaded.config?.content);
+ explorer = openPath === EXPLORER_ENDPOINT;
+ } catch {
+ // Config may be missing during init; fall back to GraphQL sandbox.
+ }
+
+ welcome({ port, https, open, openPath, explorer });
});
prog
@@ -102,19 +123,21 @@ prog.parse(process.argv, { unknown: (arg) => `Unknown option: ${arg}` });
* The welcome message for the user when starting the server.
*
* Yoinked from [SvelteKit's CLI](https://github.com/sveltejs/kit/blob/2c133ff5b8798c885161ed57bfb45c88fc77f516/packages/kit/src/cli.js) with some modifications.
- *
- * @param serverConfig server config object
*/
function welcome({
port,
https,
open,
+ openPath,
+ explorer,
}: {
open: boolean;
https: boolean;
port: number;
+ openPath: string;
+ explorer: boolean;
}): void {
- if (open) launch(port, https);
+ if (open) void launch(port, https, openPath);
console.log(
colors.bold(
@@ -128,15 +151,27 @@ function welcome({
if (details.family !== 'IPv4') return;
if (details.internal) {
- // Always show HTTP endpoint
+ if (explorer) {
+ console.log(
+ ` ${colors.gray('explorer:')} http://${colors.bold(
+ `localhost:${port}${EXPLORER_ENDPOINT}`
+ )}`
+ );
+ }
console.log(
` ${colors.gray('graphql:')} http://${colors.bold(
`localhost:${port}${GRAPHQL_ENDPOINT}`
)}`
);
- // Show HTTPS endpoint if enabled (uses port + 1)
if (https) {
+ if (explorer) {
+ console.log(
+ ` ${colors.gray('explorer:')} https://${colors.bold(
+ `localhost:${port + 1}${EXPLORER_ENDPOINT}`
+ )}`
+ );
+ }
console.log(
` ${colors.gray('graphql:')} https://${colors.bold(
`localhost:${port + 1}${GRAPHQL_ENDPOINT}`
diff --git a/packages/flatbread/src/cli/openPath.test.ts b/packages/flatbread/src/cli/openPath.test.ts
new file mode 100644
index 00000000..d88065db
--- /dev/null
+++ b/packages/flatbread/src/cli/openPath.test.ts
@@ -0,0 +1,19 @@
+import test from 'ava';
+import { effortGraphContent } from '@flatbread/effort-graph';
+import {
+ EXPLORER_ENDPOINT,
+ GRAPHQL_ENDPOINT,
+ resolveCliOpenPath,
+} from './openPath.js';
+
+test('opens explorer root for a full Effort Graph preset', (t) => {
+ t.is(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+});
+
+test('opens GraphQL sandbox when no explorer preset matches', (t) => {
+ t.is(
+ resolveCliOpenPath([{ collection: 'Post', path: 'posts' }]),
+ GRAPHQL_ENDPOINT
+ );
+ t.is(resolveCliOpenPath(undefined), GRAPHQL_ENDPOINT);
+});
diff --git a/packages/flatbread/src/cli/openPath.ts b/packages/flatbread/src/cli/openPath.ts
new file mode 100644
index 00000000..38c5fee7
--- /dev/null
+++ b/packages/flatbread/src/cli/openPath.ts
@@ -0,0 +1,16 @@
+import { matchExplorerPreset } from '@flatbread/explorer';
+import type { ContentEntry } from '@flatbread/core';
+
+export const GRAPHQL_ENDPOINT = '/graphql';
+export const EXPLORER_ENDPOINT = '/';
+
+/**
+ * Browser path for `flatbread start --open`.
+ * Explorer root when a preset matches; otherwise the Apollo sandbox.
+ */
+export function resolveCliOpenPath(
+ content: readonly ContentEntry[] | undefined
+): string {
+ if (content && matchExplorerPreset(content)) return EXPLORER_ENDPOINT;
+ return GRAPHQL_ENDPOINT;
+}
diff --git a/packages/flatbread/src/cli/runner.ts b/packages/flatbread/src/cli/runner.ts
index 16092c31..8e198b0a 100644
--- a/packages/flatbread/src/cli/runner.ts
+++ b/packages/flatbread/src/cli/runner.ts
@@ -50,6 +50,18 @@ export default function orchestrateProcesses({
gql.on('message', (msg) => {
if (msg !== 'flatbread-gql-ready') return;
+ const hasCorunner =
+ typeof corunner === 'string' && corunner.trim().length > 0;
+
+ // Server-only mode: `flatbread start` with no framework corunner.
+ // Keep the parent alive so the GraphQL (and explorer) process stays up.
+ if (!hasCorunner) {
+ gql.on('close', (code) => {
+ process.exit(code ?? 1);
+ });
+ return;
+ }
+
// Start the target process (e.g. the dev server or the build script)
const targetProcess = spawn(pkgManager ?? 'npm run', [corunner], {
shell: true,
diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts
new file mode 100644
index 00000000..76573cae
--- /dev/null
+++ b/packages/flatbread/src/graphql/explorerMount.test.ts
@@ -0,0 +1,90 @@
+import test from 'ava';
+import express from 'express';
+import { join } from 'node:path';
+import { effortGraphContent } from '@flatbread/effort-graph';
+import {
+ EXPLORER_BOOTSTRAP_PATH,
+ getExplorerStaticDir,
+} from '@flatbread/explorer';
+import { mountExplorerIfMatched, resolveOpenPath } from './explorerMount.js';
+
+test('resolveOpenPath prefers explorer for Effort Graph configs', (t) => {
+ t.is(resolveOpenPath(effortGraphContent()), '/');
+ t.is(resolveOpenPath([{ collection: 'Post', path: 'posts' }]), '/graphql');
+});
+
+test.serial(
+ 'mounts SPA at / and leaves /graphql for Apollo when assets exist',
+ async (t) => {
+ const staticDir = getExplorerStaticDir();
+ const indexPath = join(staticDir, 'index.html');
+ // Build must have produced assets; skip soft-fail would hide regressions.
+ const { access } = await import('node:fs/promises');
+ try {
+ await access(indexPath);
+ } catch {
+ t.fail(
+ `Explorer assets missing at ${indexPath}. Build @flatbread/explorer first.`
+ );
+ return;
+ }
+
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, effortGraphContent());
+ t.truthy(mounted);
+ t.is(mounted!.openPath, '/');
+
+ // Capture handlers by issuing a fake request through the stack.
+ const server = app.listen(0);
+ t.teardown(
+ () =>
+ new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ })
+ );
+ await new Promise((resolve) => server.once('listening', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ t.fail('expected TCP address');
+ return;
+ }
+ const base = `http://127.0.0.1:${address.port}`;
+
+ const home = await fetch(`${base}/`);
+ t.is(home.status, 200);
+ const html = await home.text();
+ t.true(html.includes('__FLATBREAD_EXPLORER__'));
+ t.true(html.includes('effort-graph'));
+
+ const boot = await fetch(`${base}${EXPLORER_BOOTSTRAP_PATH}`);
+ t.is(boot.status, 200);
+ const json = (await boot.json()) as { preset: string; graphqlPath: string };
+ t.is(json.preset, 'effort-graph');
+ t.is(json.graphqlPath, '/graphql');
+ }
+);
+
+test.serial('does not mount explorer for ordinary content', async (t) => {
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, [
+ { collection: 'Post', path: 'posts' },
+ ]);
+ t.is(mounted, null);
+
+ const server = app.listen(0);
+ t.teardown(
+ () =>
+ new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ })
+ );
+ await new Promise((resolve) => server.once('listening', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ t.fail('expected TCP address');
+ return;
+ }
+ const res = await fetch(`http://127.0.0.1:${address.port}/`);
+ // No route registered → Express default 404
+ t.is(res.status, 404);
+});
diff --git a/packages/flatbread/src/graphql/explorerMount.ts b/packages/flatbread/src/graphql/explorerMount.ts
new file mode 100644
index 00000000..2f68a1ac
--- /dev/null
+++ b/packages/flatbread/src/graphql/explorerMount.ts
@@ -0,0 +1,107 @@
+import {
+ EXPLORER_BOOTSTRAP_PATH,
+ getExplorerStaticDir,
+ matchExplorerPreset,
+ type ExplorerPresetMatch,
+} from '@flatbread/explorer';
+import type { ContentEntry } from '@flatbread/core';
+import express, {
+ type Express,
+ type Request,
+ type Response,
+ type NextFunction,
+} from 'express';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const GRAPHQL_PATH = '/graphql';
+const EVENTS_PATH = '/events';
+
+export interface ExplorerMountResult {
+ match: ExplorerPresetMatch;
+ /** Browser path to open (`/` when the explorer is mounted). */
+ openPath: '/';
+}
+
+/**
+ * When a registered explorer preset matches, serve the SPA at `/`.
+ * Callers should mount Apollo afterward; this middleware `next()`s for
+ * `/graphql` and `/events` so those API routes still work. Returns null when
+ * no preset matches.
+ */
+export function mountExplorerIfMatched(
+ app: Express,
+ content: readonly ContentEntry[]
+): ExplorerMountResult | null {
+ const match = matchExplorerPreset(content);
+ if (!match) return null;
+
+ const staticDir = getExplorerStaticDir();
+ const indexHtmlPath = path.join(staticDir, 'index.html');
+ if (!fs.existsSync(indexHtmlPath)) {
+ console.warn(
+ `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.`
+ );
+ return null;
+ }
+
+ const bootstrap = {
+ preset: match.preset,
+ graphqlPath: GRAPHQL_PATH,
+ eventsPath: EVENTS_PATH,
+ };
+
+ app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res) => {
+ res.json(bootstrap);
+ });
+
+ // Inject bootstrap into index.html so the SPA knows same-origin endpoints
+ // without an extra round-trip before first paint.
+ let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8');
+ const bootScript = ``;
+ if (indexHtml.includes('')) {
+ indexHtml = indexHtml.replace('', `${bootScript}`);
+ } else {
+ indexHtml = `${bootScript}${indexHtml}`;
+ }
+
+ app.use(
+ express.static(staticDir, {
+ index: false,
+ fallthrough: true,
+ })
+ );
+
+ app.get(['/', '/index.html'], (_req, res) => {
+ res.type('html').send(indexHtml);
+ });
+
+ // SPA fallback for client routes — never steal API paths. `/events` and
+ // `/graphql` are registered after this mount and must receive `next()`.
+ app.use((req: Request, res: Response, next: NextFunction) => {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
+ const pathname = req.path;
+ if (
+ pathname === GRAPHQL_PATH ||
+ pathname === EVENTS_PATH ||
+ pathname.startsWith(`${GRAPHQL_PATH}/`) ||
+ pathname.startsWith('/__flatbread/')
+ ) {
+ return next();
+ }
+ if (path.extname(pathname)) return next();
+ res.type('html').send(indexHtml);
+ });
+
+ return { match, openPath: '/' };
+}
+
+/** Open path for `--open`: explorer root when mounted, else Apollo sandbox. */
+export function resolveOpenPath(
+ content: readonly ContentEntry[] | undefined
+): string {
+ if (content && matchExplorerPreset(content)) return '/';
+ return GRAPHQL_PATH;
+}
diff --git a/packages/flatbread/src/graphql/liveServer.test.ts b/packages/flatbread/src/graphql/liveServer.test.ts
index f6148c84..d3bf8039 100644
--- a/packages/flatbread/src/graphql/liveServer.test.ts
+++ b/packages/flatbread/src/graphql/liveServer.test.ts
@@ -67,7 +67,7 @@ function makeConfig(fixture: Fixture): ConfigResult {
}
async function queryTitles(port: number): Promise {
- const response = await fetch(`http://localhost:${port}`, {
+ const response = await fetch(`http://localhost:${port}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index 9605b8ef..6370c213 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -19,6 +19,7 @@ import express, { type RequestHandler } from 'express';
import http from 'http';
import { loadFlatbreadConfig } from '../utils/getSchema';
import { createEffortGraphComposition } from './effortGraphComposition';
+import { mountExplorerIfMatched } from './explorerMount';
export interface GraphqlServerOptions {
port?: number;
@@ -30,6 +31,8 @@ export interface RunningGraphqlServer {
readonly port: number;
readonly reloader: LiveSchemaReloader;
readonly effortGraph?: EffortGraphLiveBridge;
+ /** True when the content-relation explorer SPA is mounted at `/`. */
+ readonly explorer: boolean;
close(): Promise;
}
@@ -108,6 +111,10 @@ export async function startGraphqlServer(
if (old) old.stopWhenDrained();
},
});
+ // Explorer SPA first so `/` is the visualizer when a preset matches. It must
+ // `next()` for `/events` and `/graphql` (see explorerMount).
+ const explorerMount = mountExplorerIfMatched(app, config.content);
+
app.get('/events', (req, res) => {
res.status(200).set({
'Content-Type': 'text/event-stream',
@@ -256,6 +263,7 @@ export async function startGraphqlServer(
port,
reloader,
effortGraph,
+ explorer: explorerMount !== null,
async close() {
if (closed) return;
closed = true;
diff --git a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
index c71db353..fff0a970 100644
--- a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
+++ b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
@@ -44,7 +44,7 @@ function config(
}
async function query(port: number, source: string) {
- const response = await fetch(`http://localhost:${port}`, {
+ const response = await fetch(`http://localhost:${port}/graphql`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: source }),
@@ -66,6 +66,10 @@ test.serial(
});
t.teardown(() => server.close());
t.truthy(server.effortGraph);
+ t.true(server.explorer);
+ const home = await fetch(`http://localhost:${server.port}/`);
+ t.is(home.status, 200);
+ t.true((await home.text()).includes('__FLATBREAD_EXPLORER__'));
const result = await server.effortGraph!.writer.mutate({
type: 'CreateEffort',
title: 'Committed effort',
@@ -89,6 +93,11 @@ test.serial(
});
t.teardown(() => server.close());
t.is(server.effortGraph, undefined);
+ t.false(server.explorer);
+ const home = await fetch(`http://localhost:${server.port}/`);
+ // Without an explorer preset, Apollo still owns `/` (catch-all mount).
+ t.not(home.headers.get('content-type') ?? '', 'text/html');
+ t.false((await home.text()).includes('__FLATBREAD_EXPLORER__'));
}
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f62dd69..4bbfa6c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -97,64 +97,6 @@ importers:
specifier: 4.7.4
version: 4.7.4
- examples/effort-viz:
- dependencies:
- '@react-three/drei':
- specifier: ^10.0.6
- version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0))(@types/react@19.1.9)(@types/three@0.175.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0)
- '@react-three/fiber':
- specifier: ^9.1.2
- version: 9.6.1(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0)
- flatbread:
- specifier: workspace:*
- version: link:../../packages/flatbread
- next:
- specifier: 15.4.4
- version: 15.4.4(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.89.2)
- react:
- specifier: 19.1.0
- version: 19.1.0
- react-dom:
- specifier: 19.1.0
- version: 19.1.0(react@19.1.0)
- react-markdown:
- specifier: 10.1.0
- version: 10.1.0(@types/react@19.1.9)(react@19.1.0)
- rehype-sanitize:
- specifier: ^6.0.0
- version: 6.0.0
- remark-gfm:
- specifier: ^4.0.1
- version: 4.0.1
- three:
- specifier: ^0.175.0
- version: 0.175.0
- devDependencies:
- '@tailwindcss/postcss':
- specifier: ^4.1.11
- version: 4.1.11
- '@types/node':
- specifier: ^20.19.10
- version: 20.19.10
- '@types/react':
- specifier: ^19.1.9
- version: 19.1.9
- '@types/react-dom':
- specifier: ^19.1.7
- version: 19.1.7(@types/react@19.1.9)
- '@types/three':
- specifier: ^0.175.0
- version: 0.175.0
- tailwindcss:
- specifier: ^4.1.11
- version: 4.1.11
- tsx:
- specifier: ^4.21.0
- version: 4.21.0
- typescript:
- specifier: ^5.9.2
- version: 5.9.2
-
examples/nextjs:
dependencies:
'@graphql-typed-document-node/core':
@@ -224,7 +166,7 @@ importers:
version: 9.6.1
autoprefixer:
specifier: ^10.4.21
- version: 10.4.21(postcss@8.5.14)
+ version: 10.4.21(postcss@8.5.6)
eslint:
specifier: ^9.33.0
version: 9.33.0(jiti@2.5.1)
@@ -407,6 +349,70 @@ importers:
specifier: 6.0.3
version: 6.0.3
+ packages/explorer:
+ dependencies:
+ '@flatbread/effort-graph':
+ specifier: workspace:*
+ version: link:../effort-graph
+ devDependencies:
+ '@react-three/drei':
+ specifier: ^10.0.6
+ version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0))(@types/react@19.1.9)(@types/three@0.175.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0)
+ '@react-three/fiber':
+ specifier: ^9.1.2
+ version: 9.6.1(@types/react@19.1.9)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.175.0)
+ '@tailwindcss/postcss':
+ specifier: ^4.1.11
+ version: 4.1.11
+ '@types/node':
+ specifier: ^20.19.10
+ version: 20.19.10
+ '@types/react':
+ specifier: ^19.1.9
+ version: 19.1.9
+ '@types/react-dom':
+ specifier: ^19.1.7
+ version: 19.1.7(@types/react@19.1.9)
+ '@types/three':
+ specifier: ^0.175.0
+ version: 0.175.0
+ '@vitejs/plugin-react':
+ specifier: ^4.7.0
+ version: 4.7.0(vite@6.4.3(@types/node@20.19.10)(jiti@2.5.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1))
+ react:
+ specifier: 19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: 19.1.0
+ version: 19.1.0(react@19.1.0)
+ react-markdown:
+ specifier: 10.1.0
+ version: 10.1.0(@types/react@19.1.9)(react@19.1.0)
+ rehype-sanitize:
+ specifier: ^6.0.0
+ version: 6.0.0
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
+ tailwindcss:
+ specifier: ^4.1.11
+ version: 4.1.11
+ three:
+ specifier: ^0.175.0
+ version: 0.175.0
+ tsup:
+ specifier: 8.5.1
+ version: 8.5.1(@swc/core@1.13.3)(jiti@2.5.1)(postcss@8.5.14)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.1)
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^6.3.5
+ version: 6.4.3(@types/node@20.19.10)(jiti@2.5.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)
+
packages/flatbread:
dependencies:
'@apollo/server':
@@ -430,6 +436,9 @@ importers:
'@flatbread/effort-graph':
specifier: workspace:*
version: link:../effort-graph
+ '@flatbread/explorer':
+ specifier: workspace:*
+ version: link:../explorer
'@flatbread/source-filesystem':
specifier: workspace:*
version: link:../source-filesystem
@@ -955,6 +964,10 @@ packages:
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-replace-supers@7.27.1':
resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==}
engines: {node: '>=6.9.0'}
@@ -1203,6 +1216,18 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-react-jsx@7.27.1':
resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==}
engines: {node: '>=6.9.0'}
@@ -1331,6 +1356,12 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -1349,6 +1380,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
@@ -1367,6 +1404,12 @@ packages:
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
@@ -1385,6 +1428,12 @@ packages:
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
@@ -1403,6 +1452,12 @@ packages:
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
@@ -1421,6 +1476,12 @@ packages:
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
@@ -1439,6 +1500,12 @@ packages:
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
@@ -1457,6 +1524,12 @@ packages:
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
@@ -1475,6 +1548,12 @@ packages:
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
@@ -1493,6 +1572,12 @@ packages:
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
@@ -1511,6 +1596,12 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
@@ -1541,6 +1632,12 @@ packages:
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
@@ -1559,6 +1656,12 @@ packages:
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
@@ -1577,6 +1680,12 @@ packages:
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
@@ -1595,6 +1704,12 @@ packages:
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
@@ -1613,6 +1728,12 @@ packages:
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
@@ -1631,12 +1752,24 @@ packages:
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
@@ -1655,12 +1788,24 @@ packages:
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
@@ -1679,12 +1824,24 @@ packages:
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
@@ -1703,6 +1860,12 @@ packages:
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
@@ -1721,6 +1884,12 @@ packages:
cpu: [arm64]
os: [win32]
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
@@ -1739,6 +1908,12 @@ packages:
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
@@ -1757,6 +1932,12 @@ packages:
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
@@ -2171,92 +2352,78 @@ packages:
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@img/sharp-wasm32@0.34.3':
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
@@ -2417,28 +2584,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@15.4.4':
resolution: {integrity: sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@15.4.4':
resolution: {integrity: sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@15.4.4':
resolution: {integrity: sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@15.4.4':
resolution: {integrity: sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==}
@@ -2539,42 +2702,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -2718,42 +2875,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.18':
resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.18':
resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.18':
resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.18':
resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==}
@@ -2778,6 +2929,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
'@rolldown/pluginutils@1.0.0-rc.18':
resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==}
@@ -2827,67 +2981,56 @@ packages:
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.46.2':
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.46.2':
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.46.2':
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.46.2':
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.46.2':
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.46.2':
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.46.2':
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
@@ -2993,28 +3136,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-arm64-musl@1.13.3':
resolution: {integrity: sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@swc/core-linux-x64-gnu@1.13.3':
resolution: {integrity: sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-x64-musl@1.13.3':
resolution: {integrity: sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@swc/core-win32-arm64-msvc@1.13.3':
resolution: {integrity: sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==}
@@ -3090,28 +3229,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.11':
resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.11':
resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.11':
resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.11':
resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==}
@@ -3515,49 +3650,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3587,6 +3714,12 @@ packages:
peerDependencies:
react: '>= 16.8.0'
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@@ -5167,6 +5300,11 @@ packages:
engines: {node: '>=12'}
hasBin: true
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
@@ -6667,56 +6805,48 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -7999,6 +8129,10 @@ packages:
'@types/react': '>=18'
react: '>=18'
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
@@ -8792,10 +8926,6 @@ packages:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
- tinyglobby@0.2.14:
- resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
- engines: {node: '>=12.0.0'}
-
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -9285,6 +9415,46 @@ packages:
terser:
optional: true
+ vite@6.4.3:
+ resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
vite@8.0.11:
resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9965,6 +10135,8 @@ snapshots:
'@babel/helper-plugin-utils@7.27.1': {}
+ '@babel/helper-plugin-utils@7.29.7': {}
+
'@babel/helper-replace-supers@7.27.1(@babel/core@7.28.0)':
dependencies:
'@babel/core': 7.28.0
@@ -10220,6 +10392,16 @@ snapshots:
'@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.28.0)':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/helper-plugin-utils': 7.29.7
+
'@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.0)':
dependencies:
'@babel/core': 7.28.0
@@ -10375,6 +10557,9 @@ snapshots:
'@esbuild/aix-ppc64@0.21.5':
optional: true
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -10384,6 +10569,9 @@ snapshots:
'@esbuild/android-arm64@0.21.5':
optional: true
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
'@esbuild/android-arm64@0.27.7':
optional: true
@@ -10393,6 +10581,9 @@ snapshots:
'@esbuild/android-arm@0.21.5':
optional: true
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
'@esbuild/android-arm@0.27.7':
optional: true
@@ -10402,6 +10593,9 @@ snapshots:
'@esbuild/android-x64@0.21.5':
optional: true
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
'@esbuild/android-x64@0.27.7':
optional: true
@@ -10411,6 +10605,9 @@ snapshots:
'@esbuild/darwin-arm64@0.21.5':
optional: true
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
'@esbuild/darwin-arm64@0.27.7':
optional: true
@@ -10420,6 +10617,9 @@ snapshots:
'@esbuild/darwin-x64@0.21.5':
optional: true
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
'@esbuild/darwin-x64@0.27.7':
optional: true
@@ -10429,6 +10629,9 @@ snapshots:
'@esbuild/freebsd-arm64@0.21.5':
optional: true
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/freebsd-arm64@0.27.7':
optional: true
@@ -10438,6 +10641,9 @@ snapshots:
'@esbuild/freebsd-x64@0.21.5':
optional: true
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
'@esbuild/freebsd-x64@0.27.7':
optional: true
@@ -10447,6 +10653,9 @@ snapshots:
'@esbuild/linux-arm64@0.21.5':
optional: true
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
'@esbuild/linux-arm64@0.27.7':
optional: true
@@ -10456,6 +10665,9 @@ snapshots:
'@esbuild/linux-arm@0.21.5':
optional: true
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
'@esbuild/linux-arm@0.27.7':
optional: true
@@ -10465,6 +10677,9 @@ snapshots:
'@esbuild/linux-ia32@0.21.5':
optional: true
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
'@esbuild/linux-ia32@0.27.7':
optional: true
@@ -10480,6 +10695,9 @@ snapshots:
'@esbuild/linux-loong64@0.21.5':
optional: true
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
'@esbuild/linux-loong64@0.27.7':
optional: true
@@ -10489,6 +10707,9 @@ snapshots:
'@esbuild/linux-mips64el@0.21.5':
optional: true
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
'@esbuild/linux-mips64el@0.27.7':
optional: true
@@ -10498,6 +10719,9 @@ snapshots:
'@esbuild/linux-ppc64@0.21.5':
optional: true
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
'@esbuild/linux-ppc64@0.27.7':
optional: true
@@ -10507,6 +10731,9 @@ snapshots:
'@esbuild/linux-riscv64@0.21.5':
optional: true
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
'@esbuild/linux-riscv64@0.27.7':
optional: true
@@ -10516,6 +10743,9 @@ snapshots:
'@esbuild/linux-s390x@0.21.5':
optional: true
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
'@esbuild/linux-s390x@0.27.7':
optional: true
@@ -10525,9 +10755,15 @@ snapshots:
'@esbuild/linux-x64@0.21.5':
optional: true
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
'@esbuild/linux-x64@0.27.7':
optional: true
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/netbsd-arm64@0.27.7':
optional: true
@@ -10537,9 +10773,15 @@ snapshots:
'@esbuild/netbsd-x64@0.21.5':
optional: true
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
'@esbuild/netbsd-x64@0.27.7':
optional: true
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/openbsd-arm64@0.27.7':
optional: true
@@ -10549,9 +10791,15 @@ snapshots:
'@esbuild/openbsd-x64@0.21.5':
optional: true
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
'@esbuild/openbsd-x64@0.27.7':
optional: true
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
'@esbuild/openharmony-arm64@0.27.7':
optional: true
@@ -10561,6 +10809,9 @@ snapshots:
'@esbuild/sunos-x64@0.21.5':
optional: true
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
'@esbuild/sunos-x64@0.27.7':
optional: true
@@ -10570,6 +10821,9 @@ snapshots:
'@esbuild/win32-arm64@0.21.5':
optional: true
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
'@esbuild/win32-arm64@0.27.7':
optional: true
@@ -10579,6 +10833,9 @@ snapshots:
'@esbuild/win32-ia32@0.21.5':
optional: true
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
'@esbuild/win32-ia32@0.27.7':
optional: true
@@ -10588,6 +10845,9 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
'@esbuild/win32-x64@0.27.7':
optional: true
@@ -11891,6 +12151,8 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.18':
optional: true
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
'@rolldown/pluginutils@1.0.0-rc.18': {}
'@rollup/plugin-node-resolve@13.3.0(rollup@2.79.2)':
@@ -12115,7 +12377,7 @@ snapshots:
enhanced-resolve: 5.18.3
jiti: 2.5.1
lightningcss: 1.30.1
- magic-string: 0.30.17
+ magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.1.11
@@ -12178,7 +12440,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.11
'@tailwindcss/oxide': 4.1.11
- postcss: 8.5.6
+ postcss: 8.5.14
tailwindcss: 4.1.11
'@theguild/federation-composition@0.19.1(graphql@16.5.0)':
@@ -12373,7 +12635,7 @@ snapshots:
'@types/mdast@4.0.4':
dependencies:
- '@types/unist': 2.0.11
+ '@types/unist': 3.0.3
'@types/mime@1.3.5': {}
@@ -12648,6 +12910,18 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.1.0
+ '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@20.19.10)(jiti@2.5.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1))':
+ dependencies:
+ '@babel/core': 7.28.0
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.28.0)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.28.0)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.3(@types/node@20.19.10)(jiti@2.5.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -13028,14 +13302,14 @@ snapshots:
auto-bind@4.0.0: {}
- autoprefixer@10.4.21(postcss@8.5.14):
+ autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.25.2
caniuse-lite: 1.0.30001733
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
- postcss: 8.5.14
+ postcss: 8.5.6
postcss-value-parser: 4.2.0
ava@4.3.1(@ava/typescript@3.0.1):
@@ -14390,6 +14664,35 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@@ -14484,7 +14787,7 @@ snapshots:
get-tsconfig: 4.10.1
is-bun-module: 2.0.0
stable-hash: 0.0.5
- tinyglobby: 0.2.14
+ tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.5.1))
@@ -14934,17 +15237,13 @@ snapshots:
transitivePeerDependencies:
- encoding
- fdir@6.4.6(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
fdir@6.4.6(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
- fdir@6.5.0(picomatch@4.0.4):
+ fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
- picomatch: 4.0.4
+ picomatch: 4.0.5
fetch-blob@3.2.0:
dependencies:
@@ -17940,9 +18239,9 @@ snapshots:
dependencies:
postcss: 8.5.6
- postcss-scss@4.0.9(postcss@8.5.6):
+ postcss-scss@4.0.9(postcss@8.5.14):
dependencies:
- postcss: 8.5.6
+ postcss: 8.5.14
postcss-selector-parser@6.1.2:
dependencies:
@@ -18134,6 +18433,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-refresh@0.17.0: {}
+
react-use-measure@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -19042,8 +19343,8 @@ snapshots:
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
- postcss: 8.5.6
- postcss-scss: 4.0.9(postcss@8.5.6)
+ postcss: 8.5.14
+ postcss-scss: 4.0.9(postcss@8.5.14)
optionalDependencies:
svelte: 5.38.0
@@ -19228,15 +19529,10 @@ snapshots:
tinyexec@1.1.2: {}
- tinyglobby@0.2.14:
- dependencies:
- fdir: 6.4.6(picomatch@4.0.4)
- picomatch: 4.0.4
-
tinyglobby@0.2.16:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
tinygradient@1.1.5:
dependencies:
@@ -19916,6 +20212,24 @@ snapshots:
sass: 1.89.2
terser: 5.43.1
+ vite@6.4.3(@types/node@20.19.10)(jiti@2.5.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+ postcss: 8.5.14
+ rollup: 4.46.2
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 20.19.10
+ fsevents: 2.3.3
+ jiti: 2.5.1
+ lightningcss: 1.32.0
+ sass: 1.89.2
+ terser: 5.43.1
+ tsx: 4.21.0
+ yaml: 2.8.1
+
vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.5.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1):
dependencies:
lightningcss: 1.32.0
diff --git a/tsconfig.json b/tsconfig.json
index 763f6772..cd154817 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,6 +17,8 @@
"@flatbread/proof": ["./packages/proof/src/index.ts"],
"@flatbread/resolver-svimg": ["./packages/resolver-svimg/src/index.ts"],
"@flatbread/utils": ["./packages/utils/src/index.ts"],
+ "@flatbread/explorer": ["./packages/explorer/src/node/index.ts"],
+ "@flatbread/effort-graph": ["./packages/effort-graph/src/index.ts"],
"@flatbread/source-filesystem": [
"./packages/source-filesystem/src/index.ts"
],
From bc899ad416ab06bbf469a2cdc7d349aeed066995 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Jul 2026 03:05:35 +0000
Subject: [PATCH 02/10] fix(explorer): restore monorepo path aliases for
typecheck
Explorer tsconfig overrode root `paths` with only `@/*`, so
`@flatbread/effort-graph` was invisible during CI typecheck
(before build). Re-declare the monorepo path map, raise `lib` to
ESNext, and set `skipLibCheck` so transitive source resolves cleanly.
Change-Id: I8f7f21203bccae30ddabf597929f1610fed5b2f6
---
packages/explorer/tsconfig.json | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/packages/explorer/tsconfig.json b/packages/explorer/tsconfig.json
index c1a0a22d..1aafd509 100644
--- a/packages/explorer/tsconfig.json
+++ b/packages/explorer/tsconfig.json
@@ -2,14 +2,33 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"noEmit": true,
"ignoreDeprecations": "6.0",
+ "skipLibCheck": true,
"types": ["node"],
- "baseUrl": ".",
+ "baseUrl": "../..",
"paths": {
- "@/*": ["./src/web/*"]
+ "@/*": ["./packages/explorer/src/web/*"],
+ "flatbread": ["./packages/flatbread/src/index.ts"],
+ "@flatbread/codegen": ["./packages/codegen/src/index.ts"],
+ "@flatbread/core": ["./packages/core/src/index.ts"],
+ "@flatbread/config": ["./packages/config/src/index.ts"],
+ "@flatbread/proof": ["./packages/proof/src/index.ts"],
+ "@flatbread/resolver-svimg": ["./packages/resolver-svimg/src/index.ts"],
+ "@flatbread/utils": ["./packages/utils/src/index.ts"],
+ "@flatbread/explorer": ["./packages/explorer/src/node/index.ts"],
+ "@flatbread/effort-graph": ["./packages/effort-graph/src/index.ts"],
+ "@flatbread/source-filesystem": [
+ "./packages/source-filesystem/src/index.ts"
+ ],
+ "@flatbread/transformer-markdown": [
+ "./packages/transformer-markdown/src/index.ts"
+ ],
+ "@flatbread/transformer-yaml": [
+ "./packages/transformer-yaml/src/index.ts"
+ ]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
From 58a9a82f780095dd0544b4661d7a2b0d9e94d306 Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Tue, 28 Jul 2026 01:59:28 -0700
Subject: [PATCH 03/10] fix(explorer): harden mount gate, tests, and watch
lifecycle
Keep --open and mount on the shared assets gate, isolate AVA fixtures
with a static-dir override (no package-global index.html renames), use
vite base `/` for nested SPA routes, toggle explorer on config reload
under --watch, and document the built-assets requirement in the skill.
Co-authored-by: Cursor
Change-Id: I1f5f06aa4e0caba4e517f4608c39e907cb567f45
Co-authored-by: Cursor
---
.agents/skills/effort-graph/setup.md | 14 +-
CONTRIBUTING.md | 2 +-
package.json | 1 +
.../effort-graph/skills/effort-graph/setup.md | 12 +-
packages/explorer/README.md | 17 +-
packages/explorer/src/node/index.ts | 7 +-
packages/explorer/src/node/staticDir.test.ts | 51 ++++-
packages/explorer/src/node/staticDir.ts | 24 +++
.../explorer/src/web/core/endpoints.test.ts | 168 ++++++++++++++-
packages/explorer/vite.config.ts | 15 +-
packages/flatbread/src/cli/index.ts | 4 +-
packages/flatbread/src/cli/openPath.test.ts | 53 ++++-
packages/flatbread/src/cli/openPath.ts | 19 +-
.../src/graphql/explorerMount.test.ts | 176 +++++++++++-----
.../flatbread/src/graphql/explorerMount.ts | 156 +++++++++-----
packages/flatbread/src/graphql/liveServer.ts | 28 ++-
.../src/graphql/liveServerEffortGraph.test.ts | 194 +++++++++++++++++
.../graphql/liveServerExplorerWatch.test.ts | 196 ++++++++++++++++++
18 files changed, 997 insertions(+), 140 deletions(-)
create mode 100644 packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts
diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index d38a95ac..c59f8966 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -88,14 +88,20 @@ client polling loops. Semantic changes go through `flatbread effort write`.
## 4. Open the explorer (optional)
With a complete `effortGraphContent()` preset in config, Flatbread serves the
-content-relation explorer automatically:
+content-relation explorer automatically (`@flatbread/explorer` ships with
+`flatbread`). In this monorepo, build explorer assets first
+(`pnpm --filter @flatbread/explorer build` or `pnpm play:efforts`):
```bash
flatbread start --watch --open
```
-- Explorer UI: `http://localhost:5057/`
+Mounting and `--open` share the same assets gate: the explorer mounts at `/`
+only when the preset matches **and** packaged SPA assets are present;
+otherwise Flatbread skips the SPA mount and `--open` falls back to
+`/graphql`.
+
+- Explorer UI (when mounted): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
-No separate app install is required; `@flatbread/explorer` ships with
-`flatbread`.
+No separate app install is required.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c41a59bf..63305475 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,7 +35,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex
- Build all packages: `pnpm build`
- **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example.
- **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias.
-- **Effort Graph explorer:** after `pnpm build`, run `pnpm play:efforts` (`flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`).
+- **Effort Graph explorer:** run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`). For HMR on the SPA shell, run `flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel (Vite on **5173** proxies API routes to **5057**).
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
diff --git a/package.json b/package.json
index 50bbc8b6..54fb5e3a 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"lint:fix:prettier": "pretty-quick --staged",
"typecheck": "pnpm --filter @flatbread/proof --filter @flatbread/explorer typecheck",
"play": "cd examples/nextjs && pnpm dev",
+ "preplay:efforts": "pnpm --filter @flatbread/explorer build",
"play:efforts": "pnpm exec flatbread start --watch --open",
"play:build": "pnpm build && cd examples/nextjs && pnpm build",
"prepublish:ci": "pnpm install --frozen-lockfile && pnpm build:types",
diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md
index d38a95ac..a1b26eee 100644
--- a/packages/effort-graph/skills/effort-graph/setup.md
+++ b/packages/effort-graph/skills/effort-graph/setup.md
@@ -88,14 +88,18 @@ client polling loops. Semantic changes go through `flatbread effort write`.
## 4. Open the explorer (optional)
With a complete `effortGraphContent()` preset in config, Flatbread serves the
-content-relation explorer automatically:
+content-relation explorer automatically (`@flatbread/explorer` ships with
+`flatbread`):
```bash
flatbread start --watch --open
```
-- Explorer UI: `http://localhost:5057/`
+Mounting and `--open` share the same assets gate: if the packaged SPA assets
+are missing, Flatbread skips the explorer mount and `--open` falls back to
+`/graphql`.
+
+- Explorer UI (when mounted): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
-No separate app install is required; `@flatbread/explorer` ships with
-`flatbread`.
+No separate app install is required.
diff --git a/packages/explorer/README.md b/packages/explorer/README.md
index c3bf0df4..838754b7 100644
--- a/packages/explorer/README.md
+++ b/packages/explorer/README.md
@@ -13,7 +13,9 @@ flatbread start --watch --open
# → http://localhost:5057/graphql Apollo sandbox
```
-No separate Next app is required.
+No separate Next app is required. Requires a built `dist/static` (see
+[Develop in the monorepo](#develop-in-the-monorepo)); `pnpm play:efforts` runs
+that build automatically.
## Static deploy
@@ -31,6 +33,7 @@ Same-origin deploys (assets served by Flatbread) need no query param.
| Export | Role |
| ------------------------------ | ------------------------------------------ |
| `getExplorerStaticDir()` | Absolute path to `dist/static` for Express |
+| `explorerAssetsPresent()` | Whether prebuilt `index.html` exists |
| `matchExplorerPreset(content)` | Detect Effort Graph (and later presets) |
| `EXPLORER_BOOTSTRAP_PATH` | Bootstrap JSON path Flatbread injects |
@@ -40,6 +43,14 @@ There is no public React component export in v1.
```bash
pnpm --filter @flatbread/explorer test
-pnpm --filter @flatbread/explorer build
-pnpm play:efforts # flatbread start --watch --open from repo root
+pnpm play:efforts # builds explorer, then flatbread start --watch --open
+```
+
+For UI-only iteration with HMR, run Flatbread and Vite in separate terminals
+(Vite proxies `/graphql` and `/events` to Flatbread on port **5057**, or
+`FLATBREAD_PORT` when set):
+
+```bash
+pnpm exec flatbread start --watch # terminal 1 — GraphQL on :5057
+pnpm --filter @flatbread/explorer dev # terminal 2 — SPA on :5173
```
diff --git a/packages/explorer/src/node/index.ts b/packages/explorer/src/node/index.ts
index d75175a9..97c492e8 100644
--- a/packages/explorer/src/node/index.ts
+++ b/packages/explorer/src/node/index.ts
@@ -1,4 +1,9 @@
-export { getExplorerStaticDir, EXPLORER_BOOTSTRAP_PATH } from './staticDir.js';
+export {
+ explorerAssetsPresent,
+ getExplorerStaticDir,
+ setExplorerStaticDirOverride,
+ EXPLORER_BOOTSTRAP_PATH,
+} from './staticDir.js';
export {
matchExplorerPreset,
type ExplorerPresetId,
diff --git a/packages/explorer/src/node/staticDir.test.ts b/packages/explorer/src/node/staticDir.test.ts
index f710d1b1..ee24d461 100644
--- a/packages/explorer/src/node/staticDir.test.ts
+++ b/packages/explorer/src/node/staticDir.test.ts
@@ -1,8 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
import path from 'node:path';
-import { describe, it } from 'node:test';
-import { getExplorerStaticDir } from './staticDir.js';
+import { afterEach, describe, it } from 'node:test';
+import {
+ explorerAssetsPresent,
+ getExplorerStaticDir,
+ setExplorerStaticDirOverride,
+} from './staticDir.js';
+
+afterEach(() => {
+ setExplorerStaticDirOverride(undefined);
+});
describe('getExplorerStaticDir', () => {
it('resolves to a path ending in dist/static', () => {
@@ -24,4 +34,41 @@ describe('getExplorerStaticDir', () => {
assert.ok(fs.existsSync(index), `expected ${index} after vite build`);
}
});
+
+ it('honors setExplorerStaticDirOverride when set', async () => {
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-static-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(getExplorerStaticDir(), emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('explorerAssetsPresent', () => {
+ it('reflects whether index.html exists under the static dir', async () => {
+ const indexPath = path.join(getExplorerStaticDir(), 'index.html');
+ if (!fs.existsSync(indexPath)) {
+ assert.equal(explorerAssetsPresent(), false);
+ return;
+ }
+
+ assert.equal(explorerAssetsPresent(), true);
+
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-absent-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/explorer/src/node/staticDir.ts b/packages/explorer/src/node/staticDir.ts
index 654551d6..6668be95 100644
--- a/packages/explorer/src/node/staticDir.ts
+++ b/packages/explorer/src/node/staticDir.ts
@@ -1,16 +1,40 @@
+import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** HTTP path Flatbread serves for explorer bootstrap JSON. */
export const EXPLORER_BOOTSTRAP_PATH = '/__flatbread/explorer.json';
+let staticDirOverride: string | undefined;
+
+/**
+ * Test-only: force `getExplorerStaticDir()` to `dir`.
+ * Pass `undefined` to clear. Not for production callers.
+ */
+export function setExplorerStaticDirOverride(dir: string | undefined): void {
+ staticDirOverride = dir;
+}
+
/**
* Absolute path to the prebuilt SPA assets shipped in this package.
* Flatbread mounts these with `express.static` when a preset matches.
+ * Honors `setExplorerStaticDirOverride` when set (tests only).
*/
export function getExplorerStaticDir(): string {
+ if (staticDirOverride !== undefined) {
+ return staticDirOverride;
+ }
const here = path.dirname(fileURLToPath(import.meta.url));
// Works from both `src/node` (tests) and `dist/node` (published).
const packageRoot = path.resolve(here, '../..');
return path.join(packageRoot, 'dist', 'static');
}
+
+/**
+ * True when prebuilt SPA `index.html` exists under `getExplorerStaticDir()`.
+ * Flatbread uses this with `matchExplorerPreset` before mounting or advertising
+ * explorer.
+ */
+export function explorerAssetsPresent(): boolean {
+ return fs.existsSync(path.join(getExplorerStaticDir(), 'index.html'));
+}
diff --git a/packages/explorer/src/web/core/endpoints.test.ts b/packages/explorer/src/web/core/endpoints.test.ts
index 5fc140ce..23f1cd92 100644
--- a/packages/explorer/src/web/core/endpoints.test.ts
+++ b/packages/explorer/src/web/core/endpoints.test.ts
@@ -1,20 +1,47 @@
import assert from 'node:assert/strict';
-import { describe, it } from 'node:test';
+import { afterEach, describe, it } from 'node:test';
import {
+ type ExplorerBootstrap,
normalizeGraphqlUrl,
resolveEventsUrl,
resolveGraphqlEndpoint,
} from './endpoints.js';
+const DEFAULT_BOOTSTRAP: ExplorerBootstrap = {
+ preset: 'effort-graph',
+ graphqlPath: '/graphql',
+ eventsPath: '/events',
+};
+
+/** Stub browser globals for resolveEventsUrl (reads window.__FLATBREAD_EXPLORER__). */
+function withExplorerBootstrap(
+ bootstrap: ExplorerBootstrap,
+ run: () => void
+): void {
+ const globalWithWindow = globalThis as typeof globalThis & {
+ window?: Window & { __FLATBREAD_EXPLORER__?: ExplorerBootstrap };
+ };
+ const previous = globalWithWindow.window;
+ globalWithWindow.window = {
+ location: { origin: 'http://localhost:5057', search: '' },
+ __FLATBREAD_EXPLORER__: bootstrap,
+ } as Window & { __FLATBREAD_EXPLORER__?: ExplorerBootstrap };
+ try {
+ run();
+ } finally {
+ if (previous === undefined) {
+ delete globalWithWindow.window;
+ } else {
+ globalWithWindow.window = previous;
+ }
+ }
+}
+
describe('resolveGraphqlEndpoint', () => {
it('prefers ?endpoint= over bootstrap', () => {
const endpoint = resolveGraphqlEndpoint(
'?endpoint=https://api.example.com/graphql',
- {
- preset: 'effort-graph',
- graphqlPath: '/graphql',
- eventsPath: '/events',
- },
+ DEFAULT_BOOTSTRAP,
'http://localhost:5057'
);
assert.equal(endpoint, 'https://api.example.com/graphql');
@@ -23,11 +50,7 @@ describe('resolveGraphqlEndpoint', () => {
it('uses bootstrap same-origin paths', () => {
const endpoint = resolveGraphqlEndpoint(
'',
- {
- preset: 'effort-graph',
- graphqlPath: '/graphql',
- eventsPath: '/events',
- },
+ DEFAULT_BOOTSTRAP,
'http://localhost:5057'
);
assert.equal(endpoint, 'http://localhost:5057/graphql');
@@ -37,6 +60,42 @@ describe('resolveGraphqlEndpoint', () => {
const endpoint = resolveGraphqlEndpoint('', undefined, 'file://');
assert.equal(endpoint, 'http://localhost:5057/graphql');
});
+
+ it('resolves relative ?endpoint= against the default Node origin', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=/alt/graphql',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:9999'
+ );
+ assert.equal(endpoint, 'http://localhost:5057/alt/graphql');
+ });
+
+ it('resolves host-without-scheme ?endpoint= by prepending http', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=api.example.com',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:5057'
+ );
+ assert.equal(endpoint, 'http://api.example.com/graphql');
+ });
+
+ it('trims whitespace in ?endpoint=', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=%20%20https://api.example.com/graphql%20%20',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:5057'
+ );
+ assert.equal(endpoint, 'https://api.example.com/graphql');
+ });
+
+ it('falls back when ?endpoint= is whitespace-only', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=%20%20%20',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:5057'
+ );
+ assert.equal(endpoint, 'http://localhost:5057/graphql');
+ });
});
describe('normalizeGraphqlUrl', () => {
@@ -46,6 +105,54 @@ describe('normalizeGraphqlUrl', () => {
'https://api.example.com/graphql'
);
});
+
+ it('preserves an explicit /graphql path', () => {
+ assert.equal(
+ normalizeGraphqlUrl('https://api.example.com/custom/graphql'),
+ 'https://api.example.com/custom/graphql'
+ );
+ });
+
+ it('resolves a relative path against the default Node origin', () => {
+ assert.equal(
+ normalizeGraphqlUrl('/alt/graphql'),
+ 'http://localhost:5057/alt/graphql'
+ );
+ });
+
+ it('prepends http when given a host without a scheme', () => {
+ assert.equal(
+ normalizeGraphqlUrl('api.example.com'),
+ 'http://api.example.com/graphql'
+ );
+ });
+
+ it('trims surrounding whitespace', () => {
+ assert.equal(
+ normalizeGraphqlUrl(' https://api.example.com/graphql '),
+ 'https://api.example.com/graphql'
+ );
+ });
+
+ it('falls back to localhost when given whitespace only', () => {
+ assert.equal(normalizeGraphqlUrl(' '), 'http://localhost:5057/graphql');
+ });
+});
+
+describe('normalizeGraphqlUrl with window', () => {
+ afterEach(() => {
+ delete (globalThis as { window?: Window }).window;
+ });
+
+ it('resolves relative paths against window.location.origin', () => {
+ (globalThis as { window?: Window }).window = {
+ location: { origin: 'http://localhost:5173', search: '' },
+ } as Window;
+ assert.equal(
+ normalizeGraphqlUrl('/dev/graphql'),
+ 'http://localhost:5173/dev/graphql'
+ );
+ });
});
describe('resolveEventsUrl', () => {
@@ -55,4 +162,43 @@ describe('resolveEventsUrl', () => {
'http://localhost:5057/events'
);
});
+
+ it('uses default /events when window bootstrap is absent', () => {
+ assert.equal(
+ resolveEventsUrl('https://api.example.com/graphql'),
+ 'https://api.example.com/events'
+ );
+ });
+
+ it('uses custom eventsPath from window bootstrap', () => {
+ withExplorerBootstrap(
+ {
+ preset: 'effort-graph',
+ graphqlPath: '/graphql',
+ eventsPath: '/custom-events',
+ },
+ () => {
+ assert.equal(
+ resolveEventsUrl('http://localhost:5057/graphql'),
+ 'http://localhost:5057/custom-events'
+ );
+ }
+ );
+ });
+
+ it('derives custom eventsPath from the GraphQL endpoint origin only', () => {
+ withExplorerBootstrap(
+ {
+ preset: 'effort-graph',
+ graphqlPath: '/graphql',
+ eventsPath: '/sse/stream',
+ },
+ () => {
+ assert.equal(
+ resolveEventsUrl('https://api.example.com/graphql'),
+ 'https://api.example.com/sse/stream'
+ );
+ }
+ );
+ });
});
diff --git a/packages/explorer/vite.config.ts b/packages/explorer/vite.config.ts
index c811390c..943b5622 100644
--- a/packages/explorer/vite.config.ts
+++ b/packages/explorer/vite.config.ts
@@ -2,10 +2,13 @@ import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+const flatbreadPort = process.env.FLATBREAD_PORT ?? '5057';
+const flatbreadTarget = `http://localhost:${flatbreadPort}`;
+
export default defineConfig({
plugins: [react()],
root: '.',
- base: './',
+ base: '/',
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/web'),
@@ -18,5 +21,15 @@ export default defineConfig({
},
server: {
port: 5173,
+ proxy: {
+ '/graphql': {
+ target: flatbreadTarget,
+ changeOrigin: true,
+ },
+ '/events': {
+ target: flatbreadTarget,
+ changeOrigin: true,
+ },
+ },
},
});
diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts
index b1231d1f..d9a66f6d 100644
--- a/packages/flatbread/src/cli/index.ts
+++ b/packages/flatbread/src/cli/index.ts
@@ -10,7 +10,7 @@ import { registerEffortCommands } from './effort';
import {
EXPLORER_ENDPOINT,
GRAPHQL_ENDPOINT,
- resolveCliOpenPath,
+ resolveOpenPath,
} from './openPath';
import { loadFlatbreadConfig } from '../utils/getSchema';
@@ -75,7 +75,7 @@ prog
let explorer = false;
try {
const loaded = await loadFlatbreadConfig(process.cwd());
- openPath = resolveCliOpenPath(loaded.config?.content);
+ openPath = resolveOpenPath(loaded.config?.content);
explorer = openPath === EXPLORER_ENDPOINT;
} catch {
// Config may be missing during init; fall back to GraphQL sandbox.
diff --git a/packages/flatbread/src/cli/openPath.test.ts b/packages/flatbread/src/cli/openPath.test.ts
index d88065db..b82bdd0c 100644
--- a/packages/flatbread/src/cli/openPath.test.ts
+++ b/packages/flatbread/src/cli/openPath.test.ts
@@ -1,19 +1,70 @@
import test from 'ava';
import { effortGraphContent } from '@flatbread/effort-graph';
+import {
+ explorerAssetsPresent,
+ setExplorerStaticDirOverride,
+} from '@flatbread/explorer';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import { join } from 'node:path';
import {
EXPLORER_ENDPOINT,
GRAPHQL_ENDPOINT,
resolveCliOpenPath,
+ resolveOpenPath,
} from './openPath.js';
-test('opens explorer root for a full Effort Graph preset', (t) => {
+test('resolveCliOpenPath is an alias of resolveOpenPath', (t) => {
+ t.is(resolveCliOpenPath, resolveOpenPath);
+});
+
+test('opens explorer root when preset matches and assets are present', (t) => {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ t.fail(
+ 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
+ );
+ return;
+ }
+ t.is(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.is(resolveOpenPath(effortGraphContent()), '/');
t.is(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.is(resolveCliOpenPath(effortGraphContent()), '/');
});
test('opens GraphQL sandbox when no explorer preset matches', (t) => {
+ t.is(
+ resolveOpenPath([{ collection: 'Post', path: 'posts' }]),
+ GRAPHQL_ENDPOINT
+ );
t.is(
resolveCliOpenPath([{ collection: 'Post', path: 'posts' }]),
GRAPHQL_ENDPOINT
);
+ t.is(resolveOpenPath(undefined), GRAPHQL_ENDPOINT);
t.is(resolveCliOpenPath(undefined), GRAPHQL_ENDPOINT);
});
+
+test.serial(
+ 'opens GraphQL sandbox when preset matches but assets are missing',
+ async (t) => {
+ const emptyDir = await mkdtemp(
+ join(os.tmpdir(), 'flatbread-explorer-openpath-')
+ );
+ setExplorerStaticDirOverride(emptyDir);
+ t.teardown(async () => {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ });
+
+ t.is(resolveOpenPath(effortGraphContent()), GRAPHQL_ENDPOINT);
+ t.is(resolveOpenPath(effortGraphContent()), '/graphql');
+ t.not(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.not(resolveOpenPath(effortGraphContent()), '/');
+
+ t.is(resolveCliOpenPath(effortGraphContent()), GRAPHQL_ENDPOINT);
+ t.is(resolveCliOpenPath(effortGraphContent()), '/graphql');
+ t.not(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.not(resolveCliOpenPath(effortGraphContent()), '/');
+ }
+);
diff --git a/packages/flatbread/src/cli/openPath.ts b/packages/flatbread/src/cli/openPath.ts
index 38c5fee7..c855b6c2 100644
--- a/packages/flatbread/src/cli/openPath.ts
+++ b/packages/flatbread/src/cli/openPath.ts
@@ -1,16 +1,25 @@
-import { matchExplorerPreset } from '@flatbread/explorer';
+import {
+ explorerAssetsPresent,
+ matchExplorerPreset,
+} from '@flatbread/explorer';
import type { ContentEntry } from '@flatbread/core';
export const GRAPHQL_ENDPOINT = '/graphql';
export const EXPLORER_ENDPOINT = '/';
/**
- * Browser path for `flatbread start --open`.
- * Explorer root when a preset matches; otherwise the Apollo sandbox.
+ * Browser path for `--open` / welcome: `/` only when an explorer preset matches
+ * **and** static assets are present (same gate as `mountExplorerIfMatched`);
+ * otherwise `/graphql`.
*/
-export function resolveCliOpenPath(
+export function resolveOpenPath(
content: readonly ContentEntry[] | undefined
): string {
- if (content && matchExplorerPreset(content)) return EXPLORER_ENDPOINT;
+ if (content && matchExplorerPreset(content) && explorerAssetsPresent()) {
+ return EXPLORER_ENDPOINT;
+ }
return GRAPHQL_ENDPOINT;
}
+
+/** Alias of `resolveOpenPath` for existing call sites. */
+export const resolveCliOpenPath = resolveOpenPath;
diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts
index 76573cae..32759118 100644
--- a/packages/flatbread/src/graphql/explorerMount.test.ts
+++ b/packages/flatbread/src/graphql/explorerMount.test.ts
@@ -1,66 +1,149 @@
-import test from 'ava';
+import test, { type ExecutionContext } from 'ava';
import express from 'express';
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
import { join } from 'node:path';
import { effortGraphContent } from '@flatbread/effort-graph';
import {
EXPLORER_BOOTSTRAP_PATH,
+ explorerAssetsPresent,
getExplorerStaticDir,
+ setExplorerStaticDirOverride,
} from '@flatbread/explorer';
-import { mountExplorerIfMatched, resolveOpenPath } from './explorerMount.js';
+import { mountExplorerIfMatched } from './explorerMount.js';
-test('resolveOpenPath prefers explorer for Effort Graph configs', (t) => {
- t.is(resolveOpenPath(effortGraphContent()), '/');
- t.is(resolveOpenPath([{ collection: 'Post', path: 'posts' }]), '/graphql');
-});
+async function listen(app: express.Express) {
+ const server = app.listen(0);
+ await new Promise((resolve) => server.once('listening', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ server.close();
+ throw new Error('expected TCP address');
+ }
+ return {
+ base: `http://127.0.0.1:${address.port}`,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ }),
+ };
+}
+
+function requireExplorerAssets(t: ExecutionContext): boolean {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ t.fail(
+ `Explorer assets missing at ${join(
+ getExplorerStaticDir(),
+ 'index.html'
+ )}. Build @flatbread/explorer first.`
+ );
+ return false;
+ }
+ return true;
+}
test.serial(
- 'mounts SPA at / and leaves /graphql for Apollo when assets exist',
+ 'mounts SPA at / and leaves /graphql and /events for downstream handlers',
async (t) => {
- const staticDir = getExplorerStaticDir();
- const indexPath = join(staticDir, 'index.html');
- // Build must have produced assets; skip soft-fail would hide regressions.
- const { access } = await import('node:fs/promises');
- try {
- await access(indexPath);
- } catch {
- t.fail(
- `Explorer assets missing at ${indexPath}. Build @flatbread/explorer first.`
- );
- return;
- }
+ if (!requireExplorerAssets(t)) return;
const app = express();
const mounted = mountExplorerIfMatched(app, effortGraphContent());
t.truthy(mounted);
t.is(mounted!.openPath, '/');
- // Capture handlers by issuing a fake request through the stack.
- const server = app.listen(0);
- t.teardown(
- () =>
- new Promise((resolve, reject) => {
- server.close((err) => (err ? reject(err) : resolve()));
- })
- );
- await new Promise((resolve) => server.once('listening', resolve));
- const address = server.address();
- if (!address || typeof address === 'string') {
- t.fail('expected TCP address');
- return;
- }
- const base = `http://127.0.0.1:${address.port}`;
-
- const home = await fetch(`${base}/`);
+ app.get('/graphql', (_req, res) => {
+ res.json({ route: 'graphql' });
+ });
+ app.get('/events', (_req, res) => {
+ res.type('text/event-stream').send('event: test\ndata: {}\n\n');
+ });
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const home = await fetch(`${server.base}/`);
t.is(home.status, 200);
const html = await home.text();
t.true(html.includes('__FLATBREAD_EXPLORER__'));
t.true(html.includes('effort-graph'));
- const boot = await fetch(`${base}${EXPLORER_BOOTSTRAP_PATH}`);
+ const boot = await fetch(`${server.base}${EXPLORER_BOOTSTRAP_PATH}`);
t.is(boot.status, 200);
const json = (await boot.json()) as { preset: string; graphqlPath: string };
t.is(json.preset, 'effort-graph');
t.is(json.graphqlPath, '/graphql');
+
+ const graphql = await fetch(`${server.base}/graphql`);
+ t.is(graphql.status, 200);
+ const graphqlBody = await graphql.text();
+ t.false(graphqlBody.includes('__FLATBREAD_EXPLORER__'));
+ t.true(graphqlBody.includes('"route":"graphql"'));
+
+ const events = await fetch(`${server.base}/events`);
+ t.is(events.status, 200);
+ const eventsBody = await events.text();
+ t.false(eventsBody.includes('__FLATBREAD_EXPLORER__'));
+ t.true(eventsBody.includes('event: test'));
+ }
+);
+
+test.serial(
+ 'serves injected HTML for extensionless SPA client routes',
+ async (t) => {
+ if (!requireExplorerAssets(t)) return;
+
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, effortGraphContent());
+ t.truthy(mounted);
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const clientRoute = await fetch(`${server.base}/effort-graph/view`);
+ t.is(clientRoute.status, 200);
+ const html = await clientRoute.text();
+ t.true(html.includes('__FLATBREAD_EXPLORER__'));
+ t.true(html.includes('effort-graph'));
+ }
+);
+
+test.serial(
+ 'warns and returns null without SPA routes when assets are missing',
+ async (t) => {
+ const emptyDir = await mkdtemp(
+ join(os.tmpdir(), 'flatbread-explorer-mount-')
+ );
+ setExplorerStaticDirOverride(emptyDir);
+ t.teardown(async () => {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ });
+
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warnings.push(args.map(String).join(' '));
+ };
+ t.teardown(() => {
+ console.warn = originalWarn;
+ });
+
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, effortGraphContent());
+ t.is(mounted, null);
+ t.true(
+ warnings.some((message) =>
+ message.includes('Flatbread explorer assets missing')
+ )
+ );
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const home = await fetch(`${server.base}/`);
+ t.is(home.status, 404);
}
);
@@ -71,20 +154,9 @@ test.serial('does not mount explorer for ordinary content', async (t) => {
]);
t.is(mounted, null);
- const server = app.listen(0);
- t.teardown(
- () =>
- new Promise((resolve, reject) => {
- server.close((err) => (err ? reject(err) : resolve()));
- })
- );
- await new Promise((resolve) => server.once('listening', resolve));
- const address = server.address();
- if (!address || typeof address === 'string') {
- t.fail('expected TCP address');
- return;
- }
- const res = await fetch(`http://127.0.0.1:${address.port}/`);
- // No route registered → Express default 404
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const res = await fetch(`${server.base}/`);
t.is(res.status, 404);
});
diff --git a/packages/flatbread/src/graphql/explorerMount.ts b/packages/flatbread/src/graphql/explorerMount.ts
index 2f68a1ac..ae78e19a 100644
--- a/packages/flatbread/src/graphql/explorerMount.ts
+++ b/packages/flatbread/src/graphql/explorerMount.ts
@@ -1,5 +1,6 @@
import {
EXPLORER_BOOTSTRAP_PATH,
+ explorerAssetsPresent,
getExplorerStaticDir,
matchExplorerPreset,
type ExplorerPresetMatch,
@@ -10,6 +11,7 @@ import express, {
type Request,
type Response,
type NextFunction,
+ type RequestHandler,
} from 'express';
import fs from 'node:fs';
import path from 'node:path';
@@ -23,64 +25,112 @@ export interface ExplorerMountResult {
openPath: '/';
}
+export interface ExplorerMountHandle {
+ /** Whether SPA middleware currently serves `/` (preset match ∧ assets). */
+ isActive(): boolean;
+ /**
+ * Re-evaluate preset + assets against new content.
+ * Does not touch the Express stack; only the gate + cached injected HTML.
+ * Warns (once per transition into missing-assets) when preset matches but
+ * assets are absent.
+ */
+ update(content: readonly ContentEntry[]): void;
+}
+
+interface ExplorerBootstrap {
+ preset: ExplorerPresetMatch['preset'];
+ graphqlPath: string;
+ eventsPath: string;
+}
+
/**
- * When a registered explorer preset matches, serve the SPA at `/`.
- * Callers should mount Apollo afterward; this middleware `next()`s for
- * `/graphql` and `/events` so those API routes still work. Returns null when
- * no preset matches.
+ * Registers bootstrap, static, `/`/`index.html`, and SPA fallback once.
+ * Inactive gate → all of those `next()` so Apollo/SSE own the paths.
+ * Toggle activity with {@link ExplorerMountHandle.update} on config reload.
*/
-export function mountExplorerIfMatched(
+export function mountExplorer(
app: Express,
content: readonly ContentEntry[]
-): ExplorerMountResult | null {
- const match = matchExplorerPreset(content);
- if (!match) return null;
-
- const staticDir = getExplorerStaticDir();
- const indexHtmlPath = path.join(staticDir, 'index.html');
- if (!fs.existsSync(indexHtmlPath)) {
- console.warn(
- `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.`
- );
- return null;
- }
-
- const bootstrap = {
- preset: match.preset,
+): ExplorerMountHandle {
+ let active = false;
+ let indexHtml = '';
+ let bootstrap: ExplorerBootstrap = {
+ preset: 'effort-graph',
graphqlPath: GRAPHQL_PATH,
eventsPath: EVENTS_PATH,
};
+ let staticMiddleware: RequestHandler | null = null;
+ /** True while the last evaluation was preset-match + missing assets. */
+ let inMissingAssets = false;
- app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res) => {
- res.json(bootstrap);
- });
+ const evaluate = (nextContent: readonly ContentEntry[]) => {
+ const match = matchExplorerPreset(nextContent);
+ if (!match) {
+ active = false;
+ inMissingAssets = false;
+ staticMiddleware = null;
+ return;
+ }
+
+ const staticDir = getExplorerStaticDir();
+ if (!explorerAssetsPresent()) {
+ if (!inMissingAssets) {
+ console.warn(
+ `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.`
+ );
+ inMissingAssets = true;
+ }
+ active = false;
+ staticMiddleware = null;
+ return;
+ }
+
+ inMissingAssets = false;
+ bootstrap = {
+ preset: match.preset,
+ graphqlPath: GRAPHQL_PATH,
+ eventsPath: EVENTS_PATH,
+ };
- // Inject bootstrap into index.html so the SPA knows same-origin endpoints
- // without an extra round-trip before first paint.
- let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8');
- const bootScript = ``;
- if (indexHtml.includes('')) {
- indexHtml = indexHtml.replace('', `${bootScript}`);
- } else {
- indexHtml = `${bootScript}${indexHtml}`;
- }
-
- app.use(
- express.static(staticDir, {
+ const indexHtmlPath = path.join(staticDir, 'index.html');
+ let html = fs.readFileSync(indexHtmlPath, 'utf8');
+ const bootScript = ``;
+ if (html.includes('')) {
+ html = html.replace('', `${bootScript}`);
+ } else {
+ html = `${bootScript}${html}`;
+ }
+ indexHtml = html;
+ staticMiddleware = express.static(staticDir, {
index: false,
fallthrough: true,
- })
- );
+ });
+ active = true;
+ };
- app.get(['/', '/index.html'], (_req, res) => {
+ evaluate(content);
+
+ app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res, next) => {
+ if (!active) return next();
+ res.json(bootstrap);
+ });
+
+ app.use((req, res, next) => {
+ if (!active || !staticMiddleware) return next();
+ return staticMiddleware(req, res, next);
+ });
+
+ app.get(['/', '/index.html'], (_req, res, next) => {
+ if (!active) return next();
res.type('html').send(indexHtml);
});
// SPA fallback for client routes — never steal API paths. `/events` and
// `/graphql` are registered after this mount and must receive `next()`.
app.use((req: Request, res: Response, next: NextFunction) => {
+ if (!active) return next();
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const pathname = req.path;
if (
@@ -95,13 +145,25 @@ export function mountExplorerIfMatched(
res.type('html').send(indexHtml);
});
- return { match, openPath: '/' };
+ return {
+ isActive: () => active,
+ update: evaluate,
+ };
}
-/** Open path for `--open`: explorer root when mounted, else Apollo sandbox. */
-export function resolveOpenPath(
- content: readonly ContentEntry[] | undefined
-): string {
- if (content && matchExplorerPreset(content)) return '/';
- return GRAPHQL_PATH;
+/**
+ * When a registered explorer preset matches and static assets are present,
+ * serve the SPA at `/`. Prefer {@link mountExplorer} when the mount must
+ * react to config reload (mutable gate). Returns null when inactive after the
+ * initial evaluation (no preset, or assets missing — warns; does not throw).
+ */
+export function mountExplorerIfMatched(
+ app: Express,
+ content: readonly ContentEntry[]
+): ExplorerMountResult | null {
+ const handle = mountExplorer(app, content);
+ if (!handle.isActive()) return null;
+ const match = matchExplorerPreset(content);
+ if (!match) return null;
+ return { match, openPath: '/' };
}
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index 6370c213..94ed50ec 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -19,7 +19,7 @@ import express, { type RequestHandler } from 'express';
import http from 'http';
import { loadFlatbreadConfig } from '../utils/getSchema';
import { createEffortGraphComposition } from './effortGraphComposition';
-import { mountExplorerIfMatched } from './explorerMount';
+import { mountExplorer } from './explorerMount';
export interface GraphqlServerOptions {
port?: number;
@@ -31,7 +31,11 @@ export interface RunningGraphqlServer {
readonly port: number;
readonly reloader: LiveSchemaReloader;
readonly effortGraph?: EffortGraphLiveBridge;
- /** True when the content-relation explorer SPA is mounted at `/`. */
+ /**
+ * Whether the explorer SPA currently answers `/`.
+ * May change under `--watch` when config reload adds or removes a matching
+ * explorer preset (same mutable-gate pattern as Apollo's generation swap).
+ */
readonly explorer: boolean;
close(): Promise;
}
@@ -111,9 +115,19 @@ export async function startGraphqlServer(
if (old) old.stopWhenDrained();
},
});
- // Explorer SPA first so `/` is the visualizer when a preset matches. It must
- // `next()` for `/events` and `/graphql` (see explorerMount).
- const explorerMount = mountExplorerIfMatched(app, config.content);
+ // Explorer SPA first so `/` is the visualizer when a preset matches. Middleware
+ // is registered once; the gate toggles when replaceConfig commits (watch
+ // applyConfig and any direct reload). Inactive → `next()` for `/events` /
+ // `/graphql`.
+ const explorerHandle = mountExplorer(app, config.content);
+ const replaceConfig = reloader.replaceConfig.bind(reloader);
+ reloader.replaceConfig = async (nextConfig) => {
+ const result = await replaceConfig(nextConfig);
+ if (result.status === 'committed') {
+ explorerHandle.update(nextConfig.content);
+ }
+ return result;
+ };
app.get('/events', (req, res) => {
res.status(200).set({
@@ -263,7 +277,9 @@ export async function startGraphqlServer(
port,
reloader,
effortGraph,
- explorer: explorerMount !== null,
+ get explorer() {
+ return explorerHandle.isActive();
+ },
async close() {
if (closed) return;
closed = true;
diff --git a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
index fff0a970..9b8f3d92 100644
--- a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
+++ b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts
@@ -1,11 +1,16 @@
import test from 'ava';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
+import os from 'node:os';
import { join, relative } from 'node:path';
import filesystem from '@flatbread/source-filesystem';
import markdownTransformer from '@flatbread/transformer-markdown';
import { initializeConfig } from '@flatbread/core';
import { effortGraphContent } from '@flatbread/effort-graph';
+import {
+ explorerAssetsPresent,
+ setExplorerStaticDirOverride,
+} from '@flatbread/explorer';
import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core';
import type { EffortGraphMutation } from '@flatbread/effort-graph';
import { startGraphqlServer } from './liveServer.js';
@@ -55,9 +60,121 @@ async function query(port: number, source: string) {
};
}
+/** Same SSE reader pattern as liveServerEvents.test.ts. */
+interface SseEvent {
+ event: string;
+ data: string;
+}
+function createSseReader(
+ body: ReadableStream,
+ timeoutMs = 5_000
+): {
+ next(predicate: (event: SseEvent) => boolean): Promise;
+ close(): Promise;
+} {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ const events: SseEvent[] = [];
+ const waiters: Array<{
+ predicate: (event: SseEvent) => boolean;
+ resolve: (event: SseEvent) => void;
+ reject: (reason: Error) => void;
+ timer: NodeJS.Timeout;
+ }> = [];
+ let buffer = '';
+ let finished = false;
+
+ const dispatch = (event: SseEvent) => {
+ for (let i = 0; i < waiters.length; i++) {
+ if (waiters[i].predicate(event)) {
+ const waiter = waiters.splice(i, 1)[0];
+ clearTimeout(waiter.timer);
+ waiter.resolve(event);
+ return;
+ }
+ }
+ events.push(event);
+ };
+
+ const flushBuffer = () => {
+ let index: number;
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
+ const block = buffer.slice(0, index);
+ buffer = buffer.slice(index + 2);
+ if (!block.length || block.startsWith(':')) continue;
+ const parsed: SseEvent = { event: 'message', data: '' };
+ const dataLines: string[] = [];
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event:')) parsed.event = line.slice(6).trim();
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
+ }
+ parsed.data = dataLines.join('\n');
+ dispatch(parsed);
+ }
+ };
+
+ const pump = async () => {
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) return;
+ buffer += decoder.decode(value, { stream: true });
+ flushBuffer();
+ }
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error(String(error));
+ for (const waiter of waiters.splice(0)) {
+ clearTimeout(waiter.timer);
+ waiter.reject(err);
+ }
+ } finally {
+ finished = true;
+ for (const waiter of waiters.splice(0)) {
+ clearTimeout(waiter.timer);
+ waiter.reject(new Error('SSE stream closed before match'));
+ }
+ }
+ };
+ void pump();
+
+ return {
+ next(predicate) {
+ const buffered = events.findIndex(predicate);
+ if (buffered !== -1)
+ return Promise.resolve(events.splice(buffered, 1)[0]);
+ if (finished)
+ return Promise.reject(new Error('SSE stream already closed'));
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ const index = waiters.findIndex((w) => w.timer === timer);
+ if (index !== -1) waiters.splice(index, 1);
+ reject(new Error('Timed out waiting for SSE event'));
+ }, timeoutMs);
+ if (typeof timer.unref === 'function') timer.unref();
+ waiters.push({ predicate, resolve, reject, timer });
+ });
+ },
+ async close() {
+ try {
+ await reader.cancel();
+ } catch {
+ // Ignore: server may have already ended the response.
+ }
+ },
+ };
+}
+
test.serial(
'active preset exposes the bridge and a mutation is strictly readable end-to-end',
async (t) => {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ t.fail(
+ 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
+ );
+ return;
+ }
+
const fixture = await makeDir();
t.teardown(() => rm(fixture.dir, { recursive: true, force: true }));
const server = await startGraphqlServer({
@@ -82,6 +199,83 @@ test.serial(
}
);
+test.serial(
+ 'explorer mount leaves /events SSE working when assets exist',
+ async (t) => {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ t.fail(
+ 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
+ );
+ return;
+ }
+
+ const fixture = await makeDir();
+ t.teardown(() => rm(fixture.dir, { recursive: true, force: true }));
+ const server = await startGraphqlServer({
+ config: config(fixture.relativeRoot, true),
+ port: 0,
+ });
+ t.teardown(() => server.close());
+
+ t.true(server.explorer);
+
+ const response = await fetch(`http://localhost:${server.port}/events`, {
+ headers: { accept: 'text/event-stream' },
+ });
+ t.is(response.status, 200);
+ t.regex(response.headers.get('content-type') ?? '', /text\/event-stream/i);
+
+ const stream = createSseReader(response.body!);
+ const ready = await stream.next((event) => event.event === 'ready');
+ t.deepEqual(JSON.parse(ready.data), {
+ generation: server.reloader.generation,
+ });
+ await stream.close();
+ }
+);
+
+test.serial(
+ 'missing explorer assets soft-fails with explorer false but GraphQL still works',
+ async (t) => {
+ const emptyDir = await mkdtemp(
+ join(os.tmpdir(), 'flatbread-explorer-live-')
+ );
+ setExplorerStaticDirOverride(emptyDir);
+ t.teardown(async () => {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ });
+
+ const fixture = await makeDir();
+ t.teardown(() => rm(fixture.dir, { recursive: true, force: true }));
+ const server = await startGraphqlServer({
+ config: config(fixture.relativeRoot, true),
+ port: 0,
+ });
+ t.teardown(() => server.close());
+
+ t.false(server.explorer);
+ t.truthy(server.effortGraph);
+
+ const home = await fetch(`http://localhost:${server.port}/`);
+ t.not(home.headers.get('content-type') ?? '', 'text/html');
+ t.false((await home.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const created = await server.effortGraph!.writer.mutate({
+ type: 'CreateEffort',
+ title: 'GraphQL without explorer assets',
+ body: '',
+ });
+ await server.effortGraph!.waitForCommittedGeneration(created.generation);
+ const response = await query(server.port, '{ allEfforts { title } }');
+ t.deepEqual(response.errors, undefined);
+ t.deepEqual(response.data?.allEfforts, [
+ { title: 'GraphQL without explorer assets' },
+ ]);
+ }
+);
+
test.serial(
'inactive config exposes no effortGraph and behaves as before',
async (t) => {
diff --git a/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts b/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts
new file mode 100644
index 00000000..977f9a1f
--- /dev/null
+++ b/packages/flatbread/src/graphql/liveServerExplorerWatch.test.ts
@@ -0,0 +1,196 @@
+import test from 'ava';
+import express from 'express';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { join, relative } from 'node:path';
+import filesystem from '@flatbread/source-filesystem';
+import markdownTransformer from '@flatbread/transformer-markdown';
+import { initializeConfig } from '@flatbread/core';
+import { effortGraphContent } from '@flatbread/effort-graph';
+import {
+ explorerAssetsPresent,
+ setExplorerStaticDirOverride,
+} from '@flatbread/explorer';
+import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core';
+import { mountExplorer } from './explorerMount.js';
+import { startGraphqlServer } from './liveServer.js';
+
+async function makeDir() {
+ const dir = await mkdtemp(join(process.cwd(), '.tmp-explorer-watch-'));
+ const root = join(dir, 'graph');
+ for (const path of [
+ 'efforts',
+ 'issues',
+ 'findings',
+ 'decisions',
+ 'constraints',
+ 'risks',
+ 'citations',
+ 'blobs',
+ ])
+ await mkdir(join(root, path), { recursive: true });
+ await mkdir(join(root, 'plain'), { recursive: true });
+ return { dir, root, relativeRoot: relative(process.cwd(), root) };
+}
+
+function config(
+ root: string,
+ active: boolean
+): ConfigResult {
+ return {
+ config: initializeConfig({
+ source: filesystem(),
+ transformer: markdownTransformer(),
+ content: active
+ ? effortGraphContent(root)
+ : [{ collection: 'Plain', path: `${root}/plain` }],
+ }),
+ };
+}
+
+async function listen(app: express.Express) {
+ const server = app.listen(0);
+ await new Promise((resolve) => server.once('listening', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ server.close();
+ throw new Error('expected TCP address');
+ }
+ return {
+ base: `http://127.0.0.1:${address.port}`,
+ close: () =>
+ new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ }),
+ };
+}
+
+async function query(port: number, source: string) {
+ const response = await fetch(`http://localhost:${port}/graphql`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query: source }),
+ });
+ return (await response.json()) as {
+ data?: Record;
+ errors?: Array<{ message: string }>;
+ };
+}
+
+function requireExplorerAssets(): void {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ throw new Error(
+ 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
+ );
+ }
+}
+
+test.serial(
+ 'mountExplorer gate toggles SPA off and on without remounting Express',
+ async (t) => {
+ requireExplorerAssets();
+
+ const app = express();
+ const handle = mountExplorer(app, effortGraphContent('.flatbread-efforts'));
+ t.true(handle.isActive());
+
+ app.post('/graphql', (_req, res) => {
+ res.json({ data: { ok: true } });
+ });
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const homeActive = await fetch(`${server.base}/`);
+ t.is(homeActive.status, 200);
+ t.true((await homeActive.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ handle.update([{ collection: 'Post', path: 'posts' }]);
+ t.false(handle.isActive());
+
+ const homeInactive = await fetch(`${server.base}/`);
+ t.is(homeInactive.status, 404);
+ t.false((await homeInactive.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const graphql = await fetch(`${server.base}/graphql`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query: '{ __typename }' }),
+ });
+ t.is(graphql.status, 200);
+ t.true((await graphql.text()).includes('"ok":true'));
+
+ handle.update(effortGraphContent('.flatbread-efforts'));
+ t.true(handle.isActive());
+
+ const homeReenabled = await fetch(`${server.base}/`);
+ t.is(homeReenabled.status, 200);
+ t.true((await homeReenabled.text()).includes('__FLATBREAD_EXPLORER__'));
+ }
+);
+
+test.serial(
+ 'config reload via replaceConfig clears sticky explorer when preset is removed',
+ async (t) => {
+ requireExplorerAssets();
+
+ const fixture = await makeDir();
+ t.teardown(() => rm(fixture.dir, { recursive: true, force: true }));
+
+ const server = await startGraphqlServer({
+ config: config(fixture.relativeRoot, true),
+ port: 0,
+ });
+ t.teardown(() => server.close());
+
+ t.true(server.explorer);
+ const homeBefore = await fetch(`http://localhost:${server.port}/`);
+ t.is(homeBefore.status, 200);
+ t.true((await homeBefore.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const inactive = config(fixture.relativeRoot, false).config!;
+ const result = await server.reloader.replaceConfig(inactive);
+ t.is(result.status, 'committed');
+ t.false(server.explorer);
+
+ const homeAfter = await fetch(`http://localhost:${server.port}/`);
+ t.false((await homeAfter.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const gql = await query(server.port, '{ __typename }');
+ t.deepEqual(gql.errors, undefined);
+ t.is(gql.data?.__typename, 'Query');
+ }
+);
+
+test.serial(
+ 'config reload via replaceConfig enables explorer when preset is added',
+ async (t) => {
+ requireExplorerAssets();
+
+ const fixture = await makeDir();
+ t.teardown(() => rm(fixture.dir, { recursive: true, force: true }));
+
+ const server = await startGraphqlServer({
+ config: config(fixture.relativeRoot, false),
+ port: 0,
+ });
+ t.teardown(() => server.close());
+
+ t.false(server.explorer);
+ const homeBefore = await fetch(`http://localhost:${server.port}/`);
+ t.false((await homeBefore.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const active = config(fixture.relativeRoot, true).config!;
+ const result = await server.reloader.replaceConfig(active);
+ t.is(result.status, 'committed');
+ t.true(server.explorer);
+
+ const homeAfter = await fetch(`http://localhost:${server.port}/`);
+ t.is(homeAfter.status, 200);
+ t.true((await homeAfter.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ const gql = await query(server.port, '{ __typename }');
+ t.deepEqual(gql.errors, undefined);
+ t.is(gql.data?.__typename, 'Query');
+ }
+);
From 75fc81af33380297cf772948409af724117d8691 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Jul 2026 10:10:12 +0000
Subject: [PATCH 04/10] fix(explorer): sync skill projection and harden watcher
teardown
Keep .agents/skills/effort-graph/setup.md identical to the package
skill source so skills:check passes. Tolerate @parcel/watcher EINVAL
on Node 22 when unsubscribe runs after the native handle is gone.
Change-Id: Ida461356c8d75fde5b3a7203e587433661d45107
---
.agents/skills/effort-graph/setup.md | 8 +++-----
packages/flatbread/src/graphql/liveServer.ts | 9 ++++++++-
2 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index c59f8966..a1b26eee 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -89,16 +89,14 @@ client polling loops. Semantic changes go through `flatbread effort write`.
With a complete `effortGraphContent()` preset in config, Flatbread serves the
content-relation explorer automatically (`@flatbread/explorer` ships with
-`flatbread`). In this monorepo, build explorer assets first
-(`pnpm --filter @flatbread/explorer build` or `pnpm play:efforts`):
+`flatbread`):
```bash
flatbread start --watch --open
```
-Mounting and `--open` share the same assets gate: the explorer mounts at `/`
-only when the preset matches **and** packaged SPA assets are present;
-otherwise Flatbread skips the SPA mount and `--open` falls back to
+Mounting and `--open` share the same assets gate: if the packaged SPA assets
+are missing, Flatbread skips the explorer mount and `--open` falls back to
`/graphql`.
- Explorer UI (when mounted): `http://localhost:5057/`
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index 94ed50ec..f211a1b3 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -284,7 +284,14 @@ export async function startGraphqlServer(
if (closed) return;
closed = true;
await coordinator?.dispose();
- await subscription?.unsubscribe();
+ // @parcel/watcher can throw EINVAL ("Unable to remove watcher") on Node
+ // 22 when the native handle is already gone during teardown.
+ try {
+ await subscription?.unsubscribe();
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (!message.includes('Unable to remove watcher')) throw error;
+ }
await current?.stop();
await new Promise((closeResolve) => {
if (httpServer.listening) httpServer.close(() => closeResolve());
From 9fde89a0e93a9efbf2ba9c19c86d50514ced5477 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 28 Jul 2026 10:21:09 +0000
Subject: [PATCH 05/10] fix(flatbread): ignore ephemeral tmp dirs in watch
subscribe
Concurrent AVA fixtures under cwd (.tmp-effort-*, .journal) race
inotify_add_watch while liveServer watches the repo root. Ignore those
paths, keep watcher callback errors from rejecting the server, and retry
subscribe when inotify_add_watch reports a missing path.
Change-Id: I8144a16864a00dd5a263a68c8ab08ea7d05b9105
---
packages/flatbread/src/graphql/liveServer.ts | 32 +++++++++++++++++---
1 file changed, 27 insertions(+), 5 deletions(-)
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index f211a1b3..1b4e3c46 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -251,21 +251,43 @@ export async function startGraphqlServer(
cwd,
(error, events) => {
if (error) {
+ // Concurrent AVA fixtures under cwd can race inotify; never let
+ // watcher noise reject the owning test/server promise.
console.error('Flatbread watcher error:', error);
return;
}
- coordinator!.push(
- events.map((event) => ({ path: event.path, type: event.type }))
- );
+ try {
+ coordinator!.push(
+ events.map((event) => ({
+ path: event.path,
+ type: event.type,
+ }))
+ );
+ } catch (pushError) {
+ console.error('Flatbread watcher push failed:', pushError);
+ }
},
- { ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'] }
+ {
+ ignore: [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/dist/**',
+ // Ephemeral test fixtures and effort-graph journals under cwd.
+ // Keep `.tmp-live-server-test-*` visible so watch-mode AVA can
+ // exercise real filesystem edits.
+ '**/.tmp-effort-*/**',
+ '**/.tmp-explorer-*/**',
+ '**/.journal/**',
+ ],
+ }
);
break;
} catch (error: unknown) {
const isEnoent =
error instanceof Error &&
((error as NodeJS.ErrnoException).code === 'ENOENT' ||
- error.message.includes('No such file or directory'));
+ error.message.includes('No such file or directory') ||
+ error.message.includes('inotify_add_watch'));
if (!isEnoent || attempt === 2) throw error;
await new Promise((resolve) =>
setTimeout(resolve, 100 * (attempt + 1))
From 51a61f139a02a3b0b0d8e69f1874fe3a015c6575 Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Tue, 28 Jul 2026 12:22:37 -0700
Subject: [PATCH 06/10] fix(explorer): address review on watch scope, SPA
paths, and CLI honesty
Resolve the eight findings on #233. Two were real bugs; the rest closed
test gaps or removed a lie in the CLI.
1. `GET /events/` returned the explorer's HTML instead of the event
stream. The SPA fallback in `explorerMount.ts` excluded `/graphql` by
both exact match and prefix but `/events` by exact match only, and it
runs before the SSE handler. Exclude the `/events/` prefix too.
2. `-H, --https` advertised a server that does not exist. Nothing read
`FLATBREAD_HTTPS`, so the flag never changed how Flatbread listens,
yet the banner printed `https://localhost:` and `--open`
opened an HTTPS URL. Drop the dead plumbing and the false banner
lines, keep the flag parsing so existing scripts still run, and say
in its help text that Flatbread serves HTTP only.
3. Watch mode no longer bakes AVA fixture names into production.
`watchIgnore.ts` holds the production ignore list (`node_modules`,
`.git`, `dist`, `.journal`); the one watch-enabled test passes its
fixture globs through a new `watchIgnore` option. Content in a
directory named `.tmp-effort-*` now hot-reloads like anything else.
4. `--open` waited for nothing and could hit a refused connection on a
cold start. The runner now calls `onReady` on `flatbread-gql-ready`
and the browser launch hangs off that. The fork still happens before
the config load, so config work stays off the path to ready.
5. The server-only keep-alive branch moved into `ready.ts` behind
injected spawn and exit, so ten unit tests cover it without forking.
New tests also cover the watcher error and push soft-fail paths, the
reload-time missing-assets latch in `mountExplorer.update`, the
bootstrap `eventsPath` contract, and static assets with and without
file extensions.
Docs: rewrite the explorer README, the CONTRIBUTING bullet, and the
effort-graph setup notes to the repo's plain-English rules. Drop the
`## Local DX` and `## Package surface` headings, spell out SPA and HMR
on first use, and replace the abstract "assets gate" wording with what
Flatbread actually does. Correct the stale HTTPS gotcha in AGENTS.md and
drop `--https` from the Next.js example script.
Verified with `pnpm verify`.
Co-authored-by: Cursor
Change-Id: I425dff6ebafa4fd82dd6ea5937158447bf9d4a61
---
.agents/skills/effort-graph/setup.md | 8 +-
AGENTS.md | 2 +-
CONTRIBUTING.md | 5 +-
examples/nextjs/package.json | 2 +-
.../effort-graph/skills/effort-graph/setup.md | 8 +-
packages/explorer/README.md | 24 +-
packages/flatbread/src/cli/index.ts | 56 ++---
packages/flatbread/src/cli/ready.test.ts | 211 ++++++++++++++++++
packages/flatbread/src/cli/ready.ts | 74 ++++++
packages/flatbread/src/cli/runner.ts | 59 ++---
.../src/graphql/explorerMount.test.ts | 186 +++++++++++++--
.../flatbread/src/graphql/explorerMount.ts | 4 +-
.../flatbread/src/graphql/liveServer.test.ts | 155 +++++++++++++
packages/flatbread/src/graphql/liveServer.ts | 46 ++--
.../flatbread/src/graphql/watchIgnore.test.ts | 52 +++++
packages/flatbread/src/graphql/watchIgnore.ts | 26 +++
16 files changed, 791 insertions(+), 127 deletions(-)
create mode 100644 packages/flatbread/src/cli/ready.test.ts
create mode 100644 packages/flatbread/src/cli/ready.ts
create mode 100644 packages/flatbread/src/graphql/watchIgnore.test.ts
create mode 100644 packages/flatbread/src/graphql/watchIgnore.ts
diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index a1b26eee..9c52d456 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -95,11 +95,11 @@ content-relation explorer automatically (`@flatbread/explorer` ships with
flatbread start --watch --open
```
-Mounting and `--open` share the same assets gate: if the packaged SPA assets
-are missing, Flatbread skips the explorer mount and `--open` falls back to
-`/graphql`.
+Flatbread checks for the prebuilt single-page app (SPA) assets under
+`dist/static/`. When those assets are missing, Flatbread does not serve the
+explorer and `flatbread start --open` opens `/graphql` instead.
-- Explorer UI (when mounted): `http://localhost:5057/`
+- Explorer UI (when served): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
No separate app install is required.
diff --git a/AGENTS.md b/AGENTS.md
index 38faa0fa..cdbbc9ff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,7 +65,7 @@ The repo uses Mergify stacks for PR management. The `mergify-cli` is installed v
- **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit.
- **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically.
- **Build before test.** All packages must be built (`pnpm build`) before running tests or starting dev servers. `pnpm test` handles this automatically.
-- **The Next.js example `dev` script uses `--https`.** This requires an SSL certificate. In headless/CI environments, run without `--https`: `pnpm exec flatbread start -- next dev --turbopack`.
+- **`-H, --https` does not make Flatbread serve HTTPS.** The server listens over plain HTTP whatever you pass. From `examples/nextjs`, run `pnpm exec flatbread start -- next dev --turbopack`.
- **Full local CI parity check:** `pnpm verify` runs lint, typecheck, build, and all tests.
### Weave merge driver
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 63305475..9d2a76e0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,7 +35,10 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex
- Build all packages: `pnpm build`
- **Workspace libraries (watch-only):** `pnpm dev` — runs package `dev` scripts (e.g. `tsup --watch`) for `packages/*`; it does **not** start the Next.js example.
- **Next.js example:** prefer the flow under [Recommended onboarding](#recommended-onboarding-try-flatbread-in-the-nextjs-example); or `pnpm play` as a convenience alias.
-- **Effort Graph explorer:** run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`). When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/` (Apollo sandbox at `/graphql`). For HMR on the SPA shell, run `flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel (Vite on **5173** proxies API routes to **5057**).
+- **Effort Graph explorer:**
+ 1. Run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`).
+ 2. When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/`. The Apollo sandbox is at `/graphql`.
+ 3. For hot module replacement (HMR) on the single-page app (SPA) shell, run `flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel. Vite on **5173** proxies API routes to **5057**.
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
index a88a368a..dc9b1692 100644
--- a/examples/nextjs/package.json
+++ b/examples/nextjs/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "flatbread start --watch --https -- next dev --turbopack",
+ "dev": "flatbread start --watch -- next dev --turbopack",
"codegen": "flatbread codegen --watch",
"demo:edit": "node scripts/demo-edit.mjs",
"demo:restore": "node scripts/demo-restore.mjs",
diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md
index a1b26eee..9c52d456 100644
--- a/packages/effort-graph/skills/effort-graph/setup.md
+++ b/packages/effort-graph/skills/effort-graph/setup.md
@@ -95,11 +95,11 @@ content-relation explorer automatically (`@flatbread/explorer` ships with
flatbread start --watch --open
```
-Mounting and `--open` share the same assets gate: if the packaged SPA assets
-are missing, Flatbread skips the explorer mount and `--open` falls back to
-`/graphql`.
+Flatbread checks for the prebuilt single-page app (SPA) assets under
+`dist/static/`. When those assets are missing, Flatbread does not serve the
+explorer and `flatbread start --open` opens `/graphql` instead.
-- Explorer UI (when mounted): `http://localhost:5057/`
+- Explorer UI (when served): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
No separate app install is required.
diff --git a/packages/explorer/README.md b/packages/explorer/README.md
index 838754b7..e2f661b4 100644
--- a/packages/explorer/README.md
+++ b/packages/explorer/README.md
@@ -1,10 +1,10 @@
# `@flatbread/explorer`
-Content-relation explorer for Flatbread. v1 ships a generic SPA shell plus an
-**Effort Graph** preset. When your config uses `effortGraphContent()`,
-`flatbread start` serves this UI at `/`.
+Content-relation explorer for Flatbread. v1 ships a generic single-page app
+(SPA) shell plus an **Effort Graph** preset. When your config uses
+`effortGraphContent()`, `flatbread start` serves this UI at `/`.
-## Local DX
+## Try it locally
```bash
# flatbread.config.js includes effortGraphContent()
@@ -13,9 +13,11 @@ flatbread start --watch --open
# → http://localhost:5057/graphql Apollo sandbox
```
-No separate Next app is required. Requires a built `dist/static` (see
-[Develop in the monorepo](#develop-in-the-monorepo)); `pnpm play:efforts` runs
-that build automatically.
+No separate Next.js app is required. Flatbread checks for the prebuilt assets
+under `dist/static/` (see [Develop in the monorepo](#develop-in-the-monorepo)).
+When those assets are missing, Flatbread does not serve the explorer and
+`flatbread start --open` opens `/graphql` instead. `pnpm play:efforts` runs
+the explorer build automatically.
## Static deploy
@@ -28,7 +30,7 @@ https://your-host.example/?endpoint=https://api.example.com/graphql
Same-origin deploys (assets served by Flatbread) need no query param.
-## Package surface (v1)
+## What the package exports
| Export | Role |
| ------------------------------ | ------------------------------------------ |
@@ -46,9 +48,9 @@ pnpm --filter @flatbread/explorer test
pnpm play:efforts # builds explorer, then flatbread start --watch --open
```
-For UI-only iteration with HMR, run Flatbread and Vite in separate terminals
-(Vite proxies `/graphql` and `/events` to Flatbread on port **5057**, or
-`FLATBREAD_PORT` when set):
+For UI-only iteration with hot module replacement (HMR), run Flatbread and Vite
+in separate terminals (Vite proxies `/graphql` and `/events` to Flatbread on
+port **5057**, or `FLATBREAD_PORT` when set):
```bash
pnpm exec flatbread start --watch # terminal 1 — GraphQL on :5057
diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts
index d9a66f6d..9403fc2d 100644
--- a/packages/flatbread/src/cli/index.ts
+++ b/packages/flatbread/src/cli/index.ts
@@ -19,11 +19,7 @@ import { loadFlatbreadConfig } from '../utils/getSchema';
*
* Yoinked from [SvelteKit's CLI](https://github.com/sveltejs/kit/blob/2c133ff5b8798c885161ed57bfb45c88fc77f516/packages/kit/src/cli.js).
*/
-async function launch(
- port: number,
- https: boolean,
- openPath: string
-): Promise {
+async function launch(port: number, openPath: string): Promise {
const { exec } = await import('child_process');
let cmd = 'open';
if (process.platform == 'win32') {
@@ -36,7 +32,7 @@ async function launch(
}
}
const path = openPath.startsWith('/') ? openPath : `/${openPath}`;
- exec(`${cmd} ${https ? 'https' : 'http'}://localhost:${port}${path}`);
+ exec(`${cmd} http://localhost:${port}${path}`);
}
const prog = sade('flatbread').version(version);
@@ -45,7 +41,11 @@ prog
.command('start [corunner]', 'Start flatbread with a GraphQL server')
.option('--, _', 'Pass options to the corunning script')
.option('-p, --port', 'Port to run the GraphQL server', 5057)
- .option('-H, --https', 'Use self-signed HTTPS certificate', false)
+ .option(
+ '-H, --https',
+ 'Reserved. Flatbread serves HTTP only; this flag does nothing',
+ false
+ )
.option('-w, --watch', 'Hot-swap content and reload config', false)
.option(
'-o, --open',
@@ -56,19 +56,31 @@ prog
'-X, --exec',
'The runner to execute the corunning script with. Defaults to your package manager (i.e. npm, pnpm, yarn)'
)
- .action(async (corunner, { _, port, https, watch, open, exec }) => {
+ .action(async (corunner, { _, port, watch, open, exec }) => {
const args = Array.isArray(_) ? _ : [];
const secondaryScript =
typeof corunner === 'string' && corunner.length > 0
? `${corunner} ${args.join(' ')}`.trim()
: '';
+ // Fork before reading the config so config load stays off the critical
+ // path to `flatbread-gql-ready` (and to the corunner). `--open` waits on
+ // this promise instead, so it always opens the resolved path.
+ let settleOpenPath!: (path: string) => void;
+ const resolvedOpenPath = new Promise((resolve) => {
+ settleOpenPath = resolve;
+ });
+
orchestrateProcesses({
corunner: secondaryScript,
flatbreadPort: port,
- https,
watch,
packageManager: exec,
+ onReady: open
+ ? () => {
+ void resolvedOpenPath.then((path) => launch(port, path));
+ }
+ : undefined,
});
let openPath = GRAPHQL_ENDPOINT;
@@ -80,8 +92,9 @@ prog
} catch {
// Config may be missing during init; fall back to GraphQL sandbox.
}
+ settleOpenPath(openPath);
- welcome({ port, https, open, openPath, explorer });
+ welcome({ port, explorer });
});
prog
@@ -126,19 +139,11 @@ prog.parse(process.argv, { unknown: (arg) => `Unknown option: ${arg}` });
*/
function welcome({
port,
- https,
- open,
- openPath,
explorer,
}: {
- open: boolean;
- https: boolean;
port: number;
- openPath: string;
explorer: boolean;
}): void {
- if (open) void launch(port, https, openPath);
-
console.log(
colors.bold(
gradient.fruit('\n Flatbread 🥯') + gradient.vice(` v${version}\n`)
@@ -163,21 +168,6 @@ function welcome({
`localhost:${port}${GRAPHQL_ENDPOINT}`
)}`
);
-
- if (https) {
- if (explorer) {
- console.log(
- ` ${colors.gray('explorer:')} https://${colors.bold(
- `localhost:${port + 1}${EXPLORER_ENDPOINT}`
- )}`
- );
- }
- console.log(
- ` ${colors.gray('graphql:')} https://${colors.bold(
- `localhost:${port + 1}${GRAPHQL_ENDPOINT}`
- )}`
- );
- }
} else {
if (details.mac === '00:00:00:00:00:00') return;
}
diff --git a/packages/flatbread/src/cli/ready.test.ts b/packages/flatbread/src/cli/ready.test.ts
new file mode 100644
index 00000000..a615d1d3
--- /dev/null
+++ b/packages/flatbread/src/cli/ready.test.ts
@@ -0,0 +1,211 @@
+import test from 'ava';
+import {
+ createGqlReadyHandler,
+ type ChildLike,
+ type ReadyHandlerDeps,
+} from './ready.js';
+
+function createFakeChild(): ChildLike & {
+ emitClose(code: number | null): void;
+} {
+ const closeListeners: Array<(code: number | null) => void> = [];
+ return {
+ on(event: 'close', listener: (code: number | null) => void) {
+ if (event === 'close') {
+ closeListeners.push(listener);
+ }
+ return this;
+ },
+ kill() {
+ return undefined;
+ },
+ emitClose(code: number | null) {
+ for (const listener of closeListeners) {
+ listener(code);
+ }
+ },
+ };
+}
+
+function createDeps(
+ overrides: Partial & {
+ spawned?: string[];
+ exits?: number[];
+ readyCount?: { value: number };
+ } = {}
+): ReadyHandlerDeps & {
+ spawned: string[];
+ exits: number[];
+ readyCount: { value: number };
+} {
+ const spawned = overrides.spawned ?? [];
+ const exits = overrides.exits ?? [];
+ const readyCount = overrides.readyCount ?? { value: 0 };
+
+ return {
+ corunner: overrides.corunner ?? '',
+ packageManager: overrides.packageManager ?? null,
+ spawnCorunner:
+ overrides.spawnCorunner ??
+ ((command: string) => {
+ spawned.push(command);
+ return createFakeChild();
+ }),
+ onExit:
+ overrides.onExit ??
+ ((code: number) => {
+ exits.push(code);
+ }),
+ onReady:
+ overrides.onReady ??
+ (() => {
+ readyCount.value += 1;
+ }),
+ spawned,
+ exits,
+ readyCount,
+ };
+}
+
+test('server-only: onReady fires once and nothing is spawned', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: '' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ const result = handle('flatbread-gql-ready');
+
+ t.true(result.accepted);
+ t.true(result.serverOnly);
+ t.is(deps.readyCount.value, 1);
+ t.deepEqual(deps.spawned, []);
+ t.deepEqual(deps.exits, []);
+});
+
+test('server-only: process exits with child close code 0', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: ' ' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ gql.emitClose(0);
+
+ t.deepEqual(deps.exits, [0]);
+});
+
+test('server-only: null child close code exits with 1', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: '' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ gql.emitClose(null);
+
+ t.deepEqual(deps.exits, [1]);
+});
+
+test('corunner: spawns with package manager and onReady still fires', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'pnpm',
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ const result = handle('flatbread-gql-ready');
+
+ t.true(result.accepted);
+ t.false(result.serverOnly);
+ t.is(deps.readyCount.value, 1);
+ t.deepEqual(deps.spawned, ['pnpm']);
+});
+
+test('corunner: defaults package manager command to npm run', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next build',
+ packageManager: null,
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+
+ t.deepEqual(deps.spawned, ['npm run']);
+});
+
+test('corunner: GraphQL child close exits parent with that code', (t) => {
+ const gql = createFakeChild();
+ const corunnerChild = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'pnpm',
+ spawnCorunner: () => corunnerChild,
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ gql.emitClose(2);
+
+ t.deepEqual(deps.exits, [2]);
+});
+
+test('corunner: corunner child close exits parent with that code', (t) => {
+ const gql = createFakeChild();
+ const corunnerChild = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'pnpm',
+ spawnCorunner: () => corunnerChild,
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ corunnerChild.emitClose(0);
+
+ t.deepEqual(deps.exits, [0]);
+});
+
+test('corunner: null close code exits with 1', (t) => {
+ const gql = createFakeChild();
+ const corunnerChild = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'yarn',
+ spawnCorunner: () => corunnerChild,
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ corunnerChild.emitClose(null);
+
+ t.deepEqual(deps.exits, [1]);
+});
+
+test('second flatbread-gql-ready does not spawn twice', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'pnpm',
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ const first = handle('flatbread-gql-ready');
+ const second = handle('flatbread-gql-ready');
+
+ t.true(first.accepted);
+ t.false(second.accepted);
+ t.is(deps.readyCount.value, 1);
+ t.deepEqual(deps.spawned, ['pnpm']);
+});
+
+test('ignores unrelated IPC messages', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: 'next dev', packageManager: 'pnpm' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ const result = handle('something-else');
+
+ t.false(result.accepted);
+ t.false(result.serverOnly);
+ t.is(deps.readyCount.value, 0);
+ t.deepEqual(deps.spawned, []);
+});
diff --git a/packages/flatbread/src/cli/ready.ts b/packages/flatbread/src/cli/ready.ts
new file mode 100644
index 00000000..0c95c07a
--- /dev/null
+++ b/packages/flatbread/src/cli/ready.ts
@@ -0,0 +1,74 @@
+/**
+ * Pure ready-message decision for `flatbread start`.
+ *
+ * The runner forks the GraphQL server and listens for `flatbread-gql-ready`.
+ * This module decides what happens next: keep the parent alive (server-only)
+ * or spawn the corunner. Callers inject spawn and exit so tests stay fast.
+ */
+
+/** Narrow child-process surface used by the ready handler. */
+export interface ChildLike {
+ on(event: 'close', listener: (code: number | null) => void): unknown;
+ kill(): unknown;
+}
+
+export interface ReadyHandlerDeps {
+ readonly corunner: string;
+ readonly packageManager: string | null;
+ spawnCorunner(packageManagerCommand: string): ChildLike;
+ onExit(code: number): void;
+ onReady?: () => void;
+}
+
+export interface ReadyHandleResult {
+ /** True when this call took the server-only keep-alive branch. */
+ readonly serverOnly: boolean;
+ /** True when this call was the first accepted `flatbread-gql-ready`. */
+ readonly accepted: boolean;
+}
+
+/**
+ * Build the IPC message handler that runs when the GraphQL child is ready.
+ *
+ * A second `flatbread-gql-ready` is ignored so the corunner is never spawned twice.
+ */
+export function createGqlReadyHandler(
+ gqlChild: ChildLike,
+ deps: ReadyHandlerDeps
+): (msg: unknown) => ReadyHandleResult {
+ let handled = false;
+
+ return (msg: unknown): ReadyHandleResult => {
+ if (msg !== 'flatbread-gql-ready') {
+ return { serverOnly: false, accepted: false };
+ }
+
+ if (handled) {
+ return { serverOnly: false, accepted: false };
+ }
+ handled = true;
+
+ deps.onReady?.();
+
+ const hasCorunner =
+ typeof deps.corunner === 'string' && deps.corunner.trim().length > 0;
+
+ if (!hasCorunner) {
+ gqlChild.on('close', (code) => {
+ deps.onExit(code ?? 1);
+ });
+ return { serverOnly: true, accepted: true };
+ }
+
+ const packageManagerCommand = deps.packageManager ?? 'npm run';
+ const targetProcess = deps.spawnCorunner(packageManagerCommand);
+
+ for (const script of [gqlChild, targetProcess]) {
+ script.on('close', (code) => {
+ deps.onExit(code ?? 1);
+ });
+ }
+
+ return { serverOnly: false, accepted: true };
+ };
+}
diff --git a/packages/flatbread/src/cli/runner.ts b/packages/flatbread/src/cli/runner.ts
index 8e198b0a..76fdf682 100644
--- a/packages/flatbread/src/cli/runner.ts
+++ b/packages/flatbread/src/cli/runner.ts
@@ -7,13 +7,15 @@ import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import colors from 'kleur';
import { getRunCommand } from '@flatbread/utils';
+import { createGqlReadyHandler } from './ready';
export interface OrchestraOptions {
corunner: string;
flatbreadPort: number;
- https?: boolean;
watch?: boolean;
packageManager: string | null;
+ /** Called once when the GraphQL child reports ready (before the corunner starts). */
+ onReady?: () => void;
}
/**
@@ -26,9 +28,9 @@ export interface OrchestraOptions {
export default function orchestrateProcesses({
corunner,
flatbreadPort,
- https = false,
watch = false,
packageManager = null,
+ onReady,
}: OrchestraOptions) {
const pkgManager = packageManager || getRunCommand(process.cwd());
@@ -41,49 +43,30 @@ export default function orchestrateProcesses({
...process.env,
NODE_OPTIONS: '--experimental-vm-modules',
FLATBREAD_PORT: String(flatbreadPort),
- FLATBREAD_HTTPS: String(https),
FLATBREAD_WATCH: watch ? '1' : '0',
},
});
let runningScripts = [gql];
- gql.on('message', (msg) => {
- if (msg !== 'flatbread-gql-ready') return;
-
- const hasCorunner =
- typeof corunner === 'string' && corunner.trim().length > 0;
-
- // Server-only mode: `flatbread start` with no framework corunner.
- // Keep the parent alive so the GraphQL (and explorer) process stays up.
- if (!hasCorunner) {
- gql.on('close', (code) => {
- process.exit(code ?? 1);
+ const handleReady = createGqlReadyHandler(gql, {
+ corunner,
+ packageManager: pkgManager,
+ onReady,
+ onExit: (code) => {
+ process.exit(code);
+ },
+ spawnCorunner: (packageManagerCommand) => {
+ const targetProcess = spawn(packageManagerCommand, [corunner], {
+ shell: true,
+ stdio: 'inherit',
});
- return;
- }
-
- // Start the target process (e.g. the dev server or the build script)
- const targetProcess = spawn(pkgManager ?? 'npm run', [corunner], {
- shell: true,
- stdio: 'inherit',
- });
+ runningScripts.push(targetProcess);
+ return targetProcess;
+ },
+ });
- runningScripts.push(targetProcess);
-
- // Exit the parent process when the target process exits
- for (let script of runningScripts) {
- script.on('close', (code) => {
- //
- // If the target process exited with a non-zero `code`, exit the parent process with the same `code`
- //
- // If the target process closes with a null `code`, exit the parent process with an exit code of 1
- // (this usually indicates an error originating outside of the target process, where it is killed before it can exit)
- //
- // See https://nodejs.org/api/child_process.html#event-exit
- //
- process.exit(code ?? 1);
- });
- }
+ gql.on('message', (msg) => {
+ handleReady(msg);
});
// End any remaining child processes when the parent process exits
diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts
index 32759118..bab68314 100644
--- a/packages/flatbread/src/graphql/explorerMount.test.ts
+++ b/packages/flatbread/src/graphql/explorerMount.test.ts
@@ -1,6 +1,6 @@
import test, { type ExecutionContext } from 'ava';
import express from 'express';
-import { mkdtemp, rm } from 'node:fs/promises';
+import { mkdtemp, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import { join } from 'node:path';
import { effortGraphContent } from '@flatbread/effort-graph';
@@ -10,7 +10,7 @@ import {
getExplorerStaticDir,
setExplorerStaticDirOverride,
} from '@flatbread/explorer';
-import { mountExplorerIfMatched } from './explorerMount.js';
+import { mountExplorer, mountExplorerIfMatched } from './explorerMount.js';
async function listen(app: express.Express) {
const server = app.listen(0);
@@ -53,11 +53,13 @@ test.serial(
t.truthy(mounted);
t.is(mounted!.openPath, '/');
- app.get('/graphql', (_req, res) => {
+ // Register after the mount, same order as liveServer.ts.
+ // use() so /graphql/anything and /events/ reach the sentinel too.
+ app.use('/graphql', (_req, res) => {
res.json({ route: 'graphql' });
});
- app.get('/events', (_req, res) => {
- res.type('text/event-stream').send('event: test\ndata: {}\n\n');
+ app.use('/events', (_req, res) => {
+ res.json({ sse: true });
});
const server = await listen(app);
@@ -71,21 +73,30 @@ test.serial(
const boot = await fetch(`${server.base}${EXPLORER_BOOTSTRAP_PATH}`);
t.is(boot.status, 200);
- const json = (await boot.json()) as { preset: string; graphqlPath: string };
+ const json = (await boot.json()) as {
+ preset: string;
+ graphqlPath: string;
+ eventsPath: string;
+ };
t.is(json.preset, 'effort-graph');
t.is(json.graphqlPath, '/graphql');
+ t.is(json.eventsPath, '/events');
+
+ for (const path of ['/graphql', '/graphql/anything'] as const) {
+ const res = await fetch(`${server.base}${path}`);
+ t.is(res.status, 200, path);
+ const body = await res.text();
+ t.false(body.includes('__FLATBREAD_EXPLORER__'), path);
+ t.true(body.includes('"route":"graphql"'), path);
+ }
- const graphql = await fetch(`${server.base}/graphql`);
- t.is(graphql.status, 200);
- const graphqlBody = await graphql.text();
- t.false(graphqlBody.includes('__FLATBREAD_EXPLORER__'));
- t.true(graphqlBody.includes('"route":"graphql"'));
-
- const events = await fetch(`${server.base}/events`);
- t.is(events.status, 200);
- const eventsBody = await events.text();
- t.false(eventsBody.includes('__FLATBREAD_EXPLORER__'));
- t.true(eventsBody.includes('event: test'));
+ for (const path of ['/events', '/events/'] as const) {
+ const res = await fetch(`${server.base}${path}`);
+ t.is(res.status, 200, path);
+ const body = await res.text();
+ t.false(body.includes('__FLATBREAD_EXPLORER__'), path);
+ t.true(body.includes('"sse":true'), path);
+ }
}
);
@@ -109,6 +120,82 @@ test.serial(
}
);
+test.serial(
+ 'missing static file with extension falls through SPA fallback',
+ async (t) => {
+ if (!requireExplorerAssets(t)) return;
+
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, effortGraphContent());
+ t.truthy(mounted);
+
+ app.use((_req, res) => {
+ res.status(404).json({ sentinel: 'missing-asset' });
+ });
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const res = await fetch(`${server.base}/missing-asset.js`);
+ t.is(res.status, 404);
+ const body = await res.text();
+ t.false(body.includes('__FLATBREAD_EXPLORER__'));
+ t.true(body.includes('"sentinel":"missing-asset"'));
+ }
+);
+
+test.serial(
+ 'existing static asset returns 200 without injected explorer HTML',
+ async (t) => {
+ if (!requireExplorerAssets(t)) return;
+
+ const assetsDir = join(getExplorerStaticDir(), 'assets');
+ const files = await readdir(assetsDir);
+ const assetFile = files.find(
+ (name) => name.endsWith('.js') && !name.endsWith('.map')
+ );
+ if (!assetFile) {
+ t.fail(`No .js asset under ${assetsDir}`);
+ return;
+ }
+
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, effortGraphContent());
+ t.truthy(mounted);
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const res = await fetch(`${server.base}/assets/${assetFile}`);
+ t.is(res.status, 200);
+ const body = await res.text();
+ t.false(body.includes('__FLATBREAD_EXPLORER__'));
+ }
+);
+
+test.serial(
+ 'inactive gate lets bootstrap path fall through to downstream handler',
+ async (t) => {
+ const app = express();
+ const mounted = mountExplorerIfMatched(app, [
+ { collection: 'Post', path: 'posts' },
+ ]);
+ t.is(mounted, null);
+
+ app.get(EXPLORER_BOOTSTRAP_PATH, (_req, res) => {
+ res.json({ sentinel: 'bootstrap-inactive' });
+ });
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const res = await fetch(`${server.base}${EXPLORER_BOOTSTRAP_PATH}`);
+ t.is(res.status, 200);
+ const json = (await res.json()) as { sentinel: string };
+ t.is(json.sentinel, 'bootstrap-inactive');
+ }
+);
+
test.serial(
'warns and returns null without SPA routes when assets are missing',
async (t) => {
@@ -147,6 +234,71 @@ test.serial(
}
);
+test.serial(
+ 'update deactivates once when assets vanish and reactivates when restored',
+ async (t) => {
+ if (!requireExplorerAssets(t)) return;
+
+ const emptyDir = await mkdtemp(
+ join(os.tmpdir(), 'flatbread-explorer-reload-')
+ );
+ t.teardown(async () => {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ });
+
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warnings.push(args.map(String).join(' '));
+ };
+ t.teardown(() => {
+ console.warn = originalWarn;
+ });
+
+ const app = express();
+ const handle = mountExplorer(app, effortGraphContent());
+ t.true(handle.isActive());
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ const homeActive = await fetch(`${server.base}/`);
+ t.is(homeActive.status, 200);
+ t.true((await homeActive.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ setExplorerStaticDirOverride(emptyDir);
+ handle.update(effortGraphContent());
+ t.false(handle.isActive());
+ t.is(
+ warnings.filter((message) =>
+ message.includes('Flatbread explorer assets missing')
+ ).length,
+ 1
+ );
+
+ const homeInactive = await fetch(`${server.base}/`);
+ t.false((await homeInactive.text()).includes('__FLATBREAD_EXPLORER__'));
+
+ handle.update(effortGraphContent());
+ t.false(handle.isActive());
+ t.is(
+ warnings.filter((message) =>
+ message.includes('Flatbread explorer assets missing')
+ ).length,
+ 1
+ );
+
+ setExplorerStaticDirOverride(undefined);
+ handle.update(effortGraphContent());
+ t.true(handle.isActive());
+
+ const homeRestored = await fetch(`${server.base}/`);
+ t.is(homeRestored.status, 200);
+ t.true((await homeRestored.text()).includes('__FLATBREAD_EXPLORER__'));
+ }
+);
+
test.serial('does not mount explorer for ordinary content', async (t) => {
const app = express();
const mounted = mountExplorerIfMatched(app, [
diff --git a/packages/flatbread/src/graphql/explorerMount.ts b/packages/flatbread/src/graphql/explorerMount.ts
index ae78e19a..3bf01d5c 100644
--- a/packages/flatbread/src/graphql/explorerMount.ts
+++ b/packages/flatbread/src/graphql/explorerMount.ts
@@ -128,7 +128,8 @@ export function mountExplorer(
});
// SPA fallback for client routes — never steal API paths. `/events` and
- // `/graphql` are registered after this mount and must receive `next()`.
+ // `/graphql` (exact and under those prefixes) register after this mount
+ // and must receive `next()`.
app.use((req: Request, res: Response, next: NextFunction) => {
if (!active) return next();
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
@@ -137,6 +138,7 @@ export function mountExplorer(
pathname === GRAPHQL_PATH ||
pathname === EVENTS_PATH ||
pathname.startsWith(`${GRAPHQL_PATH}/`) ||
+ pathname.startsWith(`${EVENTS_PATH}/`) ||
pathname.startsWith('/__flatbread/')
) {
return next();
diff --git a/packages/flatbread/src/graphql/liveServer.test.ts b/packages/flatbread/src/graphql/liveServer.test.ts
index d3bf8039..c2c66b84 100644
--- a/packages/flatbread/src/graphql/liveServer.test.ts
+++ b/packages/flatbread/src/graphql/liveServer.test.ts
@@ -6,6 +6,13 @@ import markdownTransformer from '@flatbread/transformer-markdown';
import { initializeConfig } from '@flatbread/core';
import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core';
import { startGraphqlServer } from './liveServer';
+import type { WatchSubscribe, WatchSubscribeEvent } from './liveServer';
+
+/** Concurrent AVA fixtures under cwd; keep them out of this suite's watcher. */
+const TEST_WATCH_IGNORE = [
+ '**/.tmp-effort-*/**',
+ '**/.tmp-explorer-*/**',
+] as const;
interface Fixture {
/** Absolute temp dir inside the repo (source-filesystem resolves content paths against cwd). */
@@ -143,6 +150,7 @@ test.serial(
config: makeConfig(fixture),
port: 0,
watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
});
try {
@@ -339,3 +347,150 @@ test.serial(
]);
}
);
+
+type WatchCallback = (
+ error: Error | null,
+ events: WatchSubscribeEvent[]
+) => unknown;
+
+function captureWatcherSubscribe(): {
+ subscribe: WatchSubscribe;
+ getCallback: () => WatchCallback;
+} {
+ let callback: WatchCallback | undefined;
+ const subscribe: WatchSubscribe = async (_dir, cb) => {
+ callback = cb;
+ return { unsubscribe: async () => undefined };
+ };
+ return {
+ subscribe,
+ getCallback: () => {
+ if (!callback) throw new Error('watcher subscribe was never called');
+ return callback;
+ },
+ };
+}
+
+test.serial(
+ 'watcher callback error keeps the server alive and answering queries',
+ async (t) => {
+ const fixture = await makeFixture();
+ t.teardown(fixture.cleanup);
+ const stub = captureWatcherSubscribe();
+
+ const server = await startGraphqlServer({
+ config: makeConfig(fixture),
+ port: 0,
+ watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
+ watcherSubscribe: stub.subscribe,
+ });
+
+ try {
+ t.notThrows(() =>
+ stub.getCallback()(new Error('inotify_add_watch race'), [])
+ );
+ t.deepEqual(await queryTitles(server.port), [
+ 'Original Title',
+ 'Second Post',
+ ]);
+ t.is(server.reloader.generation, 0);
+ } finally {
+ await server.close();
+ }
+ }
+);
+
+test.serial(
+ 'watcher push failure is logged and the server keeps serving',
+ async (t) => {
+ const fixture = await makeFixture();
+ t.teardown(fixture.cleanup);
+ const stub = captureWatcherSubscribe();
+ const errors: unknown[][] = [];
+ const originalError = console.error;
+ console.error = (...args: unknown[]) => {
+ errors.push(args);
+ };
+ t.teardown(() => {
+ console.error = originalError;
+ });
+
+ const server = await startGraphqlServer({
+ config: makeConfig(fixture),
+ port: 0,
+ watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
+ watcherSubscribe: stub.subscribe,
+ });
+
+ try {
+ const badEvents = {
+ map(): never {
+ throw new Error('synthetic push boom');
+ },
+ } as unknown as WatchSubscribeEvent[];
+
+ t.notThrows(() => stub.getCallback()(null, badEvents));
+ t.true(
+ errors.some(
+ (args) =>
+ typeof args[0] === 'string' &&
+ args[0].includes('Flatbread watcher push failed:')
+ ),
+ `expected push failure log, got: ${JSON.stringify(errors)}`
+ );
+ t.deepEqual(await queryTitles(server.port), [
+ 'Original Title',
+ 'Second Post',
+ ]);
+ } finally {
+ await server.close();
+ }
+ }
+);
+
+test.serial(
+ 'stubbed watcher update reaches the reindex path on the same port',
+ async (t) => {
+ const fixture = await makeFixture();
+ t.teardown(fixture.cleanup);
+ const stub = captureWatcherSubscribe();
+
+ const server = await startGraphqlServer({
+ config: makeConfig(fixture),
+ port: 0,
+ watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
+ watcherSubscribe: stub.subscribe,
+ });
+
+ try {
+ t.deepEqual(await queryTitles(server.port), [
+ 'Original Title',
+ 'Second Post',
+ ]);
+
+ await writeFile(fixture.postOne, POST_ONE('Stubbed Watch Title'));
+ stub.getCallback()(null, [{ path: fixture.postOne, type: 'update' }]);
+
+ await Promise.race([
+ server.reloader.waitForGeneration(1),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error('stubbed watcher did not commit within 5s')),
+ 5_000
+ )
+ ),
+ ]);
+
+ t.deepEqual(await queryTitles(server.port), [
+ 'Stubbed Watch Title',
+ 'Second Post',
+ ]);
+ t.true(server.reloader.generation >= 1);
+ } finally {
+ await server.close();
+ }
+ }
+);
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index 1b4e3c46..dcc4459b 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -20,12 +20,37 @@ import http from 'http';
import { loadFlatbreadConfig } from '../utils/getSchema';
import { createEffortGraphComposition } from './effortGraphComposition';
import { mountExplorer } from './explorerMount';
+import { buildWatchIgnore } from './watchIgnore';
+
+/** Event shape consumed from `@parcel/watcher` (and test stubs). */
+export interface WatchSubscribeEvent {
+ path: string;
+ type: 'create' | 'update' | 'delete';
+}
+
+/**
+ * Narrow subscribe signature used by watch mode.
+ * Matches `@parcel/watcher`'s subscribe for the options we pass.
+ */
+export type WatchSubscribe = (
+ dir: string,
+ callback: (error: Error | null, events: WatchSubscribeEvent[]) => unknown,
+ opts?: { ignore?: string[] }
+) => Promise<{ unsubscribe(): Promise }>;
export interface GraphqlServerOptions {
port?: number;
config: ConfigResult;
watch?: boolean;
cwd?: string;
+ /**
+ * Extra glob patterns to drop from the `--watch` subscription, on top of
+ * {@link DEFAULT_WATCH_IGNORE}. Tests use this to keep concurrent fixture
+ * directories out of the watched tree; production passes nothing.
+ */
+ watchIgnore?: readonly string[];
+ /** Test seam: overrides the `@parcel/watcher` subscribe implementation. */
+ watcherSubscribe?: WatchSubscribe;
}
export interface RunningGraphqlServer {
readonly port: number;
@@ -206,7 +231,8 @@ export async function startGraphqlServer(
let subscription: { unsubscribe(): Promise } | undefined;
let coordinator: WatchCoordinator | undefined;
if (options.watch) {
- const { subscribe } = await import('@parcel/watcher');
+ const subscribe: WatchSubscribe =
+ options.watcherSubscribe ?? (await import('@parcel/watcher')).subscribe;
coordinator = createWatchCoordinator({
config,
cwd,
@@ -245,14 +271,14 @@ export async function startGraphqlServer(
: 'codegen refresh';
console.error(`Flatbread ${label} failed:`, result.error);
});
+ const ignore = buildWatchIgnore(options.watchIgnore);
for (let attempt = 0; attempt < 3; attempt++) {
try {
subscription = await subscribe(
cwd,
(error, events) => {
if (error) {
- // Concurrent AVA fixtures under cwd can race inotify; never let
- // watcher noise reject the owning test/server promise.
+ // Watcher noise must not reject the owning server promise.
console.error('Flatbread watcher error:', error);
return;
}
@@ -267,19 +293,7 @@ export async function startGraphqlServer(
console.error('Flatbread watcher push failed:', pushError);
}
},
- {
- ignore: [
- '**/node_modules/**',
- '**/.git/**',
- '**/dist/**',
- // Ephemeral test fixtures and effort-graph journals under cwd.
- // Keep `.tmp-live-server-test-*` visible so watch-mode AVA can
- // exercise real filesystem edits.
- '**/.tmp-effort-*/**',
- '**/.tmp-explorer-*/**',
- '**/.journal/**',
- ],
- }
+ { ignore }
);
break;
} catch (error: unknown) {
diff --git a/packages/flatbread/src/graphql/watchIgnore.test.ts b/packages/flatbread/src/graphql/watchIgnore.test.ts
new file mode 100644
index 00000000..ce549b19
--- /dev/null
+++ b/packages/flatbread/src/graphql/watchIgnore.test.ts
@@ -0,0 +1,52 @@
+import test from 'ava';
+import picomatch from 'picomatch';
+import { DEFAULT_WATCH_IGNORE, buildWatchIgnore } from './watchIgnore.js';
+
+/** True when any glob matches the path (dotfiles included). */
+function matchesAny(patterns: readonly string[], path: string): boolean {
+ return patterns.some((pattern) => picomatch(pattern, { dot: true })(path));
+}
+
+test('DEFAULT_WATCH_IGNORE leaves live-server test fixtures visible', (t) => {
+ t.false(
+ matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-live-server-test-abc/posts/a.md')
+ );
+});
+
+test('buildWatchIgnore extras drop concurrent effort and explorer fixtures', (t) => {
+ const ignore = buildWatchIgnore([
+ '**/.tmp-effort-*/**',
+ '**/.tmp-explorer-*/**',
+ ]);
+ t.true(matchesAny(ignore, '.tmp-effort-live-x/posts/a.md'));
+ t.true(matchesAny(ignore, '.tmp-explorer-watch-x/content/b.md'));
+ t.false(matchesAny(ignore, '.tmp-live-server-test-abc/posts/a.md'));
+});
+
+test('DEFAULT_WATCH_IGNORE matches journal, node_modules, .git, and dist', (t) => {
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'content/.journal/entry.json'));
+ t.true(
+ matchesAny(DEFAULT_WATCH_IGNORE, 'packages/foo/node_modules/x/index.js')
+ );
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, '.git/objects/ab/cd'));
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'packages/flatbread/dist/index.js'));
+});
+
+test('buildWatchIgnore de-duplicates extras and keeps defaults first', (t) => {
+ const ignore = buildWatchIgnore([
+ '**/dist/**',
+ '**/.tmp-effort-*/**',
+ '**/.tmp-effort-*/**',
+ '**/custom/**',
+ ]);
+ t.deepEqual(ignore, [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/dist/**',
+ '**/.journal/**',
+ '**/.tmp-effort-*/**',
+ '**/custom/**',
+ ]);
+ t.is(ignore.filter((pattern) => pattern === '**/dist/**').length, 1);
+ t.is(ignore.filter((pattern) => pattern === '**/.tmp-effort-*/**').length, 1);
+});
diff --git a/packages/flatbread/src/graphql/watchIgnore.ts b/packages/flatbread/src/graphql/watchIgnore.ts
new file mode 100644
index 00000000..653ae451
--- /dev/null
+++ b/packages/flatbread/src/graphql/watchIgnore.ts
@@ -0,0 +1,26 @@
+/**
+ * Glob patterns the `--watch` subscription drops by default.
+ * Production noise only — never test-fixture names.
+ */
+export const DEFAULT_WATCH_IGNORE: readonly string[] = [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/dist/**',
+ '**/.journal/**',
+];
+
+/**
+ * Defaults plus any caller-supplied globs, de-duplicated.
+ * Defaults stay first; extras keep their relative order.
+ */
+export function buildWatchIgnore(extra?: readonly string[]): string[] {
+ const result: string[] = [...DEFAULT_WATCH_IGNORE];
+ const seen = new Set(result);
+ if (!extra) return result;
+ for (const pattern of extra) {
+ if (seen.has(pattern)) continue;
+ seen.add(pattern);
+ result.push(pattern);
+ }
+ return result;
+}
From 2d590b766272a7611c0d4343ec9877a174afcdc2 Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Tue, 28 Jul 2026 12:41:24 -0700
Subject: [PATCH 07/10] fix(explorer): close review residuals and stale HTTPS
docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second review pass moved the verdict to COMMENT with no blockers. Close
five residuals it raised, plus three false HTTPS claims it missed.
Two were real defects:
1. `flatbread start` hung forever when the forked GraphQL child died
before it sent `flatbread-gql-ready` — a broken config or a taken
port left the parent alive with nothing to do. `createGqlReadyHandler`
now attaches `exit` and `error` listeners as soon as it is built and
exits with the child's code when ready never arrived. After ready
those listeners stand down, so the parent still exits once.
2. `mountExplorer.evaluate` read `index.html` right after checking that
it exists. If the file went away between the two calls the read threw
and, because `update` runs inside the `replaceConfig` wrapper, a
watch-mode config reload failed. The read and inject now sit in a
`try`/`catch` that mirrors the missing-assets branch: warn once,
deactivate, keep serving. The warn-once latch clears only on success.
Three smaller ones:
3. Tests now pin the exact contents of `DEFAULT_WATCH_IGNORE` and assert
the AVA fixture prefixes are absent from it. Re-baking them in would
have stayed green otherwise, which defeats the point of the change.
4. The watcher callback-error test asserts its log line, matching its
push-failure sibling.
5. `--open` resolves the browser path inside `onReady` instead of at
config-load time. `resolveOpenPath` checks for built assets, so the
answer is now taken as close as possible to when the child mounts.
Declined two suggestions. Extracting a "ready and path settled" sequencer
would test a two-line promise chain whose single-launch guarantee already
comes from the tested `handled` latch. Requiring a hashed `assets/*.js`
file in `explorerAssetsPresent()` would tie the mount gate to Vite's
output naming, so a build-config change could silently disable the
explorer.
Docs: `examples/nextjs/README.md` and `CONTRIBUTING.md` still promised
local HTTPS from `pnpm dev`, which stopped being true when `--https` left
the script. Three more claims were wrong and unreported:
`flatbread-flow-agentic-workflows.md` cited a port `5058` HTTPS listener
that never existed, `docs/local-dev-loop.md` told readers to avoid an
"HTTPS convenience script" that is now the same command, and
`packages/flatbread/README.md` repeated the HTTPS claim.
Verified with `pnpm verify`.
Co-authored-by: Cursor
Change-Id: I4bf1bdd5e702c385ccb477932bf18652bcb2f4fe
---
CONTRIBUTING.md | 3 +-
docs/local-dev-loop.md | 13 ++-
examples/nextjs/README.md | 7 +-
flatbread-flow-agentic-workflows.md | 2 +-
packages/flatbread/README.md | 2 +-
packages/flatbread/src/cli/index.ts | 25 ++++--
packages/flatbread/src/cli/ready.test.ts | 80 ++++++++++++++++++-
packages/flatbread/src/cli/ready.ts | 27 ++++++-
.../src/graphql/explorerMount.test.ts | 61 +++++++++++++-
.../flatbread/src/graphql/explorerMount.ts | 42 ++++++----
.../flatbread/src/graphql/liveServer.test.ts | 16 ++++
.../flatbread/src/graphql/watchIgnore.test.ts | 14 ++++
12 files changed, 250 insertions(+), 42 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9d2a76e0..7e345baf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,8 +24,7 @@ Use this path first. The Next.js app reads shared content from
2. `cd examples/nextjs`
3. One-shot codegen: `pnpm exec flatbread codegen --verbose` (output: `generated/graphql.ts`; globs and dirs come from `flatbread.config.js`).
4. Run the app **and** Flatbread together with **`flatbread start`** (there is **no** `flatbread dev` subcommand):
- - **`pnpm dev`** — starts Next with local HTTPS and watches Flatbread content, config, and GraphQL documents. GraphQL runs on **5057** and Next on **3000**.
- - Headless / no HTTPS: `pnpm exec flatbread start --watch -- next dev --turbopack`.
+ - **`pnpm dev`** — starts Next and watches Flatbread content, config, and GraphQL documents (`pnpm exec flatbread start --watch -- next dev --turbopack`). GraphQL runs on **5057** and Next on **3000**.
Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nextjs && pnpm dev`** — same as step 4 above, not a separate product command.
diff --git a/docs/local-dev-loop.md b/docs/local-dev-loop.md
index 214aaba0..71934d34 100644
--- a/docs/local-dev-loop.md
+++ b/docs/local-dev-loop.md
@@ -26,12 +26,11 @@ cd examples/nextjs
pnpm exec flatbread codegen --verbose
```
-For development, use the unified watcher. This path avoids the example
-package's HTTPS convenience script and keeps the Flatbread GraphQL endpoint on
-plain HTTP port `5057`.
+For development, use the unified watcher. It serves GraphQL on port `5057`,
+refreshes generated artifacts, and runs Next.js. The example package's
+`pnpm dev` script runs the same command.
```bash
-# serve GraphQL, refresh generated artifacts, and run Next.js without HTTPS
pnpm exec flatbread start --watch -- next dev --turbopack
```
@@ -104,10 +103,8 @@ Watch mode does the following:
or config change. If a change is invalid, it keeps the previous schema.
- Watch mode is a long-running process; do not use it in CI or one-shot
scripts.
-- The Next.js example `pnpm dev` includes `--https` for local convenience, but
- the Flatbread GraphQL endpoint remains documented as HTTP on `5057`. In
- headless environments prefer
- `pnpm exec flatbread start --watch -- next dev --turbopack`.
+- Flatbread serves plain HTTP. The `-H, --https` flag does not change how it
+ listens, so the GraphQL endpoint is always HTTP on `5057`.
- Codegen failures are logged and do not undo a committed schema generation.
- Watch mode requires a source plugin with `fetchPaths`; sources without it fail
fast at startup.
diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md
index a9b80494..eb570260 100644
--- a/examples/nextjs/README.md
+++ b/examples/nextjs/README.md
@@ -35,8 +35,9 @@ tests. For this guide, focus on posts, authors, and tags.
4. **Start Flatbread and Next** (**there is no `flatbread dev`** — use
**`flatbread start`**):
- - **With local HTTPS:** `pnpm dev`. This runs watch mode and starts Next.
- - **Headless or no HTTPS:** `pnpm exec flatbread start --watch -- next dev --turbopack`.
+ - `pnpm dev` — runs watch mode and starts Next
+ (`pnpm exec flatbread start --watch -- next dev --turbopack`).
+ GraphQL on **5057**, Next on **3000**.
With `--watch`, Flatbread reloads valid content and config changes and
refreshes generated types. You do not need a second codegen watcher.
@@ -47,7 +48,7 @@ tests. For this guide, focus on posts, authors, and tags.
| Script | Purpose |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
-| `pnpm dev` | **`flatbread start --watch`** + Next dev (HTTPS). GraphQL on **5057**, Next on **3000**. |
+| `pnpm dev` | **`flatbread start --watch`** + Next dev. GraphQL on **5057**, Next on **3000**. |
| `pnpm build` | **`flatbread start`** wrapping **`next build`** so schema/codegen paths resolve during build. |
| `pnpm start` | **`next start` only** — production Next; does **not** run Flatbread unless you arrange it. |
| `pnpm run codegen` | Optional separate type watcher. Use it only when `flatbread start --watch` is not running. |
diff --git a/flatbread-flow-agentic-workflows.md b/flatbread-flow-agentic-workflows.md
index 64e7e18e..43df26fc 100644
--- a/flatbread-flow-agentic-workflows.md
+++ b/flatbread-flow-agentic-workflows.md
@@ -92,7 +92,7 @@ When running this workflow under the `proof` skill (the `@flatbread/proof` packa
- **`readonly: true` and `tools:` frontmatter are advisory in DAG mode.** A subagent acting as `flatbread-architecture-planner` can still write files because the runner does not pass agent frontmatter to `Agent.create`. `Do not edit files.` is purely a prompt-level instruction; reinforce it in the prompt body for read-only tasks.
- **Project skills do auto-attach.** `proof` and `flatbread-major-migration` are visible to subagents via description, so subtask prompts can reference them by name without re-explaining their contents.
- **Same-rank file-write safety for Flatbread.** The coupled chain `packages/core` → `packages/codegen` → `examples/nextjs` is the most common contention. Treat the safe parallel cuts list below as the source of truth.
-- **Same-rank port-5057 safety.** `flatbread start` always binds port `5057` (and `5058` for HTTPS, per `packages/flatbread/src/cli/index.ts:128-135`). Never put two tasks that invoke `flatbread start`, `pnpm dev`, `pnpm build`, or `agent-browser` against `examples/nextjs` in the same rank.
+- **Same-rank port-5057 safety.** `flatbread start` binds port `5057` over plain HTTP. Never put two tasks that invoke `flatbread start`, `pnpm dev`, `pnpm build`, or `agent-browser` against `examples/nextjs` in the same rank.
- **2000-char upstream stitch cap on a 4000-char `STREAM_CAP`.** Each child sees at most 2000 chars of each parent's `resultText`, and the parent's output is itself capped at 4000 chars. Subagent output must lead with the structured headings the downstream task needs and group related entries (e.g. `packages/core/src/{generators,resolvers,types}.ts`) to fit in the window.
### Default DAG shape per phase
diff --git a/packages/flatbread/README.md b/packages/flatbread/README.md
index c22b959f..31286bfa 100644
--- a/packages/flatbread/README.md
+++ b/packages/flatbread/README.md
@@ -233,7 +233,7 @@ Wire your framework so the CLI wraps dev/build (**`flatbread start`** passes thr
}
```
-In the Next.js example, **`pnpm dev`** starts Next with local HTTPS and starts
+In the Next.js example, **`pnpm dev`** starts Next and starts
Flatbread in watch mode. The GraphQL endpoint is
**`http://localhost:5057/graphql`** and the Next app is on **`3000`**.
**`pnpm start`** runs production Next without Flatbread.
diff --git a/packages/flatbread/src/cli/index.ts b/packages/flatbread/src/cli/index.ts
index 9403fc2d..e1767119 100644
--- a/packages/flatbread/src/cli/index.ts
+++ b/packages/flatbread/src/cli/index.ts
@@ -1,6 +1,7 @@
import sade from 'sade';
import colors from 'kleur';
import gradient from 'gradient-string';
+import type { ContentEntry } from '@flatbread/core';
import { version } from '../../package.json';
import { networkInterfaces, release } from 'node:os';
import orchestrateProcesses from './runner';
@@ -65,11 +66,15 @@ prog
// Fork before reading the config so config load stays off the critical
// path to `flatbread-gql-ready` (and to the corunner). `--open` waits on
- // this promise instead, so it always opens the resolved path.
- let settleOpenPath!: (path: string) => void;
- const resolvedOpenPath = new Promise((resolve) => {
- settleOpenPath = resolve;
- });
+ // this promise, then resolves the browser path as late as possible.
+ let settleOpenContent!: (
+ content: readonly ContentEntry[] | undefined
+ ) => void;
+ const loadedOpenContent = new Promise(
+ (resolve) => {
+ settleOpenContent = resolve;
+ }
+ );
orchestrateProcesses({
corunner: secondaryScript,
@@ -78,21 +83,25 @@ prog
packageManager: exec,
onReady: open
? () => {
- void resolvedOpenPath.then((path) => launch(port, path));
+ void loadedOpenContent.then((content) => {
+ void launch(port, resolveOpenPath(content));
+ });
}
: undefined,
});
let openPath = GRAPHQL_ENDPOINT;
let explorer = false;
+ let contentForOpen: readonly ContentEntry[] | undefined;
try {
const loaded = await loadFlatbreadConfig(process.cwd());
- openPath = resolveOpenPath(loaded.config?.content);
+ contentForOpen = loaded.config?.content;
+ openPath = resolveOpenPath(contentForOpen);
explorer = openPath === EXPLORER_ENDPOINT;
} catch {
// Config may be missing during init; fall back to GraphQL sandbox.
}
- settleOpenPath(openPath);
+ settleOpenContent(contentForOpen);
welcome({ port, explorer });
});
diff --git a/packages/flatbread/src/cli/ready.test.ts b/packages/flatbread/src/cli/ready.test.ts
index a615d1d3..c2345d1b 100644
--- a/packages/flatbread/src/cli/ready.test.ts
+++ b/packages/flatbread/src/cli/ready.test.ts
@@ -7,12 +7,23 @@ import {
function createFakeChild(): ChildLike & {
emitClose(code: number | null): void;
+ emitExit(code: number | null): void;
+ emitError(err?: Error): void;
} {
const closeListeners: Array<(code: number | null) => void> = [];
+ const exitListeners: Array<(code: number | null) => void> = [];
+ const errorListeners: Array<(err: Error) => void> = [];
return {
- on(event: 'close', listener: (code: number | null) => void) {
+ on(
+ event: 'close' | 'exit' | 'error',
+ listener: ((code: number | null) => void) | ((err: Error) => void)
+ ) {
if (event === 'close') {
- closeListeners.push(listener);
+ closeListeners.push(listener as (code: number | null) => void);
+ } else if (event === 'exit') {
+ exitListeners.push(listener as (code: number | null) => void);
+ } else if (event === 'error') {
+ errorListeners.push(listener as (err: Error) => void);
}
return this;
},
@@ -24,6 +35,16 @@ function createFakeChild(): ChildLike & {
listener(code);
}
},
+ emitExit(code: number | null) {
+ for (const listener of exitListeners) {
+ listener(code);
+ }
+ },
+ emitError(err: Error = new Error('spawn failed')) {
+ for (const listener of errorListeners) {
+ listener(err);
+ }
+ },
};
}
@@ -32,15 +53,18 @@ function createDeps(
spawned?: string[];
exits?: number[];
readyCount?: { value: number };
+ errors?: string[];
} = {}
): ReadyHandlerDeps & {
spawned: string[];
exits: number[];
readyCount: { value: number };
+ errors: string[];
} {
const spawned = overrides.spawned ?? [];
const exits = overrides.exits ?? [];
const readyCount = overrides.readyCount ?? { value: 0 };
+ const errors = overrides.errors ?? [];
return {
corunner: overrides.corunner ?? '',
@@ -61,9 +85,15 @@ function createDeps(
(() => {
readyCount.value += 1;
}),
+ logError:
+ overrides.logError ??
+ ((message: string) => {
+ errors.push(message);
+ }),
spawned,
exits,
readyCount,
+ errors,
};
}
@@ -209,3 +239,49 @@ test('ignores unrelated IPC messages', (t) => {
t.is(deps.readyCount.value, 0);
t.deepEqual(deps.spawned, []);
});
+
+test('child exit before ready exits parent with child code', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: 'next dev', packageManager: 'pnpm' });
+ createGqlReadyHandler(gql, deps);
+
+ gql.emitExit(7);
+
+ t.deepEqual(deps.exits, [7]);
+ t.is(deps.readyCount.value, 0);
+ t.deepEqual(deps.spawned, []);
+ t.true(
+ deps.errors.some((message) =>
+ message.includes('exited before ready (code 7)')
+ )
+ );
+});
+
+test('child exit with null code before ready exits parent with 1', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: '' });
+ createGqlReadyHandler(gql, deps);
+
+ gql.emitExit(null);
+
+ t.deepEqual(deps.exits, [1]);
+ t.is(deps.readyCount.value, 0);
+ t.true(
+ deps.errors.some((message) =>
+ message.includes('exited before ready (code null)')
+ )
+ );
+});
+
+test('child exit after ready does not exit twice', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: '' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ gql.emitExit(3);
+ gql.emitClose(0);
+
+ t.deepEqual(deps.exits, [0]);
+ t.deepEqual(deps.errors, []);
+});
diff --git a/packages/flatbread/src/cli/ready.ts b/packages/flatbread/src/cli/ready.ts
index 0c95c07a..f53a6089 100644
--- a/packages/flatbread/src/cli/ready.ts
+++ b/packages/flatbread/src/cli/ready.ts
@@ -9,6 +9,8 @@
/** Narrow child-process surface used by the ready handler. */
export interface ChildLike {
on(event: 'close', listener: (code: number | null) => void): unknown;
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
+ on(event: 'error', listener: (err: Error) => void): unknown;
kill(): unknown;
}
@@ -18,6 +20,8 @@ export interface ReadyHandlerDeps {
spawnCorunner(packageManagerCommand: string): ChildLike;
onExit(code: number): void;
onReady?: () => void;
+ /** Override for tests; defaults to `console.error`. */
+ logError?: (message: string) => void;
}
export interface ReadyHandleResult {
@@ -30,13 +34,34 @@ export interface ReadyHandleResult {
/**
* Build the IPC message handler that runs when the GraphQL child is ready.
*
- * A second `flatbread-gql-ready` is ignored so the corunner is never spawned twice.
+ * Also attaches `exit` / `error` listeners so a child that dies before ready
+ * exits the parent instead of hanging. A second `flatbread-gql-ready` is
+ * ignored so the corunner is never spawned twice.
*/
export function createGqlReadyHandler(
gqlChild: ChildLike,
deps: ReadyHandlerDeps
): (msg: unknown) => ReadyHandleResult {
let handled = false;
+ const logError = deps.logError ?? ((message) => console.error(message));
+
+ const exitBeforeReady = (code: number | null): void => {
+ if (handled) return;
+ handled = true;
+ logError(
+ `Flatbread GraphQL server exited before ready (code ${
+ code === null ? 'null' : code
+ }).`
+ );
+ deps.onExit(code ?? 1);
+ };
+
+ gqlChild.on('exit', (code) => {
+ exitBeforeReady(code);
+ });
+ gqlChild.on('error', () => {
+ exitBeforeReady(1);
+ });
return (msg: unknown): ReadyHandleResult => {
if (msg !== 'flatbread-gql-ready') {
diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts
index bab68314..b1d4b87a 100644
--- a/packages/flatbread/src/graphql/explorerMount.test.ts
+++ b/packages/flatbread/src/graphql/explorerMount.test.ts
@@ -1,6 +1,13 @@
import test, { type ExecutionContext } from 'ava';
import express from 'express';
-import { mkdtemp, readdir, rm } from 'node:fs/promises';
+import {
+ mkdir,
+ mkdtemp,
+ readdir,
+ rm,
+ unlink,
+ writeFile,
+} from 'node:fs/promises';
import os from 'node:os';
import { join } from 'node:path';
import { effortGraphContent } from '@flatbread/effort-graph';
@@ -299,6 +306,58 @@ test.serial(
}
);
+test.serial(
+ 'update deactivates when index.html becomes unreadable after a present check',
+ async (t) => {
+ const staticDir = await mkdtemp(
+ join(os.tmpdir(), 'flatbread-explorer-eisdir-')
+ );
+ const indexHtmlPath = join(staticDir, 'index.html');
+ await writeFile(
+ indexHtmlPath,
+ '\n',
+ 'utf8'
+ );
+ setExplorerStaticDirOverride(staticDir);
+ t.teardown(async () => {
+ setExplorerStaticDirOverride(undefined);
+ await rm(staticDir, { recursive: true, force: true });
+ });
+
+ const warnings: string[] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => {
+ warnings.push(args.map(String).join(' '));
+ };
+ t.teardown(() => {
+ console.warn = originalWarn;
+ });
+
+ const app = express();
+ const handle = mountExplorer(app, effortGraphContent());
+ t.true(handle.isActive());
+
+ const server = await listen(app);
+ t.teardown(server.close);
+
+ // existsSync stays true for a directory; readFileSync throws EISDIR.
+ await unlink(indexHtmlPath);
+ await mkdir(indexHtmlPath);
+
+ handle.update(effortGraphContent());
+ t.false(handle.isActive());
+ t.is(
+ warnings.filter((message) =>
+ message.includes('Flatbread explorer assets missing')
+ ).length,
+ 1
+ );
+
+ const home = await fetch(`${server.base}/`);
+ t.false((await home.text()).includes('__FLATBREAD_EXPLORER__'));
+ }
+);
+
test.serial('does not mount explorer for ordinary content', async (t) => {
const app = express();
const mounted = mountExplorerIfMatched(app, [
diff --git a/packages/flatbread/src/graphql/explorerMount.ts b/packages/flatbread/src/graphql/explorerMount.ts
index 3bf01d5c..806c069d 100644
--- a/packages/flatbread/src/graphql/explorerMount.ts
+++ b/packages/flatbread/src/graphql/explorerMount.ts
@@ -85,7 +85,6 @@ export function mountExplorer(
return;
}
- inMissingAssets = false;
bootstrap = {
preset: match.preset,
graphqlPath: GRAPHQL_PATH,
@@ -93,21 +92,34 @@ export function mountExplorer(
};
const indexHtmlPath = path.join(staticDir, 'index.html');
- let html = fs.readFileSync(indexHtmlPath, 'utf8');
- const bootScript = ``;
- if (html.includes('')) {
- html = html.replace('', `${bootScript}`);
- } else {
- html = `${bootScript}${html}`;
+ try {
+ let html = fs.readFileSync(indexHtmlPath, 'utf8');
+ const bootScript = ``;
+ if (html.includes('')) {
+ html = html.replace('', `${bootScript}`);
+ } else {
+ html = `${bootScript}${html}`;
+ }
+ indexHtml = html;
+ staticMiddleware = express.static(staticDir, {
+ index: false,
+ fallthrough: true,
+ });
+ active = true;
+ inMissingAssets = false;
+ } catch {
+ // Race: assets looked present, then the read failed (gone / not a file).
+ if (!inMissingAssets) {
+ console.warn(
+ `Flatbread explorer assets missing at ${staticDir}. Run \`pnpm --filter @flatbread/explorer build\`.`
+ );
+ inMissingAssets = true;
+ }
+ active = false;
+ staticMiddleware = null;
}
- indexHtml = html;
- staticMiddleware = express.static(staticDir, {
- index: false,
- fallthrough: true,
- });
- active = true;
};
evaluate(content);
diff --git a/packages/flatbread/src/graphql/liveServer.test.ts b/packages/flatbread/src/graphql/liveServer.test.ts
index c2c66b84..2b7789a5 100644
--- a/packages/flatbread/src/graphql/liveServer.test.ts
+++ b/packages/flatbread/src/graphql/liveServer.test.ts
@@ -377,6 +377,14 @@ test.serial(
const fixture = await makeFixture();
t.teardown(fixture.cleanup);
const stub = captureWatcherSubscribe();
+ const errors: unknown[][] = [];
+ const originalError = console.error;
+ console.error = (...args: unknown[]) => {
+ errors.push(args);
+ };
+ t.teardown(() => {
+ console.error = originalError;
+ });
const server = await startGraphqlServer({
config: makeConfig(fixture),
@@ -390,6 +398,14 @@ test.serial(
t.notThrows(() =>
stub.getCallback()(new Error('inotify_add_watch race'), [])
);
+ t.true(
+ errors.some(
+ (args) =>
+ typeof args[0] === 'string' &&
+ args[0].includes('Flatbread watcher error:')
+ ),
+ `expected watcher error log, got: ${JSON.stringify(errors)}`
+ );
t.deepEqual(await queryTitles(server.port), [
'Original Title',
'Second Post',
diff --git a/packages/flatbread/src/graphql/watchIgnore.test.ts b/packages/flatbread/src/graphql/watchIgnore.test.ts
index ce549b19..d31d9289 100644
--- a/packages/flatbread/src/graphql/watchIgnore.test.ts
+++ b/packages/flatbread/src/graphql/watchIgnore.test.ts
@@ -7,12 +7,26 @@ function matchesAny(patterns: readonly string[], path: string): boolean {
return patterns.some((pattern) => picomatch(pattern, { dot: true })(path));
}
+test('DEFAULT_WATCH_IGNORE is exactly the four production globs', (t) => {
+ t.deepEqual(DEFAULT_WATCH_IGNORE, [
+ '**/node_modules/**',
+ '**/.git/**',
+ '**/dist/**',
+ '**/.journal/**',
+ ]);
+});
+
test('DEFAULT_WATCH_IGNORE leaves live-server test fixtures visible', (t) => {
t.false(
matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-live-server-test-abc/posts/a.md')
);
});
+test('DEFAULT_WATCH_IGNORE does not hide effort or explorer test fixtures', (t) => {
+ t.false(matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-effort-live-x/graph/a.md'));
+ t.false(matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-explorer-watch-x/graph/a.md'));
+});
+
test('buildWatchIgnore extras drop concurrent effort and explorer fixtures', (t) => {
const ignore = buildWatchIgnore([
'**/.tmp-effort-*/**',
From 0c6b58178f0f7f633d8b475da8654fdb4fe5f381 Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Tue, 28 Jul 2026 12:53:57 -0700
Subject: [PATCH 08/10] fix(flatbread): close the inotify race in watch-mode
startup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI failed on `test (22.x, ubuntu-latest)` with:
inotify_add_watch on '.../.tmp-effort-live-HAxTYp' failed:
No such file or directory
Watch mode subscribes to the repository root, because content paths
resolve against `process.cwd()`. AVA runs four test files at once, and
`liveServerEffortGraph.test.ts` creates and deletes `.tmp-effort-live-*`
directories under that root. Parcel's initial scan reached one after it
was gone.
The ignore globs never closed this. `**/.tmp-effort-*/**` matches paths
*inside* the directory, not the directory itself, and parcel registers
each directory with inotify on the way down — which is why the error
names the directory, not a child. Earlier green runs were luck.
Two changes:
1. `directoryIgnore()` returns both forms, the bare directory and its
contents glob, and the production defaults now use it. Parcel skips
`node_modules`, `.git`, `dist`, and `.journal` outright instead of
registering them and then filtering their children.
`buildWatchIgnore` expands any caller glob ending in `/**` the same
way, so callers cannot reintroduce the hole by accident.
2. Subscribe retries widen from three attempts to five with exponential
backoff. This is real hardening, not a test crutch: deleting a
directory while the server starts should not crash it. The delays are
injectable so tests do not sleep.
This is not a proof against every ordering. Continuous churn in an
unignored directory under the watched root could still exhaust the
retries. It closes the known failure and narrows the rest.
Local runs never caught this: macOS uses FSEvents, which watches a tree
without the per-directory syscall that races on Linux inotify.
Verified with `pnpm verify`.
Co-authored-by: Cursor
Change-Id: I6529ad43c054d00c23174789cb18202be926adf1
---
.../flatbread/src/graphql/liveServer.test.ts | 83 ++++++++++++++++++-
packages/flatbread/src/graphql/liveServer.ts | 20 ++++-
.../flatbread/src/graphql/watchIgnore.test.ts | 38 ++++++++-
packages/flatbread/src/graphql/watchIgnore.ts | 37 +++++++--
4 files changed, 161 insertions(+), 17 deletions(-)
diff --git a/packages/flatbread/src/graphql/liveServer.test.ts b/packages/flatbread/src/graphql/liveServer.test.ts
index 2b7789a5..4dd32c82 100644
--- a/packages/flatbread/src/graphql/liveServer.test.ts
+++ b/packages/flatbread/src/graphql/liveServer.test.ts
@@ -7,12 +7,16 @@ import { initializeConfig } from '@flatbread/core';
import type { ConfigResult, LoadedFlatbreadConfig } from '@flatbread/core';
import { startGraphqlServer } from './liveServer';
import type { WatchSubscribe, WatchSubscribeEvent } from './liveServer';
+import { directoryIgnore } from './watchIgnore.js';
-/** Concurrent AVA fixtures under cwd; keep them out of this suite's watcher. */
+/** Concurrent AVA fixtures under cwd; skip the dirs themselves and their trees. */
const TEST_WATCH_IGNORE = [
- '**/.tmp-effort-*/**',
- '**/.tmp-explorer-*/**',
-] as const;
+ ...directoryIgnore('**/.tmp-effort-*'),
+ ...directoryIgnore('**/.tmp-explorer-*'),
+];
+
+/** Zero backoff so subscribe-retry tests stay fast. */
+const FAST_SUBSCRIBE_RETRIES = [0, 0, 0, 0] as const;
interface Fixture {
/** Absolute temp dir inside the repo (source-filesystem resolves content paths against cwd). */
@@ -510,3 +514,74 @@ test.serial(
}
}
);
+
+test.serial(
+ 'subscribe retries past transient inotify_add_watch failures',
+ async (t) => {
+ const fixture = await makeFixture();
+ t.teardown(fixture.cleanup);
+
+ let attempts = 0;
+ let callback: WatchCallback | undefined;
+ const subscribe: WatchSubscribe = async (_dir, cb) => {
+ attempts++;
+ if (attempts < 3) {
+ throw new Error(
+ "inotify_add_watch on '/tmp/.tmp-effort-live-race' failed: No such file or directory"
+ );
+ }
+ callback = cb;
+ return { unsubscribe: async () => undefined };
+ };
+
+ const server = await startGraphqlServer({
+ config: makeConfig(fixture),
+ port: 0,
+ watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
+ watcherSubscribe: subscribe,
+ watcherSubscribeRetryDelays: FAST_SUBSCRIBE_RETRIES,
+ });
+
+ try {
+ t.is(attempts, 3);
+ t.truthy(callback);
+ t.deepEqual(await queryTitles(server.port), [
+ 'Original Title',
+ 'Second Post',
+ ]);
+ } finally {
+ await server.close();
+ }
+ }
+);
+
+test.serial(
+ 'subscribe rejects when inotify_add_watch fails on every attempt',
+ async (t) => {
+ const fixture = await makeFixture();
+ t.teardown(fixture.cleanup);
+
+ let attempts = 0;
+ const subscribe: WatchSubscribe = async () => {
+ attempts++;
+ throw new Error(
+ "inotify_add_watch on '/tmp/.tmp-effort-live-gone' failed: No such file or directory"
+ );
+ };
+
+ await t.throwsAsync(
+ () =>
+ startGraphqlServer({
+ config: makeConfig(fixture),
+ port: 0,
+ watch: true,
+ watchIgnore: TEST_WATCH_IGNORE,
+ watcherSubscribe: subscribe,
+ watcherSubscribeRetryDelays: FAST_SUBSCRIBE_RETRIES,
+ }),
+ { message: /inotify_add_watch/ }
+ );
+ t.is(attempts, FAST_SUBSCRIBE_RETRIES.length + 1);
+ }
+);
diff --git a/packages/flatbread/src/graphql/liveServer.ts b/packages/flatbread/src/graphql/liveServer.ts
index dcc4459b..01fabad2 100644
--- a/packages/flatbread/src/graphql/liveServer.ts
+++ b/packages/flatbread/src/graphql/liveServer.ts
@@ -51,6 +51,12 @@ export interface GraphqlServerOptions {
watchIgnore?: readonly string[];
/** Test seam: overrides the `@parcel/watcher` subscribe implementation. */
watcherSubscribe?: WatchSubscribe;
+ /**
+ * Backoff delays (ms) between subscribe retries after a transient
+ * ENOENT / inotify race. Defaults to 100, 200, 400, 800 (five attempts).
+ * Tests pass zeros to keep the suite fast.
+ */
+ watcherSubscribeRetryDelays?: readonly number[];
}
export interface RunningGraphqlServer {
readonly port: number;
@@ -272,7 +278,11 @@ export async function startGraphqlServer(
console.error(`Flatbread ${label} failed:`, result.error);
});
const ignore = buildWatchIgnore(options.watchIgnore);
- for (let attempt = 0; attempt < 3; attempt++) {
+ const retryDelays = options.watcherSubscribeRetryDelays ?? [
+ 100, 200, 400, 800,
+ ];
+ const maxAttempts = retryDelays.length + 1;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
subscription = await subscribe(
cwd,
@@ -297,14 +307,16 @@ export async function startGraphqlServer(
);
break;
} catch (error: unknown) {
- const isEnoent =
+ const isTransientWatchError =
error instanceof Error &&
((error as NodeJS.ErrnoException).code === 'ENOENT' ||
error.message.includes('No such file or directory') ||
error.message.includes('inotify_add_watch'));
- if (!isEnoent || attempt === 2) throw error;
+ if (!isTransientWatchError || attempt === maxAttempts - 1) {
+ throw error;
+ }
await new Promise((resolve) =>
- setTimeout(resolve, 100 * (attempt + 1))
+ setTimeout(resolve, retryDelays[attempt]!)
);
}
}
diff --git a/packages/flatbread/src/graphql/watchIgnore.test.ts b/packages/flatbread/src/graphql/watchIgnore.test.ts
index d31d9289..65660ef1 100644
--- a/packages/flatbread/src/graphql/watchIgnore.test.ts
+++ b/packages/flatbread/src/graphql/watchIgnore.test.ts
@@ -1,17 +1,32 @@
import test from 'ava';
import picomatch from 'picomatch';
-import { DEFAULT_WATCH_IGNORE, buildWatchIgnore } from './watchIgnore.js';
+import {
+ DEFAULT_WATCH_IGNORE,
+ buildWatchIgnore,
+ directoryIgnore,
+} from './watchIgnore.js';
/** True when any glob matches the path (dotfiles included). */
function matchesAny(patterns: readonly string[], path: string): boolean {
return patterns.some((pattern) => picomatch(pattern, { dot: true })(path));
}
-test('DEFAULT_WATCH_IGNORE is exactly the four production globs', (t) => {
+test('directoryIgnore pairs the bare directory with its contents glob', (t) => {
+ t.deepEqual(directoryIgnore('**/node_modules'), [
+ '**/node_modules',
+ '**/node_modules/**',
+ ]);
+});
+
+test('DEFAULT_WATCH_IGNORE is exactly the production directory pairs', (t) => {
t.deepEqual(DEFAULT_WATCH_IGNORE, [
+ '**/node_modules',
'**/node_modules/**',
+ '**/.git',
'**/.git/**',
+ '**/dist',
'**/dist/**',
+ '**/.journal',
'**/.journal/**',
]);
});
@@ -25,6 +40,12 @@ test('DEFAULT_WATCH_IGNORE leaves live-server test fixtures visible', (t) => {
test('DEFAULT_WATCH_IGNORE does not hide effort or explorer test fixtures', (t) => {
t.false(matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-effort-live-x/graph/a.md'));
t.false(matchesAny(DEFAULT_WATCH_IGNORE, '.tmp-explorer-watch-x/graph/a.md'));
+ t.false(
+ DEFAULT_WATCH_IGNORE.some(
+ (pattern) =>
+ pattern.includes('.tmp-effort-') || pattern.includes('.tmp-explorer-')
+ )
+ );
});
test('buildWatchIgnore extras drop concurrent effort and explorer fixtures', (t) => {
@@ -32,17 +53,23 @@ test('buildWatchIgnore extras drop concurrent effort and explorer fixtures', (t)
'**/.tmp-effort-*/**',
'**/.tmp-explorer-*/**',
]);
+ t.true(matchesAny(ignore, '.tmp-effort-live-x'));
t.true(matchesAny(ignore, '.tmp-effort-live-x/posts/a.md'));
+ t.true(matchesAny(ignore, '.tmp-explorer-watch-x'));
t.true(matchesAny(ignore, '.tmp-explorer-watch-x/content/b.md'));
t.false(matchesAny(ignore, '.tmp-live-server-test-abc/posts/a.md'));
});
test('DEFAULT_WATCH_IGNORE matches journal, node_modules, .git, and dist', (t) => {
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'content/.journal'));
t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'content/.journal/entry.json'));
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'node_modules'));
t.true(
matchesAny(DEFAULT_WATCH_IGNORE, 'packages/foo/node_modules/x/index.js')
);
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, '.git'));
t.true(matchesAny(DEFAULT_WATCH_IGNORE, '.git/objects/ab/cd'));
+ t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'packages/flatbread/dist'));
t.true(matchesAny(DEFAULT_WATCH_IGNORE, 'packages/flatbread/dist/index.js'));
});
@@ -54,13 +81,20 @@ test('buildWatchIgnore de-duplicates extras and keeps defaults first', (t) => {
'**/custom/**',
]);
t.deepEqual(ignore, [
+ '**/node_modules',
'**/node_modules/**',
+ '**/.git',
'**/.git/**',
+ '**/dist',
'**/dist/**',
+ '**/.journal',
'**/.journal/**',
+ '**/.tmp-effort-*',
'**/.tmp-effort-*/**',
+ '**/custom',
'**/custom/**',
]);
t.is(ignore.filter((pattern) => pattern === '**/dist/**').length, 1);
t.is(ignore.filter((pattern) => pattern === '**/.tmp-effort-*/**').length, 1);
+ t.is(ignore.filter((pattern) => pattern === '**/.tmp-effort-*').length, 1);
});
diff --git a/packages/flatbread/src/graphql/watchIgnore.ts b/packages/flatbread/src/graphql/watchIgnore.ts
index 653ae451..72d4ded3 100644
--- a/packages/flatbread/src/graphql/watchIgnore.ts
+++ b/packages/flatbread/src/graphql/watchIgnore.ts
@@ -1,26 +1,49 @@
+/**
+ * Skip a directory and everything under it.
+ * Parcel registers each directory with inotify; a contents-only glob (ending
+ * in slash-star-star) matches children, not the directory itself, so both
+ * forms are required.
+ */
+export function directoryIgnore(directoryPattern: string): string[] {
+ return [directoryPattern, `${directoryPattern}/**`];
+}
+
/**
* Glob patterns the `--watch` subscription drops by default.
* Production noise only — never test-fixture names.
+ *
+ * Each entry is a directory pair: the bare directory (so parcel never calls
+ * `inotify_add_watch` on it) plus a contents glob for everything under it.
*/
export const DEFAULT_WATCH_IGNORE: readonly string[] = [
- '**/node_modules/**',
- '**/.git/**',
- '**/dist/**',
- '**/.journal/**',
+ ...directoryIgnore('**/node_modules'),
+ ...directoryIgnore('**/.git'),
+ ...directoryIgnore('**/dist'),
+ ...directoryIgnore('**/.journal'),
];
/**
* Defaults plus any caller-supplied globs, de-duplicated.
* Defaults stay first; extras keep their relative order.
+ * Contents-only globs also get their bare-directory form so parcel skips
+ * the directory itself, not only its children.
*/
export function buildWatchIgnore(extra?: readonly string[]): string[] {
const result: string[] = [...DEFAULT_WATCH_IGNORE];
const seen = new Set(result);
if (!extra) return result;
for (const pattern of extra) {
- if (seen.has(pattern)) continue;
- seen.add(pattern);
- result.push(pattern);
+ for (const expanded of expandIgnorePattern(pattern)) {
+ if (seen.has(expanded)) continue;
+ seen.add(expanded);
+ result.push(expanded);
+ }
}
return result;
}
+
+/** Prefer bare-then-contents order when a contents-only glob is supplied. */
+function expandIgnorePattern(pattern: string): string[] {
+ if (!pattern.endsWith('/**')) return [pattern];
+ return directoryIgnore(pattern.slice(0, -3));
+}
From 71d9f4d48fba60e55c1bfda4f74caae570d09047 Mon Sep 17 00:00:00 2001
From: Tony Ketcham
Date: Tue, 28 Jul 2026 13:12:17 -0700
Subject: [PATCH 09/10] fix(explorer): honor caller origin and reject a
directory as index.html
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Third review pass. Four fixes, three declines.
1. `resolveGraphqlEndpoint` takes a `locationOrigin` argument, and every
branch honored it except the `?endpoint=` one, which called
`normalizeGraphqlUrl` and let it re-derive its own base from `window`.
In a browser both agree, so nothing was broken in practice — but the
argument was a lie on that path, and a test pinned the wrong answer.
`normalizeGraphqlUrl` now takes the origin as an optional second
argument and `resolveGraphqlEndpoint` threads it through. A bare host
like `example.com/graphql` is absolute, so the recursive call does not
forward an origin.
2. `explorerAssetsPresent()` used `existsSync`, which is true for a
directory. A directory named `index.html` made the gate report assets
present, so `--open` opened `/` and the banner advertised an explorer
that could not serve. It now requires a regular file.
3. `ready.ts` wires an `error` listener for a child that dies before
ready, but only `exit` was tested. Added both cases, including that an
error after ready does not exit twice.
4. `buildWatchIgnore` expanded a contents glob into the bare directory
form but not the reverse, so a bare pattern left the contents watched.
The expansion is symmetric now. A last segment with a non-leading dot,
such as `**/*.log`, stays a file glob.
Fix 2 changed what an older test proves: a directory named `index.html`
no longer survives the present check, so that case now exercises the
missing-assets branch rather than the read failure it was written for.
Renamed it to say what it tests. The `try`/`catch` around the read stays
as a guard for a true race — the file going away between the check and
the read — which no test can trigger on demand.
Declined three. Hiding `setExplorerStaticDirOverride` is not possible:
`packages/flatbread` is a separate package and its tests need the export.
Extracting an `--open` sequencer would test a two-line promise chain
whose single-launch guarantee already comes from the tested `handled`
latch. The welcome banner prints before the server is ready by design, so
it uses the config-load-time answer while `--open` re-resolves later.
Docs: the CONTRIBUTING HMR step now uses `pnpm exec flatbread`, since the
CLI is not on PATH.
Verified with `pnpm verify`.
Co-authored-by: Cursor
Change-Id: I9409ae0a1f8551765c221be0311dd68f6c284687
---
CONTRIBUTING.md | 2 +-
packages/explorer/src/node/staticDir.test.ts | 45 ++++++++++++++++++
packages/explorer/src/node/staticDir.ts | 10 +++-
.../explorer/src/web/core/endpoints.test.ts | 47 +++++++++++++++++--
packages/explorer/src/web/core/endpoints.ts | 16 ++++---
packages/flatbread/src/cli/ready.test.ts | 30 ++++++++++++
.../src/graphql/explorerMount.test.ts | 3 +-
.../flatbread/src/graphql/watchIgnore.test.ts | 35 ++++++++++++++
packages/flatbread/src/graphql/watchIgnore.ts | 20 ++++++--
9 files changed, 187 insertions(+), 21 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7e345baf..ca2c83f9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -37,7 +37,7 @@ Optional **`pnpm play`** from the repo root is a shortcut for **`cd examples/nex
- **Effort Graph explorer:**
1. Run `pnpm play:efforts` (builds `@flatbread/explorer` via `preplay:efforts`, then `flatbread start --watch --open`).
2. When `flatbread.config.js` uses `effortGraphContent()`, Flatbread serves `@flatbread/explorer` at `http://localhost:5057/`. The Apollo sandbox is at `/graphql`.
- 3. For hot module replacement (HMR) on the single-page app (SPA) shell, run `flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel. Vite on **5173** proxies API routes to **5057**.
+ 3. For hot module replacement (HMR) on the single-page app (SPA) shell, run `pnpm exec flatbread start --watch` and `pnpm --filter @flatbread/explorer dev` in parallel. Vite on **5173** proxies API routes to **5057**.
- Check local CI parity before opening a PR: `pnpm verify`
## Working on a package
diff --git a/packages/explorer/src/node/staticDir.test.ts b/packages/explorer/src/node/staticDir.test.ts
index ee24d461..aa04340d 100644
--- a/packages/explorer/src/node/staticDir.test.ts
+++ b/packages/explorer/src/node/staticDir.test.ts
@@ -51,6 +51,51 @@ describe('getExplorerStaticDir', () => {
});
describe('explorerAssetsPresent', () => {
+ it('returns true when index.html is a regular file', async () => {
+ const staticDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-present-')
+ );
+ try {
+ await fs.promises.writeFile(
+ path.join(staticDir, 'index.html'),
+ '\n',
+ 'utf8'
+ );
+ setExplorerStaticDirOverride(staticDir);
+ assert.equal(explorerAssetsPresent(), true);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(staticDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns false when index.html is missing', async () => {
+ const emptyDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-absent-')
+ );
+ try {
+ setExplorerStaticDirOverride(emptyDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(emptyDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns false when index.html is a directory', async () => {
+ const staticDir = await mkdtemp(
+ path.join(os.tmpdir(), 'flatbread-explorer-eisdir-')
+ );
+ try {
+ await fs.promises.mkdir(path.join(staticDir, 'index.html'));
+ setExplorerStaticDirOverride(staticDir);
+ assert.equal(explorerAssetsPresent(), false);
+ } finally {
+ setExplorerStaticDirOverride(undefined);
+ await rm(staticDir, { recursive: true, force: true });
+ }
+ });
+
it('reflects whether index.html exists under the static dir', async () => {
const indexPath = path.join(getExplorerStaticDir(), 'index.html');
if (!fs.existsSync(indexPath)) {
diff --git a/packages/explorer/src/node/staticDir.ts b/packages/explorer/src/node/staticDir.ts
index 6668be95..b7cc907f 100644
--- a/packages/explorer/src/node/staticDir.ts
+++ b/packages/explorer/src/node/staticDir.ts
@@ -31,10 +31,16 @@ export function getExplorerStaticDir(): string {
}
/**
- * True when prebuilt SPA `index.html` exists under `getExplorerStaticDir()`.
+ * True when prebuilt SPA `index.html` is a regular file under
+ * `getExplorerStaticDir()`. A directory with that name does not count.
* Flatbread uses this with `matchExplorerPreset` before mounting or advertising
* explorer.
*/
export function explorerAssetsPresent(): boolean {
- return fs.existsSync(path.join(getExplorerStaticDir(), 'index.html'));
+ const indexPath = path.join(getExplorerStaticDir(), 'index.html');
+ try {
+ return fs.statSync(indexPath).isFile();
+ } catch {
+ return false;
+ }
}
diff --git a/packages/explorer/src/web/core/endpoints.test.ts b/packages/explorer/src/web/core/endpoints.test.ts
index 23f1cd92..bdee3abe 100644
--- a/packages/explorer/src/web/core/endpoints.test.ts
+++ b/packages/explorer/src/web/core/endpoints.test.ts
@@ -61,20 +61,38 @@ describe('resolveGraphqlEndpoint', () => {
assert.equal(endpoint, 'http://localhost:5057/graphql');
});
- it('resolves relative ?endpoint= against the default Node origin', () => {
+ it('resolves relative ?endpoint= against the injected origin', () => {
const endpoint = resolveGraphqlEndpoint(
'?endpoint=/alt/graphql',
DEFAULT_BOOTSTRAP,
'http://localhost:9999'
);
- assert.equal(endpoint, 'http://localhost:5057/alt/graphql');
+ assert.equal(endpoint, 'http://localhost:9999/alt/graphql');
+ });
+
+ it('resolves bare / ?endpoint= against the injected origin', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=/',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:9999'
+ );
+ assert.equal(endpoint, 'http://localhost:9999/');
});
it('resolves host-without-scheme ?endpoint= by prepending http', () => {
const endpoint = resolveGraphqlEndpoint(
'?endpoint=api.example.com',
DEFAULT_BOOTSTRAP,
- 'http://localhost:5057'
+ 'http://localhost:9999'
+ );
+ assert.equal(endpoint, 'http://api.example.com/graphql');
+ });
+
+ it('resolves protocol-relative ?endpoint= without using the injected host', () => {
+ const endpoint = resolveGraphqlEndpoint(
+ '?endpoint=//api.example.com/graphql',
+ DEFAULT_BOOTSTRAP,
+ 'http://localhost:9999'
);
assert.equal(endpoint, 'http://api.example.com/graphql');
});
@@ -120,9 +138,30 @@ describe('normalizeGraphqlUrl', () => {
);
});
+ it('resolves a relative path against an injected origin', () => {
+ assert.equal(
+ normalizeGraphqlUrl('/alt/graphql', 'http://localhost:9999'),
+ 'http://localhost:9999/alt/graphql'
+ );
+ });
+
+ it('resolves bare / against an injected origin', () => {
+ assert.equal(
+ normalizeGraphqlUrl('/', 'http://localhost:9999'),
+ 'http://localhost:9999/'
+ );
+ });
+
it('prepends http when given a host without a scheme', () => {
assert.equal(
- normalizeGraphqlUrl('api.example.com'),
+ normalizeGraphqlUrl('api.example.com', 'http://localhost:9999'),
+ 'http://api.example.com/graphql'
+ );
+ });
+
+ it('resolves a protocol-relative URL without using the injected host', () => {
+ assert.equal(
+ normalizeGraphqlUrl('//api.example.com/graphql', 'http://localhost:9999'),
'http://api.example.com/graphql'
);
});
diff --git a/packages/explorer/src/web/core/endpoints.ts b/packages/explorer/src/web/core/endpoints.ts
index 4730c2b9..d25dd177 100644
--- a/packages/explorer/src/web/core/endpoints.ts
+++ b/packages/explorer/src/web/core/endpoints.ts
@@ -32,7 +32,7 @@ export function resolveGraphqlEndpoint(
const params = new URLSearchParams(search);
const fromQuery = params.get('endpoint');
if (fromQuery) {
- return normalizeGraphqlUrl(fromQuery);
+ return normalizeGraphqlUrl(fromQuery, locationOrigin);
}
if (bootstrap?.graphqlPath) {
@@ -67,7 +67,12 @@ export function resolveEventsUrl(graphqlEndpoint: string): string {
return new URL(eventsPath, origin).href;
}
-export function normalizeGraphqlUrl(value: string): string {
+export function normalizeGraphqlUrl(
+ value: string,
+ locationOrigin: string = typeof window !== 'undefined'
+ ? window.location.origin
+ : `http://localhost:${DEFAULT_PORT}`
+): string {
const trimmed = value.trim();
if (!trimmed) {
return `http://localhost:${DEFAULT_PORT}${DEFAULT_GRAPHQL_PATH}`;
@@ -81,12 +86,9 @@ export function normalizeGraphqlUrl(value: string): string {
} catch {
// Relative path like `/graphql`
if (trimmed.startsWith('/')) {
- const origin =
- typeof window !== 'undefined'
- ? window.location.origin
- : `http://localhost:${DEFAULT_PORT}`;
- return new URL(trimmed, origin).href;
+ return new URL(trimmed, locationOrigin).href;
}
+ // Bare host is absolute; do not forward the caller's origin.
return normalizeGraphqlUrl(`http://${trimmed}`);
}
}
diff --git a/packages/flatbread/src/cli/ready.test.ts b/packages/flatbread/src/cli/ready.test.ts
index c2345d1b..4cddbb30 100644
--- a/packages/flatbread/src/cli/ready.test.ts
+++ b/packages/flatbread/src/cli/ready.test.ts
@@ -285,3 +285,33 @@ test('child exit after ready does not exit twice', (t) => {
t.deepEqual(deps.exits, [0]);
t.deepEqual(deps.errors, []);
});
+
+test('child error before ready exits parent with 1', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: 'next dev', packageManager: 'pnpm' });
+ createGqlReadyHandler(gql, deps);
+
+ gql.emitError(new Error('spawn failed'));
+
+ t.deepEqual(deps.exits, [1]);
+ t.is(deps.readyCount.value, 0);
+ t.deepEqual(deps.spawned, []);
+ t.true(
+ deps.errors.some((message) =>
+ message.includes('exited before ready (code 1)')
+ )
+ );
+});
+
+test('child error after ready does not exit a second time', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({ corunner: '' });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ handle('flatbread-gql-ready');
+ gql.emitError(new Error('late error'));
+ gql.emitClose(0);
+
+ t.deepEqual(deps.exits, [0]);
+ t.deepEqual(deps.errors, []);
+});
diff --git a/packages/flatbread/src/graphql/explorerMount.test.ts b/packages/flatbread/src/graphql/explorerMount.test.ts
index b1d4b87a..c2cadb97 100644
--- a/packages/flatbread/src/graphql/explorerMount.test.ts
+++ b/packages/flatbread/src/graphql/explorerMount.test.ts
@@ -307,7 +307,7 @@ test.serial(
);
test.serial(
- 'update deactivates when index.html becomes unreadable after a present check',
+ 'update deactivates when a directory replaces index.html',
async (t) => {
const staticDir = await mkdtemp(
join(os.tmpdir(), 'flatbread-explorer-eisdir-')
@@ -340,7 +340,6 @@ test.serial(
const server = await listen(app);
t.teardown(server.close);
- // existsSync stays true for a directory; readFileSync throws EISDIR.
await unlink(indexHtmlPath);
await mkdir(indexHtmlPath);
diff --git a/packages/flatbread/src/graphql/watchIgnore.test.ts b/packages/flatbread/src/graphql/watchIgnore.test.ts
index 65660ef1..6462dbe8 100644
--- a/packages/flatbread/src/graphql/watchIgnore.test.ts
+++ b/packages/flatbread/src/graphql/watchIgnore.test.ts
@@ -98,3 +98,38 @@ test('buildWatchIgnore de-duplicates extras and keeps defaults first', (t) => {
t.is(ignore.filter((pattern) => pattern === '**/.tmp-effort-*/**').length, 1);
t.is(ignore.filter((pattern) => pattern === '**/.tmp-effort-*').length, 1);
});
+
+test('buildWatchIgnore expands bare directory extras into contents globs', (t) => {
+ const ignore = buildWatchIgnore(['**/.tmp-effort-*', '**/custom']);
+ t.true(ignore.includes('**/.tmp-effort-*'));
+ t.true(ignore.includes('**/.tmp-effort-*/**'));
+ t.true(ignore.includes('**/custom'));
+ t.true(ignore.includes('**/custom/**'));
+ t.true(matchesAny(ignore, '.tmp-effort-live-x/posts/a.md'));
+ t.true(matchesAny(ignore, 'packages/custom/file.md'));
+});
+
+test('buildWatchIgnore expands contents globs into bare directory form', (t) => {
+ const ignore = buildWatchIgnore(['**/custom/**']);
+ const customIndex = ignore.indexOf('**/custom');
+ const contentsIndex = ignore.indexOf('**/custom/**');
+ t.true(customIndex >= 0);
+ t.true(contentsIndex >= 0);
+ t.true(customIndex < contentsIndex);
+});
+
+test('buildWatchIgnore de-duplicates when both bare and contents forms are passed', (t) => {
+ const ignore = buildWatchIgnore(['**/custom', '**/custom/**', '**/custom']);
+ t.is(ignore.filter((pattern) => pattern === '**/custom').length, 1);
+ t.is(ignore.filter((pattern) => pattern === '**/custom/**').length, 1);
+ const customIndex = ignore.indexOf('**/custom');
+ t.is(ignore[customIndex + 1], '**/custom/**');
+});
+
+test('buildWatchIgnore leaves file globs alone', (t) => {
+ const ignore = buildWatchIgnore(['**/*.log', '**/notes.txt']);
+ t.true(ignore.includes('**/*.log'));
+ t.true(ignore.includes('**/notes.txt'));
+ t.false(ignore.includes('**/*.log/**'));
+ t.false(ignore.includes('**/notes.txt/**'));
+});
diff --git a/packages/flatbread/src/graphql/watchIgnore.ts b/packages/flatbread/src/graphql/watchIgnore.ts
index 72d4ded3..c6128dd0 100644
--- a/packages/flatbread/src/graphql/watchIgnore.ts
+++ b/packages/flatbread/src/graphql/watchIgnore.ts
@@ -25,8 +25,8 @@ export const DEFAULT_WATCH_IGNORE: readonly string[] = [
/**
* Defaults plus any caller-supplied globs, de-duplicated.
* Defaults stay first; extras keep their relative order.
- * Contents-only globs also get their bare-directory form so parcel skips
- * the directory itself, not only its children.
+ * Directory patterns expand both ways: bare ↔ contents (`/**`), so parcel
+ * skips the directory itself and everything under it.
*/
export function buildWatchIgnore(extra?: readonly string[]): string[] {
const result: string[] = [...DEFAULT_WATCH_IGNORE];
@@ -42,8 +42,18 @@ export function buildWatchIgnore(extra?: readonly string[]): string[] {
return result;
}
-/** Prefer bare-then-contents order when a contents-only glob is supplied. */
+/**
+ * Prefer bare-then-contents order. Expand either form into the pair.
+ * File globs stay as-is: a last segment with a non-leading "." (e.g. `*.log`)
+ * is treated as a filename, not a directory.
+ */
function expandIgnorePattern(pattern: string): string[] {
- if (!pattern.endsWith('/**')) return [pattern];
- return directoryIgnore(pattern.slice(0, -3));
+ if (pattern.endsWith('/**')) {
+ return directoryIgnore(pattern.slice(0, -3));
+ }
+ const base = pattern.slice(pattern.lastIndexOf('/') + 1);
+ if (base.includes('.') && !base.startsWith('.')) {
+ return [pattern];
+ }
+ return directoryIgnore(pattern);
}
From ba1be3fe65566540e8c140a421294b2b6bf6b027 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 29 Jul 2026 03:57:56 +0000
Subject: [PATCH 10/10] fix(explorer): normalize relative endpoint root and
serialize shared-state tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Last round of review fixes. Two were worth taking on merit; two are cheap
guards against a bad first experience.
1. `?endpoint=/` pointed the client at the explorer's own HTML instead of
the API. `normalizeGraphqlUrl` mapped an empty or root path to
`/graphql`, but only for absolute URLs — the relative branch skipped
it, so `/` and `http://host/` disagreed. Both now resolve to
`/graphql`, and a real relative path such as `/alt/graphql` still
passes through untouched.
2. `openPath.test.ts` mixed plain and serial tests over
`setExplorerStaticDirOverride`, which is module-global. AVA runs the
tests in one file at the same time, so the serial case pointing the
override at an empty directory could race the cases that need real
assets. Every case in that file is serial now. No other test file
shares the hazard.
3. A throwing `spawnCorunner` escaped into an EventEmitter listener, so
Node exited on an uncaught exception. It crashed rather than hung, but
a raw stack trace is a poor way to say a dev script would not start.
The spawn is wrapped now: log the command that failed, then exit 1.
4. The explorer README and both skill setup copies showed bare
`flatbread start`, which is not on PATH. They use `npx` now so a reader
can copy the line and run it. The monorepo section still uses
`pnpm exec`, which is right for contributors working in this repo.
Verified with `pnpm verify`.
Change-Id: If1dc2a178035439a89579089a5f997c82a721357
---
.agents/skills/effort-graph/setup.md | 4 +--
.../effort-graph/skills/effort-graph/setup.md | 4 +--
packages/explorer/README.md | 4 +--
.../explorer/src/web/core/endpoints.test.ts | 12 ++++---
packages/explorer/src/web/core/endpoints.ts | 3 +-
packages/flatbread/src/cli/openPath.test.ts | 31 ++++++++++---------
packages/flatbread/src/cli/ready.test.ts | 27 ++++++++++++++++
packages/flatbread/src/cli/ready.ts | 11 ++++++-
8 files changed, 69 insertions(+), 27 deletions(-)
diff --git a/.agents/skills/effort-graph/setup.md b/.agents/skills/effort-graph/setup.md
index 9c52d456..d961a875 100644
--- a/.agents/skills/effort-graph/setup.md
+++ b/.agents/skills/effort-graph/setup.md
@@ -92,12 +92,12 @@ content-relation explorer automatically (`@flatbread/explorer` ships with
`flatbread`):
```bash
-flatbread start --watch --open
+npx flatbread start --watch --open
```
Flatbread checks for the prebuilt single-page app (SPA) assets under
`dist/static/`. When those assets are missing, Flatbread does not serve the
-explorer and `flatbread start --open` opens `/graphql` instead.
+explorer and `npx flatbread start --open` opens `/graphql` instead.
- Explorer UI (when served): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
diff --git a/packages/effort-graph/skills/effort-graph/setup.md b/packages/effort-graph/skills/effort-graph/setup.md
index 9c52d456..d961a875 100644
--- a/packages/effort-graph/skills/effort-graph/setup.md
+++ b/packages/effort-graph/skills/effort-graph/setup.md
@@ -92,12 +92,12 @@ content-relation explorer automatically (`@flatbread/explorer` ships with
`flatbread`):
```bash
-flatbread start --watch --open
+npx flatbread start --watch --open
```
Flatbread checks for the prebuilt single-page app (SPA) assets under
`dist/static/`. When those assets are missing, Flatbread does not serve the
-explorer and `flatbread start --open` opens `/graphql` instead.
+explorer and `npx flatbread start --open` opens `/graphql` instead.
- Explorer UI (when served): `http://localhost:5057/`
- Apollo GraphQL sandbox: `http://localhost:5057/graphql`
diff --git a/packages/explorer/README.md b/packages/explorer/README.md
index e2f661b4..0227709e 100644
--- a/packages/explorer/README.md
+++ b/packages/explorer/README.md
@@ -8,7 +8,7 @@ Content-relation explorer for Flatbread. v1 ships a generic single-page app
```bash
# flatbread.config.js includes effortGraphContent()
-flatbread start --watch --open
+npx flatbread start --watch --open
# → http://localhost:5057/ explorer
# → http://localhost:5057/graphql Apollo sandbox
```
@@ -16,7 +16,7 @@ flatbread start --watch --open
No separate Next.js app is required. Flatbread checks for the prebuilt assets
under `dist/static/` (see [Develop in the monorepo](#develop-in-the-monorepo)).
When those assets are missing, Flatbread does not serve the explorer and
-`flatbread start --open` opens `/graphql` instead. `pnpm play:efforts` runs
+`npx flatbread start --open` opens `/graphql` instead. `pnpm play:efforts` runs
the explorer build automatically.
## Static deploy
diff --git a/packages/explorer/src/web/core/endpoints.test.ts b/packages/explorer/src/web/core/endpoints.test.ts
index bdee3abe..a897fa55 100644
--- a/packages/explorer/src/web/core/endpoints.test.ts
+++ b/packages/explorer/src/web/core/endpoints.test.ts
@@ -70,13 +70,13 @@ describe('resolveGraphqlEndpoint', () => {
assert.equal(endpoint, 'http://localhost:9999/alt/graphql');
});
- it('resolves bare / ?endpoint= against the injected origin', () => {
+ it('resolves bare / ?endpoint= to /graphql against the injected origin', () => {
const endpoint = resolveGraphqlEndpoint(
'?endpoint=/',
DEFAULT_BOOTSTRAP,
'http://localhost:9999'
);
- assert.equal(endpoint, 'http://localhost:9999/');
+ assert.equal(endpoint, 'http://localhost:9999/graphql');
});
it('resolves host-without-scheme ?endpoint= by prepending http', () => {
@@ -145,10 +145,12 @@ describe('normalizeGraphqlUrl', () => {
);
});
- it('resolves bare / against an injected origin', () => {
+ it('normalizes relative / to /graphql like an absolute origin URL', () => {
+ assert.equal(normalizeGraphqlUrl('/'), 'http://localhost:5057/graphql');
+ assert.equal(normalizeGraphqlUrl('http://host/'), 'http://host/graphql');
assert.equal(
- normalizeGraphqlUrl('/', 'http://localhost:9999'),
- 'http://localhost:9999/'
+ normalizeGraphqlUrl('/alt/graphql'),
+ 'http://localhost:5057/alt/graphql'
);
});
diff --git a/packages/explorer/src/web/core/endpoints.ts b/packages/explorer/src/web/core/endpoints.ts
index d25dd177..79fe231b 100644
--- a/packages/explorer/src/web/core/endpoints.ts
+++ b/packages/explorer/src/web/core/endpoints.ts
@@ -86,7 +86,8 @@ export function normalizeGraphqlUrl(
} catch {
// Relative path like `/graphql`
if (trimmed.startsWith('/')) {
- return new URL(trimmed, locationOrigin).href;
+ const path = trimmed === '/' ? DEFAULT_GRAPHQL_PATH : trimmed;
+ return new URL(path, locationOrigin).href;
}
// Bare host is absolute; do not forward the caller's origin.
return normalizeGraphqlUrl(`http://${trimmed}`);
diff --git a/packages/flatbread/src/cli/openPath.test.ts b/packages/flatbread/src/cli/openPath.test.ts
index b82bdd0c..2beed373 100644
--- a/packages/flatbread/src/cli/openPath.test.ts
+++ b/packages/flatbread/src/cli/openPath.test.ts
@@ -14,25 +14,28 @@ import {
resolveOpenPath,
} from './openPath.js';
-test('resolveCliOpenPath is an alias of resolveOpenPath', (t) => {
+test.serial('resolveCliOpenPath is an alias of resolveOpenPath', (t) => {
t.is(resolveCliOpenPath, resolveOpenPath);
});
-test('opens explorer root when preset matches and assets are present', (t) => {
- setExplorerStaticDirOverride(undefined);
- if (!explorerAssetsPresent()) {
- t.fail(
- 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
- );
- return;
+test.serial(
+ 'opens explorer root when preset matches and assets are present',
+ (t) => {
+ setExplorerStaticDirOverride(undefined);
+ if (!explorerAssetsPresent()) {
+ t.fail(
+ 'Explorer assets missing. Build @flatbread/explorer first (`pnpm --filter @flatbread/explorer build`).'
+ );
+ return;
+ }
+ t.is(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.is(resolveOpenPath(effortGraphContent()), '/');
+ t.is(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
+ t.is(resolveCliOpenPath(effortGraphContent()), '/');
}
- t.is(resolveOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
- t.is(resolveOpenPath(effortGraphContent()), '/');
- t.is(resolveCliOpenPath(effortGraphContent()), EXPLORER_ENDPOINT);
- t.is(resolveCliOpenPath(effortGraphContent()), '/');
-});
+);
-test('opens GraphQL sandbox when no explorer preset matches', (t) => {
+test.serial('opens GraphQL sandbox when no explorer preset matches', (t) => {
t.is(
resolveOpenPath([{ collection: 'Post', path: 'posts' }]),
GRAPHQL_ENDPOINT
diff --git a/packages/flatbread/src/cli/ready.test.ts b/packages/flatbread/src/cli/ready.test.ts
index 4cddbb30..97353dd2 100644
--- a/packages/flatbread/src/cli/ready.test.ts
+++ b/packages/flatbread/src/cli/ready.test.ts
@@ -162,6 +162,33 @@ test('corunner: defaults package manager command to npm run', (t) => {
t.deepEqual(deps.spawned, ['npm run']);
});
+test('corunner: spawn throw logs and exits with 1', (t) => {
+ const gql = createFakeChild();
+ const deps = createDeps({
+ corunner: 'next dev',
+ packageManager: 'pnpm',
+ spawnCorunner: () => {
+ throw new Error('ENOENT');
+ },
+ });
+ const handle = createGqlReadyHandler(gql, deps);
+
+ const result = handle('flatbread-gql-ready');
+
+ t.true(result.accepted);
+ t.false(result.serverOnly);
+ t.is(deps.readyCount.value, 1);
+ t.deepEqual(deps.exits, [1]);
+ t.true(
+ deps.errors.some(
+ (message) =>
+ message.includes('Failed to start corunner') &&
+ message.includes('pnpm') &&
+ message.includes('next dev')
+ )
+ );
+});
+
test('corunner: GraphQL child close exits parent with that code', (t) => {
const gql = createFakeChild();
const corunnerChild = createFakeChild();
diff --git a/packages/flatbread/src/cli/ready.ts b/packages/flatbread/src/cli/ready.ts
index f53a6089..ef5b8939 100644
--- a/packages/flatbread/src/cli/ready.ts
+++ b/packages/flatbread/src/cli/ready.ts
@@ -86,7 +86,16 @@ export function createGqlReadyHandler(
}
const packageManagerCommand = deps.packageManager ?? 'npm run';
- const targetProcess = deps.spawnCorunner(packageManagerCommand);
+ let targetProcess: ChildLike;
+ try {
+ targetProcess = deps.spawnCorunner(packageManagerCommand);
+ } catch {
+ logError(
+ `Failed to start corunner with "${packageManagerCommand} ${deps.corunner.trim()}".`
+ );
+ deps.onExit(1);
+ return { serverOnly: false, accepted: true };
+ }
for (const script of [gqlChild, targetProcess]) {
script.on('close', (code) => {