Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/hazae41/next-as-immutable
Create immutable Next.js webapps
https://github.com/hazae41/next-as-immutable
Last synced: 3 months ago
JSON representation
Create immutable Next.js webapps
- Host: GitHub
- URL: https://github.com/hazae41/next-as-immutable
- Owner: hazae41
- Created: 2024-07-16T15:08:08.000Z (6 months ago)
- Default Branch: main
- Last Pushed: 2024-08-04T14:49:05.000Z (5 months ago)
- Last Synced: 2024-10-07T21:31:08.598Z (3 months ago)
- Language: TypeScript
- Size: 274 KB
- Stars: 6
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
Awesome Lists containing this project
- awesome-ccamel - hazae41/next-as-immutable - An immutable Next.js webapp starter (TypeScript)
README
# Next.js as Immutable
Create [immutable](https://github.com/hazae41/immutable) Next.js webapps that are secure and resilient.
```bash
npm i -D @hazae41/next-as-immutable
```[**Node Package 📦**](https://www.npmjs.com/package/@hazae41/next-as-immutable)
## Examples
Here is a list of immutable Next.js webapps
- https://wallet.brume.money / https://github.com/brumewallet/wallet
- https://dstorage.hazae41.me/v0 / https://github.com/hazae41/dstorage## Setup
Install [`@hazae41/immutable`](https://github.com/hazae41/immutable)
```bash
npm i @hazae41/immutable
```Install `@hazae41/next-as-immutable` as `devDependencies`
```bash
npm i -D @hazae41/next-as-immutable
```Modify your `package.json` to add `node ./scripts/build.mjs` in order to postprocess each production build
```json
"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
```js
const { withNextAsImmutable } = require("@hazae41/next-as-immutable")module.exports = withNextAsImmutable({
/**
* Your Next.js config
*/
})
```Create a `./serve.json` file with this content
```json
{
"headers": [
{
"source": "**/*",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}
```You can build your service-worker with [NextSidebuild](https://github.com/hazae41/next-sidebuild)
Just name your service-worker like `.latest.js` and put it in the `./public` folder
Add this glue code to your service-worker
```tsx
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
```html
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.readylocation.reload()
} catch (error) {
message.textContent = "Failed to load."
console.error(error)
}
```
Create a `./scripts/build.mjs` file with this content
```tsx
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`)
continueconst dirname = path.dirname(pathname)
const filename = path.basename(pathname)if (!filename.endsWith(".html"))
continuefs.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`)
continueconst 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`))
continueconst 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
```tsx
await navigator.serviceWorker.register("/service_worker.js")
```You now have to do this (always use `.latest.js`)
```tsx
await Immutable.register("/service_worker.latest.js")
```You can use the returned async function to update your app
```tsx
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!