Write HTML and CSS, get a PDF. Rendered by Cloudflare Browser Run. Sign in with Google to save documents, or connect an agent over MCP to write them for you.
Live at https://htmlcsspdf.ben2.com/
The MCP server is packaged as a Claude Code plugin, in plugins/htmlcsspdf:
/plugin marketplace add afternoon/htmlcsspdf
/plugin install htmlcsspdf@htmlcsspdfThe first tool call opens a browser to sign in and approve access. There is nothing to configure — no API key, no client id — because the endpoint answers an unauthenticated call with an RFC 9728 challenge and the client discovers registration, consent and the token exchange from there. See MCP below.
Alongside the six tools, the plugin carries a skill for writing documents: that
page size and margins come from an @page rule and nowhere else, and that
saving runs an element allowlist which drops rather than escapes — no <script>,
no <form>, and inline <svg> only as static graphics, which is the one that
catches people out.
To run it against a dev server, point plugins/htmlcsspdf/.mcp.json at
http://localhost:5173/api/mcp and load it directly, without installing:
claude --plugin-dir ./plugins/htmlcsspdfThe OAuth flow accepts plain HTTP on loopback, so it works the same way there.
Plugins are a Claude Code feature, but the server underneath one is an ordinary remote MCP endpoint, so claude.ai can reach it directly. Under Settings → Connectors, choose Add custom connector and give it the URL:
https://htmlcsspdf.ben2.com/api/mcp
Leave the client id and secret blank. Registration here is open, so Claude registers itself on first connect (RFC 7591) and the browser takes you through the same sign-in and consent screen. Adding it on the web is enough for the desktop and mobile apps too — connectors follow the account, and the mobile apps cannot add one themselves.
Two differences from the plugin are worth knowing. The writing-documents
skill does not come with it, because a connector carries tools and nothing
else; on claude.ai you get the six tools, and the @page rule and the
sanitiser's allowlist are things you will have to say yourself. And a connector
must be https with a public hostname, so a local dev server cannot be reached
this way — that is what --plugin-dir above is for.
bun install
cp .dev.vars.example .dev.vars # then fill it in, see Auth setup
bun run devThen open http://localhost:5173. The Cloudflare Vite plugin runs the server
routes in the real Workers runtime, so the BROWSER and DB bindings work
locally and both client and server code hot-reload — no build step in the loop.
Restart the dev server after a dependency changes. Adding or removing a
package while it is running makes Vite re-optimise its dependency pre-bundle,
and the SSR module runner keeps modules from the previous one. The result is a
server render that fails with something like Cannot read properties of null (reading 'useState') or (0 , __vite_ssr_import_1__.t) is not a function,
sometimes with a note about more than one copy of React. Nothing is wrong with
the code — the page still hydrates on the client, which is what makes it easy
to miss. Restart, or rm -rf node_modules/.vite if it persists.
The editor works without any of the auth setup below; only saving needs it.
Create an OAuth 2.0 Client (type: Web application) in the Google Cloud Console and register both redirect URIs — omitting the localhost one is the usual reason local sign-in fails:
http://localhost:5173/api/auth/callback/google
https://htmlcsspdf.ben2.com/api/auth/callback/google
These four names are declared under secrets.required in wrangler.jsonc, so
wrangler types emits them from the config rather than inferring them from
whatever .dev.vars happens to hold. Without that, the committed
worker-configuration.d.ts depends on an untracked file: a fresh clone
regenerates it on postinstall without the secrets, and tsc then fails on
perfectly correct code. CI found that the day it was switched on.
Put the client id and secret in .dev.vars for local work (it is gitignored),
and in production set them as secrets:
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put BETTER_AUTH_SECRET # openssl rand -base64 32
wrangler secret put BETTER_AUTH_URL # https://htmlcsspdf.ben2.comD1 holds the Better Auth tables and the documents themselves.
wrangler d1 migrations apply htmlcsspdf --local
wrangler d1 migrations apply htmlcsspdf --remoteThe auth tables in migrations/0001_initial.sql are generated by
bunx @better-auth/cli generate, not hand-written — regenerate them rather than
editing if the library version moves. better-auth migrate will not work here:
it needs a live connection, and D1 bindings only exist inside a request.
migrations/0004_oauth_provider.sql is derived the same way but not by that
CLI, whose latest release predates the OAuth provider plugin entirely. It comes
from getSchema over the configured plugins, and
src/server/authSchema.test.ts re-derives that schema on every test run and
checks the SQL against it — so a library upgrade that adds a column fails a
test rather than a deploy.
A document that exists saves itself. Once the editor is on /d/<id> and the
user is signed in, a pause in typing writes the content — Save is then a way to
store the work now, not the only way it is ever stored. Work that is not a
document yet stays explicit: a draft has no id to write to and no owner to
write it for, so / keeps its localStorage draft and its Save button, and
signing in is what turns one into the other.
The two halves of a save are paced differently. Content is a database row, so
it is written on every pause; the preview image behind it is a browser render
against a quota shared with the PDF output, so it is asked for separately once
the editing has actually stopped (POST /api/documents/<id>/thumbnail). An
auto-save therefore writes quietly — capturePreview: false — while pressing
Save captures as it always did.
Document cards show a preview captured by Browser Run, stored in R2 and served back through the worker so ownership is checked before the bucket is touched.
wrangler r2 bucket create htmlcsspdf-thumbnailsCapture runs after the save has been acknowledged, never as part of it. Browser Run is rate-limited and quota-bound, so it is the most likely thing here to fail — and a missing preview image must never turn a successful save into an error. A failed capture logs and leaves the card showing a placeholder until the next capture succeeds.
Every content write clears the stored capture, because it depicts content that no longer exists — a card showing a stale preview is worse than one showing none. The POST above renders the stored document rather than content supplied by the caller, and does nothing when the current revision has already been captured, so a repeated request costs no browser time.
Built on TanStack Start. There is no hand-written Worker entry: wrangler.jsonc
points main at @tanstack/react-start/server-entry, which serves the client
assets and dispatches server routes.
src/routes/api.render.ts—POST /api/render. Takes{html, css}, renders with Puppeteer via theBROWSERbinding, returns PDF bytes. Deliberately open to anonymous callers: previewing before signing in is the point.src/routes/api.auth.$.ts— Better Auth's catch-all (sign-in, callback, session, sign-out).src/routes/api.documents*.ts— the document CRUD API, used by the browser.src/routes/api.mcp.ts—POST /api/mcp. The MCP endpoint, behind an OAuth access token. See MCP below.src/routes/__root.tsx— the document shell. SSR is on; the root loader resolves the session so the header does not flash a signed-out state.src/routes/index.tsx—/, always a new document.src/routes/d.$id.tsx—/d/<id>, an existing document.src/routes/docs.tsx—/docs, the document list.src/server/— server-only modules:auth.ts,documents.ts(D1 queries),render.ts(Browser Run),thumbnails.ts,session.ts,loaderData.ts,mcpServer.ts(the MCP tools),appUrl.ts(links back into the app),authPlugins.ts,discovery.ts,nativeClientRegistration.ts,mcpFailure.ts.src/sanitize.ts— the HTML allowlist. Runs on the client for feedback and on the server as the actual boundary.src/App.tsx— three-pane UI, render lifecycle, draft persistence, error overlay, download and save buttons.src/useDocumentSave.ts— the save interaction: auto-save for a stored document, creation for a new one, and the sign-in round trip in between.src/EditableName.tsx— the document name, in the header and on each card. Click to edit; Enter, blur or the Save name button commits, Escape abandons. Both states share every box-affecting property so the swap shifts nothing.src/documentName.ts— naming rules shared by client and server, so the name shown can never differ from the name stored.src/Editor.tsx— CodeMirror 6 wrapper.src/Divider.tsx— draggable split handles.src/dropFiles.ts— what a dropped file means; see Dropping files below.src/useFileDrop.ts— react-dropzone wiring: the page target and the picker.src/DropZone.tsx— wraps the page in that target and shows the overlay.src/Toast.tsx/src/useToast.ts— the one transient message a page shows.
POST /api/mcp exposes the document API to agents, so an assistant can write a
document on someone's behalf. It speaks MCP 2026-07-28, and 2025-era clients
are served too — legacy: "stateless", the SDK's own fallback over the same
server factory. plugins/htmlcsspdf packages it for Claude Code, and claude.ai
reaches the same endpoint as a custom connector; see Installing the plugin
and Connecting from claude.ai above.
Six tools, each a thin call into src/server/documents.ts: list_documents,
get_document, create_document, update_document, rename_document,
delete_document. Agents get exactly what the browser gets — the same
ownership clause in every query, the same sanitiser on every write, the same
404 for somebody else's document.
Every result that names a document carries its url alongside its id:
create_document hands back the link to what it has just written,
get_document returns it, and list_documents carries one per entry. An agent
is usually about to tell a person where the document is, and a link it composed
itself from an id is a link it guessed. It is a field rather than a seventh
tool, so it costs no extra round trip and an agent cannot end up holding an id
without the URL for it. The URL is built in src/server/appUrl.ts from
BETTER_AUTH_URL — the same origin the OAuth issuer and the MCP resource
identifier come from — and not from the request's own Host, which is whatever
hostname the call happened to arrive at rather than the one a person can open.
Rendering is deliberately not a tool. Browser Run is 10 browser-minutes a
day on the free plan, and an agent would spend that far faster than a person
pressing Preview. Anything that wants a PDF can go through /api/render.
The app is its own OAuth 2.1 authorization server, via @better-auth/mcp
alongside the Google sign-in that was already there. Nothing is configured on
the agent's side beyond the URL: it discovers the rest starting from a refusal.
- An unauthenticated
POST /api/mcpanswers401with an RFC 9728WWW-Authenticate: Bearer resource_metadata="..."header. - That URL —
/.well-known/oauth-protected-resource/api/mcp— names the canonical resource, the scopes, and the authorization server. /.well-known/oauth-authorization-server/api/authdescribes the endpoints.- The agent registers itself (RFC 7591), then sends the user to
/oauth2/authorizewith PKCE andresource=(RFC 8707). - Signed out, that lands on
/login; signed in, on/consent. Accepting returns the browser to the agent with a code. - The code is exchanged for a JWT access token whose
audis the MCP resource and whosesubis the user id — which is what every tool passes to the queries.
Step 4 needs one thing said about it. Registration metadata carries an
application_type that OpenID Connect defaults to "web", and a web client's
redirect URIs must be https and must not be loopback — but RFC 7591, the spec
MCP clients register under, has no such field, so a client is under no
obligation to send one. A client running on somebody's machine has nowhere but
loopback to receive the redirect, so claude mcp add was refused with web clients require https redirect URIs on non-loopback hosts: http://localhost:60369/callback before any consent screen appeared.
src/server/nativeClientRegistration.ts is a hooks.before middleware that
derives the field from the redirect URIs instead, by SEP-837's rule — a
loopback host or a non-http(s) scheme means a native application — which is
the same rule the MCP client SDK applies when it fills the field in itself, so
the two cannot disagree about a client. Every one of the provider's own checks
still runs, a client that states its type keeps what it stated, and registering
still authorises nothing: consent remains the trust boundary.
Because the client SDK now sends the derived value, an SDK-driven flow cannot
see the refusal at all — e2e/mcpFlow.test.ts posts the registration as raw
HTTP for that reason, and separately runs the whole flow on a loopback
callback, since the redirect URI is checked again at authorize and at the token
exchange.
Two scopes, and they are real rather than decorative: a token carrying only
documents:read gets a server on which the write tools are not registered,
so tools/list describes what that token can actually do instead of
advertising three tools that would always fail.
What an agent ends up holding is decided by the WWW-Authenticate challenge,
not by the resource metadata: the MCP client SDK requests exactly the scopes
that challenge names and never asks for more. So challengeScopes in
api.mcp.ts advertises what the resource offers — read and write — while
requiredScopes stays at the read it insists on. Letting the former default
to the latter capped every agent at read-only and made the write tools
unreachable in practice, which is a thing only an end-to-end test can notice.
This endpoint was modern-only for a while — legacy: "reject" — on the
reasoning that one era means one code path and no 2025 exchange served by
accident. What changed that is evidence, not taste: claude mcp add could not
connect. Its client never negotiates, so it opened the plain 2025 handshake and
was answered -32022 Unsupported protocol version: 2025-11-25 before it ever
reached a tool. Strictness that turns away the clients you have buys nothing.
So legacy: "stateless". The legacy leg is the SDK's own stateless fallback
built from the same buildMcpServer factory, which is what keeps the two eras
from drifting: a 2025-era client gets what a 2026-era one gets, a revision
behind. It costs a 405 on GET and DELETE — the 2025 session operations, which
this endpoint never had. Authorization is untouched; both eras go through the
same token.
A client that negotiates still gets 2026-07-28, decided by the SDK's own probe
per request, so nothing regresses for a modern agent. e2e/mcpFlow.test.ts
pins both eras, including a write through the legacy leg — so when every client
negotiates, dropping the option is a one-line change with a test to say whether
it is safe yet.
Worth writing down, since it cost a morning: the client's fallback is
conservative by design. Its server/discover probe accepts only a definitive
modern answer; a 401, 403 or 5xx is a hard error, and anything else — a 4xx
carrying -32020/-32021/-32001, or any response that is not a JSON-RPC
result — reads as "not era evidence" and drops it to 2025. So an endpoint that
serves only the modern era is one edge case away from being unreachable.
Verifying an access token reads the authorization server's key set over HTTP:
@better-auth/mcp fetches ${BETTER_AUTH_URL}/api/auth/jwks, which on this
deployment is the same Worker. By default a Worker's fetch to its own zone is
routed to the zone's origin server, skipping every Worker mapped to the URL —
and there is no origin behind this hostname but the Worker, so that subrequest
came back as a Cloudflare error page. A non-OK answer there throws a plain
Error, which requireMcpAuth cannot read as an authorization failure, so it
is re-thrown rather than turned into a challenge, and every authorized MCP call
died. Hence global_fetch_strictly_public in wrangler.jsonc: it sends such a
request back through Cloudflare's front door, where it reaches this Worker.
Nothing else here fetches its own origin.
What made that a morning's work rather than a minute's is worth keeping in
mind. An exception escaping a server route is answered by the runtime as
{"status":500,"unhandled":true,"message":"HTTPError"} — the message withheld,
because a framework cannot know what is safe to say — and that is the whole of
what an agent showed its operator. src/server/mcpFailure.ts wraps the route
so the endpoint says it itself: a JSON-RPC error naming the failure, and a 503
rather than a 401, since the token was never the problem and a challenge would
only send somebody back through sign-in to fail the same way.
Note the shape of .well-known routing. RFC 8414 and RFC 9728 both insert the
identifier's path into the well-known prefix rather than appending to it, so
those documents have to be served from the site root — src/server/discovery.ts
forwards them into the auth handler, whose plugin hooks run before base-path
matching. Without those routes the app would answer an agent's discovery
request with the React shell and a 200, which is worse than a 404 because a
client cannot tell it from a malformed document.
allowDynamicClientRegistration and allowUnauthenticatedClientRegistration
are both on. They have to be: agents arrive with no client id and nobody is
standing by to issue one. The alternative — Client ID Metadata Documents, where
identity is proven by domain ownership — needs a fetch transport that resolves
DNS once, rejects RFC 6890 special-use addresses and pins the resolved address
for the connection. Workers exposes none of that, so CIMD cannot be implemented
here correctly, and implementing it incorrectly would be worse than not
claiming it.
So registering proves nothing, and the consent screen carries the whole
decision. That is why src/ConsentPage.tsx presents the client's name as a
claim rather than a fact: an app can register under any name it likes, and a
consent screen that shows a self-asserted name as established fact is actively
misleading.
The provider does not park a pending authorization in a cookie. It appends the
whole authorization request, signed, to the /login and /consent URLs, and
expects it back as oauth_query — that is how the server knows which request
is being answered, and why an edited query is rejected before consent is
recorded. oauthProviderClient() in src/authClient.ts attaches exactly the
parameters the signature covers. On /login this matters twice over: the
signed query travels to Google inside the state parameter, so the provider
resumes the authorization by itself the moment a session cookie exists. The
login page never learns where the user is going next, and does not need to.
Every query in src/server/documents.ts takes a userId and folds it into the
WHERE clause. There is deliberately no exported function that reads or writes
a document without naming an owner, so a new caller cannot skip a check it was
never offered. Someone else's document is indistinguishable from one that does
not exist — both 404, since confirming existence would leak it.
Document HTML and CSS live in D1 columns rather than object storage. A document is two text fields, so one row is the whole thing: no second write to keep consistent, and nothing to leak from a bucket. Only thumbnails, being binary, go to R2, and they are served through the worker so ownership is checked before the bucket is touched.
Route loaders read D1 directly (src/server/loaderData.ts) rather than
fetching the app's own API. Server rendering runs in the same isolate as the API
routes, so an HTTP call would be a network round trip to reach code next door —
and on Workers a request to the worker's own hostname does not reliably loop
back to itself.
src/sanitize.ts is an allowlist over a parse5 tree, and it is a security
boundary: its output is rendered by a real browser on our infrastructure and
served back to users. Output is re-serialised from the parsed tree, which is
what defeats mutation-XSS and stops already-escaped text being resurrected as
markup.
The rule is no code execution, not no network access:
- Allowed — semantic document content, tables, and images and fonts from
any host. The render browser holds no credentials and runs in a per-render
incognito context, so an outbound fetch discloses nothing the author does not
already have. Also inline
<svg>, as a static-graphics subset: shapes, text, gradients, clips and masks, for icons and logos. - Blocked —
script,iframe,object,embed,form,link,base, everyon*attribute,javascript:URLs, anddata:URLs that are not raster images (data:image/svg+xmlcan carry script). - Blocked inside SVG —
script, theanimate/setfamily, SVG's ownaandstyle, andforeignObject, the integration point where HTML parsing resumes mid-SVG.
The allowlist is keyed on namespace, not just tag name, because several
names exist in both HTML and SVG — title, a, style, image — with
different parsing rules and different attributes. SVG matching is also
case-sensitive, since viewBox is not viewbox. Inline SVG is admitted where a
data:image/svg+xml URL is not for one reason: inline SVG is parsed into the
tree and checked element by element, while an SVG inside a base64 URL is opaque
to the sanitiser.
The editor reports what would be removed so authors are not left guessing, but that check is advisory: the server sanitises on render and on save, so nothing executable is ever stored.
Private and link-local addresses are not blocked. Cloudflare documents no SSRF protection for the browser container's own network stack, so this rests on Chrome's Local Network Access defaults. Revisit if that matters to you.
HTML sits above CSS at a 2:1 height ratio; the editor column and the preview split the width 1:1. Both splits are draggable (and keyboard-nudgeable with the arrow keys when a divider is focused), clamped to 15–85% so a pane can never be collapsed shut. The split is remembered in localStorage.
Both pages take dropped files, and the whole window is the target — the header, the gap between panes and the preview all accept a drop. New from files on the document list reaches the same thing through the file picker, since a drag is a pointer gesture with no keyboard equivalent.
- In the editor, an HTML file replaces the HTML pane and a stylesheet replaces the CSS pane. Dropping one of each fills both; the pane no file speaks for is left alone.
- On the document list there is nothing to overwrite, so a drop creates a document and opens it, named after the file rather than "Untitled".
- More than two files, two files of the same kind, a file that is neither HTML nor CSS, and a file over the 2 MB document limit are all refused in a toast, and nothing is changed.
The split is deliberate. react-dropzone owns the drag plumbing — the
enter/leave counting a naive boolean gets wrong, directory expansion, and the
document-level guard that stops the browser navigating to a dropped file. What
a drop means is decided by src/dropFiles.ts, a plain module with no
framework or DOM imports beyond the slice of File it reads, so both pages
answer identically and the messages stay ours rather than becoming generic
rejection codes. accept is passed to useDropzone only to filter the
picker's dialog; rejections are forwarded into the same rules, so dropping a
PDF says so by name instead of being a silent non-event.
Two things that are not obvious:
- The editor declines file drops (
declineFileDropsinsrc/Editor.tsx). CodeMirror reads a dropped file itself and pastes its text at the cursor. Left enabled, a dropped stylesheet would both replace the CSS pane and be pasted into whichever pane it landed on — and since its read is asynchronous, the paste would land last and win.EditorView.domEventHandlersreturning true marks the event handled, which stops the built-in handler:computeHandlersappends the built-in last andrunHandlersbreaks on the first handler returning true. The event still bubbles, so the drop zone above still receives it. Only file drags are declined — dragging selected text within the editor stays CodeMirror's. - The zone is a wrapper, not the window. react-dropzone delivers through
the element its root props are on, so
.drop-rootfills the viewport and.appsizes itself against it.noClickandnoKeyboardare set, because a click or Enter anywhere on a page-sized target would otherwise open a file dialog.
The file extension is consulted only when the browser reports no MIME type at all, which happens for files dragged from places the platform has no mapping for. A type we recognise and do not accept is a rejection, not an invitation to guess from the name.
src/document.ts validates and formats the editor content — a plain module
with no framework imports, so it is testable without rendering.
- Validation runs before every render, so a syntax error costs no browser time. Errors surface in the preview overlay with a line number.
- Format (toolbar) runs Prettier over both panes. Invalid input is left untouched and reports the error rather than mangling the text.
Two notes on parser choice:
- CSS is validated with Prettier's postcss parser, not
css-tree. css-tree implements the CSS spec's error recovery and silently auto-closes an unclosed block at EOF, sobody { color:parses clean. - HTML validation is deliberately lenient. The HTML spec requires parsers to
recover from unclosed tags, so
<div><p>x</div>is valid and renders fine. Only genuinely unparseable markup (an unterminated tag) is reported.
Prettier is loaded on demand — it is ~395kB and is not needed until the first render or Format press.
Auto preview is off by default — each render costs browser time. Press Preview (or ⌘/Ctrl+Enter) to render on demand. Tick Auto preview to re-render automatically 1s after you stop typing. One render runs on first load so the pane isn't empty.
Page size and margins come entirely from your CSS @page rule
(preferCSSPageSize: true). There is no page-setup UI by design.
Works on the Workers Free plan: 10 browser-minutes/day, 3 concurrent browsers, and one new browser instance per 20 seconds.
That last limit is why the Worker reuses sessions: it calls
puppeteer.sessions() to find an idle session and puppeteer.connect()s to
it, only launching a new browser when none is free, and disconnect()s
(never close()s) so the session stays warm. Cold launch is ~40s; warm
renders are ~2-3s.
Two isolation rules go with that reuse, both in src/server/render.ts:
- A fresh browser context per render. Cookies, localStorage, cache and
service workers are context-scoped, not page-scoped, so
page.close()alone leaves them behind. Since sessions are shared across users, without this one person's document could plant a cookie or poison the cache for another's. - JavaScript disabled before
setContent.setContentis implemented asdocument.write, so inline script would otherwise run. The sanitiser already strips it; this makes a sanitiser bypass inert rather than exploitable.
Enabled in vite.config.ts via react({ compiler: true }), so components are
written without memo, useMemo or useCallback and let the compiler memoise
— which it does more precisely than hand-written dependency arrays.
Two things to know if you touch this:
- It is
compiler: true, not ababel.pluginsentry. On Vite 8 this plugin transforms with oxc and only reaches for Babel when asked; passingbabel-plugin-react-compilerthroughbabelis accepted silently and does nothing at all. The only dependency needed isoxc-transform-react. - The compiler bails out on Rules of React violations rather than failing
loudly, so
useHookAtTopLevelanduseExhaustiveDependenciesare enabled inbiome.jsonto make those visible.
To check it is actually running, look for memo caches in the client bundle:
bun run build && grep -c '\.c)(' dist/client/assets/*.jsThe pre-existing useCallback calls in App.tsx, Divider.tsx and
useLayout.ts are deliberately left alone — renderNow in particular is
load-bearing for an effect dependency.
bun run test # everything: unit, then end-to-end
bun run test:unit # vitest run
bun run test:e2e # vitest run --config vitest.e2e.config.ts
bun run test:watchCI (.github/workflows/ci.yml) runs bun run check and bun run test on every
push and pull request. Nothing is configured on the runner: the end-to-end
suite starts its own server and applies its own migrations, and needs no
secrets.
src/Editor.test.tsx guards a subtle regression: the CodeMirror view must
never be recreated while typing. See the compartment note in Editor.tsx.
Server tests opt into the node environment with a // @vitest-environment node
pragma — they use node:sqlite, which the jsdom environment cannot bundle.
They share src/server/testDatabase.ts, a real SQLite database running the
real migrations behind a D1 face.
src/server/mcpServer.test.ts drives the tools over InMemoryTransport rather
than by reaching into the server, so tool schemas and result shaping are
exercised the way a client would exercise them. cloudflare:workers is aliased
to src/server/workers.testStub.ts in vitest.config.ts, since that module
resolves only inside the Workers runtime.
e2e/ runs the real app — vite dev, so workerd through the Cloudflare plugin
— and drives /api/mcp over HTTP with the real MCP client SDK. Discovery,
dynamic registration, PKCE, the resource indicator and the request envelope are
all performed by the same code an agent runs, so the test fails if we are
interoperable only with ourselves.
One step is substituted, and only one: signing in with Google, which no test
can drive. e2e/globalSetup.ts writes a session row and signs the cookie
itself, standing in for exactly that and nothing more — e2e/approve.ts then
walks the authorize redirect and answers the consent endpoint the way the
consent page does.
Everything the run needs, it arranges: migrations are applied, two throwaway
people are created with random ids so a shared local database never leaks state
between runs, and the server is started and stopped. With no .dev.vars — CI —
the Worker falls back to the variables in e2e/environment.ts, and
readVars() resolves them the same way Wrangler does so the test and the
server always agree on the secret.
It also drops the jwks row first. Better Auth encrypts the JWKS private key
with BETTER_AUTH_SECRET, so a key left behind by a run under a different
secret cannot be decrypted and token signing fails with an opaque 500 — which
is what you would hit the first time you add a .dev.vars after running the
suite without one. Keys are regenerated on demand, so dropping it costs
nothing.
The port is fixed at 5173 and strictPort is on. BETTER_AUTH_URL carries the
port, and it becomes the OAuth issuer and the MCP resource identifier, so a
server that quietly moved to the next free port would mint tokens its own
endpoint rejects. Failing to bind says what actually went wrong.
A .well-known route's filename escapes the leading dot as [.] —
src/routes/[.]well-known.oauth-protected-resource.ts. TanStack splits route
filenames on ., so an unescaped one would be read as a path separator and the
route would be generated at the wrong URL rather than failing.
Do not add #toolbar=0 to the preview iframe's blob URL — Chrome's PDF
viewer then lays out to zero height and the preview renders blank.
#navpanes=0&view=FitH is safe: it hides the page-list sidebar and scales
the page to the pane width.
Pass the language extension to Editor through a CodeMirror Compartment,
never as a useEffect dependency. Callers build it inline
(language={htmlLang()}), so its identity changes on every render; using it
as a dep tears down and rebuilds the EditorView on every keystroke, which
detaches the focused DOM node and drops the cursor.