diff --git a/docs/DATADOG.md b/docs/DATADOG.md index 99c552e63..a9bc269e4 100644 --- a/docs/DATADOG.md +++ b/docs/DATADOG.md @@ -158,7 +158,7 @@ audio_only, color_adjustments, sharpen, denoise, stabilization, fill_framing, custom_size, crop. Interactive features: `history` (undo, redo), `project_file` (save, open), `preview_tool` (compare, grid, framing_guide, save_frame), -`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder), `text_tool` (import_captions, set_timing, caption_box), `join_clips` (add, reorder, start, done, cancel, undo), `soundtrack` (add, remove, replace, mix, loop). Only these names are ever sent; never values, +`safe_area`, `export_profile`, `trim_timeline` (seek, drag_handle), `crop_tool` (aspect, drag, reset), `batch_export` (start, done, done_with_failures, cancel, save_folder), `text_tool` (import_captions, set_timing, caption_box), `join_clips` (add, reorder, start, done, cancel, undo), `soundtrack` (add, remove, replace, mix, loop), `pwa` (installed, offline_ready, update). Only these names are ever sent; never values, names or text. **Synthetic check.** A Datadog Synthetic browser test opens production every diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0e62b3d90..aea530ab8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -64,6 +64,26 @@ npx netlify-cli deploy --dir out # draft URL npx netlify-cli deploy --dir out --prod # production (maintainers only) ``` +## Offline support (service worker) + +`public/sw.js` lets the editor open and export without a connection once it +has been used online. `bun run build` ends with +`scripts/build-sw-manifest.mjs`, which writes the list of files to +precache, and a version hash of their contents, into `out/sw.js`. Every +deploy that changes the site therefore changes `sw.js`, which is how +browsers notice the update. Visitors get a "Reload" notice; the page +never reloads by itself. + +- Serve `/sw.js` with `Cache-Control: no-cache`, so browsers always check + it for updates. `netlify.toml`, `public/_headers` and `vercel.json` all + do this. +- Cached pages keep the headers they were served with, COOP/COEP included, + so offline behaves like online. Fix isolation on the host, not in the + worker. +- The FFmpeg cores (jsDelivr) are cached the first time they download and + still pass their SRI checks. User videos and exports are never cached. +- Development builds (`bun run dev`) don't register the worker. + ## Other static hosts | Host | Headers | Notes | diff --git a/docs/USAGE.md b/docs/USAGE.md index 2302faf03..d74a3f7b2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -159,6 +159,18 @@ field. Reframe works it out when the file loads; this can take a moment for long recordings. +## Offline and installing + +After you've opened a video once with a connection, Reframe also works +offline: the editor and its video engine are saved in your browser, and +the footer says **Works offline** once both are. With the network off you +can open the site, load videos and export as usual. Browsers can install it +as an app (the install button in the address bar, or **Add to Home +Screen** on phones). When a new version is out, a small notice offers +**Reload**; nothing reloads by itself, so an export is never interrupted. +The saved copy takes about 35 MB, and the browser may clear it when space +runs low, in which case it's saved again next time you're online. + ## Privacy Your videos never leave your device. Reframe collects anonymous performance diff --git a/netlify.toml b/netlify.toml index 9018a7dc4..582dc43cb 100644 --- a/netlify.toml +++ b/netlify.toml @@ -54,3 +54,11 @@ Strict-Transport-Security = "max-age=31536000; includeSubDomains" Cross-Origin-Opener-Policy = "same-origin" Cross-Origin-Embedder-Policy = "require-corp" + +# The service worker (public/sw.js) must always be re-checked, or browsers +# keep running an old one after a deploy. Its content changes with every +# build (scripts/build-sw-manifest.mjs), which is how updates are noticed. +[[headers]] + for = "/sw.js" + [headers.values] + Cache-Control = "no-cache" diff --git a/package.json b/package.json index 723be2926..9af43be3f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev --webpack", - "build": "next build --webpack", + "build": "next build --webpack && node scripts/build-sw-manifest.mjs out", "start": "next start", "lint": "eslint .", "test": "vitest", diff --git a/public/_headers b/public/_headers index 0462c40a6..5a95c6bf0 100644 --- a/public/_headers +++ b/public/_headers @@ -21,3 +21,7 @@ Strict-Transport-Security: max-age=31536000; includeSubDomains Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp + +# The service worker must always be re-checked (see netlify.toml). +/sw.js + Cache-Control: no-cache diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 000000000..dd446e205 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 000000000..c21219b98 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 000000000..e40b98ed5 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 000000000..9fee289c4 Binary files /dev/null and b/public/icons/maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..8e8ee8856 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Reframe — video editor", + "short_name": "Reframe", + "description": "Resize, trim, crop and export videos on your device. Works offline once opened.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f5f5f3", + "theme_color": "#12100e", + "categories": ["photo", "productivity", "utilities"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 000000000..41e3127a3 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,111 @@ +/* Reframe service worker: opens the editor offline. + * + * - The app's own files are precached on install from PRECACHE, which + * scripts/build-sw-manifest.mjs writes into the built copy of this file. + * Pages are fetched from the network first (so a deploy shows at once) and + * from the cache when offline; other files come from the cache first. + * - The video engine (FFmpeg core files on jsDelivr) is kept in its own + * cache the first time it downloads, and served from there after. They are + * CORS responses, so the browser still checks their SRI hashes. + * - A cached page keeps the headers it was served with, including the + * cross-origin isolation ones (COOP/COEP), so the multi-threaded engine + * works offline wherever it works online. + * - User videos and exports are never cached: they are blob: URLs, which + * never reach a service worker. + */ +const PRECACHE = null; // replaced at build time + +const APP_CACHE_PREFIX = "reframe-app-"; +const ENGINE_CACHE = "reframe-engine-v1"; +const ENGINE_URLS = ["https://cdn.jsdelivr.net/npm/@ffmpeg/core@", "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"]; +/** How long a page request waits for the network before using the cache. */ +const PAGE_TIMEOUT_MS = 4000; + +const appCache = PRECACHE ? `${APP_CACHE_PREFIX}${PRECACHE.version}` : null; + +/** What to do with a request: "engine", "page", "asset" or "network" (leave it alone). */ +function route(url, request) { + if (request.method !== "GET") return "network"; + if (ENGINE_URLS.some((prefix) => url.href.startsWith(prefix))) return "engine"; + if (url.origin !== self.location.origin || !appCache) return "network"; + if (request.mode === "navigate") return "page"; + if (url.pathname === "/sw.js") return "network"; + return "asset"; +} + +/** The precached page for a path: "/contact/" and "/contact.html" are "/contact"; unknown paths get "/". */ +function pageKey(pathname) { + const path = pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/"; + const name = path === "/index" ? "/" : path; + return PRECACHE.pages.includes(name) ? name : "/"; +} + +async function page(request, url) { + const cached = () => caches.open(appCache).then((cache) => cache.match(pageKey(url.pathname))); + try { + const network = await Promise.race([ + fetch(request), + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PAGE_TIMEOUT_MS)), + ]); + return network; + } catch (error) { + const hit = await cached(); + if (hit) return hit; + throw error; + } +} + +async function asset(request) { + const cache = await caches.open(appCache); + // Next's page data requests carry a ?_rsc= query; the file is the same. + const hit = await cache.match(request, { ignoreSearch: true }); + return hit ?? fetch(request); +} + +async function engine(request) { + const cache = await caches.open(ENGINE_CACHE); + const hit = await cache.match(request.url); + if (hit) return hit; + const response = await fetch(request); + if (response.ok && response.type === "cors") { + await cache.put(request.url, response.clone()); + // The page checks whether every file it needs is in now. + const clients = await self.clients.matchAll({ includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: "engine-cached" })); + } + return response; +} + +self.addEventListener("install", (event) => { + if (!appCache) return; + event.waitUntil( + caches + .open(appCache) + .then((cache) => cache.addAll([...PRECACHE.pages, ...PRECACHE.assets].map((url) => new Request(url, { cache: "reload" })))) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key.startsWith(APP_CACHE_PREFIX) && key !== appCache).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +// The page asks for this when the user chooses to load an update. +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "skip-waiting") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + const kind = route(url, event.request); + if (kind === "engine") event.respondWith(engine(event.request)); + else if (kind === "page") event.respondWith(page(event.request, url)); + else if (kind === "asset") event.respondWith(asset(event.request)); +}); + +// For tests (test code reads these; the browser ignores them). +self.__reframeSw = { route, pageKey, ENGINE_CACHE, APP_CACHE_PREFIX }; diff --git a/scripts/build-sw-manifest.mjs b/scripts/build-sw-manifest.mjs new file mode 100644 index 000000000..025de3cf9 --- /dev/null +++ b/scripts/build-sw-manifest.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Lists the static export's files for the service worker (public/sw.js) to +// precache, so the editor opens offline. Runs after `next build`: +// +// node scripts/build-sw-manifest.mjs [out] +// +// Writes { version, pages, assets } into out/sw.js in place of its +// placeholder. `version` is a hash of every listed file's contents, so a +// deploy that changes anything changes sw.js too: browsers then install the +// new worker, which precaches into a new cache and removes the old one. +// Source maps, the headers file and sw.js itself are left out. +import { createHash } from "node:crypto"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SKIP = [/\.map$/, /^_headers$/, /^sw\.js$/, /(^|\/)\.DS_Store$/]; +/** The line in public/sw.js that the build replaces. */ +export const PLACEHOLDER = "const PRECACHE = null; // replaced at build time"; +/** Error pages aren't opened directly, so they aren't precached as pages. */ +const SKIP_PAGES = new Set(["404", "_not-found"]); + +/** + * Splits the export's files (paths relative to out/, with "/") into page + * URLs ("index.html" → "/", "contact.html" → "/contact") and asset URLs. + */ +export function precacheList(files) { + const pages = []; + const assets = []; + for (const file of [...files].sort()) { + if (SKIP.some((re) => re.test(file))) continue; + if (file.endsWith(".html") && !file.includes("/")) { + const name = file.slice(0, -".html".length); + if (SKIP_PAGES.has(name)) continue; + pages.push(name === "index" ? "/" : `/${name}`); + continue; + } + if (file.endsWith(".html")) continue; + assets.push(`/${file}`); + } + return { pages, assets }; +} + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => (entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)])) + ); + return files.flat(); +} + +export async function buildManifest(root) { + const files = (await walk(root)).map((path) => relative(root, path).split(sep).join("/")); + const list = precacheList(files); + const hash = createHash("sha256"); + const included = new Set([...list.assets.map((a) => a.slice(1)), ...files.filter((f) => f.endsWith(".html"))]); + for (const file of [...included].sort()) { + hash.update(file); + hash.update(await readFile(join(root, file))); + } + return { version: hash.digest("hex").slice(0, 16), ...list }; +} + +/** sw.js with the precache list in place of its placeholder. */ +export function injectManifest(source, manifest) { + if (!source.includes(PLACEHOLDER)) throw new Error("sw.js has no precache placeholder"); + return source.replace(PLACEHOLDER, `const PRECACHE = ${JSON.stringify(manifest)};`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const root = process.argv[2] ?? "out"; + const manifest = await buildManifest(root); + const swPath = join(root, "sw.js"); + await writeFile(swPath, injectManifest(await readFile(swPath, "utf8"), manifest)); + console.log(`sw.js: ${manifest.pages.length} pages, ${manifest.assets.length} assets, version ${manifest.version}`); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d15a5478b..a87bf5df1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -64,7 +64,9 @@ export const metadata: Metadata = { icons: { icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], shortcut: "/favicon.svg", + apple: "/icons/apple-touch-icon.png", }, + manifest: "/manifest.webmanifest", }; export default function RootLayout({ diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a57a9c5a5..936b865c7 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import OfflineStatus from "./OfflineStatus"; import { Github, Twitter, @@ -51,6 +52,7 @@ export default function Footer() { ))} + {/* Navigation */} diff --git a/src/components/OfflineStatus.tsx b/src/components/OfflineStatus.tsx new file mode 100644 index 000000000..506360098 --- /dev/null +++ b/src/components/OfflineStatus.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { CloudOff, RefreshCw, X } from "lucide-react"; +import { useOfflineSupport, type OfflineState } from "@/hooks/useOfflineSupport"; + +const TEXT: Record, string> = { + preparing: "Saving the editor for offline use…", + "needs-engine": "Open a video once while online to use the editor offline.", + ready: "Works offline", +}; + +/** + * Whether the editor opens offline (in the footer), and a small notice when + * a new version is ready to load. + */ +export default function OfflineStatus() { + const { state, updateReady, applyUpdate, dismissUpdate } = useOfflineSupport(); + return ( + <> + {state !== "unavailable" && ( +

+

+ )} + {updateReady && ( +
+ A new version of Reframe is ready. + + +
+ )} + + ); +} diff --git a/src/hooks/useOfflineSupport.ts b/src/hooks/useOfflineSupport.ts new file mode 100644 index 000000000..896629c35 --- /dev/null +++ b/src/hooks/useOfflineSupport.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { trackFeature } from "@/lib/featureTelemetry"; +import { engineFileUrls } from "@/lib/engineUrls"; + +/** Must match ENGINE_CACHE in public/sw.js. */ +const ENGINE_CACHE = "reframe-engine-v1"; +const REPORTED_KEY = "reframe:offline-ready-reported"; + +export type OfflineState = + /** No service worker (unsupported, or a development build). */ + | "unavailable" + /** The app is being saved for offline use. */ + | "preparing" + /** The app is saved, but the video engine hasn't been downloaded yet. */ + | "needs-engine" + /** The editor and the engine both open offline. */ + | "ready"; + +async function engineCached(): Promise { + if (typeof caches === "undefined") return false; + try { + const cache = await caches.open(ENGINE_CACHE); + const cached = new Set((await cache.keys()).map((request) => request.url)); + return engineFileUrls(self.crossOriginIsolated === true).every((url) => cached.has(url)); + } catch { + return false; + } +} + +/** + * Registers the service worker (production builds only) and reports whether + * the editor works offline yet. When a new version has been installed, + * `updateReady` is true and `applyUpdate` switches to it and reloads; it + * never reloads on its own, so it can't interrupt an export. + */ +export function useOfflineSupport() { + const [state, setState] = useState("unavailable"); + const [waiting, setWaiting] = useState(null); + + useEffect(() => { + if (process.env.NODE_ENV !== "production" || typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; + let cancelled = false; + const sw = navigator.serviceWorker; + + const refresh = async () => { + if (cancelled) return; + if (!sw.controller) { + setState("preparing"); + return; + } + const ready = await engineCached(); + if (cancelled) return; + setState(ready ? "ready" : "needs-engine"); + if (ready) { + try { + if (!localStorage.getItem(REPORTED_KEY)) { + localStorage.setItem(REPORTED_KEY, "1"); + trackFeature("pwa", "offline_ready"); + } + } catch { + // storage blocked: report nothing + } + } + }; + + const watch = (registration: ServiceWorkerRegistration) => { + // A worker waiting while another controls the page is an update. + if (registration.waiting && sw.controller) setWaiting(registration.waiting); + registration.addEventListener("updatefound", () => { + const installing = registration.installing; + installing?.addEventListener("statechange", () => { + if (installing.state === "installed" && sw.controller) setWaiting(installing); + if (installing.state === "activated") void refresh(); + }); + }); + }; + + const onMessage = (event: MessageEvent) => { + if (event.data?.type === "engine-cached") void refresh(); + }; + const onControllerChange = () => void refresh(); + const onInstalled = () => trackFeature("pwa", "installed"); + + sw.addEventListener("message", onMessage); + sw.addEventListener("controllerchange", onControllerChange); + window.addEventListener("appinstalled", onInstalled); + sw.register("/sw.js", { scope: "/" }) + .then((registration) => { + if (!cancelled) watch(registration); + return refresh(); + }) + .catch(() => { + if (!cancelled) setState("unavailable"); + }); + + return () => { + cancelled = true; + sw.removeEventListener("message", onMessage); + sw.removeEventListener("controllerchange", onControllerChange); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + + const applyUpdate = useCallback(() => { + if (!waiting) return; + trackFeature("pwa", "update"); + navigator.serviceWorker.addEventListener("controllerchange", () => window.location.reload(), { once: true }); + waiting.postMessage({ type: "skip-waiting" }); + }, [waiting]); + + return { state, updateReady: waiting !== null, applyUpdate, dismissUpdate: () => setWaiting(null) }; +} diff --git a/src/lib/engineUrls.ts b/src/lib/engineUrls.ts new file mode 100644 index 000000000..67e4f1f69 --- /dev/null +++ b/src/lib/engineUrls.ts @@ -0,0 +1,25 @@ +/** + * Where the FFmpeg cores are downloaded from. Shared by the engine worker + * (src/lib/ffmpeg.worker.ts, which checks each file's SRI hash) and the + * offline check (src/hooks/useOfflineSupport.ts, which looks for these files + * in the service worker's cache). + */ +export const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; +// MT core aligned to the same version and distribution format as CORE_BASE_URL. +// Previously used @0.12.6/dist/esm which caused two problems: +// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied +// to different file contents, causing all MT fetches to fail with an integrity +// error instead of falling back gracefully. +// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and +// produced subtle runtime failures in crossOriginIsolated contexts. +export const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd"; + +/** + * The engine files a page loads: the multi-threaded core when cross-origin + * isolated, which also needs its worker script, otherwise the + * single-threaded one. + */ +export function engineFileUrls(isolated: boolean): string[] { + const base = isolated ? MT_CORE_BASE_URL : CORE_BASE_URL; + return ["ffmpeg-core.js", "ffmpeg-core.wasm", ...(isolated ? ["ffmpeg-core.worker.js"] : [])].map((name) => `${base}/${name}`); +} diff --git a/src/lib/featureTelemetry.ts b/src/lib/featureTelemetry.ts index 5e3602139..e29d41e6f 100644 --- a/src/lib/featureTelemetry.ts +++ b/src/lib/featureTelemetry.ts @@ -22,6 +22,7 @@ export const FEATURE_ACTIONS = { text_tool: ["import_captions", "set_timing", "caption_box"], join_clips: ["add", "reorder", "start", "done", "cancel", "undo"], soundtrack: ["add", "remove", "replace", "mix", "loop"], + pwa: ["installed", "offline_ready", "update"], // One event per editing tool an export used (see toolsUsed). trim: ["exported"], rotate: ["exported"], diff --git a/src/lib/ffmpeg.worker.ts b/src/lib/ffmpeg.worker.ts index 49f0e5387..84e6bac1e 100644 --- a/src/lib/ffmpeg.worker.ts +++ b/src/lib/ffmpeg.worker.ts @@ -16,15 +16,7 @@ import { } from "./ffmpegArgs"; import { frameRateFromLog } from "./joinClips"; -const CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; -// MT core aligned to the same version and distribution format as CORE_BASE_URL. -// Previously used @0.12.6/dist/esm which caused two problems: -// 1. The version mismatch meant SRI hashes computed for @0.12.10 were applied -// to different file contents, causing all MT fetches to fail with an integrity -// error instead of falling back gracefully. -// 2. The ESM distribution is not consumed the same way as UMD by ffmpeg.js and -// produced subtle runtime failures in crossOriginIsolated contexts. -const MT_CORE_BASE_URL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@0.12.10/dist/umd"; +import { CORE_BASE_URL, MT_CORE_BASE_URL } from "./engineUrls"; // Keys are full URLs so hashes for the non-MT and MT packages never collide. // Every engine file is integrity-checked, so a tampered CDN response fails to diff --git a/src/lib/tests/deployConfig.test.ts b/src/lib/tests/deployConfig.test.ts index cfed8dedf..c964d13c6 100644 --- a/src/lib/tests/deployConfig.test.ts +++ b/src/lib/tests/deployConfig.test.ts @@ -67,9 +67,11 @@ describe("cross-origin isolation compatibility", () => { // Under COEP require-corp, cross-origin loads need CORS (or CORP). // fetch() defaults to mode "cors"; jsdelivr answers with // Access-Control-Allow-Origin: *. The cores are then used as blob: URLs, - // which are same-origin. - expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@"); - expect(worker).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"); + // which are same-origin. The URLs live in engineUrls.ts. + const urls = read("src/lib/engineUrls.ts"); + expect(worker).toContain('from "./engineUrls"'); + expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core@"); + expect(urls).toContain("https://cdn.jsdelivr.net/npm/@ffmpeg/core-mt@"); expect(worker).not.toMatch(/mode:\s*["']no-cors["']/); }); diff --git a/src/lib/tests/serviceWorker.test.ts b/src/lib/tests/serviceWorker.test.ts new file mode 100644 index 000000000..11ecf2d31 --- /dev/null +++ b/src/lib/tests/serviceWorker.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { precacheList, injectManifest, PLACEHOLDER } from "../../../scripts/build-sw-manifest.mjs"; +import { engineFileUrls } from "../engineUrls"; + +const SOURCE = readFileSync(join(__dirname, "../../../public/sw.js"), "utf8"); + +type Route = (url: URL, request: { method: string; mode: string }) => string; + +/** Runs sw.js against a stand-in `self`, as the browser would, and returns its test hooks. */ +function loadWorker(precache: unknown) { + const listeners: string[] = []; + const self = { + location: { origin: "https://reframe.test" }, + addEventListener: (type: string) => listeners.push(type), + } as Record; + const source = precache ? injectManifest(SOURCE, precache) : SOURCE; + new Function("self", "caches", source)(self, {}); + return { ...(self.__reframeSw as { route: Route; pageKey: (path: string) => string }), listeners }; +} + +const PRECACHE = { version: "abc", pages: ["/", "/contact", "/privacy"], assets: ["/_next/static/chunks/app.js"] }; +const get = (mode = "cors") => ({ method: "GET", mode }); + +describe("precache list (scripts/build-sw-manifest.mjs)", () => { + it("turns HTML files into page URLs and keeps everything else as assets", () => { + const list = precacheList([ + "index.html", + "contact.html", + "privacy.html", + "404.html", + "_not-found.html", + "contact/__next._tree.txt", + "_next/static/chunks/app.js", + "_next/static/chunks/app.js.map", + "_headers", + "sw.js", + "fonts/Geist-Regular.ttf", + ]); + expect(list.pages).toEqual(["/contact", "/", "/privacy"]); + expect(list.assets).toEqual(["/_next/static/chunks/app.js", "/contact/__next._tree.txt", "/fonts/Geist-Regular.ttf"]); + }); + + it("writes the list into sw.js in place of its placeholder", () => { + expect(SOURCE).toContain(PLACEHOLDER); + const built = injectManifest(SOURCE, PRECACHE); + expect(built).not.toContain(PLACEHOLDER); + expect(built).toContain('const PRECACHE = {"version":"abc"'); + expect(() => injectManifest("// no placeholder", PRECACHE)).toThrow(/placeholder/); + }); +}); + +describe("service worker routing (public/sw.js)", () => { + const sw = loadWorker(PRECACHE); + + it("handles install, activate, fetch and update messages", () => { + expect(sw.listeners).toEqual(expect.arrayContaining(["install", "activate", "fetch", "message"])); + }); + + it("keeps every engine file the page loads, in either mode", () => { + for (const url of [...engineFileUrls(true), ...engineFileUrls(false)]) { + expect(sw.route(new URL(url), get())).toBe("engine"); + } + }); + + it("serves the app's pages and files, and leaves everything else alone", () => { + expect(sw.route(new URL("https://reframe.test/?preset=square"), get("navigate"))).toBe("page"); + expect(sw.route(new URL("https://reframe.test/_next/static/chunks/app.js"), get())).toBe("asset"); + expect(sw.route(new URL("https://reframe.test/sw.js"), get())).toBe("network"); + expect(sw.route(new URL("https://reframe.test/"), { method: "POST", mode: "cors" })).toBe("network"); + // Other sites (analytics, other CDN files) are never cached. + expect(sw.route(new URL("https://browser-intake-datadoghq.com/api"), get())).toBe("network"); + expect(sw.route(new URL("https://cdn.jsdelivr.net/npm/other@1/x.js"), get())).toBe("network"); + }); + + it("finds the cached page for a path, and the editor for anything unknown", () => { + expect(sw.pageKey("/")).toBe("/"); + expect(sw.pageKey("/contact")).toBe("/contact"); + expect(sw.pageKey("/contact/")).toBe("/contact"); + expect(sw.pageKey("/privacy.html")).toBe("/privacy"); + expect(sw.pageKey("/index.html")).toBe("/"); + expect(sw.pageKey("/nope")).toBe("/"); + }); + + it("does nothing for the app itself before a build has written the list", () => { + const dev = loadWorker(null); + expect(dev.route(new URL("https://reframe.test/"), get("navigate"))).toBe("network"); + expect(dev.route(new URL(engineFileUrls(false)[1]!), get())).toBe("engine"); + }); +}); diff --git a/test/e2e/offline.spec.ts b/test/e2e/offline.spec.ts new file mode 100644 index 000000000..36aa2c224 --- /dev/null +++ b/test/e2e/offline.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +// Offline support through the real browser: the service worker saves the +// app and the video engine, then with the network off the editor still +// opens, is cross-origin isolated exactly as online, and exports. +const FIXTURES = join(__dirname, "..", "fixtures"); + +async function openVideo(page: Page) { + await page.locator('input[type=file][accept="video/*"]').first().setInputFiles(join(FIXTURES, "normal.mp4")); + await expect(page.getByRole("button", { name: "Export video" }).first()).toBeVisible(); +} + +test("the editor opens and exports with the network off once it has been used online", async ({ page, context }, testInfo) => { + test.slow(); + await page.addInitScript(() => localStorage.setItem("reframe_onboarding_complete", "1")); + await page.goto("/"); + const onlineIsolated = await page.evaluate(() => crossOriginIsolated); + expect(onlineIsolated).toBe(testInfo.project.name === "multi-threaded"); + + // The worker installs, precaches the app and takes control of the page. + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); + } + }); + const status = page.getByTestId("offline-status"); + await expect(status).toHaveAttribute("data-state", "needs-engine"); + + // Opening a video loads the engine (for the filmstrip), which the worker keeps. + await openVideo(page); + await expect(status).toHaveAttribute("data-state", "ready", { timeout: 120_000 }); + await expect(status).toHaveText("Works offline"); + + await context.setOffline(true); + await page.reload(); + await expect(page.locator('input[type=file][accept="video/*"]').first()).toBeAttached(); + expect(await page.evaluate(() => navigator.onLine)).toBe(false); + // Same isolation as online, so the same engine loads. + expect(await page.evaluate(() => crossOriginIsolated)).toBe(onlineIsolated); + + await openVideo(page); + await page.getByRole("button", { name: /^Trim\b/ }).first().click(); + await page.getByLabel("Trim end time in seconds").fill("2"); + await page.getByRole("button", { name: "Export video" }).first().click(); + const download = page.getByRole("link", { name: /^Download MP4$/ }); + await expect(download).toBeVisible({ timeout: 180_000 }); + const [file] = await Promise.all([page.waitForEvent("download"), download.click()]); + const out = testInfo.outputPath("offline.mp4"); + await file.saveAs(out); + const seconds = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", out], { encoding: "utf8" })); + expect(seconds).toBeGreaterThan(1.8); + expect(seconds).toBeLessThan(2.3); +}); + +test("the app can be installed: the manifest is linked and complete", async ({ page, request }) => { + await page.goto("/"); + const href = await page.locator('link[rel="manifest"]').getAttribute("href"); + expect(href).toBe("/manifest.webmanifest"); + const manifest = await (await request.get(href!)).json(); + expect(manifest).toMatchObject({ name: expect.any(String), short_name: "Reframe", start_url: "/", display: "standalone" }); + const sizes = manifest.icons.map((icon: { sizes: string }) => icon.sizes); + expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"])); + expect(manifest.icons.some((icon: { purpose?: string }) => icon.purpose === "maskable")).toBe(true); + for (const icon of manifest.icons) expect((await request.get(icon.src)).ok()).toBe(true); +}); diff --git a/vercel.json b/vercel.json index 709d0f724..320affb8e 100644 --- a/vercel.json +++ b/vercel.json @@ -22,6 +22,10 @@ "value": "require-corp" } ] + }, + { + "source": "/sw.js", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] }