Compare commits

...

22 commits

Author SHA1 Message Date
21765054e0 chore: update gh discussions link on readme 2025-04-27 18:51:33 +02:00
15a8becfe4 feat: initial undefined options handling 2025-04-07 23:52:24 +01:00
6e64d75b1c feat: update type names and make serviceWorker options optional) 2025-04-07 23:48:39 +01:00
2ae36ffb81 feat: organize preset exports 2025-04-07 23:44:01 +01:00
8d7e5df515 feat: add presets stale-while-revalidate & delete-old-caches 2025-04-07 23:22:56 +01:00
00e382dc70 chore: organize workspace scripts 2025-04-07 20:59:58 +01:00
e5f644e241 refactor: js -> ts 2025-04-07 20:51:55 +01:00
6d2a090387 feat: use AstroIntegration.assets instead of deprecated routes 2025-04-06 18:47:12 +02:00
e3c72fe50a chore: update readme background 2025-04-06 17:03:20 +02:00
7474425106 chore: remove email tickets option 2025-04-06 17:01:36 +02:00
ba9a8aa36b chore: update readme 2025-04-06 15:38:36 +02:00
294a3b25fc chore: remove strategies dir; add sample sw 2025-04-06 14:42:54 +02:00
bd3370f868 chore: organize workspaces 2025-04-06 14:33:57 +02:00
1db93f0c8f feat: initial strategies dir 2025-04-05 21:01:58 +02:00
bbb1bf4445 chore: update deps; astro v5 migrate 'hybrid' -> 'static' rendering 2025-04-05 11:03:51 +02:00
f5466082c2 chore: update repo info 2025-02-09 15:03:57 +01:00
beddea0157 chore: add auto mirror to github build script 2025-02-09 14:49:26 +01:00
81886bd763 chore: update cozy link on readme 2025-01-21 19:55:24 +01:00
c3b9a3bac0 chore: gitignore eslint cache 2024-12-27 16:56:36 +01:00
96e9ca07d5 chore: add check script and use in precommit 2024-12-27 16:56:12 +01:00
bb056b09f5 chore: set up eslint, prettier, husky 2024-12-26 00:54:23 +01:00
75b29eddc5 chore: set up prettier & format code 2024-12-26 00:40:32 +01:00
46 changed files with 4777 additions and 2318 deletions

11
.build.yml Normal file
View file

@ -0,0 +1,11 @@
image: alpine/edge
secrets:
- bbfcb6dc-7c4a-42ee-a11a-022f0339a133
environment:
REPO: astro-sw
GH_USER: ayoayco
tasks:
- push-mirror: |
cd ~/"${REPO}"
git config --global credential.helper store
git push --mirror "https://github.com/${GH_USER}/${REPO}"

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ package-lock.json
*swo *swo
*swp *swp
.eslintcache

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
npm run check

7
.prettierignore Normal file
View file

@ -0,0 +1,7 @@
# someday let's think about formatting html
**/*.html
**/*.md
**/*.css
**/*.yml
**/*.yaml

View file

@ -9,16 +9,28 @@ The integration accepts the path to your service worker and automatically inject
It works on all Astro output options: `static`, `server`, or `hybrid`, and lets developers retain the flexibility for various [caching strategies](https://developer.chrome.com/docs/workbox/caching-strategies-overview/). It works on all Astro output options: `static`, `server`, or `hybrid`, and lets developers retain the flexibility for various [caching strategies](https://developer.chrome.com/docs/workbox/caching-strategies-overview/).
## Background
This integration was originally developed to support the Caching strategy needs of [Cozy](https://cozy.pub) -- the modern reading companion for the Web. You can find [an example service worker in the repository](https://github.com/ayoayco/Cozy/blob/main/src/sw.mjs).
There is a work in progress adding `presets` for using common caching strategies and customizing the behavior of the service worker via config options. This aims to reduce the need for writing the service worker script by hand for most use cases.
Get in touch:
1. Chat via Discord: [Ayo's Projects](https://discord.gg/kkvW7GYNAp)
1. Submit tickets via [SourceHut todo](https://todo.sr.ht/~ayoayco/astro-sw)
1. Start a [GitHub discussion](https://github.com/ayoayco/astro-sw/discussions)
1. Email me: [ayo@ayco.io](mailto:ayo@ayco.io)
## Installation ## Installation
In your [Astro](https://astro.build) project: In your [Astro](https://astro.build) project:
```bash ```bash
# if using npm # if using npm
$ npm i -D @ayco/astro-sw $ npm i @ayco/astro-sw
# if using pnpm # if using pnpm
$ pnpm add -D @ayco/astro-sw $ pnpm add @ayco/astro-sw
``` ```
## Minimal Usage ## Minimal Usage
@ -136,6 +148,3 @@ The integration accepts a configuration object with the following properties
| esbuild | [BuildOptions](https://esbuild.github.io/api/) | optional | custom build options for your service worker script | | esbuild | [BuildOptions](https://esbuild.github.io/api/) | optional | custom build options for your service worker script |
| registrationHooks | object | optional | provide callbacks for various registration events; see section on [Registration Hooks](#registration-hooks) | | registrationHooks | object | optional | provide callbacks for various registration events; see section on [Registration Hooks](#registration-hooks) |
## Background
This integration was developed to support the Caching strategy needs of [Cozy](https://cozy.ayco.io) -- the modern reading companion for the Web. You can find [an example service worker in the repository](https://github.com/ayoayco/Cozy/blob/main/src/sw.mjs).

View file

@ -1,239 +0,0 @@
/**
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
*/
import { readFile, writeFile, readdir, unlink } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve, dirname, join } from 'node:path';
import { build } from 'esbuild';
const ASTROSW = '@ayco/astro-sw';
/**
* @typedef {import('astro').AstroIntegration} AstroIntegration
* @typedef {import('esbuild').BuildOptions} BuildOptions
*/
/**
* Accepts configuration options with service worker path
* and injects needed variables such as `__assets` generated by Astro
* @param {{
* path: string,
* assetCachePrefix?: string,
* assetCacheVersionID?: string,
* customRoutes?: string[],
* excludeRoutes?: string[],
* logAssets?: true,
* esbuild?: BuildOptions,
* registrationHooks?: {
* installing?: () => void,
* waiting?: () => void,
* active?: () => void,
* error?: (error) => void,
* unsupported?: () => void,
* afterRegistration?: () => void,
* }
* experimental?: {
* strategy?: {
* fetchFn: () => void,
* installFn: () => void,
* activateFn: () => void,
* waitFn: () => void,
* }
* }
* }} options
* @returns {AstroIntegration}
*/
export default function serviceWorker(options) {
let {
assetCachePrefix = ASTROSW,
assetCacheVersionID = '0',
path: serviceWorkerPath = undefined,
customRoutes = [],
excludeRoutes = [],
logAssets = false,
esbuild = {},
registrationHooks = {}
} = options || {};
const {
installing: installingFn = () => { },
waiting: waitingFn = () => { },
active: activeFn = () => { },
error: errorFn = () => { },
unsupported: unsupportedFn = () => { },
afterRegistration: afterRegistrationFn = () => { },
} = registrationHooks;
/**
* @type {Array<string>}
*/
let assets = [];
const registrationScript = `const registerSW = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
(${installingFn.toString()})();
} else if (registration.waiting) {
(${waitingFn.toString()})();
} else if (registration.active) {
(${activeFn.toString()})();
}
(${afterRegistrationFn.toString()})();
} catch (error) {
(${errorFn.toString()})(error);
}
} else {
(${unsupportedFn.toString()})();
}
}
registerSW();`
let output = 'static';
const __dirname = resolve(dirname('.'));
return {
'name': ASTROSW,
'hooks': {
'astro:config:setup': async ({ injectScript, config: _config, command, logger }) => {
if (!serviceWorkerPath || serviceWorkerPath === '') {
// REQUIRED OPTION IS MISSING
logger.error('Missing required path to service worker script');
}
// const transformedScript=await transform(registrationScript)
output = _config.output;
if (command === 'build') {
injectScript('page', registrationScript);
}
},
'astro:config:done': async ({ injectTypes, logger }) => {
let injectedTypes = `
declare const __assets: string[];
declare const __version: string;
declare const __prefix: string;`
injectTypes({ filename: 'caching.d.ts', content: injectedTypes })
},
'astro:build:ssr': ({ manifest }) => {
assets = manifest.assets
},
'astro:build:done': async ({ dir, routes, pages, logger }) => {
const outfile = fileURLToPath(new URL('./sw.js', dir));
const swPath = (serviceWorkerPath && serviceWorkerPath !== '')
? join(__dirname, serviceWorkerPath)
: undefined;
let originalScript;
const _publicFiles = (await readdir(dir, { withFileTypes: true }) ?? [])
.filter(dirent => dirent.isFile())
.map(dirent => `/${dirent.name}`);
const _routes = routes
.filter(({ isIndex }) => isIndex)
.flatMap(({ pathname }) => pathname === '/' ? pathname : [pathname, `${pathname}/`])
.filter(pathname => pathname !== '')
?? [];
const _pages = pages
.filter(({ pathname }) => pathname !== '')
.map(({ pathname }) => `/${pathname}`)
?? [];
const _pagesWithoutEndSlash = pages
.filter(({ pathname }) => pathname !== '')
.map(({ pathname }) => {
const lastChar = pathname.slice(-1);
const len = pathname.length;
return lastChar === '/'
? `/${pathname.slice(0, len - 1)}`
: `/${pathname}`;
})
.filter(pathname => pathname !== '')
?? [];
const _excludeRoutes = [
...excludeRoutes,
...excludeRoutes.map(route => `${route}/`)
];
assets = [...new Set([
...assets,
..._routes,
..._pages,
..._pagesWithoutEndSlash,
...customRoutes,
..._publicFiles
])].filter(asset => !!asset
&& asset !== ''
&& !asset.includes('404')
&& !asset.includes('index.html')
&& !_excludeRoutes.includes(asset)
);
if (logAssets) {
logger.info(`${assets.length} assets for caching: \n${assets.toString().replaceAll(',', '\n ▶ ')}\n`);
} else {
logger.info(`${assets.length} assets for caching.`);
}
try {
logger.info(`Using service worker in path: ${swPath}`);
originalScript = await readFile(swPath);
} catch {
logger.error(`Service worker script not found! ${swPath}`)
if (!swPath) {
logger.error(`
[${ASTROSW}] ERR: The 'path' option is required!
[${ASTROSW}] INFO: Please see service worker options in https://ayco.io/gh/astro-sw#readme
`);
}
}
const assetsDeclaration = `const __assets = ${JSON.stringify(assets)};\n`;
const versionDeclaration = `const __version = ${JSON.stringify(assetCacheVersionID)};\n`;
const prefixDeclaration = `const __prefix = ${JSON.stringify(assetCachePrefix)};\n`;
const tempFile = `${swPath}.tmp.ts`;
try {
await writeFile(
tempFile,
assetsDeclaration + versionDeclaration + prefixDeclaration + originalScript,
{ flag: 'w+' }
);
} catch (err) {
logger.error(err.toString())
}
try {
await build({
bundle: true,
...esbuild,
outfile,
platform: 'browser',
entryPoints: [tempFile],
})
} catch (err) {
logger.error(err.toString())
}
// remove temp file
try {
await unlink(tempFile);
} catch (err) {
logger.error(err.toString())
}
}
}
}
};

View file

@ -1,38 +0,0 @@
// @ts-check
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import serviceWorker from "./astro-sw.js";
export default defineConfig({
output: "hybrid",
adapter: node({
mode: "middleware"
}),
site: 'https://ayo.ayco.io',
integrations: [
serviceWorker({
path: './src/example_sw.js',
customRoutes: [
// '/threads'
],
excludeRoutes: [
'/exclude'
],
logAssets: true,
esbuild: {
minify: true
},
registrationHooks: {
installing: () => console.log('>>> installing...'),
waiting: () => console.log('>>> waiting...'),
active: () => console.log('>>> active...'),
error: (error) => console.error('>>> error', error),
'afterRegistration': async () => {
const sw = await navigator.serviceWorker.getRegistration();
console.log('>>> registrered', sw)
}
},
})
]
});

39
demo/astro.config.mjs Normal file
View file

@ -0,0 +1,39 @@
// @ts-check
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import serviceWorker from '@ayco/astro-sw'
import { deleteOldCaches, staleWhileRevalidate } from '@ayco/astro-sw/presets'
export default defineConfig({
output: 'static',
adapter: node({
mode: 'middleware',
}),
site: 'https://ayo.ayco.io',
integrations: [
serviceWorker({
path: './src/example_sw.js',
presets: [staleWhileRevalidate(), deleteOldCaches()],
customRoutes: [
// '/threads'
],
excludeRoutes: ['/exclude'],
assetCachePrefix: 'hey',
logAssets: true,
esbuild: {
minify: true,
},
registrationHooks: {
installing: () => console.log('>>> installing...'),
waiting: () => console.log('>>> waiting...'),
active: () => console.log('>>> active...'),
error: (error) => console.error('>>> error', error),
afterRegistration: async () => {
const sw = await navigator.serviceWorker.getRegistration()
console.log('>>> registrered', sw)
},
},
}),
],
})

25
demo/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "demo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "astro dev",
"dev": "astro dev",
"build": "astro build",
"build:preview:static": "astro build && astro preview",
"build:preview": "astro build && node ./server.mjs"
},
"author": "Ayo Ayco",
"license": "MIT",
"description": "",
"devDependencies": {
"astro": "^5.6.1",
"@astrojs/node": "^9.1.3",
"@fastify/middie": "^9.0.3",
"@fastify/static": "^8.1.1",
"astro-eslint-parser": "^1.2.2",
"fastify": "^5.2.2",
"@ayco/astro-sw": "workspace:*"
}
}

View file

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

18
demo/server.mjs Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env node
import Fastify from 'fastify'
import fastifyMiddie from '@fastify/middie'
import fastifyStatic from '@fastify/static'
import { fileURLToPath } from 'node:url'
import { handler as ssrHandler } from './dist/server/entry.mjs'
const app = Fastify({ logger: true })
await app
.register(fastifyStatic, {
root: fileURLToPath(new URL('./dist/client', import.meta.url)),
})
.register(fastifyMiddie)
app.use(ssrHandler)
app.listen({ port: 4321 })

View file

@ -0,0 +1,16 @@
import { defineCollection, z } from 'astro:content'
const blog = defineCollection({
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
})
export const collections = { blog }

View file

91
demo/src/example_sw.js Normal file
View file

@ -0,0 +1,91 @@
/**
* Note: @ayco/astro-sw integration injects variables `__prefix`, `__version`, & `__assets`
* -- find usage in package readme; `astro.config.mjs` integrations
* @see https://ayco.io/n/@ayco/astro-sw
*/
const cacheName = `${__prefix ?? 'app'}-v${__version ?? '000'}`
const addResourcesToCache = async (resources) => {
const cache = await caches.open(cacheName)
console.log('adding resources to cache...', resources)
await cache.addAll(resources)
}
console.log('test log', { hello: 'world' })
const putInCache = async (request, response) => {
const cache = await caches.open(cacheName)
console.log('adding one response to cache...', request)
await cache.put(request, response)
}
const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
// First try to get the resource from the cache
const responseFromCache = await caches.match(request)
if (responseFromCache) {
return responseFromCache
}
// Next try to use the preloaded response, if it's there
// NOTE: Chrome throws errors regarding preloadResponse, see:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1420515
// https://github.com/mdn/dom-examples/issues/145
// To avoid those errors, remove or comment out this block of preloadResponse
// code along with enableNavigationPreload() and the "activate" listener.
const preloadResponse = await preloadResponsePromise
if (preloadResponse) {
console.info('using preload response', preloadResponse)
putInCache(request, preloadResponse.clone())
return preloadResponse
}
// Next try to get the resource from the network
try {
const responseFromNetwork = await fetch(request.clone())
// response may be used only once
// we need to save clone to put one copy in cache
// and serve second one
putInCache(request, responseFromNetwork.clone())
return responseFromNetwork
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
const fallbackResponse = await caches.match(fallbackUrl)
if (fallbackResponse) {
return fallbackResponse
}
// when even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
})
}
}
const enableNavigationPreload = async () => {
if (self.registration.navigationPreload) {
// Enable navigation preloads!
await self.registration.navigationPreload.enable()
}
}
self.addEventListener('activate', (event) => {
console.log('activating...', event)
event.waitUntil(enableNavigationPreload())
})
self.addEventListener('install', (event) => {
console.log('installing...', event)
event.waitUntil(addResourcesToCache(__assets ?? []))
})
self.addEventListener('fetch', (event) => {
console.log('fetch happened', event.request)
event.respondWith(
cacheFirst({
request: event.request,
preloadResponsePromise: event.preloadResponse,
fallbackUrl: './',
})
)
})

View file

@ -1,3 +1,5 @@
--- ---
--- ---
404 404

View file

@ -0,0 +1,17 @@
---
import { type CollectionEntry, getCollection } from 'astro:content'
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}))
}
type Props = CollectionEntry<'blog'>
const post = Astro.props
const { Content } = await post.render()
---
<Content />

View file

@ -0,0 +1,7 @@
---
---
blog index
<a href="/blog/building-a-cozy-web">post</a>

View file

@ -1,4 +1,5 @@
--- ---
--- ---
exclude exclude

View file

@ -1,15 +1,14 @@
--- ---
export const prerender = false export const prerender = false
--- ---
<!DOCTYPE html>
<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello</title> <title>Hello</title>
</head> </head>
<body> <body> Hello </body>
Hello
</body>
</html> </html>
<!-- <Fragment set:html={content} /> --> <!-- <Fragment set:html={content} /> -->

5
demo/tsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

41
eslint.config.mjs Normal file
View file

@ -0,0 +1,41 @@
import globals from 'globals'
import eslintPluginAstro from 'eslint-plugin-astro'
import jsPlugin from '@eslint/js'
import tseslint from 'typescript-eslint'
import astroSwGlobals from '@ayco/astro-sw/globals'
import astroParser from 'astro-eslint-parser'
export default [
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...astroSwGlobals,
},
},
},
// add more generic rule sets here, such as:
jsPlugin.configs.recommended,
...tseslint.configs.recommended,
...eslintPluginAstro.configs['recommended'],
...eslintPluginAstro.configs['jsx-a11y-recommended'],
{
ignores: [
'**/dist/*',
'**/.output/*',
'**/.astro/*',
'**/node_modules/*',
'**/env.d.ts',
],
},
{
files: ['**/*.astro'],
languageOptions: {
parser: astroParser,
parserOptions: {
parser: tseslint.parser,
},
},
},
]

View file

@ -1,46 +1,37 @@
{ {
"name": "@ayco/astro-sw", "name": "astro-sw-monorepo",
"version": "0.8.14", "version": "1.0.0",
"description": "Use your own authored service worker with Astro", "private": true,
"homepage": "https://github.com/ayoayco/astro-sw", "description": "> [!NOTE] > This project moved to [SourceHut](https://git.sr.ht/~ayoayco/astro-sw).",
"scripts": {
"format": "prettier . --write",
"lint": "eslint . --config eslint.config.mjs --cache",
"check": "npm run format && npm run lint",
"prepare": "husky",
"test": "pnpm -F @ayco/astro-sw test",
"build": "pnpm -F @ayco/astro-sw build",
"demo": "pnpm run build && pnpm -F demo build:preview"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ayoayco/astro-sw" "url": "git+ssh://git@git.sr.ht/~ayoayco/astro-sw.git"
}, },
"exports": {
".": {
"import": "./astro-sw.js"
},
"./globals": {
"import": "./globals.js"
}
},
"main": "./astro-sw.js",
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"start": "astro dev",
"build": "astro build",
"build:preview:static": "astro build && astro preview",
"build:preview": "astro build && node ./server.mjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"withastro",
"perf"
],
"author": "Ayo Ayco", "author": "Ayo Ayco",
"license": "MIT", "license": "MIT",
"homepage": "https://ayco.io/n/@ayco/astro-sw#readme",
"devDependencies": { "devDependencies": {
"@astrojs/node": "^8.3.3", "@ayco/astro-sw": "workspace:*",
"@fastify/middie": "^8.3.1", "@eslint/js": "^9.24.0",
"@fastify/static": "^7.0.4", "eslint": "^9.24.0",
"astro": "^4.15.2", "eslint-plugin-astro": "^1.3.1",
"fastify": "^4.28.1" "eslint-plugin-jsx-a11y": "^6.10.2",
}, "globals": "^16.0.0",
"dependencies": { "husky": "^9.1.7",
"esbuild": "^0.23.1" "prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.29.0",
"vitest": "^3.1.1"
} }
} }

58
package/package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "@ayco/astro-sw",
"version": "0.9.0",
"description": "Use your own authored service worker with Astro",
"homepage": "https://ayco.io/n/@ayco/astro-sw",
"repository": {
"type": "git",
"url": "https://git.sr.ht/~ayoayco/astro-sw"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./globals": {
"types": "./dist/eslint/globals.d.ts",
"default": "./dist/eslint/globals.js"
},
"./presets": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
},
"./presets/*": {
"types": "./dist/presets/*/index.d.ts",
"default": "./dist/presets/*/index.js"
}
},
"files": [
"dist"
],
"main": "./astro-sw.js",
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"build": "tsup src/index.ts src/presets/index.ts src/presets/**/index.ts src/eslint/globals.ts --format esm --dts --clean",
"test": "vitest run",
"version:patch": "npm version patch",
"version:minor": "npm version minor",
"version:major": "npm version major"
},
"keywords": [
"withastro",
"perf"
],
"author": "Ayo Ayco",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.2"
},
"peerDependencies": {
"astro": "^5.6"
},
"devDependencies": {
"@types/node": "^22.14.0"
}
}

View file

@ -2,4 +2,4 @@ export default {
__prefix: false, __prefix: false,
__version: false, __version: false,
__assets: false, __assets: false,
}; }

232
package/src/index.ts Normal file
View file

@ -0,0 +1,232 @@
/**
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
*/
import { readFile, writeFile, readdir, unlink } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { resolve, dirname, join } from 'node:path'
import { build } from 'esbuild'
import type { AstroServiceWorkerConfig } from './types'
import type { AstroIntegration } from 'astro'
const ASTROSW = '@ayco/astro-sw'
/**
* TODO: update JSDoc
* Accepts configuration options with service worker path
* and injects needed variables such as `__assets` generated by Astro
*/
export default function serviceWorker(
// TODO handle options undefined
options?: AstroServiceWorkerConfig
): AstroIntegration {
const {
presets,
assetCachePrefix = ASTROSW,
assetCacheVersionID = '0',
path: serviceWorkerPath = undefined,
customRoutes = [],
excludeRoutes = [],
logAssets = false,
esbuild = {},
registrationHooks = {},
} = options ?? {}
const {
installing: installingFn = () => {},
waiting: waitingFn = () => {},
active: activeFn = () => {},
error: errorFn = () => {},
unsupported: unsupportedFn = () => {},
afterRegistration: afterRegistrationFn = () => {},
} = registrationHooks
// TODO use presets
console.log(presets)
/**
* @type {Array<string>}
*/
let manifestAssets: string[] = []
const registrationScript = `const registerSW = async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
(${installingFn.toString()})();
} else if (registration.waiting) {
(${waitingFn.toString()})();
} else if (registration.active) {
(${activeFn.toString()})();
}
(${afterRegistrationFn.toString()})();
} catch (error) {
(${errorFn.toString()})(error);
}
} else {
(${unsupportedFn.toString()})();
}
}
registerSW();`
// let output = 'static'
const __dirname = resolve(dirname('.'))
return {
name: ASTROSW,
hooks: {
'astro:config:setup': async ({ injectScript, command, logger }) => {
if (!serviceWorkerPath || serviceWorkerPath === '') {
// REQUIRED OPTION IS MISSING
logger.error('Missing required path to service worker script')
}
// const transformedScript=await transform(registrationScript)
// output = _config.output
if (command === 'build') {
injectScript('page', registrationScript)
}
},
'astro:config:done': async ({ injectTypes }) => {
const injectedTypes = `
declare const __assets: string[];
declare const __version: string;
declare const __prefix: string;`
injectTypes({ filename: 'caching.d.ts', content: injectedTypes })
},
'astro:build:ssr': ({ manifest }) => {
manifestAssets = manifest.assets
},
'astro:build:done': async ({
dir,
assets: astroAssets,
pages,
logger,
}) => {
const outfile = fileURLToPath(new URL('./sw.js', dir))
const swPath =
serviceWorkerPath && serviceWorkerPath !== ''
? join(__dirname, serviceWorkerPath)
: undefined
let originalScript
const _publicFiles = (
(await readdir(dir, { withFileTypes: true })) ?? []
)
.filter((dirent) => dirent.isFile())
.map((dirent) => `/${dirent.name}`)
const _assets = Array.from(astroAssets.keys())
.filter((key) => !key.includes('[...slug]'))
.flatMap((key) => (key === '/' ? key : [key, `${key}/`]))
const _pages =
pages
.filter(({ pathname }) => pathname !== '')
.map(({ pathname }) => `/${pathname}`) ?? []
const _pagesWithoutEndSlash =
pages
.filter(({ pathname }) => pathname !== '')
.map(({ pathname }) => {
const lastChar = pathname.slice(-1)
const len = pathname.length
return lastChar === '/'
? `/${pathname.slice(0, len - 1)}`
: `/${pathname}`
})
.filter((pathname) => pathname !== '') ?? []
const _excludeRoutes = [
...excludeRoutes,
...excludeRoutes.map((route) => `${route}/`),
]
const __assets = [
...new Set([
...manifestAssets,
..._assets,
..._pages,
..._pagesWithoutEndSlash,
...customRoutes,
..._publicFiles,
]),
].filter(
(asset) =>
!!asset &&
asset !== '' &&
!asset.includes('404') &&
!asset.includes('index.html') &&
!_excludeRoutes.includes(asset)
)
if (logAssets) {
logger.info(
`${__assets.length} assets for caching: \n ▶ ${__assets.toString().replaceAll(',', '\n ▶ ')}\n`
)
} else {
logger.info(`${__assets.length} assets for caching.`)
}
try {
logger.info(`Using service worker in path: ${swPath}`)
// @ts-expect-error undefined error is caught via try-catch
originalScript = await readFile(swPath)
} catch (err: unknown) {
logger.error(JSON.stringify(err))
if (!swPath) {
logger.error(`
[${ASTROSW}] ERR: The 'path' option is required!
[${ASTROSW}] INFO: Please see service worker options in https://ayco.io/gh/astro-sw#readme
`)
}
}
const assetsDeclaration = `const __assets = ${JSON.stringify(__assets)};\n`
const versionDeclaration = `const __version = ${JSON.stringify(assetCacheVersionID)};\n`
const prefixDeclaration = `const __prefix = ${JSON.stringify(assetCachePrefix)};\n`
const tempFile = `${swPath}.tmp.ts`
try {
await writeFile(
tempFile,
assetsDeclaration +
versionDeclaration +
prefixDeclaration +
originalScript,
{ flag: 'w+' }
)
} catch (err) {
logger.error(JSON.stringify(err))
}
try {
await build({
bundle: true,
...esbuild,
outfile,
platform: 'browser',
entryPoints: [tempFile],
})
} catch (err) {
logger.error(JSON.stringify(err))
}
// remove temp file
try {
await unlink(tempFile)
} catch (err) {
logger.error(JSON.stringify(err))
}
},
},
}
}

View file

@ -0,0 +1,16 @@
import { AstroServiceWorkerPreset } from '../../types'
export const activateFn: AstroServiceWorkerPreset['activate'] = async ({
cacheName,
}) => {
const allowCacheNames = [cacheName]
const allCaches = await caches.keys()
allCaches.forEach((key) => {
if (!allowCacheNames.includes(key)) {
console.info('Deleting old cache', key)
caches.delete(key)
}
})
}
export default activateFn

View file

@ -0,0 +1,8 @@
import { AstroServiceWorkerPreset } from '../../types'
import activate from './activate'
export const deleteOldCaches: () => AstroServiceWorkerPreset = () => ({
activate,
})
export default deleteOldCaches

View file

@ -0,0 +1,2 @@
export { staleWhileRevalidate } from './stale-while-revalidate'
export { deleteOldCaches } from './delete-old-caches'

View file

@ -0,0 +1,93 @@
import { AstroServiceWorkerPreset } from '../../types'
export const fetchFn: AstroServiceWorkerPreset['fetch'] = ({
event,
cacheName,
}) => {
console.info('fetch happened', { data: event })
event.respondWith(
cacheAndRevalidate(
{
request: event.request,
fallbackUrl: './',
},
cacheName
)
)
}
export default fetchFn
// @ts-expect-error TODO fix types
const putInCache = async (request, response, cacheName) => {
const cache = await caches.open(cacheName)
if (response.ok) {
console.info('adding one response to cache...', request.url)
// if exists, replace
cache.keys().then((keys) => {
if (keys.includes(request)) {
cache.delete(request)
}
})
cache.put(request, response)
}
}
const cacheAndRevalidate = async (
// @ts-expect-error TODO fix types
{ request, fallbackUrl },
cacheName: string
) => {
const cache = await caches.open(cacheName)
// Try get the resource from the cache
const responseFromCache = await cache.match(request)
if (responseFromCache) {
console.info('using cached response...', responseFromCache.url)
// get network response for revalidation of cached assets
fetch(request.clone())
.then((responseFromNetwork) => {
if (responseFromNetwork) {
console.info('fetched updated resource...', responseFromNetwork.url)
putInCache(request, responseFromNetwork.clone(), cacheName)
}
})
.catch((error) => {
console.info('failed to fetch updated resource', error)
})
return responseFromCache
}
try {
// Try to get the resource from the network for 5 seconds
const responseFromNetwork = await fetch(request.clone())
// response may be used only once
// we need to save clone to put one copy in cache
// and serve second one
putInCache(request, responseFromNetwork.clone(), cacheName)
console.info('using network response', responseFromNetwork.url)
return responseFromNetwork
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// Try the fallback
const fallbackResponse = await cache.match(fallbackUrl)
if (fallbackResponse) {
console.info('using fallback cached response...', fallbackResponse.url)
return fallbackResponse
}
// when even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
})
}
}

View file

@ -0,0 +1,14 @@
/**
* preset for stale-while-revalidate caching strategy
*/
import { AstroServiceWorkerPreset } from '../../types'
import install from './install'
import fetch from './fetch'
export const staleWhileRevalidate: () => AstroServiceWorkerPreset = () => ({
install,
fetch,
})
export default staleWhileRevalidate

View file

@ -0,0 +1,33 @@
import { AstroServiceWorkerPreset } from '../../types'
declare const self: ServiceWorkerGlobalScope
export const installFn: AstroServiceWorkerPreset['install'] = ({
event,
routes,
cacheName,
}) => {
console.info('installing service worker...')
self.skipWaiting() // go straight to activate
event.waitUntil(addResourcesToCache(routes ?? [], cacheName))
}
// @ts-expect-error TODO fix types
const addResourcesToCache = async (resources, cacheName: string) => {
const cache = await caches.open(cacheName)
console.info('adding resources to cache...', resources)
try {
await cache.addAll(resources)
} catch (error) {
console.error(
'failed to add resources to cache; make sure requests exists and that there are no duplicates',
{
resources,
error,
}
)
}
}
export default installFn

38
package/src/types.ts Normal file
View file

@ -0,0 +1,38 @@
import type { BuildOptions } from 'esbuild'
export type AstroServiceWorkerPreset = {
activate?: (options: { event: ExtendableEvent; cacheName: string }) => void
install?: (options: {
event: ExtendableEvent
routes: string[]
cacheName: string
}) => void
fetch?: (options: { event: FetchEvent; cacheName: string }) => void
}
export type AstroServiceWorkerConfig = {
path?: string
presets?: AstroServiceWorkerPreset[]
assetCachePrefix?: string
assetCacheVersionID?: string
customRoutes?: string[]
excludeRoutes?: string[]
logAssets?: true
esbuild?: BuildOptions
registrationHooks?: {
installing?: () => void
waiting?: () => void
active?: () => void
error?: (error: Error) => void
unsupported?: () => void
afterRegistration?: () => void
}
experimental?: {
strategy?: {
fetchFn: () => void
installFn: () => void
activateFn: () => void
waitFn: () => void
}
}
}

View file

@ -0,0 +1,6 @@
import { expect } from 'vitest'
import { test } from 'vitest'
test('astro-sw', () => {
expect(true).toBeTruthy()
})

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- "package"
- "demo"

21
prettier.config.mjs Normal file
View file

@ -0,0 +1,21 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
trailingComma: 'es5',
tabWidth: 2,
semi: false,
singleQuote: true,
plugins: ['prettier-plugin-astro'],
overrides: [
{
files: '*.astro',
options: {
parser: 'astro',
},
},
],
}
export default config

125
sample-sw.js Normal file
View file

@ -0,0 +1,125 @@
/**
* Note: @ayco/astro-sw integration injects variables `__prefix`, `__version`, & `__assets`
* -- find usage in `astro.config.mjs` integrations
* @see https://ayco.io/n/@ayco/astro-sw
*/
const cacheName = `${__prefix ?? 'app'}-v${__version ?? '000'}`
const cleanOldCaches = async () => {
const allowCacheNames = ['cozy-reader', cacheName]
const allCaches = await caches.keys()
allCaches.forEach((key) => {
if (!allowCacheNames.includes(key)) {
console.info('Deleting old cache', key)
caches.delete(key)
}
})
}
const addResourcesToCache = async (resources) => {
const cache = await caches.open(cacheName)
console.info('adding resources to cache...', resources)
try {
await cache.addAll(resources)
} catch (error) {
console.error(
'failed to add resources to cache; make sure requests exists and that there are no duplicates',
{
resources,
error,
}
)
}
}
const putInCache = async (request, response) => {
const cache = await caches.open(cacheName)
if (response.ok) {
console.info('adding one response to cache...', request.url)
// if exists, replace
cache.keys().then((keys) => {
if (keys.includes(request)) {
cache.delete(request)
}
})
cache.put(request, response)
}
}
const cacheAndRevalidate = async ({ request, fallbackUrl }) => {
const cache = await caches.open(cacheName)
// Try get the resource from the cache
const responseFromCache = await cache.match(request)
if (responseFromCache) {
console.info('using cached response...', responseFromCache.url)
// get network response for revalidation of cached assets
fetch(request.clone())
.then((responseFromNetwork) => {
if (responseFromNetwork) {
console.info('fetched updated resource...', responseFromNetwork.url)
putInCache(request, responseFromNetwork.clone())
}
})
.catch((error) => {
console.info('failed to fetch updated resource', error)
})
return responseFromCache
}
try {
// Try to get the resource from the network for 5 seconds
const responseFromNetwork = await fetch(request.clone())
// response may be used only once
// we need to save clone to put one copy in cache
// and serve second one
putInCache(request, responseFromNetwork.clone())
console.info('using network response', responseFromNetwork.url)
return responseFromNetwork
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// Try the fallback
const fallbackResponse = await cache.match(fallbackUrl)
if (fallbackResponse) {
console.info('using fallback cached response...', fallbackResponse.url)
return fallbackResponse
}
// when even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
})
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
self.addEventListener('activate', (event) => {
console.info('activating service worker...')
cleanOldCaches()
})
self.addEventListener('install', (event) => {
console.info('installing service worker...')
self.skipWaiting() // go straight to activate
event.waitUntil(addResourcesToCache(__assets ?? []))
})
self.addEventListener('fetch', (event) => {
console.info('fetch happened', { data: event })
event.respondWith(
cacheAndRevalidate({
request: event.request,
fallbackUrl: './',
})
)
})

View file

@ -1,18 +0,0 @@
#!/usr/bin/env node
import Fastify from 'fastify';
import fastifyMiddie from '@fastify/middie';
import fastifyStatic from '@fastify/static';
import { fileURLToPath } from 'node:url';
import { handler as ssrHandler } from './dist/server/entry.mjs';
const app = Fastify({ logger: true });
await app
.register(fastifyStatic, {
root: fileURLToPath(new URL('./dist/client', import.meta.url)),
})
.register(fastifyMiddie);
app.use(ssrHandler);
app.listen({ port: 4321 });

View file

@ -1,16 +0,0 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
});
export const collections = { blog };

View file

@ -1,94 +0,0 @@
import { log } from "./utils";
/**
* Note: @ayco/astro-sw integration injects variables `__prefix`, `__version`, & `__assets`
* -- find usage in package readme; `astro.config.mjs` integrations
* @see https://ayco.io/n/@ayco/astro-sw
*/
const cacheName = `${__prefix ?? 'app'}-v${__version ?? '000'}`
const addResourcesToCache = async (resources) => {
const cache = await caches.open(cacheName);
console.log('adding resources to cache...', resources)
await cache.addAll(resources);
};
log('test log', {hello: 'world'});
const putInCache = async (request, response) => {
const cache = await caches.open(cacheName);
console.log('adding one response to cache...', request)
await cache.put(request, response);
};
const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
// First try to get the resource from the cache
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
// Next try to use the preloaded response, if it's there
// NOTE: Chrome throws errors regarding preloadResponse, see:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1420515
// https://github.com/mdn/dom-examples/issues/145
// To avoid those errors, remove or comment out this block of preloadResponse
// code along with enableNavigationPreload() and the "activate" listener.
const preloadResponse = await preloadResponsePromise;
if (preloadResponse) {
console.info('using preload response', preloadResponse);
putInCache(request, preloadResponse.clone());
return preloadResponse;
}
// Next try to get the resource from the network
try {
const responseFromNetwork = await fetch(request.clone());
// response may be used only once
// we need to save clone to put one copy in cache
// and serve second one
putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
// when even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object
return new Response('Network error happened', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
}
};
const enableNavigationPreload = async () => {
if (self.registration.navigationPreload) {
// Enable navigation preloads!
await self.registration.navigationPreload.enable();
}
};
self.addEventListener('activate', (event) => {
console.log('activating...', event)
event.waitUntil(enableNavigationPreload());
});
self.addEventListener('install', (event) => {
console.log('installing...', event)
event.waitUntil(
addResourcesToCache(__assets ?? [])
);
});
self.addEventListener('fetch', (event) => {
console.log('fetch happened', event.request)
event.respondWith(
cacheFirst({
request: event.request,
preloadResponsePromise: event.preloadResponse,
fallbackUrl: './',
})
);
});

View file

@ -1,17 +0,0 @@
---
import { type CollectionEntry, getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await post.render();
---
<Content />

View file

@ -1,5 +0,0 @@
---
---
blog index
<a href="/blog/building-a-cozy-web">post</a>

View file

@ -1,3 +0,0 @@
export function log(message: any, ...data) {
console.log(message, ...data)
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "ESNext" /* Specify what module code is generated. */,
"moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"lib": ["WebWorker", "ES2021.String"]
},
"exclude": ["./dist/**/*"]
}