Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Bu kontrol, build çıktısında uzak script/style/module bağlantısı kalmadı

## Bağımlılıklar

Çalışma zamanı bağımlılıkları CDN'den yüklenmez. npm paketleri sabit sürümlere pinlenir ve esbuild ile `dist/app.js` / `dist/app.css` içine bundle edilir. KaTeX fontları da `dist/assets/` altına kopyalanır.
Çalışma zamanı bağımlılıkları CDN'den yüklenmez. npm paketleri sabit sürümlere pinlenir ve esbuild ile içerik hash'li `dist/assets/app-<hash>.js` / `app-<hash>.css` bundle'larına dönüştürülür. KaTeX fontları da `dist/assets/` altına kopyalanır.

| Kütüphane | Sürüm | Amaç |
|-----------|-------|------|
Expand All @@ -156,7 +156,7 @@ GitHub Pages sürümü installable PWA olarak yayınlanır. Manifest, uygulama i

- `start_url` ve `scope` relative (`./`) tutulduğu için proje `/mathnote/` gibi GitHub Pages alt yollarında çalışır.
- İlk başarılı ziyarette uygulama shell'i, JavaScript/CSS, KaTeX fontları, manifest ve ikonlar precache edilir.
- `app.js`, `app.css` ve manifest gibi değişebilir çekirdek dosyalar ağ varken **network-first**, çevrimdışıyken cache fallback ile yüklenir. Bu, yeni deploy sonrasında eski JavaScript'in yanlışlıkla servis edilmesini önler.
- Uygulama JavaScript/CSS dosyaları her build'de içerik hash'li yeni URL'ler alır. Böylece eski HTML yalnızca kendi bundle sürümünü, yeni HTML de yalnızca yeni bundle sürümünü yükler; HTML/JS sürüm karışması engellenir. Manifest ve navigasyon ağ varken **network-first**, çevrimdışıyken cache fallback ile çalışır.
- Yeni service worker çalışan sayfayı zorla devralmaz; bekleyen service worker eski sekmeler kapandıktan sonra aktive olur. Böylece eski HTML ile yeni JavaScript aynı sayfada karışmaz.
- Yeni build'de dosya içeriğinden yeni bir cache sürümü üretilir; service worker aktive olduğunda eski MathNote cache'leri temizlenir.
- Service worker yalnızca HTTP/HTTPS üzerinde kaydolur. Bu nedenle `dist/index.html` dosyasını doğrudan `file://` ile açma desteği korunur.
Expand Down
19 changes: 19 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,25 @@



<script>
(() => {
if (!('serviceWorker' in navigator)) return;
if (!['http:', 'https:'].includes(window.location.protocol)) return;

window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('./sw.js', {
scope: './',
updateViaCache: 'none'
});
registration.update().catch(() => {});
} catch (error) {
console.warn('MathNote service worker bootstrap failed:', error);
}
});
})();
</script>

<!-- MATHNOTE_BUILD_JS -->

</body>
Expand Down
36 changes: 29 additions & 7 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,60 @@ import { build } from 'esbuild';
import { createHash } from 'node:crypto';
import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const outdir = new URL('../dist/', import.meta.url);
const assetsDir = new URL('../dist/assets/', import.meta.url);
const templateUrl = new URL('../index.html', import.meta.url);
const publicDir = new URL('../public/', import.meta.url);
const serviceWorkerTemplateUrl = new URL('../src/sw.js', import.meta.url);
const entryPoint = fileURLToPath(new URL('../src/app.js', import.meta.url));

await rm(outdir, { recursive: true, force: true });
await mkdir(outdir, { recursive: true });

await build({
entryPoints: [new URL('../src/app.js', import.meta.url).pathname],
entryPoints: { app: entryPoint },
bundle: true,
outfile: new URL('../dist/app.js', import.meta.url).pathname,
outdir: fileURLToPath(outdir),
entryNames: 'assets/[name]-[hash]',
assetNames: 'assets/[name]-[hash]',
format: 'iife',
platform: 'browser',
target: ['es2020'],
minify: false,
sourcemap: false,
legalComments: 'none',
assetNames: 'assets/[name]-[hash]',
loader: {
'.woff2': 'file',
'.woff': 'file',
'.ttf': 'file'
}
});

const emittedAssets = await readdir(assetsDir);
const jsBundles = emittedAssets.filter(name => /^app-[A-Z0-9]+\.js$/i.test(name));
const cssBundles = emittedAssets.filter(name => /^app-[A-Z0-9]+\.css$/i.test(name));

if (jsBundles.length !== 1 || cssBundles.length !== 1) {
throw new Error(
`Expected one hashed JS and CSS entry bundle, found JS=${jsBundles.length}, CSS=${cssBundles.length}.`
);
}

const appJsPath = `./assets/${jsBundles[0]}`;
const appCssPath = `./assets/${cssBundles[0]}`;

await cp(publicDir, outdir, { recursive: true });

let html = await readFile(templateUrl, 'utf8');
html = html.replace(
'<!-- MATHNOTE_BUILD_CSS -->',
'<link rel="stylesheet" href="./app.css">'
`<link rel="stylesheet" href="${appCssPath}">`
);
html = html.replace(
'<!-- MATHNOTE_BUILD_JS -->',
'<script defer src="./app.js"><\\/script>'
`<script defer src="${appJsPath}"></script>`
);

if (html.includes('MATHNOTE_BUILD_')) {
Expand All @@ -63,7 +80,10 @@ async function collectFiles(dirUrl, rootUrl = dirUrl) {

files.push({
url: childUrl,
path: './' + relative(rootUrl.pathname, childUrl.pathname).replaceAll('\\\\', '/')
path: './' + relative(
fileURLToPath(rootUrl),
fileURLToPath(childUrl)
).replaceAll('\\\\', '/')
});
}

Expand All @@ -86,4 +106,6 @@ serviceWorker = serviceWorker

await writeFile(new URL('../dist/sw.js', import.meta.url), serviceWorker, 'utf8');

console.log(`MathNote offline/PWA bundle written to dist/ using cache ${cacheName}.`);
console.log(
`MathNote offline/PWA bundle written to dist/ using ${appJsPath}, ${appCssPath} and cache ${cacheName}.`
);
57 changes: 42 additions & 15 deletions scripts/verify-offline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { extname } from 'node:path';
const root = new URL('../dist/', import.meta.url);
const required = [
'index.html',
'app.js',
'app.css',
'manifest.webmanifest',
'sw.js',
'icons/icon-192.png',
Expand Down Expand Up @@ -59,19 +57,43 @@ for (const forbidden of ['cdn.jsdelivr.net', 'esm.sh', 'type="module"', 'type="i
if (!html.includes('rel="manifest" href="./manifest.webmanifest"')) {
violations.push('index.html: manifest link missing or not relative');
}

if (!html.includes('id="examples-btn"')) {
violations.push('index.html: examples button missing');
}
if (!html.includes("updateViaCache: 'none'")) {
violations.push('index.html: service worker bootstrap must bypass HTTP cache');
}

const app = await readFile(new URL('app.js', root), 'utf8');
const sourceApp = await readFile(new URL('../src/app.js', import.meta.url), 'utf8');
if (!app.includes("register('./sw.js'") && !app.includes('register("./sw.js"')) {
violations.push('app.js: relative service worker registration missing');
const jsMatch = html.match(/<script defer src="(\.\/assets\/app-[A-Z0-9]+\.js)"><\/script>/i);
const cssMatch = html.match(/<link rel="stylesheet" href="(\.\/assets\/app-[A-Z0-9]+\.css)">/i);

if (!jsMatch) violations.push('index.html: hashed app JS reference missing');
if (!cssMatch) violations.push('index.html: hashed app CSS reference missing');

const rootEntries = await readdir(root);
if (rootEntries.includes('app.js') || rootEntries.includes('app.css')) {
violations.push('dist/: fixed-name app.js/app.css must not be emitted');
}

let app = '';
let appJsPath = '';
let appCssPath = '';

if (jsMatch) {
appJsPath = jsMatch[1];
app = await readFile(new URL(appJsPath.slice(2), root), 'utf8');
}
if (cssMatch) {
appCssPath = cssMatch[1];
await readFile(new URL(appCssPath.slice(2), root), 'utf8');
}

const sourceApp = await readFile(new URL('../src/app.js', import.meta.url), 'utf8');
if (sourceApp.includes("serviceWorker.register") || sourceApp.includes(".register('./sw.js'")) {
violations.push('src/app.js: service worker registration must stay independent from app boot');
}
if (!app.includes('examples-btn') || !app.includes('getDefaultContent')) {
violations.push('app.js: examples loader wiring missing');
violations.push('app bundle: examples loader wiring missing');
}

for (const id of ['examples-btn', 'backup-btn', 'backup-close', 'backup-modal', 'backup-export-btn', 'backup-import-btn', 'backup-file-input']) {
Expand All @@ -97,22 +119,25 @@ if (sw.includes('__CACHE_NAME__') || sw.includes('__PRECACHE_ASSETS__')) {
if (!/mathnote-[a-f0-9]{12}/.test(sw)) {
violations.push('sw.js: content-derived cache version missing');
}

if (!sw.includes('NETWORK_FIRST_PATHS') || !sw.includes('networkFirst(request')) {
violations.push('sw.js: mutable core assets are not network-first');
violations.push('sw.js: navigation/manifest network-first strategy missing');
}

if (sw.includes('skipWaiting()') || sw.includes('clients.claim()')) {
violations.push('sw.js: eager service-worker takeover can mix HTML and JS versions');
}
if (!sw.includes("cache: 'no-store'")) {
violations.push('sw.js: network-first core fetches should bypass the HTTP cache');
violations.push('sw.js: network-first fetches should bypass the HTTP cache');
}
if (sw.includes('caches.match(')) {
violations.push('sw.js: cross-cache lookup can serve stale assets');
}
for (const asset of ['./index.html', './app.js', './app.css', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png']) {
if (!sw.includes(asset)) violations.push(`sw.js: precache entry missing for ${asset}`);

for (const asset of ['./index.html', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png', appJsPath, appCssPath]) {
if (asset && !sw.includes(asset)) violations.push(`sw.js: precache entry missing for ${asset}`);
}

if (sw.includes("'./app.js'") || sw.includes("'./app.css'")) {
violations.push('sw.js: fixed-name application assets must not be referenced');
}

if (fontCount === 0) {
Expand All @@ -125,4 +150,6 @@ if (violations.length) {
process.exit(1);
}

console.log(`Offline/PWA verification passed. ${fontCount} local font asset(s), install manifest and versioned service worker verified.`);
console.log(
`Offline/PWA verification passed. Hashed bundles ${appJsPath} / ${appCssPath}, ${fontCount} local font asset(s), install manifest and versioned service worker verified.`
);
19 changes: 2 additions & 17 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4855,20 +4855,5 @@ document.getElementById('insert-modal').addEventListener('click', e => {
renderTabs();
initEditor(files[activeIdx].content);

// ================================================================
// PWA / SERVICE WORKER
// ================================================================

function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
if (!['http:', 'https:'].includes(window.location.protocol)) return;

window.addEventListener('load', () => {
navigator.serviceWorker
.register('./sw.js', { scope: './' })
.then(registration => registration.update().catch(() => {}))
.catch(error => console.warn('MathNote service worker registration failed:', error));
});
}

registerServiceWorker();
// Service worker registration is intentionally handled by the small inline
// bootstrap in index.html. This keeps PWA recovery independent from app boot.
4 changes: 3 additions & 1 deletion src/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const APP_SHELL = new URL('./index.html', self.registration.scope).toString();

const scopedUrl = path => new URL(path, self.registration.scope).toString();
const NETWORK_FIRST_PATHS = new Set(
['./app.js', './app.css', './manifest.webmanifest'].map(
['./manifest.webmanifest'].map(
path => new URL(path, self.registration.scope).pathname
)
);
Expand Down Expand Up @@ -84,5 +84,7 @@ self.addEventListener('fetch', event => {
return;
}

// Hashed JS/CSS/font assets are immutable for a given URL, so cache-first
// keeps offline startup fast without allowing cross-version bundle mixing.
event.respondWith(cacheFirst(request));
});
Loading