{"id":13566949,"url":"https://github.com/hazae41/next-as-immutable","last_synced_at":"2025-07-03T16:36:04.443Z","repository":{"id":248874045,"uuid":"829506690","full_name":"hazae41/next-as-immutable","owner":"hazae41","description":"An immutable Next.js webapp starter","archived":false,"fork":false,"pushed_at":"2025-04-04T16:14:56.000Z","size":139,"stargazers_count":13,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-06-26T05:10:50.189Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://next-as-immutable.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hazae41.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null},"funding":{"github":["hazae41"],"patreon":"hazae41"}},"created_at":"2024-07-16T15:08:08.000Z","updated_at":"2025-05-09T12:54:46.000Z","dependencies_parsed_at":"2024-10-01T20:20:40.676Z","dependency_job_id":"d09f52b7-e86d-4f68-a3db-fe01d32f2ce9","html_url":"https://github.com/hazae41/next-as-immutable","commit_stats":null,"previous_names":["hazae41/next-as-immutable"],"tags_count":12,"template":false,"template_full_name":null,"purl":"pkg:github/hazae41/next-as-immutable","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hazae41%2Fnext-as-immutable","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hazae41%2Fnext-as-immutable/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hazae41%2Fnext-as-immutable/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hazae41%2Fnext-as-immutable/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hazae41","download_url":"https://codeload.github.com/hazae41/next-as-immutable/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hazae41%2Fnext-as-immutable/sbom","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":263361222,"owners_count":23454895,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-01T13:02:20.136Z","updated_at":"2025-07-03T16:36:04.424Z","avatar_url":"https://github.com/hazae41.png","language":"TypeScript","funding_links":["https://github.com/sponsors/hazae41","https://patreon.com/hazae41"],"categories":["TypeScript"],"sub_categories":[],"readme":"# Next.js as Immutable\n\nAn [immutable](https://github.com/hazae41/immutable) Next.js webapp starter\n\n```bash\ngit clone https://github.com/hazae41/next-as-immutable.git\n```\n\n## Examples\n\nHere is a list of immutable Next.js webapps\n\n- https://wallet.brume.money / https://github.com/brumeproject/wallet\n\n## Migrate\n\nYou can migrate an existing Next.js webapp by following these steps\n\nInstall [`@hazae41/immutable`](https://github.com/hazae41/immutable)\n\n```bash\nnpm i @hazae41/immutable\n```\n\nModify your `package.json` to add `node ./scripts/build.mjs` in order to postprocess each production build\n\n```json\n\"scripts\": {\n  \"dev\": \"next dev\",\n  \"build\": \"next build \u0026\u0026 node ./scripts/build.mjs\",\n  \"start\": \"npx serve --config ../serve.json ./out\",\n  \"lint\": \"next lint\"\n},\n```\n\nModify your `next.config.js` to use exported build, immutable build ID, and immutable Cache-Control headers\n\n```js\nmodule.exports = {\n  /**\n   * Recommended in order to output the webapp as HTML\n   */\n  output: \"export\",\n\n  /**\n   * Recommended in order to get deterministic build ID\n   */\n  generateBuildId() {\n    return \"immutable\"\n  },\n\n  async headers() {\n    if (process.env.NODE_ENV !== \"production\")\n      return []\n    return [\n      {\n        source: \"/:path*\",\n        headers: [\n          /**\n           * Recommended in order to be embedded with strong restrictions\n           */\n          {\n            key: \"Allow-CSP-From\",\n            value: \"*\"\n          },\n          /**\n           * Mandatory in order to get almost immutable caching\n           */\n          {\n            key: \"Cache-Control\",\n            value: \"public, max-age=31536000, immutable\",\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nCreate a `./serve.json` file with this content\n\n```json\n{\n  \"headers\": [\n    {\n      \"source\": \"**/*\",\n      \"headers\": [\n        {\n          \"key\": \"Allow-CSP-From\",\n          \"value\": \"*\"\n        },\n        {\n          \"key\": \"Cache-Control\",\n          \"value\": \"public, max-age=31536000, immutable\"\n        }\n      ]\n    }\n  ]\n}\n```\n\nYou can build your service-worker with [NextSidebuild](https://github.com/hazae41/next-sidebuild)\n\nJust name your service-worker like `\u003cname\u003e.js` and put it in the `./public` folder\n\nAdd this glue code to your service-worker\n\n```tsx\nimport { Immutable } from \"@hazae41/immutable\"\n\ndeclare const self: ServiceWorkerGlobalScope\n\nself.addEventListener(\"install\", () =\u003e {\n  /**\n   * Auto-activate as the update was already accepted\n   */\n  self.skipWaiting()\n})\n\n/**\n * Declare global template\n */\ndeclare const FILES: [string, string][]\n\n/**\n * Only cache on production\n */\nif (process.env.NODE_ENV === \"production\") {\n  const cache = new Immutable.Cache(new Map(FILES))\n\n  self.addEventListener(\"activate\", (event) =\u003e {\n    /**\n     * Uncache previous version\n     */\n    event.waitUntil(cache.uncache())\n\n    /**\n     * Precache current version\n     */\n    event.waitUntil(cache.precache())\n  })\n\n  /**\n   * Respond with cache\n   */\n  self.addEventListener(\"fetch\", (event) =\u003e cache.handle(event))\n}\n```\n\nCreate a `./public/loader.html` file with this content\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\n\u003chead\u003e\n  \u003cstyle\u003e\n    html {\n      height: 100%;\n      width: 100%;\n    }\n\n    body {\n      height: 100%;\n      width: 100%;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      overflow: hidden;\n    }\n\n    @media (prefers-color-scheme: dark) {\n      body {\n        background-color: #000;\n      }\n    }\n  \u003c/style\u003e\n  \u003cscript type=\"module\"\u003e\n    await(async () =\u003e {\n      try {\n        if (navigator.userAgent.match(/(bot|spider)/) != null) {\n          const html = atob(\"INJECT_HTML\")\n\n          console.log(\"Beep boop\")\n\n          document.open()\n          document.write(html)\n          document.close()\n\n          return\n        }\n\n        const latestScriptUrl = new URL(`/service_worker.latest.js`, location.href)\n        const latestScriptRes = await fetch(latestScriptUrl, { cache: \"reload\" })\n\n        if (!latestScriptRes.ok)\n          throw new Error(`Failed to fetch latest service-worker`, { cause: latestScriptRes.status })\n\n        const cache = latestScriptRes.headers.get(\"cache-control\")\n\n        if (!cache?.includes(\"immutable\"))\n          alert(\"This webapp is not distributed as immutable. Use it at your own risk.\")\n\n        const ttl = cache?.split(\",\").map(s =\u003e s.trim()).find(s =\u003e s.startsWith(\"max-age=\"))?.split(\"=\").at(-1)\n\n        if (ttl !== \"31536000\")\n          alert(\"This webapp is distributed with a time-to-live of less than 1 year. Use it at your own risk.\")\n\n        const { pathname } = latestScriptUrl\n\n        const filename = pathname.split(\"/\").at(-1)\n        const basename = filename.split(\".\").at(0)\n\n        const latestHashBytes = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", await latestScriptRes.arrayBuffer()))\n        const latestHashRawHex = Array.from(latestHashBytes).map(b =\u003e b.toString(16).padStart(2, \"0\")).join(\"\")\n        const latestVersion = latestHashRawHex.slice(0, 6)\n\n        const latestVersionScriptPath = `${basename}.${latestVersion}.js`\n        const latestVersionScriptUrl = new URL(latestVersionScriptPath, latestScriptUrl)\n\n        localStorage.setItem(\"service_worker.current.version\", JSON.stringify(latestVersion))\n\n        await navigator.serviceWorker.register(latestVersionScriptUrl, { updateViaCache: \"all\" })\n        await navigator.serviceWorker.ready\n\n        location.reload()\n      } catch (error) {\n        console.error(error)\n\n        alert(`An error occurred when loading this website. Please try again later.`)\n\n        return\n      }\n    })()\n  \u003c/script\u003e\n\u003c/head\u003e\n\n\u003cbody\u003e\n  \u003cimg style=\"width: 100px; height: 100px;\" src=\"/favicon.png\" alt=\"favicon\" /\u003e\n\u003c/body\u003e\n\n\u003c/html\u003e\n```\n\nAnd put your webapp icon in `./public/favicon.png`\n\nCreate a `./scripts/build.mjs` file with this content\n\n```tsx\nimport crypto from \"crypto\"\nimport fs from \"fs\"\nimport path from \"path\"\n\nexport function* walkSync(dir) {\n  const files = fs.readdirSync(dir, { withFileTypes: true })\n\n  for (const file of files) {\n    if (file.isDirectory()) {\n      yield* walkSync(path.join(dir, file.name))\n    } else {\n      yield path.join(dir, file.name)\n    }\n  }\n}\n\n/**\n * Replace all .html files by loader.html\n */\nconst loader = fs.readFileSync(`./out/loader.html`, \"utf8\")\n\nfor (const pathname of walkSync(`./out`)) {\n  if (pathname === `out/loader.html`)\n    continue\n\n  const dirname = path.dirname(pathname)\n  const filename = path.basename(pathname)\n\n  if (!filename.endsWith(\".html\"))\n    continue\n\n  fs.copyFileSync(pathname, `./${dirname}/_${filename}`)\n\n  const injected = fs.readFileSync(pathname, \"utf8\")\n  const replaced = loader.replaceAll(\"INJECT_HTML\", btoa(injected))\n\n  fs.writeFileSync(pathname, replaced, \"utf8\")\n}\n\nfs.rmSync(`./out/loader.html`)\n\n/**\n * Find files to cache and compute their hash\n */\n\nconst files = new Array()\n\nfor (const pathname of walkSync(`./out`)) {\n  if (pathname === `out/service_worker.js`)\n    continue\n\n  const dirname = path.dirname(pathname)\n  const filename = path.basename(pathname)\n\n  if (fs.existsSync(`./${dirname}/_${filename}`))\n    continue\n  if (filename.endsWith(\".html\") \u0026\u0026 fs.existsSync(`./${dirname}/_${filename.slice(0, -5)}/index.html`))\n    continue\n  if (!filename.endsWith(\".html\") \u0026\u0026 fs.existsSync(`./${dirname}/_${filename}/index`))\n    continue\n\n  const relative = path.relative(`./out`, pathname)\n\n  const text = fs.readFileSync(pathname)\n  const hash = crypto.createHash(\"sha256\").update(text).digest(\"hex\")\n\n  files.push([`/${relative}`, hash])\n}\n\n/**\n * Inject `files` into the service-worker and version it\n */\n\nconst original = fs.readFileSync(`./out/service_worker.js`, \"utf8\")\nconst replaced = original.replaceAll(\"FILES\", JSON.stringify(files))\n\nconst version = crypto.createHash(\"sha256\").update(replaced).digest(\"hex\").slice(0, 6)\n\nfs.writeFileSync(`./out/service_worker.js`, replaced, \"utf8\")\nfs.writeFileSync(`./out/service_worker.latest.js`, replaced, \"utf8\")\nfs.writeFileSync(`./out/service_worker.${version}.js`, replaced, \"utf8\")\n```\n\nUse `Immutable.register(pathOrUrl)` to register your service-worker in your code\n\ne.g. If you were doing this\n\n```tsx\nawait navigator.serviceWorker.register(\"/service_worker.js\")\n```\n\nYou now have to do this (always use `.latest.js`)\n\n```tsx\nawait Immutable.register(\"/service_worker.latest.js\")\n```\n\nYou can use the returned async function to update your app\n\n```tsx\nnavigator.serviceWorker.addEventListener(\"controllerchange\", () =\u003e location.reload())\n\nconst update = await Immutable.register(\"/service_worker.latest.js\")\n\nif (update != null) {\n  /**\n   * Update available\n   */\n  button.onclick = async () =\u003e await update()\n  return\n}\n\nawait navigator.serviceWorker.ready\n```\n\nYou now have an immutable but updatable Next.js app!\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhazae41%2Fnext-as-immutable","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhazae41%2Fnext-as-immutable","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhazae41%2Fnext-as-immutable/lists"}