Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Repository files navigation

htmlcsspdf

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/

Installing the plugin

The MCP server is packaged as a Claude Code plugin, in plugins/htmlcsspdf:

/plugin marketplace add afternoon/htmlcsspdf
/plugin install htmlcsspdf@htmlcsspdf

The 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/htmlcsspdf

The OAuth flow accepts plain HTTP on loopback, so it works the same way there.

Connecting from claude.ai

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.

Running

bun install
cp .dev.vars.example .dev.vars   # then fill it in, see Auth setup
bun run dev

Then 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.

Auth setup

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.com

Database

D1 holds the Better Auth tables and the documents themselves.

wrangler d1 migrations apply htmlcsspdf --local
wrangler d1 migrations apply htmlcsspdf --remote

The 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.

Saving

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.

Thumbnails

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-thumbnails

Capture 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.

How it works

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.tsPOST /api/render. Takes {html, css}, renders with Puppeteer via the BROWSER binding, 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.tsPOST /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.

MCP

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.

Authorization

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.

  1. An unauthenticated POST /api/mcp answers 401 with an RFC 9728 WWW-Authenticate: Bearer resource_metadata="..." header.
  2. That URL — /.well-known/oauth-protected-resource/api/mcp — names the canonical resource, the scopes, and the authorization server.
  3. /.well-known/oauth-authorization-server/api/auth describes the endpoints.
  4. The agent registers itself (RFC 7591), then sends the user to /oauth2/authorize with PKCE and resource= (RFC 8707).
  5. Signed out, that lands on /login; signed in, on /consent. Accepting returns the browser to the agent with a code.
  6. The code is exchanged for a JWT access token whose aud is the MCP resource and whose sub is 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.

Why both protocol eras are served

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.

The Worker has to be allowed to reach its own JWKS

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.

Client registration is open, and the consent screen is the boundary

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 signed query

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.

Documents and access control

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.

What HTML is allowed

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.
  • Blockedscript, iframe, object, embed, form, link, base, every on* attribute, javascript: URLs, and data: URLs that are not raster images (data:image/svg+xml can carry script).
  • Blocked inside SVGscript, the animate/set family, SVG's own a and style, and foreignObject, 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.

Layout

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.

Dropping files

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 (declineFileDrops in src/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.domEventHandlers returning true marks the event handled, which stops the built-in handler: computeHandlers appends the built-in last and runHandlers breaks 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-root fills the viewport and .app sizes itself against it. noClick and noKeyboard are 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.

Validation and formatting

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, so body { 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.

Rendering

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.

Browser Run notes

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. setContent is implemented as document.write, so inline script would otherwise run. The sanitiser already strips it; this makes a sanitiser bypass inert rather than exploitable.

React Compiler

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 a babel.plugins entry. On Vite 8 this plugin transforms with oxc and only reaches for Babel when asked; passing babel-plugin-react-compiler through babel is accepted silently and does nothing at all. The only dependency needed is oxc-transform-react.
  • The compiler bails out on Rules of React violations rather than failing loudly, so useHookAtTopLevel and useExhaustiveDependencies are enabled in biome.json to 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/*.js

The 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.

Testing

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:watch

CI (.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.

End-to-end

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.

Gotchas

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.

About

Create PDFs with HTML and CSS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages