Skip to content

hazae41/next-as-immutable

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

52 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Next.js as Immutable

Create immutable Next.js webapps that are secure and resilient.

npm i -D @hazae41/next-as-immutable

Node Package 📦

Examples

Here is a list of immutable Next.js webapps

Setup

Install @hazae41/immutable

npm i @hazae41/immutable

Install @hazae41/next-as-immutable as devDependencies

npm i -D @hazae41/next-as-immutable

Modify your package.json to add node ./scripts/build.mjs in order to postprocess each production build

"scripts": {
  "dev": "next dev",
  "build": "next build && node ./scripts/build.mjs",
  "start": "npx serve --config ../serve.json ./out",
  "lint": "next lint"
},

Modify your next.config.js to use exported build, immutable build ID, and immutable Cache-Control headers

const { withNextAsImmutable } = require("@hazae41/next-as-immutable")

module.exports = withNextAsImmutable({
  /**
   * Your Next.js config
   */
})

Create a ./serve.json file with this content

{
  "headers": [
    {
      "source": "**/*",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

You can build your service-worker with NextSidebuild

Just name your service-worker like <name>.latest.js and put it in the ./public folder

Add this glue code to your service-worker

import { Immutable } from "@hazae41/immutable"

declare const self: ServiceWorkerGlobalScope

self.addEventListener("install", (event) => {
  /**
   * Auto-activate as the update was already accepted
   */
  self.skipWaiting()
})

/**
 * Declare global template
 */
declare const FILES: [string, string][]

/**
 * Only cache on production
 */
if (process.env.NODE_ENV === "production") {
  const cache = new Immutable.Cache(new Map(FILES))

  self.addEventListener("activate", (event) => {
    /**
     * Uncache previous version
     */
    event.waitUntil(cache.uncache())

    /**
     * Precache current version
     */
    event.waitUntil(cache.precache())
  })

  /**
   * Respond with cache
   */
  self.addEventListener("fetch", (event) => cache.handle(event))
}

Create a ./public/start.html file with this content

<!DOCTYPE html>
<html>

<head>
  <script type="module">
    const message = document.createElement("div")
    message.textContent = "Loading..."
    document.body.appendChild(message)

    try {
      const latestScriptUrl = new URL(`/service_worker.latest.js`, location.href)
      const latestScriptRes = await fetch(latestScriptUrl, { cache: "reload" })

      if (!latestScriptRes.ok)
        throw new Error(`Failed to fetch latest service-worker`)
      if (latestScriptRes.headers.get("cache-control") !== "public, max-age=31536000, immutable")
        throw new Error(`Wrong Cache-Control header for latest service-worker`)

      const { pathname } = latestScriptUrl

      const filename = pathname.split("/").at(-1)
      const basename = filename.split(".").at(0)

      const latestHashBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", await latestScriptRes.arrayBuffer()))
      const latestHashRawHex = Array.from(latestHashBytes).map(b => b.toString(16).padStart(2, "0")).join("")
      const latestVersion = latestHashRawHex.slice(0, 6)

      const latestVersionScriptPath = `${basename}.${latestVersion}.js`
      const latestVersionScriptUrl = new URL(latestVersionScriptPath, latestScriptUrl)

      localStorage.setItem("service_worker.current.version", JSON.stringify(latestVersion))

      await navigator.serviceWorker.register(latestVersionScriptUrl, { updateViaCache: "all" })
      await navigator.serviceWorker.ready

      location.reload()
    } catch (error) {
      message.textContent = "Failed to load."
      console.error(error)
    }
  </script>
</head>

</html>

Create a ./scripts/build.mjs file with this content

import crypto from "crypto"
import fs from "fs"
import path from "path"

export function* walkSync(dir) {
  const files = fs.readdirSync(dir, { withFileTypes: true })

  for (const file of files) {
    if (file.isDirectory()) {
      yield* walkSync(path.join(dir, file.name))
    } else {
      yield path.join(dir, file.name)
    }
  }
}

/**
 * Replace all .html files by start.html
 */

for (const pathname of walkSync(`./out`)) {
  if (pathname === `out/start.html`)
    continue

  const dirname = path.dirname(pathname)
  const filename = path.basename(pathname)

  if (!filename.endsWith(".html"))
    continue

  fs.copyFileSync(pathname, `./${dirname}/_${filename}`)
  fs.copyFileSync(`./out/start.html`, pathname)
}

fs.rmSync(`./out/start.html`)

/**
 * Find files to cache and compute their hash
 */

const files = new Array()

for (const pathname of walkSync(`./out`)) {
  if (pathname === `out/service_worker.latest.js`)
    continue

  const dirname = path.dirname(pathname)
  const filename = path.basename(pathname)

  if (fs.existsSync(`./${dirname}/_${filename}`))
    continue
  if (filename.endsWith(".html") && fs.existsSync(`./${dirname}/_${filename.slice(0, -5)}/index.html`))
    continue
  if (!filename.endsWith(".html") && fs.existsSync(`./${dirname}/_${filename}/index`))
    continue

  const text = fs.readFileSync(pathname)
  const hash = crypto.createHash("sha256").update(text).digest("hex")

  const relative = path.relative(`./out`, pathname)

  files.push([`/${relative}`, hash])
}

/**
 * Inject `files` into the service-worker and version it
 */

const original = fs.readFileSync(`./out/service_worker.latest.js`, "utf8")
const replaced = original.replaceAll("FILES", JSON.stringify(files))

const version = crypto.createHash("sha256").update(replaced).digest("hex").slice(0, 6)

fs.writeFileSync(`./out/service_worker.latest.js`, replaced, "utf8")
fs.writeFileSync(`./out/service_worker.${version}.js`, replaced, "utf8")

Use Immutable.register(pathOrUrl) to register your service-worker in your code

e.g. If you were doing this

await navigator.serviceWorker.register("/service_worker.js")

You now have to do this (always use .latest.js)

await Immutable.register("/service_worker.latest.js")

You can use the returned async function to update your app

navigator.serviceWorker.addEventListener("controllerchange", () => location.reload())

const update = await Immutable.register("/service_worker.latest.js")

if (update != null) {
  /**
   * Update available
   */
  button.onclick = async () => await update()
  return
}

await navigator.serviceWorker.ready

You now have an immutable but updatable Next.js app!

About

Create immutable Next.js webapps

Resources

Stars

Watchers

Forks

Packages

No packages published