Cloudflare
This site runs on Cloudflare as a Void app. Void
builds one Worker that serves the Docusaurus output as static assets, plus
the site's API routes and Durable Objects. The Worker is its own package,
packages/mochinori-website-worker/ (@mochinori/website-worker), separate
from the Docusaurus site in packages/mochinori-website/. Void treats any
package that depends on @docusaurus/core as a static site and refuses to
deploy it with a backend, even with inference.appType: 'void' (true of
Void 0.22.0).
In packages/mochinori-website-worker/:
void.config.tssets the Worker name and compatibility date, and serves it atmochinori.com(a custom domain, so the Worker must deploy to the Cloudflare account that holds themochinori.comzone) as well as on workers.dev. The account comes fromCLOUDFLARE_ACCOUNT_IDor your Void sign-in, not from the repo. It also tells Void to serve Docusaurus's own404.htmlfor unknown URLs.vite.config.tsaddsvoidPlugin()and uses../mochinori-website/build(the Docusaurus output) as the static assets.void deployonly runsvite build, so the Docusaurus site has to be built first. Thebuildanddeployscripts do that, andvite buildstops with an error when there is no site build to serve.routes/holds API routes. Each file exports Hono handlers per method, wrapped indefineHandler, and[name]is a path parameter.routes/api/health.tsservesGET /api/health, androutes/api/counter/[name].tsservesGETandPOST /api/counter/<name>.durable-objects/holds Durable Objects. Each module default-exports adefineDurableStatedefinition, and Void derives the binding and class from the filename.counter.tsbecomes theCOUNTERbinding and theCounterDurableObjectclass. Routes call it withCounter.get(name).increment(1).routes/api/images/stores demo image uploads in R2 (theSTORAGEbinding, bucketmochinori-demo-uploads).POST /api/imageschecks the file's magic bytes (PNG, JPEG, GIF, WebP or AVIF; never SVG), caps it at 5 MB and stores it under a random name. Posting JSON{"url": ...}instead mirrors a remote image: the Worker fetches it (publichttp(s)hosts only, each redirect checked the same way, 10 second timeout) and applies the same checks.GET /api/images/<name>serves it withnosniff, a sandboxing CSP and same-origin CORP. Uploads expire after an hour: reads of an expired object delete it, andcrons/expire-demo-images.tssweeps the bucket every 15 minutes. The rules live inserver/imageUploads.ts, which has unit tests.void.lock.jsonrecords the generated bindings and Durable Object migrations. Commit it whenever it changes: Cloudflare migrations are append-only, and the lock keeps their order.
Run it locally
pnpm run dev:website
This builds the site, then serves it with the API routes and Durable Objects
in Miniflare at http://localhost:5173. Use pnpm run start:website instead
for live-reloading docs work; it runs Docusaurus alone, without the API.
To check a production build:
pnpm run build-website
pnpm --filter @mochinori/website-worker run preview
Deploy
Every push to main deploys the site through the Deploy workflow. You
can also run that workflow from the Actions tab, or deploy from your
machine:
pnpm run deploy:website
The script builds the Docusaurus site, then runs
void deploy --platform cloudflare, which creates the
mochinori-website Worker on the first deploy and updates it afterwards.
Without --platform, Void offers to link an existing Worker, and that fails
when the Worker doesn't exist yet. If an earlier run saved such a link,
delete packages/mochinori-website-worker/.void/cloudflare-link.json and
deploy again.
The first local deploy signs you in to Cloudflare and saves the account in
packages/mochinori-website-worker/.void/project.json. The workflow needs
the CLOUDFLARE_API_TOKEN secret, from a token that can edit Workers
Scripts and Workers R2 Storage. Set CLOUDFLARE_ACCOUNT_ID too when the
token can reach more than one account, and CLOUDFLARE_WORKERS_SUBDOMAIN
(a variable or a secret) to the account's workers.dev subdomain (the part
before .workers.dev).
Pull request previews
The Preview workflow deploys each pull request as its own Worker,
mochinori-website-pr-<number>, and comments its URL on the pull request:
https://mochinori-website-pr-<number>.<subdomain>.workers.dev. Each push
redeploys it, and closing the pull request deletes it. It uses the same
secrets as the Deploy workflow, and skips pull requests from forks, which get
no secrets.
A preview is a separate Worker rather than a preview version of
mochinori-website, because Cloudflare gives no preview URL to versions of a
Worker that defines Durable Objects. So each preview has its own Durable
Objects and applies its own migrations. It shares production's R2 bucket
(the workflow creates any bucket the pull request adds) and has no cron
triggers; production's cron expires uploads for both.
Add a Cloudflare feature
- API route: add a file under
routes/. Routes under/apireach the Worker before static assets. - Durable Object: add a module under
durable-objects/, runpnpm --filter @mochinori/website-worker run prepareto regenerate types, and commit the updatedvoid.lock.json. - KV, R2, D1, queues and crons are inferred from how the code uses them. See the Void docs for each one.