SSG with Solid Start on Pages

SSG with Solid Start on Pages

Jun 23 2023
#javascript#cloudflare#pages#edge#solid-start#solid-js

Why Solid in the first place?

To put it bluntly, NextJS has gotten too complex for it to be my go-to framework. I've built a lot of random stuff in my day to day and Next was the perfect default choice for what I needed to build. I had a huge React ecosystem to pull from, I had SSR relatively easily without tons of configuration, and most importantly there wasn't tons of thought offloaded to me as a developer. I write up my getServerSideProps and go from there. But once I joined Cloudflare, I was consistently working on backend systems and didn't need to work on any UI.

And then we got Next 13 and I became confused. I wasn't really paying attention to frontend when Next 13 came out and I felt like I had lost all of my understanding overnight. Client components? Server components? App router? Total mystery. I wanted to do more frontend in my weekend projects and I just couldn't get back to the productivity I had with older versions of Next.

I had been watching the signals hype wave on Twitter and was really impressed by the performance of Solid and the createServerData helper instantly made me think of the ease of getServerSideProps in Next. And after building a small web app in Solid I was very pleased to learn that it had an official Cloudflare Pages adapter, meaning I could deploy it to my favorite platform (that happens to pay me) without having to fiddle with getting a framework's custom build tool to comply.

The catch

Solid start is new. Like, really new. There's plenty of features that NextJS has had for a while that just do not exist yet, and while a lot of those features aren't very interesting to me the lack of opt-in SSG was a bit of a bummer. Because I host on Cloudflare Pages, each request is going to be turned into a Pages Function invocation, even if it's going to serve the exact same content. Now, Pages are pretty damn cheap, but I still don't like the idea of wasting CPU cycles on something that could be served statically, so it was time to go diving into the Solid source and see what could be done about that.

Static adapter

In Solid, there are many different ways to build your application depending on how you want or where you want it, for me it usually ends up looking something like:

import solid from "solid-start/vite"; import { defineConfig } from "vite"; import cloudflare from "solid-start-cloudflare-pages"; export default defineConfig({ plugins: [solid({ adapter: cloudflare({}) })], });

But there are plenty of other adapters to choose from with the static adapter being the one that's going to be useful. When we take a peek into what makes the static adapter tick we find this snippet:

renderStatic( routes.map((url) => ({ entry: join(config.root, "dist", "server.js"), output: join( pathToDist, url.endsWith("/") ? `${url.slice(1)}index.html` : `${url.slice(1)}.html` ), url, })) );

So, under the hood the builder calls renderStatic function from solid-ssr with our generated server bundle at dist/server.js, so lets see what makes that tick...

const exec = promisify(execFile); const __dirname = fileURLToPath(new URL(".", import.meta.url)); const pathToRunner = resolve(__dirname, "writeToDisk.js"); async function run({ entry, output, url }) { const { stdout, stderr } = await exec("node", [ pathToRunner, entry, output, url, "--trace-warnings", ]); if (stdout.length) console.log(stdout); if (stderr.length) console.log(stderr); } export default async function renderStatic(config) { if (Array.isArray(config)) { await Promise.all(config.map(run)); } else await run(config); } // writeToDisk.js import { dirname, join } from "path"; import { writeFile, mkdir } from "fs"; async function write() { const server = (await import(join("file://", process.argv[2]))).default; const res = await server({ url: process.argv[4] }); mkdir(dirname(process.argv[3]), { recursive: true }, () => writeFile(process.argv[3], res, () => process.exit(0)) ); } write();

Oh, okay! It looks like it spawns a new node process to run the server and then writes the output to disk. This is a pretty clever way to do it, but it's not going to work for Pages. Page Functions have a special structure to how they export request handlers, so our server entrypoint isn't going to have a server function we can call.

Cloudflarification

How the heck does Pages work?

Now that we know how the static adapter works, we can start to think about how we can make it work for Pages. First off, we're going to need to understand how Cloudflare figures out what to respond with when your Pages application gets a request. The getting started documentation for Pages gives us a concise example of what a Pages function looks like:

export async function onRequest({ request, next }: EventContext) { return new Response("Hello, world!"); }

So now any request our Pages Function receives will now be responded to with Hello, world!. But, when you create your application with a framework you probably have a ton of static assets already that you don't need to recompute on every request, and that's where that unused next function comes in. next is a function that accepts in a request and will route to our static assets that we uploaded with our deployment. So, if we wanted to serve anything out of /assets we could do something like:

// Instead of `onRequest` we'll use `onRequestGet` to only respond to GET requests export async function onRequestGet({ request, next }: EventContext) { const url = new URL(request.url); if (url.pathname.startsWith("/assets")) { return await next(request); } return new Response("Hello, world!"); }

Sweet, so we no longer have to compute our assets to serve them but we're still running our Function on every request. Thankfully, Pages has a way for us to specify when we bypass the Function and go straight to static assets with _routes.json. Now, we can just specify a wildcard for routes we want to exclude from invoking our function.

{ "version": 1, "include": ["/*"], "exclude": ["/assets/*"] }

And now that we don't actually need to pass the request back to static assets manually we can simplify our function again:

export async function onRequestGet({ request }: EventContext) { return new Response("Hello, world!"); }

How does Solid currently do Pages?

Well, to figure that out it's probably best we just take a look at a server bundle. I'm going to use the switches.show, a perpetually WIP project of mine, as an example. So....

git clone git@github.com:zebp/switches.show.git && cd switches.show pnpm install pnpm build code .

And when we look in our functions/[[path]].js we get to see the Pages Function that Solid generates for us. Of interest we have a GET request handler:

const onRequestGet = async ({ request, next, env }) => { // Handle static assets if (/\.\w+$/.test(request.url)) { let resp = await next(request); if (resp.status === 200 || 304) { return resp; } } env.manifest = manifest; env.next = next; env.getStaticHTML = async (path) => { return next(); }; return entryServer({ request: request, clientAddress: request.headers.get("cf-connecting-ip"), locals: {}, env, }); }; // other method handlers export { onRequestGet };

This looks very similar to our previous example where we were manually routing static assets, but instead of using the _routes.json file we're just checking if the request ends with a file extension.

Static rendering our Function

Now that we understand how Pages creates our response and what code Solid generates for Pages, we can modify the static adapter to work with Pages. First, we're going to need our own writeToDisk.js that will generate our Pages Function. We can start by copying the existing writeToDisk.js and modifying it to work with Pages:

// packages/start-cloudflare-pages/writeWorkerToDisk.js import { mkdir, writeFile } from "fs/promises"; import { dirname, join } from "path"; async function write() { const { onRequestGet } = await import(join("file://", process.argv[2])); const request = new Request(process.argv[4]); const res = await onRequestGet({ request, env: {}, next: () => { throw new Error("next() not implemented"); }, }); await mkdir(dirname(process.argv[3]), { recursive: true }); await writeFile(process.argv[3], await res.text()); process.exit(0); } write();

Next, we'll need to recreate the renderStatic function to use our new writeWorkerToDisk.js:

// packages/start-cloudflare-pages/index.js const exec = promisify(execFile); const __dirname = fileURLToPath(new URL(".", import.meta.url)); const pathToRunner = resolve(__dirname, "writeWorkerToDisk.js"); async function run({ entry, output, url }) { const { stdout, stderr } = await exec( "node", [pathToRunner, entry, output, url, "--trace-warnings"], { env: { NODE_NO_WARNINGS: "1", ...process.env, }, } ); if (stdout.length) console.log(stdout); if (stderr.length) console.log(stderr); }

And update the build method to render our pages...

export default function (miniflareOptions) { return { name: "cloudflare-pages", async build(config, builder) { // lots of bundling code *snip* await config.solidOptions.router.init(); const staticRoutes = config.solidOptions.prerenderRoutes || []; writeFileSync( join(config.root, "dist", "public", "_routes.json"), JSON.stringify({ version: 1, include: ["/*"], exclude: staticRoutes, }), "utf8" ); for (let route of staticRoutes) { const originalRoute = route; if (route.startsWith("/")) route = route.slice(1); await run({ entry: join(config.root, "functions", "[[path]].js"), output: join( config.root, "dist", "public", originalRoute.endsWith("/") ? `${originalRoute}index.html` : `${route}.html` ), url: `http://localhost:8787/${route}`, }); } }, }; }

Just like that we've got prerendered routes for Solid on Pages!