Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bd86346
feat(backroad): mount apps on a sub-route of Express or Hono
sudo-vaibhav Jun 20, 2026
eb35b4b
Merge remote-tracking branch 'origin' into feat/pluggable-mount
sudo-vaibhav Jun 20, 2026
b524590
fix(review): disable credential persistence in duplication checkout (…
sudo-vaibhav Jun 20, 2026
60a23ea
fix(review): make hono base-path stripping boundary-safe (#44)
sudo-vaibhav Jun 20, 2026
844e4e9
fix(review): use Map for socket registry to avoid prototype pollution…
sudo-vaibhav Jun 20, 2026
b81deb0
fix(review): normalize '/' basePath and harden /api/uploads handler (…
sudo-vaibhav Jun 20, 2026
3a2accd
fix(review): use Map for session storage to avoid prototype pollution…
sudo-vaibhav Jun 20, 2026
b53ddae
fix(review): fail closed when jscpd report is unreadable (#44)
sudo-vaibhav Jun 20, 2026
267707e
fix(ci): ignore orphaned @ai-hero/sandcastle devDependency in knip (#44)
sudo-vaibhav Jun 20, 2026
e22ea4b
fix(review): fail closed on invalid jscpd percentage (#44)
sudo-vaibhav Jun 20, 2026
d33cc67
fix(review): handle query/hash in withBasePath already-prefixed check…
sudo-vaibhav Jun 20, 2026
1ba2e3e
fix(review): use nullish fallback for port to preserve explicit 0 (#44)
sudo-vaibhav Jun 20, 2026
0ba4f79
fix(review): unregister socket on disconnect to prevent memory leak (…
sudo-vaibhav Jun 20, 2026
e02d48c
fix(review): restrict skipped allowance to duplication job only (#44)
sudo-vaibhav Jun 20, 2026
41939a9
Merge remote-tracking branch 'origin/main' into feat/pluggable-mount
sudo-vaibhav Jun 20, 2026
767927c
fix(review): only unregister socket when it is the mapped one (#44)
sudo-vaibhav Jun 21, 2026
4d13124
fix(review): add #getSocket convenience method on RenderQueue (#44)
sudo-vaibhav Jun 21, 2026
3b00c36
fix(review): extract shared lazyRequire helper for adapters (#44)
sudo-vaibhav Jun 21, 2026
c571498
fix(e2e): fire download confirmation as a persistent toast (#44)
sudo-vaibhav Jun 21, 2026
8102a6a
fix(review): only swallow MODULE_NOT_FOUND in lazyRequire (#44)
sudo-vaibhav Jun 21, 2026
aa8e05c
fix(review): add shared base-path-aware backroadFetch wrapper (#44)
sudo-vaibhav Jun 21, 2026
af314f4
fix(review): split server/build.ts into focused modules (#44)
sudo-vaibhav Jun 21, 2026
ebc538f
fix(review): keep standalone runner agnostic of express (#44)
sudo-vaibhav Jun 21, 2026
437981b
test(review): add integration tests for express and hono outlets (#44)
sudo-vaibhav Jun 21, 2026
f2f1d18
fix(review): guard missing socket during async render flush (#44)
sudo-vaibhav Jun 21, 2026
741158e
fix(review): 404 unknown /api GET routes instead of SPA fallback (#44)
sudo-vaibhav Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 = '<!-- duplication-report -->';
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
Expand Down Expand Up @@ -346,6 +404,7 @@ jobs:
- test
- build
- knip
- duplication
- storybook-a11y
- e2e
- e2e-docs
Expand All @@ -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 }}'
Expand All @@ -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."
28 changes: 28 additions & 0 deletions .jscpd.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
135 changes: 135 additions & 0 deletions apps/docs/docs/embedding.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const sidebars: SidebarsConfig = {
items: ['configuration/themes', 'configuration/analytics', 'auth'],
},
'hosting',
'embedding',
{
type: 'category',
label: 'Advanced',
Expand Down
15 changes: 11 additions & 4 deletions examples/demo/src/pages/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,23 @@ 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)),
filename: 'backroad-report.json',
mime: 'application/json',
});
if (downloaded) {
br.write({ body: 'Report downloaded!' });
br.toast({
message: 'Report downloaded!',
variant: 'success',
duration: 6000,
});
}
};
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
},
"ignoreBinaries": ["lefthook"],
"ignoreDependencies": [
"@ai-hero/sandcastle",
Comment thread
sudo-vaibhav marked this conversation as resolved.
"docusaurus-lunr-search",
"lunr",
"@docusaurus/plugin-content-docs",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/file_upload.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
11 changes: 8 additions & 3 deletions libs/backroad-components/src/lib/socket/auth-synchronizers.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
/** 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
// directly (POST + cookies) so backroad-components stays free of the
// 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'));
}
});
};
Loading
Loading