1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
// Opening the app is instant, and a deploy never refreshes the page out from
// under you.
//
// Navigations to / are cache-first: a launch paints the installed build's
// shell without waiting on the network, and a deploy that lands meanwhile
// installs in the background and opens on the next launch. A refresh goes to
// the network and gets the new build at once, without a second reload. A page
// that installs a newer build while open reloads only if nothing was touched,
// and only as it is put away.
//
// Run: bun tools/sw-reload-test.js (needs a built dist/)
import puppeteer from './puppeteer.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const html1 = await Bun.file('dist/index.html').text();
const sw1 = await Bun.file('dist/sw.js').text();
const token = (html1.match(/_v='([a-z0-9]+)'/) || [])[1];
if (!token) { console.log('no build token in dist/index.html — run bun run build'); process.exit(1); }
// A "new deploy" is the same app under a different build token.
const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) });
const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002'), v4: restamp('deadbeef00003') };
let current = builds.v1;
let pageHits = 0; // network requests for the page itself
let preloads = 0;
const server = Bun.serve({
port: 5223,
fetch(req) {
const p = new URL(req.url).pathname;
if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } });
if (p === '/' || p === '/index.html') {
pageHits++;
// as cs nginx does: a launch's preload (no Cache-Control) gets no body
if (req.headers.get('service-worker-navigation-preload')) {
preloads++;
if (!req.headers.get('cache-control')) return new Response(null, { status: 204 });
}
return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
}
// The worker precaches the shell during install and addAll rejects on any
// miss — a 404 here means it never activates and the test proves nothing.
// The split build names its entry after the token (app-<token>.js), so
// a restamped build asks for app-deadbeef….js: the same file.
const f = Bun.file('dist' + p.replace(/deadbeef0000\d/, token));
return new Response(f, { headers: { 'cache-control': 'no-store' } });
},
});
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
let loads = 0;
page.on('load', () => { loads++; });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message.slice(0, 160)));
const running = () => page.evaluate(() => (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1]);
const controlled = async (ms = 15000) => {
for (let i = 0; i < ms / 250; i++) {
if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true;
await sleep(250);
}
return false;
};
// the worker's own build, once the newest one has taken over
const workerBuild = () => page.evaluate(() => new Promise((res) => {
const c = navigator.serviceWorker.controller;
if (!c) return res(null);
const on = (e) => { if (e.data && e.data.type === 'sw-version') { navigator.serviceWorker.removeEventListener('message', on); res(e.data.version); } };
navigator.serviceWorker.addEventListener('message', on);
c.postMessage({ type: 'sw-version' });
setTimeout(() => res(null), 2000);
}));
const waitWorker = async (tok) => { for (let i = 0; i < 60; i++) { if ((await workerBuild()) === tok) return true; await sleep(250); } return false; };
const putAway = () => page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
try {
console.log('[first visit]');
await page.goto('http://localhost:5223/', { waitUntil: 'load' });
await page.evaluate(() => navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }));
check('worker takes control', await controlled());
await sleep(3000);
check('a first visit does not reload itself', loads === 1, `${loads} load(s)`);
console.log('\n[opening the app after a deploy]');
current = builds.v2;
let hits = pageHits, pre = preloads, before = loads;
await page.goto('http://localhost:5223/?u=x', { waitUntil: 'load' });
check('the launch paints the installed build', (await running()) === token, `running ${await running()}`);
check('without fetching the page (only an empty launch preload)', pageHits - hits === preloads - pre, `${pageHits - hits} page request(s), ${preloads - pre} of them preloads`);
check('the new build installs meanwhile', await waitWorker('deadbeef00001'));
await sleep(1000);
check('and does not reload a page on screen', loads === before + 1, `${loads - before} load(s)`);
before = loads;
await page.goto('http://localhost:5223/', { waitUntil: 'load' });
check('the next launch opens the new build', (await running()) === 'deadbeef00001', `running ${await running()}`);
console.log('\n[refreshing after a deploy]');
current = builds.v3;
before = loads;
await page.reload({ waitUntil: 'load' });
check('a refresh gets the new build at once', (await running()) === 'deadbeef00002', `running ${await running()}`);
await waitWorker('deadbeef00002');
await sleep(2000);
check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`);
console.log('\n[a deploy while the page sits open, untouched]');
current = builds.v4;
before = loads;
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
check('the new build installs', await waitWorker('deadbeef00003'));
await sleep(1000);
check('no reload while on screen', loads === before, `${loads - before} reload(s)`);
await putAway();
for (let i = 0; i < 40 && loads === before; i++) await sleep(250);
check('it reloads as it is put away', loads === before + 1, `${loads - before} reload(s)`);
check('now running the newest build', (await running()) === 'deadbeef00003');
console.log('\n[a deploy while someone is using the page]');
current = builds.v1;
before = loads;
await page.mouse.click(5, 5);
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
check('the new build installs', await waitWorker(token));
await putAway();
await sleep(2000);
check('a page in use is never reloaded', loads === before, `${loads - before} reload(s)`);
check('an older build\'s chunks are still cached', await page.evaluate(async () => {
const tok = (document.documentElement.outerHTML.match(/_v='([a-z0-9]+)'/) || [])[1];
return !!(await caches.match('app-' + tok + '.js'));
}));
} catch (e) {
check('run completed', false, e.message);
} finally {
await browser.close();
server.stop(true);
}
console.log(ok ? '\n✅ sw launch + reload behaviour is right' : '\n❌ failures above');
process.exit(ok ? 0 : 1);
|