Angular SSR with Cloudflare Pages and Bun

How to setup Angular SSR with Cloudflare Pages and Bun

Introduction

Building an Angular app for WinterCG runtimes (Cloudflare / Deno Deploy) it is not an obvious task.
Angular 19 introduced the new SSR build flag experimentalPlatform that makes the it much easier (experimental zoneless is also a big step forward).

Create an Angular Application

ng new my-app \ --minimal \ --strict \ --routing \ --style=css \ --ssr \ --server-routing=false \ --experimental-zoneless

Create a worker

Install packages

npm i --save-dev wrangler itty-router
  • wrangler is the Cloudflare Workers CLI, it allows us to run our worker locally
  • itty-router is a lightweight router for Workers

Create the worker

src/worker.ts
import { renderApplication } from "@angular/platform-server"; import { default as bootstrap } from "./main.server"; // Api imports import type { IRequest } from "itty-router"; import { AutoRouter, error } from "itty-router"; // Declare the environment type declare interface Env extends Record<string, any> { ASSETS: { fetch: (url: URL) => Promise<Response> }; // ... declare more env variables here } async function render(url: URL, env: Env) { const indexUrl = new URL("/index.csr.html", url); const indexHtml = await env.ASSETS.fetch(indexUrl); const document = await indexHtml.text(); const content = await renderApplication(bootstrap, { document, url: url.pathname + url.search, }); return new Response(content, { status: 200, headers: { "content-type": "text/html", "cache-control": "public, max-age=0, s-maxage=0", }, }); } const router = AutoRouter<IRequest, [Env]>() .get("/api/hello", () => ({ message: "Hello, World!" })) .all("/api/*", () => error(404)) .get("*", (request, env) => { const url = new URL(request.url); // If the URL has a file extension, we assume it's a static file if (url.pathname.includes(".")) { return env.ASSETS.fetch(url); } else { return render(url, env); } }) .all("*", () => error(404)); export const fetch = (request: Request, env: Env) => { return router.fetch(request, env).catch((err: Error) => { console.error("[SSR Error]", err); return new Response("Internal Server Error", { status: 500 }); }); }; // Export for Cloudflare Pages export default { fetch };

Edit angular.json configuration

...
"build": {
  "builder": "@angular-devkit/build-angular:application",
  "options": {
-   "outputPath": "dist/my-app",
+   "outputPath": "dist",
    "server": "src/main.server.ts",
-   "prerender": true, /* For some reason, prerender doesn't works with MacOs */
+   "prerender": false,
    "ssr": {
      "entry": "src/server.ts"
    },
  },
  "configurations": {
+    "worker": {
+      "tsConfig": "tsconfig.worker.json",
+      "ssr": {
+        "entry": "src/worker.ts",
+        "experimentalPlatform": "neutral"
+      }
+    },
    "production": {
  ...

Add tsconfig.worker.ts

Copy tsconfig.app.json to tsconfig.worker.json and edit it:

...
  "files": [
    "src/main.ts",
    "src/main.server.ts",
-   "src/server.ts"
+   "src/worker.ts"
  ],
...

Build the worker

ng build --configuration worker

Bundle the worker

esbuild --bundle dist/server/server.mjs \ --outfile=dist/browser/_worker.js \ --platform=browser \ --target=es2022 \ --format=esm

This will create a bundled _worker.js file in the dist/browser directory, this is what Cloudflare Pages expects to find.

Run the worker locally

npx wrangler pages dev dist/browser --port 4200 --compatibility-date=2024-12-05

Create a Bun adapter

We can create a Bun adapter to run the worker locally with Bun.

Install Bun types

npm i --save-dev @types/bun

Create the Bun adapter

bun.ts
/// <reference types="@types/bun" /> // @ts-ignore - This is a workaround for the missing types import { fetch as handleRequest } from "./dist/server/server.mjs"; console.log("[Bun] Starting server on port 4200"); const env = { ...process.env, ASSETS: { async fetch({ pathname }: URL) { const browserDir = import.meta.dir + "/dist/browser"; const file = Bun.file(browserDir + pathname); if (file) { try { const headers = { "content-type": file.type }; return new Response(await file.text(), { headers }); } catch (err) { return new Response("Internal Server Error", { status: 500 }); } } else { return new Response("Not Found", { status: 404 }); } }, }, }; Bun.serve({ port: 4200, fetch(req) { console.log("[Bun] Request", req.url); return handleRequest(req, env); }, });

Run the worker with Bun

bun run bun.ts

We don't even need to bundle the worker as with wrangler dev because Bun will handle the worker's bundling and serving!