diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 149464b..456ab4e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -25,6 +25,8 @@ on: - 'commitlint.config.*' - 'knip.json' - 'knip-baseline.json' + - '.jscpd.json' + - 'tools/scripts/jscpd-check.mjs' - 'lefthook.yml' - 'release.config.js' - 'playwright.config.ts' @@ -209,6 +211,62 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }); } + # Duplication gate (jscpd). Enforces a maximum copy-paste percentage across + # the codebase; the threshold lives in .jscpd.json and ratchets down over + # time (lower it with `pnpm run dup-check:update`). Posts a sticky PR comment + # with the duplication breakdown on every run. Apply the + # `skip-duplication-gate` label to bypass for a PR. + duplication: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-duplication-gate') }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + with: + version: 11.5.2 + run_install: false + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + - name: Install deps + run: pnpm install --frozen-lockfile + - name: Run jscpd (duplication gate) + env: + DUP_REPORT_FILE: ${{ runner.temp }}/jscpd-report.md + run: pnpm run dup-check:ci + - name: Post duplication report (sticky PR comment) + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const fs = require('fs'); + const marker = ''; + let body; + try { + body = fs.readFileSync(`${process.env.RUNNER_TEMP}/jscpd-report.md`, 'utf8'); + } catch { + core.info('No duplication report file produced; skipping comment.'); + return; + } + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find((c) => (c.body ?? '').includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + # Storybook + axe accessibility scan. Builds storybook, serves it # statically, then runs @storybook/test-runner with axe-playwright on # every story. Stories own their theme + light/dark mode per swatch (the @@ -346,6 +404,7 @@ jobs: - test - build - knip + - duplication - storybook-a11y - e2e - e2e-docs @@ -363,6 +422,7 @@ jobs: echo '- Test: ${{ needs.test.result }}' echo '- Build: ${{ needs.build.result }}' echo '- Knip: ${{ needs.knip.result }}' + echo '- Duplication (jscpd): ${{ needs.duplication.result }}' echo '- Storybook a11y: ${{ needs.storybook-a11y.result }}' echo '- E2E: ${{ needs.e2e.result }}' echo '- E2E (docs): ${{ needs.e2e-docs.result }}' @@ -384,4 +444,11 @@ jobs: exit 1 fi done + # Only the duplication gate may be skipped, via the + # skip-duplication-gate label which skips that job. + dup='${{ needs.duplication.result }}' + if [ "$dup" != "success" ] && [ "$dup" != "skipped" ]; then + echo "Duplication gate failed." + exit 1 + fi echo "All checks passed." diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..375842d --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,28 @@ +{ + "mode": "strict", + "minTokens": 70, + "minLines": 8, + "format": ["typescript", "tsx", "javascript", "jsx"], + "absolute": false, + "gitignore": true, + "pattern": "{apps,libs,examples,tools}/**/*.{ts,tsx,js,jsx,mjs}", + "threshold": 0, + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "**/out-tsc/**", + "**/coverage/**", + "**/storybook-static/**", + "**/.storybook/**", + "**/.docusaurus/**", + "**/build/**", + "**/.sandcastle/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.stories.tsx", + "**/*.gen.ts", + "**/*.d.ts" + ] +} diff --git a/apps/docs/docs/embedding.md b/apps/docs/docs/embedding.md new file mode 100644 index 0000000..b4817ce --- /dev/null +++ b/apps/docs/docs/embedding.md @@ -0,0 +1,135 @@ +--- +title: Embedding in Express / Hono +sidebar_position: 7 +--- + +# Embedding a Backroad app + +`run()` starts a standalone server that owns a whole port. Sometimes you +instead want a Backroad app to live on a **sub-route of an existing server** — +e.g. an internal dashboard at `/backroad` next to your own API. The adapters +let you mount a Backroad app as a sub-app of an Express or Hono server. + +```ts +// Instead of taking a whole port… +run(executor); + +// …mount under a sub-route of an app you already have: +app.use('/backroad', backroadExpress(executor, { basePath: '/backroad' })); +``` + +The `basePath` option must match the path you mount at. It's how the prebuilt +frontend learns where it lives: the server injects it into the served HTML at +runtime, and every URL the client builds (asset paths, the Socket.IO handshake, +`/api/*` calls, the router) is prefixed with it. One published bundle therefore +works from the domain root **or** any sub-path. + +## Express + +```ts +import express from 'express'; +import { backroadExpress } from '@backroad/backroad'; + +const app = express(); + +app.get('/', (_req, res) => res.send('my own home page')); + +app.use( + '/backroad', + backroadExpress( + async (br) => { + const n = br.button({ label: 'Click me' }); + br.write({ body: n ? 'Clicked!' : 'Not yet.' }); + }, + { basePath: '/backroad' } + ) +); + +app.listen(3000); +// Backroad UI: http://localhost:3000/backroad +// Your route: http://localhost:3000/ +``` + +That's the whole integration. Socket.IO **auto-attaches** to the underlying +server on the first request, so there's no extra wiring step. + +## Hono + +Hono speaks the Web Fetch API, so run it on `@hono/node-server` and pass the +returned server to `attach()` (see [WebSockets](#websockets-and-attach) below): + +```ts +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { backroadHono } from '@backroad/backroad'; + +const app = new Hono(); +app.get('/', (c) => c.text('my own home page')); + +const br = backroadHono( + async (br) => { + const n = br.button({ label: 'Click me' }); + br.write({ body: n ? 'Clicked!' : 'Not yet.' }); + }, + { basePath: '/backroad' } +); +app.route('/backroad', br); + +const server = serve({ fetch: app.fetch, port: 3000 }); +br.attach(server); // bind Socket.IO to the Node server +``` + +Install the optional peer deps for the Hono adapter: + +```bash +pnpm add hono @hono/node-server +``` + +## WebSockets and `attach` + +Backroad uses Socket.IO, which binds at the **HTTP server** level — below the +request/response middleware that `app.use()` deals with. There are two ways to +give it the server: + +- **Auto-attach (default).** The handler grabs the server off the first HTTP + request it sees and attaches Socket.IO then. The browser always loads the + page before it opens the websocket, so this is in place in time. This is the + one-liner shown in the Express example — nothing else to do. + +- **Explicit `attach(server)`.** Both adapters also expose `.attach(server)` for + when the auto-attach can't reach the server — Hono on `@hono/node-server`, or + any setup behind a proxy that hides the underlying server: + + ```ts + const br = backroadExpress(executor, { basePath: '/backroad' }); + app.use('/backroad', br); + const server = app.listen(3000); + br.attach(server); // explicit — safe to also call; the first wins + ``` + + Calling `attach()` after auto-attach has already run is a harmless no-op. + +## Multiple apps in one process + +Each mounted app gets its own sessions and sockets, so you can run several side +by side. Give each a distinct `basePath`: + +```ts +app.use('/admin', backroadExpress(adminApp, { basePath: '/admin' })); +app.use('/reports', backroadExpress(reportsApp, { basePath: '/reports' })); +``` + +State set in one never leaks into the other. + +## Auth under a sub-path + +If you enable [auth](./auth.md) on an embedded app, point better-auth's +`baseURL` at the mounted path so its callback URLs match where the handler +actually lives, e.g. `https://yourhost/backroad`. The Backroad frontend already +prefixes the mount path onto its `/api/auth/*` calls. + +## Still want a standalone server? + +`run()` is unchanged — it's exactly this machinery with `basePath: ''` on a +server it creates for you. Reach for the adapters only when you need to embed +into an existing app. diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 849e16b..b8117f4 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -32,6 +32,7 @@ const sidebars: SidebarsConfig = { items: ['configuration/themes', 'configuration/analytics', 'auth'], }, 'hosting', + 'embedding', { type: 'category', label: 'Advanced', diff --git a/examples/demo/src/pages/widgets.ts b/examples/demo/src/pages/widgets.ts index 13bf3e7..ce87da6 100644 --- a/examples/demo/src/pages/widgets.ts +++ b/examples/demo/src/pages/widgets.ts @@ -42,9 +42,12 @@ export const backroadWidgetsExample = (br: BackroadNodeManager) => { }); } - // `downloaded` is true only on the run right after the click, so the echo - // below renders once per press — letting the e2e spec assert the round-trip - // in addition to intercepting the actual browser download. + // `downloaded` is true only on the run right after the click. The confirmation + // is fired as a toast (like Notify above) rather than a transient `br.write`: + // the click commits `true` then immediately unsets it, so a written node would + // be added on the set-rerun and removed again on the unset-rerun — a flash the + // e2e spec could miss. The toast persists for its duration, so the round-trip + // assertion is deterministic. const downloaded = br.downloadButton({ label: 'Download Report', data: () => Promise.resolve(JSON.stringify({ status: 'ok' }, null, 2)), @@ -52,6 +55,10 @@ export const backroadWidgetsExample = (br: BackroadNodeManager) => { mime: 'application/json', }); if (downloaded) { - br.write({ body: 'Report downloaded!' }); + br.toast({ + message: 'Report downloaded!', + variant: 'success', + duration: 6000, + }); } }; diff --git a/knip.json b/knip.json index 7b33e8b..769ce0d 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,7 @@ }, "ignoreBinaries": ["lefthook"], "ignoreDependencies": [ + "@ai-hero/sandcastle", "docusaurus-lunr-search", "lunr", "@docusaurus/plugin-content-docs", diff --git a/libs/backroad-components/src/lib/components/download_button.tsx b/libs/backroad-components/src/lib/components/download_button.tsx index 8feb8ac..bd3d63a 100644 --- a/libs/backroad-components/src/lib/components/download_button.tsx +++ b/libs/backroad-components/src/lib/components/download_button.tsx @@ -1,4 +1,4 @@ -import { sessionId, setRunUnsetBackroadValue } from '../socket'; +import { sessionId, setRunUnsetBackroadValue, withBasePath } from '../socket'; import { BackroadComponentRenderer } from '../types/components'; import { Button as UIButton } from 'backroad-ui'; @@ -14,9 +14,9 @@ export const DownloadButton: BackroadComponentRenderer<'download_button'> = ( // are set server-side). A transient anchor triggers the save dialog // without navigating the page away. const anchor = document.createElement('a'); - anchor.href = `/api/download/${sessionId}/${encodeURIComponent( - props.id - )}`; + anchor.href = withBasePath( + `/api/download/${sessionId}/${encodeURIComponent(props.id)}` + ); document.body.appendChild(anchor); anchor.click(); anchor.remove(); diff --git a/libs/backroad-components/src/lib/components/file_upload.tsx b/libs/backroad-components/src/lib/components/file_upload.tsx index f3c6459..fc68d01 100644 --- a/libs/backroad-components/src/lib/components/file_upload.tsx +++ b/libs/backroad-components/src/lib/components/file_upload.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { BackroadComponentRenderer } from '../types/components'; import { useDropzone } from 'react-dropzone'; -import { sessionId, setBackroadValue } from '../socket'; +import { sessionId, setBackroadValue, backroadFetch } from '../socket'; import { ClipboardDocumentIcon, CloudArrowUpIcon, @@ -51,7 +51,7 @@ export const FileUpload: BackroadComponentRenderer<'file_upload'> = (props) => { data.append('sessionId', sessionId); data.append('id', props.id); const resp = await ( - await fetch('/api/uploads', { method: 'POST', body: data }) + await backroadFetch('/api/uploads', { method: 'POST', body: data }) ).json(); console.log('upload response', resp); setBackroadValue({ id: props.id, value: resp }); diff --git a/libs/backroad-components/src/lib/socket/auth-synchronizers.ts b/libs/backroad-components/src/lib/socket/auth-synchronizers.ts index 648c80f..d6f675e 100644 --- a/libs/backroad-components/src/lib/socket/auth-synchronizers.ts +++ b/libs/backroad-components/src/lib/socket/auth-synchronizers.ts @@ -1,9 +1,14 @@ /** Wires server-driven auth events (redirect, sign-out) to browser navigation. */ +import { withBasePath } from './base-path'; +import { backroadFetch } from './fetch'; import { socket } from './client'; export const registerAuthSynchronizers = (): void => { + // Server-built auth URLs (from br.login/logout) are root-relative and don't + // know the mount sub-path, so withBasePath prefixes it (and leaves absolute + // or already-prefixed URLs alone). socket.on('auth_redirect', ({ url }) => { - window.location.assign(url); + window.location.assign(withBasePath(url)); }); // br.logout() flows through here. Hit better-auth's sign-out endpoint @@ -11,14 +16,14 @@ export const registerAuthSynchronizers = (): void => { // better-auth client SDK, then navigate to the React /auth/signin route. socket.on('auth_signout', async () => { try { - await fetch('/api/auth/sign-out', { + await backroadFetch('/api/auth/sign-out', { method: 'POST', credentials: 'include', }); } catch (err) { console.error('Sign-out request failed', err); } finally { - window.location.assign('/auth/signin'); + window.location.assign(withBasePath('/auth/signin')); } }); }; diff --git a/libs/backroad-components/src/lib/socket/base-path.ts b/libs/backroad-components/src/lib/socket/base-path.ts new file mode 100644 index 0000000..e580311 --- /dev/null +++ b/libs/backroad-components/src/lib/socket/base-path.ts @@ -0,0 +1,32 @@ +/** + * The sub-path this Backroad app is mounted under, e.g. '/backroad' — or '' at + * the domain root. The server injects `window.__BACKROAD_BASE__` into the served + * index.html (see buildBackroadHandler), and these helpers read it at call time + * so nothing has to import a baked-in constant. + */ +const readBasePath = (): string => + (typeof window !== 'undefined' && + (window as { __BACKROAD_BASE__?: string }).__BACKROAD_BASE__) || + ''; + +/** The mount sub-path ('' at the domain root). Resolved from the runtime. */ +export const getBasePath = (): string => readBasePath(); + +/** + * Prefix a root-relative path with the mount sub-path, so one prebuilt bundle's + * URLs work from any mount point. Absolute URLs, non-root-relative paths, and + * already-prefixed paths are returned unchanged. + */ +export const withBasePath = (path: string): string => { + const base = readBasePath(); + if (!base || !path.startsWith('/')) return path; + if ( + path === base || + path.startsWith(`${base}/`) || + path.startsWith(`${base}?`) || + path.startsWith(`${base}#`) + ) { + return path; + } + return `${base}${path}`; +}; diff --git a/libs/backroad-components/src/lib/socket/client.ts b/libs/backroad-components/src/lib/socket/client.ts index f9120f1..78fec44 100644 --- a/libs/backroad-components/src/lib/socket/client.ts +++ b/libs/backroad-components/src/lib/socket/client.ts @@ -1,11 +1,15 @@ /** Owns the singleton socket.io client connected to the per-tab session namespace. */ import { ClientToServerEvents, ServerToClientEvents } from '@backroad/core'; import { Socket, io } from 'socket.io-client'; +import { withBasePath } from './base-path'; import { sessionId } from './session'; export const socket: Socket = io( + // The namespace is the per-tab session id (relative to the origin, so it is + // NOT prefixed). The handshake path, however, is the one place that carries + // the mount sub-path — it must match the server's `${basePath}/api/socket.io`. `/${sessionId}`, { - path: '/api/socket.io', + path: withBasePath('/api/socket.io'), } ); diff --git a/libs/backroad-components/src/lib/socket/fetch.ts b/libs/backroad-components/src/lib/socket/fetch.ts new file mode 100644 index 0000000..4e4bf1c --- /dev/null +++ b/libs/backroad-components/src/lib/socket/fetch.ts @@ -0,0 +1,15 @@ +/** + * A drop-in `fetch` that automatically prefixes root-relative request paths + * with the app's mount sub-path (see {@link withBasePath}). One prebuilt bundle + * can talk to its server from any mount point ('' at the domain root, + * '/backroad' when embedded) without every call site remembering to wrap its + * URL. Absolute URLs, `URL`/`Request` inputs, and already-prefixed paths are + * passed through untouched. + */ +import { withBasePath } from './base-path'; + +export const backroadFetch = ( + input: RequestInfo | URL, + init?: RequestInit +): Promise => + fetch(typeof input === 'string' ? withBasePath(input) : input, init); diff --git a/libs/backroad-components/src/lib/socket/index.ts b/libs/backroad-components/src/lib/socket/index.ts index 3742a2f..abc01b9 100644 --- a/libs/backroad-components/src/lib/socket/index.ts +++ b/libs/backroad-components/src/lib/socket/index.ts @@ -4,6 +4,8 @@ import { registerAuthSynchronizers } from './auth-synchronizers'; registerAuthSynchronizers(); export { sessionId } from './session'; +export { getBasePath, withBasePath } from './base-path'; +export { backroadFetch } from './fetch'; export { socket } from './client'; export { setBackroadValue, setRunUnsetBackroadValue } from './value-setters'; export { showToast } from './show-toast'; diff --git a/libs/backroad-frontend/src/app/auth/signin.tsx b/libs/backroad-frontend/src/app/auth/signin.tsx index f8f8b9c..5a4a9e4 100644 --- a/libs/backroad-frontend/src/app/auth/signin.tsx +++ b/libs/backroad-frontend/src/app/auth/signin.tsx @@ -1,6 +1,7 @@ import { AuthUIProvider, AuthView } from '@daveyplate/better-auth-ui'; +import { withBasePath } from 'backroad-components'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { authClient } from '../../lib/auth-client'; +import { getAuthClient } from '../../lib/auth-client'; /** * Auth view route. Mounted at `/signin`, `/signin/sign-up`, @@ -23,17 +24,24 @@ export function AuthRoute() { // hard reload tears down the old socket and the new connection picks // up the fresh cookie. In-auth navigation (signin ↔ signup ↔ // forgot-password) stays a soft React Router move. + // + // navigate()/ are basename-aware (BrowserRouter basename), so the + // hrefs stay root-relative here. The hard reload path uses raw + // window.location, which is NOT basename-aware, so it must carry the mount + // prefix explicitly. const isAuthInternal = (href: string) => href.startsWith('/auth'); const navigateOrReload = (href: string) => - isAuthInternal(href) ? navigate(href) : window.location.assign(href); + isAuthInternal(href) + ? navigate(href) + : window.location.assign(withBasePath(href)); const replaceOrReload = (href: string) => isAuthInternal(href) ? navigate(href, { replace: true }) - : window.location.replace(href); + : window.location.replace(withBasePath(href)); return ( } diff --git a/libs/backroad-frontend/src/lib/auth-client.ts b/libs/backroad-frontend/src/lib/auth-client.ts index 1931c1b..4dfb28d 100644 --- a/libs/backroad-frontend/src/lib/auth-client.ts +++ b/libs/backroad-frontend/src/lib/auth-client.ts @@ -1,13 +1,23 @@ +import { getBasePath } from 'backroad-components'; import { createAuthClient } from 'better-auth/react'; -// Same-origin client: the server mounts better-auth at /api/auth/* on the -// same port the browser is talking to. better-auth-ui calls into this -// client to perform sign-in / sign-up / session reads, etc. -export const authClient = createAuthClient({ - baseURL: - typeof window !== 'undefined' - ? window.location.origin - : 'http://localhost:3333', -}); +let client: ReturnType | undefined; -export type AuthSession = typeof authClient.$Infer.Session; +// Lazily build (and memoise) the better-auth client. Same-origin: the server +// mounts better-auth at ${basePath}/api/auth/* on the same port the browser is +// talking to. Resolving the base URL on first use — rather than at import — +// keeps the mount sub-path a runtime concern and avoids a side-effectful +// singleton constructed before window.__BACKROAD_BASE__ is meaningful. +export const getAuthClient = (): ReturnType => { + if (!client) { + client = createAuthClient({ + baseURL: + typeof window !== 'undefined' + ? `${window.location.origin}${getBasePath()}` + : 'http://localhost:3333', + }); + } + return client; +}; + +export type AuthSession = ReturnType['$Infer']['Session']; diff --git a/libs/backroad-frontend/src/main.tsx b/libs/backroad-frontend/src/main.tsx index 70f766f..4580cc6 100644 --- a/libs/backroad-frontend/src/main.tsx +++ b/libs/backroad-frontend/src/main.tsx @@ -1,3 +1,4 @@ +import { getBasePath } from 'backroad-components'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; @@ -10,7 +11,8 @@ const root = ReactDOM.createRoot( root.render( - + {/* basename is the mount sub-path so all in-app routing is prefixed. */} + diff --git a/libs/backroad-frontend/vite.config.ts b/libs/backroad-frontend/vite.config.ts index 2745af2..1ee5436 100644 --- a/libs/backroad-frontend/vite.config.ts +++ b/libs/backroad-frontend/vite.config.ts @@ -5,6 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ cacheDir: '../../node_modules/.vite/backroad-frontend', + // Relative asset URLs (./assets/*) so the prebuilt bundle resolves against + // the runtime the server injects — that's what lets one build + // serve from the domain root OR any sub-path (e.g. /backroad). + base: './', build: { outDir: '../../dist/libs/backroad-frontend', emptyOutDir: true, diff --git a/libs/backroad/package.json b/libs/backroad/package.json index 28297b8..3568073 100644 --- a/libs/backroad/package.json +++ b/libs/backroad/package.json @@ -26,11 +26,19 @@ "better-auth": "^1.6.14" }, "peerDependencies": { - "better-auth": "^1.6.14" + "@hono/node-server": "^1.13.0", + "better-auth": "^1.6.14", + "hono": "^4.6.0" }, "peerDependenciesMeta": { + "@hono/node-server": { + "optional": true + }, "better-auth": { "optional": true + }, + "hono": { + "optional": true } } } diff --git a/libs/backroad/src/index.ts b/libs/backroad/src/index.ts index a7a3361..274935b 100644 --- a/libs/backroad/src/index.ts +++ b/libs/backroad/src/index.ts @@ -1,4 +1,16 @@ -// export { startBackroadServer } from './lib/server'; export { run } from './lib/runner'; +export type { BackroadRunContext } from './lib/runner'; export { BackroadNodeManager, ChatManager } from './lib/backroad'; export { Config } from './lib/server/server-socket-event-handlers/types'; + +// Pluggable mounting: embed a Backroad app inside an existing server instead of +// taking a whole port. buildBackroadHandler is the framework-agnostic core; +// backroadExpress / backroadHono are thin adapters over it. +export { + buildBackroadHandler, + type BackroadAdapterOptions, + type BackroadExecutor, + type BackroadHandler, +} from './lib/server/build'; +export { backroadExpress } from './lib/adapters/express'; +export { backroadHono } from './lib/adapters/hono'; diff --git a/libs/backroad/src/lib/adapters/adapters.test.ts b/libs/backroad/src/lib/adapters/adapters.test.ts new file mode 100644 index 0000000..1458956 --- /dev/null +++ b/libs/backroad/src/lib/adapters/adapters.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import * as http from 'http'; +import type { AddressInfo } from 'net'; +import { createRequire } from 'module'; +import { backroadExpress } from './express'; + +// A no-op executor: these tests exercise the HTTP outlet (health, mount-prefix +// stripping, download 404) which never triggers a script run, so the executor +// is never actually invoked. +const noop = () => undefined; + +// Bring a Node http.Server up on an ephemeral port and return the chosen port. +const listen = async (server: http.Server) => { + await new Promise((resolve) => server.listen(0, resolve)); + return (server.address() as AddressInfo).port; +}; + +// Tear a server down deterministically. fetch() (undici) keeps connections +// alive in a pool, which would otherwise stall server.close(), so force any +// lingering sockets shut first. +const close = (server: http.Server) => + new Promise((resolve) => { + (server as { closeAllConnections?: () => void }).closeAllConnections?.(); + server.close(() => resolve()); + }); + +describe('backroadExpress outlet', () => { + let server: http.Server | undefined; + + afterEach(async () => { + if (server) await close(server); + server = undefined; + }); + + it('serves the health probe at the root mount', async () => { + const app = express(); + app.use(backroadExpress(noop)); + server = http.createServer(app); + const port = await listen(server); + + const res = await fetch(`http://127.0.0.1:${port}/api/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('serves the health probe under a sub-path mount', async () => { + const app = express(); + app.use('/backroad', backroadExpress(noop, { basePath: '/backroad' })); + server = http.createServer(app); + const port = await listen(server); + + const res = await fetch(`http://127.0.0.1:${port}/backroad/api/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); + + it('reports the normalised basePath on the handler', () => { + expect(backroadExpress(noop).basePath).toBe(''); + expect(backroadExpress(noop, { basePath: 'backroad/' }).basePath).toBe( + '/backroad' + ); + }); + + it('404s an unknown download', async () => { + const app = express(); + app.use(backroadExpress(noop)); + server = http.createServer(app); + const port = await listen(server); + + const res = await fetch( + `http://127.0.0.1:${port}/api/download/no-session/no-id` + ); + expect(res.status).toBe(404); + }); +}); + +// hono + @hono/node-server are optional peer deps; only run the Hono outlet +// integration when both are actually installed so the suite stays green in +// environments that don't pull the optional packages. +const honoAvailable = (() => { + try { + const req = createRequire(__filename); + req.resolve('hono'); + req.resolve('@hono/node-server'); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!honoAvailable)('backroadHono outlet', () => { + let server: http.Server | undefined; + + afterEach(async () => { + if (server) await close(server); + server = undefined; + }); + + it('serves the health probe under a sub-path mount', async () => { + // Resolved through createRequire (not a static import) so tsc never tries to + // type-resolve the optional hono packages when they aren't installed. + const { backroadHono } = await import('./hono'); + const req = createRequire(__filename); + const { serve } = req('@hono/node-server'); + const { Hono } = req('hono'); + + const app = new Hono(); + app.route('/backroad', backroadHono(noop, { basePath: '/backroad' })); + server = serve({ fetch: app.fetch, port: 0 }) as unknown as http.Server; + const port = (server.address() as AddressInfo).port; + + const res = await fetch(`http://127.0.0.1:${port}/backroad/api/health`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); +}); diff --git a/libs/backroad/src/lib/adapters/express.ts b/libs/backroad/src/lib/adapters/express.ts new file mode 100644 index 0000000..00b6921 --- /dev/null +++ b/libs/backroad/src/lib/adapters/express.ts @@ -0,0 +1,23 @@ +import { + buildBackroadHandler, + type BackroadAdapterOptions, + type BackroadExecutor, + type BackroadHandler, +} from '../server/build'; + +/** + * Mount a Backroad app onto an existing Express app. + * + * ```ts + * app.use('/backroad', backroadExpress(executor, { basePath: '/backroad' })); + * ``` + * + * The returned value is an express handler that also carries `.attach(server)`. + * Socket.IO auto-attaches on the first HTTP request, so the one-liner above is + * all you need. If your setup can't expose the http.Server to a request (a + * custom proxy, say), call `.attach(server)` explicitly after `app.listen()`. + */ +export const backroadExpress = ( + executor: BackroadExecutor, + options?: BackroadAdapterOptions +): BackroadHandler => buildBackroadHandler(executor, options); diff --git a/libs/backroad/src/lib/adapters/hono.ts b/libs/backroad/src/lib/adapters/hono.ts new file mode 100644 index 0000000..3f38912 --- /dev/null +++ b/libs/backroad/src/lib/adapters/hono.ts @@ -0,0 +1,87 @@ +import * as http from 'http'; +import { + buildBackroadHandler, + type BackroadAdapterOptions, + type BackroadExecutor, +} from '../server/build'; +import { lazyRequire } from './lazy-require'; + +// hono + @hono/node-server are optional peer deps, required lazily so apps that +// only use run()/backroadExpress never need them installed (same pattern as +// better-auth). +const honoHint = + 'backroadHono requires "hono" and "@hono/node-server". Install both `hono` and `@hono/node-server`.'; + +/** + * Mount a Backroad app onto an existing Hono app (running on @hono/node-server). + * + * ```ts + * const br = backroadHono(executor, { basePath: '/backroad' }); + * app.route('/backroad', br); + * const server = serve({ fetch: app.fetch, port: 3000 }); + * br.attach(server); // Socket.IO — see note below + * ``` + * + * Hono speaks the Web Fetch API, so requests are bridged to Backroad's internal + * express handler via the raw Node req/res that @hono/node-server exposes on the + * context (`c.env.incoming` / `c.env.outgoing`). Socket.IO auto-attaches when a + * request exposes its server, but because that isn't guaranteed through Hono's + * fetch layer, calling `br.attach(server)` with the value returned by `serve()` + * is the recommended, explicit path. + */ +export const backroadHono = ( + executor: BackroadExecutor, + options?: BackroadAdapterOptions +) => { + const handler = buildBackroadHandler(executor, options); + const basePath = handler.basePath; + + const { Hono } = lazyRequire('hono', honoHint); + // RESPONSE_ALREADY_SENT tells @hono/node-server we wrote to the Node response + // ourselves and it should not build a Response from the return value. + const { RESPONSE_ALREADY_SENT } = lazyRequire( + '@hono/node-server/utils/response', + honoHint + ); + + const app = new Hono(); + app.all('*', async (c: any) => { + const incoming: http.IncomingMessage | undefined = c.env?.incoming; + const outgoing: http.ServerResponse | undefined = c.env?.outgoing; + if (!incoming || !outgoing) { + throw new Error( + 'backroadHono requires the @hono/node-server runtime (no raw Node req/res on the context).' + ); + } + + // Best-effort auto-attach; explicit br.attach(server) is the supported path. + const server = (incoming.socket as { server?: http.Server } | undefined) + ?.server; + if (server) handler.attach(server); + + // The express router matches paths relative to the mount, so strip the + // prefix Hono's route() left on the raw Node url. + if (basePath && incoming.url) { + const url = incoming.url; + const isMatch = + url === basePath || + url.startsWith(`${basePath}/`) || + url.startsWith(`${basePath}?`); + if (isMatch) { + const next = url.slice(basePath.length); + incoming.url = next ? (next.startsWith('/') ? next : `/${next}`) : '/'; + } + } + + await new Promise((resolve) => { + outgoing.on('close', () => resolve()); + outgoing.on('finish', () => resolve()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (handler as any)(incoming, outgoing, () => resolve()); + }); + + return RESPONSE_ALREADY_SENT; + }); + + return Object.assign(app, { attach: handler.attach, basePath }); +}; diff --git a/libs/backroad/src/lib/adapters/lazy-require.ts b/libs/backroad/src/lib/adapters/lazy-require.ts new file mode 100644 index 0000000..76bced6 --- /dev/null +++ b/libs/backroad/src/lib/adapters/lazy-require.ts @@ -0,0 +1,22 @@ +// Lazily require an optional peer dependency, throwing a friendly install hint +// when it is missing. Shared across the framework adapters (express, hono, …) so +// apps that only use run() never need the adapter-specific peers installed. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const lazyRequire = (name: string, hint?: string): any => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(name); + } catch (err) { + // Only swallow a genuine "module is not installed" error. Anything else + // (e.g. a syntax/runtime error thrown while loading an installed module) + // must propagate untouched so it is not misreported as a missing peer dep. + if ( + err && + typeof err === 'object' && + (err as { code?: string }).code === 'MODULE_NOT_FOUND' + ) { + throw new Error(hint ?? `This adapter requires "${name}". Install it.`); + } + throw err; + } +}; diff --git a/libs/backroad/src/lib/backroad/backroad.test.ts b/libs/backroad/src/lib/backroad/backroad.test.ts index 4bb7c2c..f22e504 100644 --- a/libs/backroad/src/lib/backroad/backroad.test.ts +++ b/libs/backroad/src/lib/backroad/backroad.test.ts @@ -4,15 +4,16 @@ import { SocketManager } from './socket-manager'; import type { BackroadUser } from '@backroad/core'; function makeSession(id = 'test-session') { - const session = new BackroadSession(id); + // Each session gets its own SocketManager (per-instance, not a global). + const socketManager = new SocketManager(); + const session = new BackroadSession(id, socketManager); const emit = vi.fn(); - // SocketManager is a process-global static map; register a stub so - // br.login()/br.logout() can find a "socket" to emit on. - SocketManager.register(id, { + // Register a stub socket so br.login()/br.logout() can find one to emit on. + socketManager.register(id, { emit, // The rest of the Socket interface is irrelevant to these tests; // cast through unknown so we don't have to stub the full surface. - } as unknown as Parameters[1]); + } as unknown as Parameters[1]); return { session, emit }; } diff --git a/libs/backroad/src/lib/backroad/backroad.ts b/libs/backroad/src/lib/backroad/backroad.ts index 9f8fd46..90f89ad 100644 --- a/libs/backroad/src/lib/backroad/backroad.ts +++ b/libs/backroad/src/lib/backroad/backroad.ts @@ -18,7 +18,6 @@ import { type DownloadDataResolver, } from '../server/sessions/session'; import { ObjectHasher } from './object-hasher'; -import { SocketManager } from './socket-manager'; type BackroadComponentFormat = { id?: BackroadComponent['id']; @@ -427,20 +426,16 @@ export class BackroadNodeManager< const url = provider ? `/api/auth/sign-in/social?provider=${encodeURIComponent(provider)}` : '/auth/signin'; - SocketManager.getSocket(this.backroadSession.sessionId).emit( - 'auth_redirect', - { url }, - () => undefined - ); + this.backroadSession.socketManager + .getSocket(this.backroadSession.sessionId) + .emit('auth_redirect', { url }, () => undefined); } logout() { // The client handler hits better-auth's sign-out endpoint to clear the // cookie, then navigates to /auth/signin. - SocketManager.getSocket(this.backroadSession.sessionId).emit( - 'auth_signout', - undefined, - () => undefined - ); + this.backroadSession.socketManager + .getSocket(this.backroadSession.sessionId) + .emit('auth_signout', undefined, () => undefined); } } diff --git a/libs/backroad/src/lib/backroad/render-queue.ts b/libs/backroad/src/lib/backroad/render-queue.ts index d9be035..48801eb 100644 --- a/libs/backroad/src/lib/backroad/render-queue.ts +++ b/libs/backroad/src/lib/backroad/render-queue.ts @@ -1,5 +1,4 @@ import { BackroadSession } from '../server/sessions/session'; -import { SocketManager } from './socket-manager'; export class RenderQueue { queue: string[] = []; @@ -24,15 +23,31 @@ export class RenderQueue { this.queue = []; return queue; } + #getSocket() { + // The session can disconnect between scheduling a flush and the microtask + // running, at which point getSocket() throws. Swallow that here so the + // async flush path can't crash the process on a stale session. + try { + return this.backroadSession.socketManager.getSocket( + this.backroadSession.sessionId + ); + } catch { + return undefined; + } + } #flushToFrontend() { - const socket = SocketManager.getSocket(this.backroadSession.sessionId); + const socket = this.#getSocket(); + // Still drain the queue even with no socket, so a later reconnect doesn't + // replay a backlog of stale nodes. const nodesToEmit = this.flush(); + if (!socket) return; socket.emit('render', nodesToEmit, () => { /* ack ignored */ }); } updateProps(props: any) { - const socket = SocketManager.getSocket(this.backroadSession.sessionId); + const socket = this.#getSocket(); + if (!socket) return; socket.emit('props_change', props, () => { console.log('props change request acked by frontend'); }); diff --git a/libs/backroad/src/lib/backroad/socket-manager/index.ts b/libs/backroad/src/lib/backroad/socket-manager/index.ts index 51a0891..d9fa202 100644 --- a/libs/backroad/src/lib/backroad/socket-manager/index.ts +++ b/libs/backroad/src/lib/backroad/socket-manager/index.ts @@ -1,13 +1,25 @@ import type { ServerSocketType } from '@backroad/core'; -const sessionToSocketMapping: Record = {}; +// One SocketManager per Backroad instance (see buildBackroadHandler). Holding +// the sessionId→socket map on the instance — rather than a module-global — +// lets two Backroad apps run in the same process without their sockets +// colliding. export class SocketManager { - static getSocket(sessionId: string) { - if (sessionId in sessionToSocketMapping) - return sessionToSocketMapping[sessionId]; + #sessionToSocketMapping = new Map(); + getSocket(sessionId: string) { + const socket = this.#sessionToSocketMapping.get(sessionId); + if (socket) return socket; else throw new Error(`No socket found for session ${sessionId}`); } - static register(sessionId: string, socket: ServerSocketType) { - sessionToSocketMapping[sessionId] = socket; + register(sessionId: string, socket: ServerSocketType) { + this.#sessionToSocketMapping.set(sessionId, socket); + } + unregister(sessionId: string, socket: ServerSocketType) { + // Only drop the mapping when it still points at the disconnecting socket. + // A stale disconnect (older connection closing after a newer socket for the + // same session has registered) must not evict the live socket. + if (this.#sessionToSocketMapping.get(sessionId) === socket) { + this.#sessionToSocketMapping.delete(sessionId); + } } } diff --git a/libs/backroad/src/lib/runner/index.ts b/libs/backroad/src/lib/runner/index.ts index 99815c6..46493fe 100644 --- a/libs/backroad/src/lib/runner/index.ts +++ b/libs/backroad/src/lib/runner/index.ts @@ -1,103 +1,43 @@ import { BackroadConfig } from '@backroad/core'; -import { BackroadNodeManager } from '../backroad'; -import { SocketManager } from '../backroad/socket-manager'; -import { startBackroadServer } from '../server'; -import { sessionManager } from '../server/sessions/session-manager'; -import { socketEventHandlers } from '../server/server-socket-event-handlers'; -export type BackroadRunContext = { - currentPath: string; -}; +import * as http from 'http'; +import { buildBackroadHandler, type BackroadExecutor } from '../server/build'; + +// Re-exported for backwards compatibility — the type now lives in build.ts +// (the shared core) alongside the executor signature. +export type { BackroadRunContext } from '../server/build'; +/** + * Run a Backroad app as a standalone server on its own port. This is the + * original entry point and is unchanged in behaviour: it's the mountable core + * (buildBackroadHandler) with basePath '' served on a plain Node http.Server. + * + * The standalone setup reads as a server setup that just happens to use the + * Backroad handler: build the app, hand it to http.createServer, listen. The + * handler is itself a full express app, so there's no extra express() wrapper + * to stand up here — that framework choice lives inside the handler/adapters. + */ export const run = async ( - executor: ( - nodeManager: BackroadNodeManager, - context: BackroadRunContext - ) => void | Promise, + executor: BackroadExecutor, backroadOptions?: BackroadConfig ) => { - const port = backroadOptions?.server?.port || 3333; - const authConfig = backroadOptions?.auth; - - ( - await startBackroadServer({ - port: port, - auth: authConfig, - }) - ).on('connection', async (socket) => { - const backroadSession = sessionManager.getSession( - socket.nsp.name.slice(1), - { - upsert: true, - } - ); - SocketManager.register(backroadSession.sessionId, socket); - - // Resolve the better-auth session once per WS connection from the upgrade - // headers, then cache it on the BackroadSession. The session is NOT - // re-checked per-message in v1 — if a user signs out in another tab, this - // connection will keep its old user object until it reconnects. - if (authConfig) { - try { - const { fromNodeHeaders } = - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('better-auth/node') as typeof import('better-auth/node'); - const resolved = await authConfig.instance.api.getSession({ - headers: fromNodeHeaders(socket.request.headers), - }); - if (resolved?.user?.id) { - backroadSession.user = { - isLoggedIn: true, - id: resolved.user.id, - name: resolved.user.name ?? '', - email: resolved.user.email ?? '', - image: resolved.user.image ?? undefined, - raw: resolved, - }; - } else { - backroadSession.user = { isLoggedIn: false }; - } - } catch (err) { - console.error('Failed to resolve auth session for WS connection', err); - backroadSession.user = { isLoggedIn: false }; - } - } + const port = backroadOptions?.server?.port ?? 3333; - // currentPath is derived purely from the triggering request — every - // run-triggering event (run_script, set_value, unset_value) carries the - // client's pathname, so the server holds no path state and assumes no - // default. No run is ever server-initiated. - const runExecutor = async (currentPath: string) => { - socket.emit('running', true, () => undefined); - try { - backroadSession.resetTree(); - await executor(backroadSession.mainPageNodeManager, { currentPath }); - } finally { - socket.emit('running', false, () => undefined); - } - }; - // execute once to populate defaults and stuff + const handler = buildBackroadHandler(executor, { + ...backroadOptions, + basePath: '', + }); - // socket.on( - // 'get_value', - // socketEventHandlers.getValue(socket, backroadSession) - // ); - socket.on( - 'set_value', - socketEventHandlers.setValue(socket, backroadSession, runExecutor) - ); - socket.on( - 'run_script', - socketEventHandlers.runScript(socket, backroadSession, runExecutor) - ); + const server = http.createServer(handler); + handler.attach(server); - socket.on( - 'unset_value', - socketEventHandlers.unsetValue(socket, backroadSession, runExecutor) + server.listen(port, () => { + console.log( + `Server started and can be accessed on http://localhost:${port}/` ); - - socket.emit('backroad_config', backroadOptions, () => { - console.log('sent backroad config to frontend'); - }); - // socket.on("get_tree", socketEventHandlers.getTree(socket, backroadSession)); + if (process.env.BACKROAD_ENV === 'dev') { + console.log( + 'Backroad is running in development mode. Frontend will be running on a separate address: http://localhost:4200/' + ); + } }); }; diff --git a/libs/backroad/src/lib/server/base-path.ts b/libs/backroad/src/lib/server/base-path.ts new file mode 100644 index 0000000..551afa4 --- /dev/null +++ b/libs/backroad/src/lib/server/base-path.ts @@ -0,0 +1,10 @@ +/** + * Normalise a user-supplied mount path into either '' (root) or '/segment' + * with no trailing slash, so every call site can compose it predictably. + */ +export const normalizeBasePath = (basePath?: string) => { + if (!basePath) return ''; + const trimmed = basePath.replace(/^\/+|\/+$/g, ''); + if (!trimmed) return ''; + return `/${trimmed}`; +}; diff --git a/libs/backroad/src/lib/server/build.ts b/libs/backroad/src/lib/server/build.ts new file mode 100644 index 0000000..b513f17 --- /dev/null +++ b/libs/backroad/src/lib/server/build.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import * as http from 'http'; +import { join } from 'path'; +import { SocketManager } from '../backroad/socket-manager'; +import { normalizeBasePath } from './base-path'; +import { createBackroadRouter } from './http-routes'; +import { createIndexHtmlRenderer } from './index-html'; +import { createSessionManager } from './sessions/session-manager'; +import { createSocketAttacher } from './socket-server'; +import type { BackroadAdapterOptions, BackroadExecutor } from './types'; + +// Types live in ./types now; re-exported here so the long-standing +// '../server/build' import path keeps working for the runner and adapters. +export type { + BackroadAdapterOptions, + BackroadExecutor, + BackroadRunContext, +} from './types'; + +export type BackroadHandler = express.Express & { + /** + * Bind Socket.IO to the host's http.Server. The returned express handler + * auto-attaches on its first request (via req.socket.server), so this is only + * needed when that lazy attach can't reach the server — e.g. Hono's + * node-server, or hosts behind a custom proxy. Calling it twice is a no-op. + */ + attach: (server: http.Server) => void; + basePath: string; +}; + +/** + * Build a mountable Backroad app: an express handler (the /api/* routes, static + * assets and SPA fallback) plus an attach() that binds Socket.IO to whatever + * http.Server ends up serving it. This is the single core shared by run() and + * every framework adapter — run() is just this with basePath: ''. + * + * The pieces it composes each live in their own module: + * - base-path.ts normalise the mount sub-path + * - index-html.ts per-request SPA document patched with the mount path + * - http-routes.ts the express Router (auth, health, uploads, downloads, SPA) + * - socket-server.ts the lazy Socket.IO attacher + * This file just wires them together onto the mountable express handler. + */ +export const buildBackroadHandler = ( + executor: BackroadExecutor, + options?: BackroadAdapterOptions +): BackroadHandler => { + const basePath = normalizeBasePath(options?.basePath); + const authConfig = options?.auth; + + // Per-instance registries — two mounted apps never share sessions or sockets. + const socketManager = new SocketManager(); + const sessionManager = createSessionManager(socketManager); + + const publicDir = join(__dirname, 'public'); + const renderIndexHtml = createIndexHtmlRenderer(publicDir, basePath); + + const router = createBackroadRouter({ + authConfig, + publicDir, + sessionManager, + renderIndexHtml, + }); + + const attach = createSocketAttacher({ + basePath, + authConfig, + executor, + options, + sessionManager, + socketManager, + }); + + // The mountable handler is a full express() app (not a bare Router) so that + // req/res get express's enhancements (res.json/send/sendFile) even when an + // adapter invokes it directly on raw Node req/res — e.g. the Hono bridge, + // where there is no parent express app to do the enhancing. + // + // A once-only auto-attach middleware sits in front of the routes: the browser + // always loads the page (HTTP) before opening the websocket, so grabbing the + // http.Server off the first request binds Socket.IO in time — giving the + // app.use('/x', backroadExpress(...)) one-liner with no explicit attach step. + const handler = express() as BackroadHandler; + handler.use((req, _res, next) => { + const server = (req.socket as { server?: http.Server }).server; + if (server) attach(server); + next(); + }); + handler.use(router); + handler.attach = attach; + handler.basePath = basePath; + return handler; +}; diff --git a/libs/backroad/src/lib/server/http-routes.ts b/libs/backroad/src/lib/server/http-routes.ts new file mode 100644 index 0000000..d933505 --- /dev/null +++ b/libs/backroad/src/lib/server/http-routes.ts @@ -0,0 +1,108 @@ +import type { BackroadConfig } from '@backroad/core'; +import express from 'express'; +import formidable from 'formidable'; +import { createSessionManager } from './sessions/session-manager'; +import { inferMimeType } from './mime'; + +type SessionManager = ReturnType; + +/** + * Build the express Router that carries every Backroad HTTP route: the better- + * auth handler, the health probe, static assets, the upload sink, the on-demand + * download endpoint and the SPA fallback. Kept separate from build.ts so the + * HTTP surface is one cohesive unit, independent of the Socket.IO wiring and the + * mountable-handler plumbing. + */ +export const createBackroadRouter = ({ + authConfig, + publicDir, + sessionManager, + renderIndexHtml, +}: { + authConfig: NonNullable['auth'] | undefined; + publicDir: string; + sessionManager: SessionManager; + renderIndexHtml: () => string; +}): express.Router => { + const router = express.Router(); + + // Mount better-auth handler BEFORE any body parser / static handler so the + // raw request body reaches better-auth. Loaded lazily so users without auth + // never need to install better-auth. + if (authConfig) { + const { toNodeHandler } = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('better-auth/node') as typeof import('better-auth/node'); + router.all('/api/auth/*', toNodeHandler(authConfig.instance)); + } + + // Tiny readiness probe — handy for Docker/orchestrator health checks and + // used by Playwright's webServer wait in CI. + router.get('/api/health', (_req, res) => { + res.json({ ok: true }); + }); + + router.use(express.static(publicDir, { index: false })); + + router.post('/api/uploads', (req, res) => { + const form = formidable({}); + form.parse<'sessionId' | 'id', 'files'>(req, (err, fields, files) => { + if (err) { + return res.status(400).json({ error: 'invalid upload payload' }); + } + const sessionId = fields.sessionId?.[0]; + const id = fields.id?.[0]; + if (!sessionId || !id) { + return res.status(400).json({ error: 'sessionId and id are required' }); + } + const session = sessionManager.getSession(sessionId); + if (!session) { + return res.status(404).json({ error: 'session not found' }); + } + const value = files.files || []; + session.setValue(id, value); + return res.json(value); + }); + }); + + // On-demand download for br.downloadButton. The payload lives in session + // state (never in the component tree), so it crosses the wire only when the + // user actually clicks — streamed here with an attachment disposition. + router.get('/api/download/:sessionId/:id', async (req, res) => { + const session = sessionManager.getSession(req.params.sessionId); + const download = session?.getDownload(req.params.id); + if (!download) { + return res.status(404).json({ error: 'download not found' }); + } + let content: string | Uint8Array; + try { + content = await download.data(); + } catch (err) { + console.error('download_button payload failed to generate', err); + return res.status(500).json({ error: 'failed to generate download' }); + } + const filename = download.filename ?? 'download'; + res.setHeader('Content-Type', download.mime ?? inferMimeType(filename)); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(filename)}"` + ); + return res.send( + typeof content === 'string' || Buffer.isBuffer(content) + ? content + : Buffer.from(content) + ); + }); + + // SPA fallback — serve the (base-path-patched) index.html for non-API GETs. + // Unknown /api/* paths must 404 as API misses, not get the SPA document. + router.get('*', (req, res) => { + if (req.path === '/api' || req.path.startsWith('/api/')) { + return res.status(404).json({ error: 'not found' }); + } + res.setHeader('Content-Type', 'text/html'); + return res.send(renderIndexHtml()); + }); + + return router; +}; diff --git a/libs/backroad/src/lib/server/index-html.ts b/libs/backroad/src/lib/server/index-html.ts new file mode 100644 index 0000000..aee301f --- /dev/null +++ b/libs/backroad/src/lib/server/index-html.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Build a renderer for the SPA entry document. index.html is read once and + * patched per request to carry the mount path: the asset bundle is built with + * relative URLs (vite base: './'), so a runtime + + * window.__BACKROAD_BASE__ is what lets one prebuilt bundle serve from any + * sub-path. + */ +export const createIndexHtmlRenderer = (publicDir: string, basePath: string) => { + let indexHtmlRaw: string | undefined; + return () => { + if (indexHtmlRaw === undefined) { + indexHtmlRaw = readFileSync(join(publicDir, 'index.html'), 'utf-8'); + } + const baseHref = basePath ? `${basePath}/` : '/'; + const inject = + `` + + ``; + // Drop any build-time tag, then inject ours right after . + return indexHtmlRaw + .replace(/]*>/i, '') + .replace(/]*)>/i, `${inject}`); + }; +}; diff --git a/libs/backroad/src/lib/server/index.ts b/libs/backroad/src/lib/server/index.ts deleted file mode 100644 index e683aeb..0000000 --- a/libs/backroad/src/lib/server/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import express from 'express'; -import formidable from 'formidable'; -import * as http from 'http'; -import path from 'path'; -import { Namespace, Server } from 'socket.io'; -// const upload = multer(); -import type { - BackroadAuthInstance, - ClientToServerEvents, - ServerToClientEvents, -} from '@backroad/core'; -import { join } from 'path'; -import { DefaultEventsMap } from 'socket.io/dist/typed-events'; -import { sessionManager } from './sessions/session-manager'; - -// Minimal extension → MIME map for download_button's auto-inference. Anything -// unknown falls back to application/octet-stream (a safe "just download it"). -const MIME_BY_EXTENSION: Record = { - txt: 'text/plain', - csv: 'text/csv', - json: 'application/json', - html: 'text/html', - xml: 'application/xml', - md: 'text/markdown', - pdf: 'application/pdf', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - svg: 'image/svg+xml', - webp: 'image/webp', - zip: 'application/zip', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -}; -const inferMimeType = (filename: string) => { - const ext = filename.split('.').pop()?.toLowerCase() ?? ''; - return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream'; -}; - -export const startBackroadServer = (options: { - port: number; - auth?: { instance: BackroadAuthInstance }; -}) => { - return new Promise< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Namespace - >((resolve) => { - const app = express(); - const server = http.createServer(app); - const io = new Server(server, { - path: '/api/socket.io', - cors: {}, - }); - - // Mount better-auth handler BEFORE any body parser / static handler so - // the raw request body reaches better-auth. Loaded lazily so users - // without auth never need to install better-auth. The /api/auth/* - // routes are the only auth endpoints — the React frontend - // (libs/backroad-frontend) renders the signin / signup UI via - // @daveyplate/better-auth-ui hitting these endpoints directly. - if (options.auth) { - const { toNodeHandler } = - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('better-auth/node') as typeof import('better-auth/node'); - app.all('/api/auth/*', toNodeHandler(options.auth.instance)); - } - - // Tiny readiness probe — handy for Docker/orchestrator health - // checks and used by Playwright's webServer wait in CI. - app.get('/api/health', (_req, res) => { - res.json({ ok: true }); - }); - - app.use(express.static(join(__dirname, 'public'))); - - app.post('/api/uploads', (req, res) => { - const form = formidable({}); - - form.parse<'sessionId' | 'id', 'files'>(req, (err, fields, files) => { - // if (err) { - // next(err); - // return; - // } - const sessionId = fields.sessionId?.[0]; - const id = fields.id?.[0]; - if (sessionId && id) { - // const file = files.files?.[0] - const session = sessionManager.getSession(sessionId); - const value = files.files || []; - session?.setValue(id, value); - return res.json(value); - } - }); - }); - // app.post< - // '/api/uploads', - // any, - // any, - // { - // sessionId: string; - // id: string; - // } - // >('/api/uploads', upload.array('files'), (req, res) => { - // const session = sessionManager.getSession(req.body.sessionId); - // console.log('received file upload request', req.files, req.files?.length); - // return res.json( - // session?.uploadManager.setFiles( - // req.body.id, - // req.files as Express.Multer.File[] - // ) - // ); - // }); - - // On-demand download for br.downloadButton. The payload lives in session - // state (never in the component tree), so it crosses the wire only when the - // user actually clicks — streamed here with an attachment disposition. - app.get('/api/download/:sessionId/:id', async (req, res) => { - const session = sessionManager.getSession(req.params.sessionId); - const download = session?.getDownload(req.params.id); - if (!download) { - return res.status(404).json({ error: 'download not found' }); - } - // Resolve the payload now — this is the first (and only) time the - // contents are computed. - let content: string | Uint8Array; - try { - content = await download.data(); - } catch (err) { - console.error('download_button payload failed to generate', err); - return res.status(500).json({ error: 'failed to generate download' }); - } - const filename = download.filename ?? 'download'; - res.setHeader('Content-Type', download.mime ?? inferMimeType(filename)); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(filename)}"` - ); - // express handles strings and Buffers natively; coerce a bare Uint8Array. - return res.send( - typeof content === 'string' || Buffer.isBuffer(content) - ? content - : Buffer.from(content) - ); - }); - - app.get('*', (req, res) => - res.sendFile(path.resolve(__dirname, 'public', 'index.html')) - ); - - server.listen(options.port, () => { - console.log( - `Server started and can be accessed on http://localhost:${options.port}/` - ); - if (process.env.BACKROAD_ENV === 'dev') { - console.log( - 'Backroad is running in development mode. Frontend will be running on a separate address: http://localhost:4200/' - ); - } - - resolve(io.of(/^\/.+$/)); - }); - }); -}; diff --git a/libs/backroad/src/lib/server/mime.ts b/libs/backroad/src/lib/server/mime.ts new file mode 100644 index 0000000..b5efcb5 --- /dev/null +++ b/libs/backroad/src/lib/server/mime.ts @@ -0,0 +1,24 @@ +// Minimal extension → MIME map for download_button's auto-inference. Anything +// unknown falls back to application/octet-stream (a safe "just download it"). +const MIME_BY_EXTENSION: Record = { + txt: 'text/plain', + csv: 'text/csv', + json: 'application/json', + html: 'text/html', + xml: 'application/xml', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + webp: 'image/webp', + zip: 'application/zip', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; + +export const inferMimeType = (filename: string) => { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream'; +}; diff --git a/libs/backroad/src/lib/server/sessions/session-manager.ts b/libs/backroad/src/lib/server/sessions/session-manager.ts index 7861e24..9cc225a 100644 --- a/libs/backroad/src/lib/server/sessions/session-manager.ts +++ b/libs/backroad/src/lib/server/sessions/session-manager.ts @@ -1,28 +1,32 @@ +import { SocketManager } from '../../backroad/socket-manager'; import { BackroadSession } from './session'; -const sessions: { [key: string]: BackroadSession | undefined } = {}; -export const sessionManager = { - getSession: ( - sessionId: BackroadSession['sessionId'], - props?: { upsert: T } - ): T extends true ? BackroadSession : BackroadSession | null => { - if (!sessions[sessionId]) { - if (props && props.upsert) { - sessions[sessionId] = new BackroadSession(sessionId); - return sessions[sessionId]; - } else { - // @ts-expect-error - this is fine - return null; + +// One session registry per Backroad instance. Created via createSessionManager +// so each mounted app (or each run()) owns its own sessions map and its own +// SocketManager — the two move together because every BackroadSession needs to +// reach its instance's sockets (render-queue, br.login/logout). +export const createSessionManager = (socketManager: SocketManager) => { + const sessions = new Map(); + return { + getSession: ( + sessionId: BackroadSession['sessionId'], + props?: { upsert: T } + ): T extends true ? BackroadSession : BackroadSession | null => { + const existing = sessions.get(sessionId); + if (!existing) { + if (props && props.upsert) { + const created = new BackroadSession(sessionId, socketManager); + sessions.set(sessionId, created); + return created; + } else { + // @ts-expect-error - this is fine + return null; + } } - } - return sessions[sessionId]; - }, - // register: (session: BackroadSession) => { - // if (session.sessionId in sessions) { - // throw new Error('Session already exists'); - // } - // sessions[session.sessionId] = session; - // }, - unregister: (session: BackroadSession) => { - delete sessions[session.sessionId]; - }, + return existing; + }, + unregister: (session: BackroadSession) => { + sessions.delete(session.sessionId); + }, + }; }; diff --git a/libs/backroad/src/lib/server/sessions/session.ts b/libs/backroad/src/lib/server/sessions/session.ts index 7d6e240..9379239 100644 --- a/libs/backroad/src/lib/server/sessions/session.ts +++ b/libs/backroad/src/lib/server/sessions/session.ts @@ -7,6 +7,7 @@ import { } from '@backroad/core'; import { BackroadNodeManager } from '../../backroad'; import { RenderQueue } from '../../backroad/render-queue'; +import { SocketManager } from '../../backroad/socket-manager'; import superjson from 'superjson'; // import { UploadManager } from './upload-manager'; @@ -38,10 +39,15 @@ export class BackroadSession { renderQueue: RenderQueue; rootNodeManager: BackroadNodeManager<'base'>; user: BackroadUser = { isLoggedIn: false }; + // The instance's socket registry. Held on the session so anything reachable + // from a session (render-queue, br.login/logout) can emit without touching a + // module-global — that's what keeps two Backroad apps isolated in one process. + socketManager: SocketManager; // uploadManager: UploadManager; - constructor(sessionId: string) { + constructor(sessionId: string, socketManager: SocketManager) { // this.uploadManager = new UploadManager(); this.sessionId = sessionId; + this.socketManager = socketManager; this.rootNodeManager = new BackroadNodeManager( getInitialTreeStructure(), this diff --git a/libs/backroad/src/lib/server/socket-server.ts b/libs/backroad/src/lib/server/socket-server.ts new file mode 100644 index 0000000..74bd718 --- /dev/null +++ b/libs/backroad/src/lib/server/socket-server.ts @@ -0,0 +1,122 @@ +import type { BackroadConfig } from '@backroad/core'; +import * as http from 'http'; +import { Server } from 'socket.io'; +import { SocketManager } from '../backroad/socket-manager'; +import { socketEventHandlers } from './server-socket-event-handlers'; +import { createSessionManager } from './sessions/session-manager'; +import type { BackroadExecutor } from './types'; + +type SessionManager = ReturnType; + +/** + * Build the lazy Socket.IO attacher for a Backroad instance. Socket.IO binds at + * the http.Server level (below express), so the mount prefix has to be baked + * into its path explicitly — that's the one place express can't strip it for us. + * + * The returned `attach(server)` is idempotent: the first call wires Socket.IO to + * the given http.Server, subsequent calls are no-ops. Both the framework + * adapters' once-only auto-attach middleware and an explicit `handler.attach()` + * funnel through here. + */ +export const createSocketAttacher = ({ + basePath, + authConfig, + executor, + options, + sessionManager, + socketManager, +}: { + basePath: string; + authConfig: NonNullable['auth'] | undefined; + executor: BackroadExecutor; + options: BackroadConfig | undefined; + sessionManager: SessionManager; + socketManager: SocketManager; +}) => { + let attached = false; + return (server: http.Server) => { + if (attached) return; + attached = true; + const io = new Server(server, { + // The one place the mount prefix must be baked in explicitly: Socket.IO + // binds at the http.Server level, below express, so express can't strip + // the prefix for it. + path: `${basePath}/api/socket.io`, + cors: {}, + }); + io.of(/^\/.+$/).on('connection', async (socket) => { + const backroadSession = sessionManager.getSession( + socket.nsp.name.slice(1), + { upsert: true } + ); + socketManager.register(backroadSession.sessionId, socket); + + // Drop the socket from the manager on disconnect so long-running servers + // with many reconnecting clients don't accumulate stale entries. + socket.on('disconnect', () => { + socketManager.unregister(backroadSession.sessionId, socket); + }); + + // Resolve the better-auth session once per WS connection from the upgrade + // headers, then cache it on the BackroadSession. + if (authConfig) { + try { + const { fromNodeHeaders } = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('better-auth/node') as typeof import('better-auth/node'); + const resolved = await authConfig.instance.api.getSession({ + headers: fromNodeHeaders(socket.request.headers), + }); + if (resolved?.user?.id) { + backroadSession.user = { + isLoggedIn: true, + id: resolved.user.id, + name: resolved.user.name ?? '', + email: resolved.user.email ?? '', + image: resolved.user.image ?? undefined, + raw: resolved, + }; + } else { + backroadSession.user = { isLoggedIn: false }; + } + } catch (err) { + console.error( + 'Failed to resolve auth session for WS connection', + err + ); + backroadSession.user = { isLoggedIn: false }; + } + } + + // currentPath is derived purely from the triggering request — every + // run-triggering event carries the client's pathname, so the server holds + // no path state. No run is ever server-initiated. + const runExecutor = async (currentPath: string) => { + socket.emit('running', true, () => undefined); + try { + backroadSession.resetTree(); + await executor(backroadSession.mainPageNodeManager, { currentPath }); + } finally { + socket.emit('running', false, () => undefined); + } + }; + + socket.on( + 'set_value', + socketEventHandlers.setValue(socket, backroadSession, runExecutor) + ); + socket.on( + 'run_script', + socketEventHandlers.runScript(socket, backroadSession, runExecutor) + ); + socket.on( + 'unset_value', + socketEventHandlers.unsetValue(socket, backroadSession, runExecutor) + ); + + socket.emit('backroad_config', options, () => { + console.log('sent backroad config to frontend'); + }); + }); + }; +}; diff --git a/libs/backroad/src/lib/server/types.ts b/libs/backroad/src/lib/server/types.ts new file mode 100644 index 0000000..c219174 --- /dev/null +++ b/libs/backroad/src/lib/server/types.ts @@ -0,0 +1,19 @@ +import type { BackroadConfig } from '@backroad/core'; +import { BackroadNodeManager } from '../backroad'; + +export type BackroadRunContext = { + currentPath: string; +}; + +export type BackroadExecutor = ( + nodeManager: BackroadNodeManager, + context: BackroadRunContext +) => void | Promise; + +// Options accepted by the mountable core and the framework adapters. Everything +// in BackroadConfig (auth, appearance, analytics) plus the sub-path the app is +// mounted under. basePath defaults to '' (root) — that's the standalone run() +// case, behaviourally identical to before this refactor. +export type BackroadAdapterOptions = NonNullable & { + basePath?: string; +}; diff --git a/package.json b/package.json index e3ac3be..d7594ca 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "sandbox:build-image": "sandcastle docker build-image --dockerfile .sandcastle/Dockerfile", "knip:ci": "node tools/scripts/knip-check.mjs", "knip:baseline": "node tools/scripts/knip-check.mjs --write", + "dup-check": "jscpd", + "dup-check:ci": "node tools/scripts/jscpd-check.mjs", + "dup-check:update": "node tools/scripts/jscpd-check.mjs --update", "size": "pnpm --filter backroad-frontend run build && size-limit", "size:why": "size-limit --why", "storybook": "pnpm --filter backroad-frontend run storybook", @@ -68,6 +71,7 @@ "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-react": "7.32.2", "eslint-plugin-react-hooks": "4.6.0", + "jscpd": "5.0.9", "jsdom": "~22.1.0", "knip": "^6.16.1", "lefthook": "^2.1.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b718acb..10bcb7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: eslint-plugin-react-hooks: specifier: 4.6.0 version: 4.6.0(eslint@8.46.0) + jscpd: + specifier: 5.0.9 + version: 5.0.9 jsdom: specifier: ~22.1.0 version: 22.1.0 @@ -386,6 +389,9 @@ importers: '@backroad/core': specifier: workspace:* version: link:../backroad-core + '@hono/node-server': + specifier: ^1.13.0 + version: 1.19.14(hono@4.12.26) express: specifier: ^4.22.1 version: 4.22.2 @@ -395,6 +401,9 @@ importers: formidable: specifier: ^3.5.4 version: 3.5.4 + hono: + specifier: ^4.6.0 + version: 4.12.26 lodash: specifier: ^4.17.23 version: 4.18.1 @@ -3509,6 +3518,15 @@ packages: integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==, } + '@hono/node-server@1.19.14': + resolution: + { + integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==, + } + engines: { node: '>=18.14.1' } + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.4.0': resolution: { @@ -10439,6 +10457,57 @@ packages: typescript: optional: true + cpd-darwin-arm64@5.0.9: + resolution: + { + integrity: sha512-hUYQKrUUmlYxl2MDD8GYESpcKc5M5eGMjiHTK+O8ooCj1ogPiNHXg+aIeDl6BuLu7v3jFDcCu5fVqtcpHzAKpw==, + } + cpu: [arm64] + os: [darwin] + + cpd-darwin-x64@5.0.9: + resolution: + { + integrity: sha512-viCFEUhUQnGundSMFq9ixi+RxfRiNDQsWP/CZErMlf20fM5h9zTtrHI9liNyRQn+ZtQUTTpabQ4PpzDV530bMg==, + } + cpu: [x64] + os: [darwin] + + cpd-linux-arm64-gnu@5.0.9: + resolution: + { + integrity: sha512-BxiLx9haM+AhDlkyFoe1ro1w4/dvAteyEGytv4XraaRNntw2NMFX1Fsa9I8waegpXydM6E0lPWRWqkG59rin3A==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + cpd-linux-x64-gnu@5.0.9: + resolution: + { + integrity: sha512-VC2bUEJ0KRZ3fJijqqFcrBl6a69vmHrrNoJkSPtFwFju8x7ce2Vs9ntEmlxeB7Ho4mIzN8OYOrlCeqyNdAZcUA==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + cpd-linux-x64-musl@5.0.9: + resolution: + { + integrity: sha512-YJekKRRgAhez2+Rrbt42fp8cJuNi/PtGuBb8jIzEtegXRRBG9CE9HaoTy0mS3JeiQuwxiT0Fnz2j8hR9gDbq0w==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + cpd-windows-x64-msvc@5.0.9: + resolution: + { + integrity: sha512-Z54yLFmRjIgTMqzeUxbZzgxj8gWxB/jjMi/QOkBOLx9yHHwzhW8MukpY7JhCOmlvPOOn6pjSVrMTxChliDcYAw==, + } + cpu: [x64] + os: [win32] + create-require@1.1.1: resolution: { @@ -13253,6 +13322,13 @@ packages: } engines: { node: '>=0.10.0' } + hono@4.12.26: + resolution: + { + integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==, + } + engines: { node: '>=16.9.0' } + hook-std@3.0.0: resolution: { @@ -14608,6 +14684,14 @@ packages: } hasBin: true + jscpd@5.0.9: + resolution: + { + integrity: sha512-52zn0erBrRCCLvKulOs/UNUsL2fAmnMRUUbwbJhfUmqI9jtVSQ2Hk20F7m58JnkmgBEoyNBSTTomrF8K+H4fLA==, + } + engines: { node: '>=18' } + hasBin: true + jsdom@22.1.0: resolution: { @@ -24570,6 +24654,10 @@ snapshots: '@hexagon/base64@1.1.28': {} + '@hono/node-server@1.19.14(hono@4.12.26)': + dependencies: + hono: 4.12.26 + '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@18.2.0))': dependencies: '@standard-schema/utils': 0.3.0 @@ -28926,6 +29014,24 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cpd-darwin-arm64@5.0.9: + optional: true + + cpd-darwin-x64@5.0.9: + optional: true + + cpd-linux-arm64-gnu@5.0.9: + optional: true + + cpd-linux-x64-gnu@5.0.9: + optional: true + + cpd-linux-x64-musl@5.0.9: + optional: true + + cpd-windows-x64-msvc@5.0.9: + optional: true + create-require@1.1.1: {} crelt@1.0.6: {} @@ -31004,6 +31110,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.12.26: {} + hook-std@3.0.0: {} hosted-git-info@2.8.9: {} @@ -32011,6 +32119,15 @@ snapshots: dependencies: argparse: 2.0.1 + jscpd@5.0.9: + optionalDependencies: + cpd-darwin-arm64: 5.0.9 + cpd-darwin-x64: 5.0.9 + cpd-linux-arm64-gnu: 5.0.9 + cpd-linux-x64-gnu: 5.0.9 + cpd-linux-x64-musl: 5.0.9 + cpd-windows-x64-msvc: 5.0.9 + jsdom@22.1.0: dependencies: abab: 2.0.6 diff --git a/tools/scripts/jscpd-check.mjs b/tools/scripts/jscpd-check.mjs new file mode 100644 index 0000000..e8d7eae --- /dev/null +++ b/tools/scripts/jscpd-check.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +// Duplication gate. Runs jscpd, compares the total duplication percentage +// against the `threshold` in .jscpd.json, and (in CI) emits a sticky PR-comment +// body + step summary. A ratchet: lower the threshold as duplication drops via +// `pnpm run dup-check:update`. Mirrors the knip baseline gate's shape. +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const SENTINEL = ''; + +function resolveJscpdBin() { + if (process.env.JSCPD_BIN) return process.env.JSCPD_BIN; + const dir = path.join(process.cwd(), 'node_modules', 'jscpd'); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, 'package.json'), 'utf8') + ); + const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.jscpd; + return path.join(dir, rel); +} + +function runJscpd() { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jscpd-gate-')); + const result = spawnSync( + process.execPath, + [ + resolveJscpdBin(), + '.', + '--silent', + '--reporters', + 'json', + '--output', + outDir, + '--no-tips', + ], + { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, NO_COLOR: '1' }, + stdio: ['ignore', 'inherit', 'inherit'], + } + ); + const code = result.status ?? 0; + const reportPath = path.join(outDir, 'jscpd-report.json'); + let report; + try { + report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + } catch { + report = null; + } + return { code, report }; +} + +function buildMarkdown(report, threshold) { + if (!report || !report.statistics) { + return `${SENTINEL}\n### 🧬 Duplication report (jscpd)\n\n⚠️ Could not parse jscpd output.\n`; + } + + const { total, formats } = report.statistics; + const pct = total?.percentage ?? 0; + const ok = pct <= threshold; + const lines = [ + SENTINEL, + '### 🧬 Duplication report (jscpd)', + '', + ok + ? `✅ **Within budget** — ${pct.toFixed( + 2 + )}% duplicated (threshold: ${threshold}%).` + : `❌ **Duplication above budget** — ${pct.toFixed( + 2 + )}% duplicated (threshold: ${threshold}%).`, + '', + `| Metric | Value |`, + `| --- | ---:|`, + `| Total lines | ${total?.lines ?? '?'} |`, + `| Duplicated lines | ${total?.duplicatedLines ?? 0} |`, + `| Duplication | ${pct.toFixed(2)}% |`, + `| Threshold | ${threshold}% |`, + `| Clones found | ${total?.clones ?? 0} |`, + `| Files scanned | ${total?.sources ?? '?'} |`, + ]; + + if (formats && typeof formats === 'object') { + const details = Object.entries(formats).map( + ([name, f]) => + `| ${name} | ${f.lines} lines | ${f.percentage.toFixed(2)}% | ${ + f.clones + } clones |` + ); + lines.push( + '', + '#### By format', + '', + '| Format | Lines | Duplication | Clones |', + '| --- | ---: | ---: | ---: |', + ...details + ); + } + + lines.push( + '', + 'Threshold is configured in `.jscpd.json`. After legitimately reducing duplication, ' + + 'update the threshold via `pnpm run dup-check:update` and commit `.jscpd.json`.' + ); + + return lines.join('\n'); +} + +function writeStepSummary(body) { + const file = process.env.GITHUB_STEP_SUMMARY; + if (!file) return; + try { + fs.appendFileSync(file, body + '\n'); + } catch (err) { + process.stderr.write( + `[jscpd] could not write step summary: ${err.message}\n` + ); + } +} + +function writeReportFile(body) { + const file = process.env.DUP_REPORT_FILE; + if (!file) return; + try { + fs.writeFileSync(file, body + '\n'); + } catch (err) { + process.stderr.write( + `[jscpd] could not write report file: ${err.message}\n` + ); + } +} + +function main() { + const update = process.argv.includes('--update'); + + if (update) { + const result = spawnSync( + process.execPath, + [ + resolveJscpdBin(), + '.', + '--silent', + '--reporters', + 'threshold', + '--no-tips', + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'inherit'], + } + ); + const match = result.stdout?.match(/(\d+\.?\d*)%\s*duplicated lines/); + if (match) { + process.stdout.write(`Current duplication: ${match[1]}%\n`); + process.stdout.write( + `Update the "threshold" field in .jscpd.json to this value.\n` + ); + } else { + process.stdout.write(result.stdout ?? ''); + process.stderr.write( + '[jscpd] could not parse duplication percentage from output.\n' + ); + } + process.exit(result.status ?? 0); + } + + const configPath = path.join(process.cwd(), '.jscpd.json'); + let threshold = 0; + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + threshold = config.threshold ?? 0; + } catch { + process.stderr.write('[jscpd] could not read .jscpd.json\n'); + process.exit(1); + } + + const { code, report } = runJscpd(); + if (!report?.statistics?.total) { + const md = buildMarkdown(report, threshold); + writeStepSummary(md); + writeReportFile(md); + process.stderr.write( + `\n✗ duplication gate failed: jscpd did not produce a readable JSON report (exit ${code}).\n` + ); + process.exit(1); + } + const pct = Number(report.statistics.total.percentage); + if (!Number.isFinite(pct)) { + const md = buildMarkdown(report, threshold); + writeStepSummary(md); + writeReportFile(md); + process.stderr.write( + `\n✗ duplication gate failed: invalid duplication percentage in jscpd report (exit ${code}).\n` + ); + process.exit(1); + } + const ok = pct <= threshold; + + const md = buildMarkdown(report, threshold); + writeStepSummary(md); + writeReportFile(md); + + process.stdout.write( + `\njscpd: ${pct}% duplicated lines (threshold: ${threshold}%)\n` + ); + + if (ok) { + process.stdout.write('✓ duplication gate passed\n'); + process.exit(0); + } + + process.stderr.write( + `\n✗ duplication gate failed: ${pct}% exceeds threshold ${threshold}%\n` + ); + process.stderr.write( + 'Deduplicate the copied code, expand ignore globs in .jscpd.json, or raise the threshold.\n' + ); + process.exit(1); +} + +main();