deno.land / x / esm@v135_2 / hot.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367/*! 🔥 esm.sh/hot * Docs: https://til.esm.sh/hot */
/// <reference lib="webworker" />
import type { ArchiveEntry, FireOptions, HotCore, Plugin } from "./server/embed/types/hot.d.ts";
const VERSION = 135;const doc: Document | undefined = globalThis.document;const kHot = "esm.sh/hot";const kMessage = "message";const kVfs = "vfs";const kTypeEsmArchive = "application/esm-archive";const kHotArchive = "#hot-archive";
/** class `VFS` implements the virtual file system by using indexed database. */class VFS { #db: IDBDatabase | Promise<IDBDatabase>;
constructor(scope: string, version: number) { const req = indexedDB.open(scope, version); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(kVfs)) { db.createObjectStore(kVfs, { keyPath: "name" }); } }; this.#db = waitIDBRequest<IDBDatabase>(req); }
async #begin(readonly = false) { let db = this.#db; if (db instanceof Promise) { db = this.#db = await db; } return db.transaction(kVfs, readonly ? "readonly" : "readwrite") .objectStore(kVfs); }
async has(name: string) { const tx = await this.#begin(true); return await waitIDBRequest<string>(tx.getKey(name)) === name; }
async get(name: string) { const tx = await this.#begin(true); const ret = await waitIDBRequest<File & { content: ArrayBuffer } | undefined>(tx.get(name)); if (ret) { return new File([ret.content], ret.name, ret); } }
async put(file: File) { const { name, type, lastModified } = file; if (await this.has(name)) { return name; } const content = await file.arrayBuffer(); const tx = await this.#begin(); return waitIDBRequest<string>(tx.put({ name, type, lastModified, content })); }
async delete(name: string) { const tx = await this.#begin(); return waitIDBRequest<void>(tx.delete(name)); }}
/** * class `Archive` implements the reader for esm-archive format. * more details see https://www.npmjs.com/package/esm-archive */class Archive { #buffer: ArrayBuffer; #entries: Record<string, ArchiveEntry> = {};
static invalidFormat = new Error("Invalid esm-archive format");
constructor(buffer: ArrayBuffer) { this.#buffer = buffer; this.#parse(); }
public checksum: number;
#parse() { const dv = new DataView(this.#buffer); const decoder = new TextDecoder(); const readUint32 = (offset: number) => dv.getUint32(offset); const readString = (offset: number, length: number) => decoder.decode(new Uint8Array(this.#buffer, offset, length)); if (this.#buffer.byteLength < 18 || readString(0, 10) !== "ESMARCHIVE") { throw Archive.invalidFormat; } const length = readUint32(10); if (length !== this.#buffer.byteLength) { throw Archive.invalidFormat; } this.checksum = readUint32(14); let offset = 18; while (offset < dv.byteLength) { const nameLen = dv.getUint16(offset); offset += 2; const name = readString(offset, nameLen); offset += nameLen; const typeLen = dv.getUint8(offset); offset += 1; const type = readString(offset, typeLen); offset += typeLen; const lastModified = readUint32(offset) * 1000; // convert to ms offset += 4; const size = readUint32(offset); offset += 4; this.#entries[name] = { name, type, lastModified, offset, size }; offset += size; } }
exists(name: string) { return name in this.#entries; }
openFile(name: string) { const info = this.#entries[name]; return info ? new File([this.#buffer.slice(info.offset, info.offset + info.size)], info.name, info) : null; }}
/** class `Hot` implements the `HotCore` interface. */class Hot implements HotCore { #vfs = new VFS(kHot, VERSION); #swScript: string | null = null; #swActive: ServiceWorker | null = null; #archive: Archive | null = null; #fireListeners: ((sw: ServiceWorker) => void)[] = []; #promises: Promise<any>[] = []; #bc = new BroadcastChannel(kHot);
get vfs() { return this.#vfs; }
onUpdateFound = () => location.reload();
onFire(handler: (reg: ServiceWorker) => void) { if (this.#swActive) { handler(this.#swActive); } else { this.#fireListeners.push(handler); } return this; }
waitUntil(...promises: readonly Promise<void>[]) { this.#promises.push(...promises); return this; }
use(...plugins: readonly Plugin[]) { plugins.forEach((plugin) => plugin.setup(this)); return this; }
async fire(options: FireOptions = {}) { const sw = navigator.serviceWorker; if (!sw) { throw new Error("Service Worker not supported"); }
const { main, swScript = "/sw.js", swUpdateViaCache } = options;
// add preload link for the main module if it's provided if (main) { appendElement("link", { rel: "modulepreload", href: main }); }
if (this.#swScript === swScript) { return; } this.#swScript = swScript;
// register Service Worker const reg = await sw.register(this.#swScript, { type: "module", updateViaCache: swUpdateViaCache, }); const tryFireApp = async () => { if (reg.active?.state === "activated") { await this.#fireApp(reg.active); main && appendElement("script", { type: "module", src: main }); } };
// detect Service Worker update available and wait for it to become installed reg.onupdatefound = () => { const { installing } = reg; if (installing) { installing.onstatechange = () => { const { waiting } = reg; // it's first install if (waiting && !sw.controller) { waiting.onstatechange = tryFireApp; } }; } };
// detect controller change sw.oncontrollerchange = this.onUpdateFound;
// fire app immediately if there's an activated Service Worker tryFireApp(); }
async #fireApp(swActive: ServiceWorker) { // download and send esm archive to Service Worker queryElements<HTMLLinkElement>(`link[rel=preload][as=fetch][type^="${kTypeEsmArchive}"][href]`, (el) => { this.#promises.push( fetch(el.href).then((res) => { if (res.ok) { if (el.type.endsWith("+gzip")) { res = new Response(res.body?.pipeThrough(new DecompressionStream("gzip"))); } return res.arrayBuffer(); } return Promise.reject(new Error(res.statusText ?? `<${res.status}>`)); }).then((arrayBuffer) => { swActive.postMessage({ [kHotArchive]: arrayBuffer }); this.#bc.onmessage = (evt) => { if (evt.data === kHotArchive) { this.onUpdateFound(); } }; }).catch((err) => { console.error("Failed to fetch", el.href, err[kMessage]); }), ); });
// wait until all promises resolved await Promise.all(this.#promises); this.#promises = [];
// fire all `fire` listeners for (const handler of this.#fireListeners) { handler(swActive); } this.#fireListeners = []; this.#swActive = swActive;
// apply "[type=hot/module]" script tags queryElements<HTMLScriptElement>("script[type='hot/module']", (el) => { const copy = el.cloneNode(true) as HTMLScriptElement; copy.type = "module"; el.replaceWith(copy); }); }
listen() { // @ts-expect-error missing types if (typeof clients === "undefined") { throw new Error("Service Worker scope not found."); }
const vfs = this.#vfs; const on: typeof addEventListener = addEventListener; const serveVFS = async (name: string) => { const file = await vfs.get(name); if (!file) { return createResponse("Not Found", {}, 404); } return createResponse(file, { "content-type": file.type }); };
this.#promises.push( vfs.get(kHotArchive).then(async (file) => { if (file) { this.#archive = new Archive(await file.arrayBuffer()); } }).catch((err) => console.error(err[kMessage])), );
on("install", (evt) => { // @ts-expect-error missing types skipWaiting(); evt.waitUntil(Promise.all(this.#promises)); });
on("activate", (evt) => { // @ts-expect-error missing types evt.waitUntil(clients.claim()); });
on("fetch", (evt) => { const { request } = evt as FetchEvent; const respondWith = (res: Response | Promise<Response>) => evt.respondWith(res); const url = new URL(request.url); const { pathname } = url; const archive = this.#archive; if (url.origin === location.origin && pathname.startsWith("/@hot/")) { respondWith(serveVFS(pathname.slice(6))); } if (archive?.exists(request.url)) { const file = archive.openFile(request.url)!; respondWith(createResponse(file, { "content-type": file.type })); } });
on(kMessage, (evt) => { const { data } = evt; if (typeof data === "object" && data !== null) { const buffer = data[kHotArchive]; if (buffer instanceof ArrayBuffer) { try { const archive = new Archive(buffer); if (archive.checksum !== this.#archive?.checksum) { this.#archive = archive; this.#bc.postMessage(kHotArchive); vfs.put(new File([buffer], kHotArchive, { type: kTypeEsmArchive })); } } catch (err) { console.error(err[kMessage]); } } } }); }}
/** query all elements by the given selectors. */function queryElements<T extends Element>( selectors: string, callback: (value: T) => void,) { // @ts-expect-error throw error if document is not available doc.querySelectorAll(selectors).forEach(callback);}
/** create a response object. */function createResponse( body: BodyInit | null, headers: HeadersInit = {}, status = 200,): Response { return new Response(body, { headers, status });}
/** append an element to the document. */function appendElement(tag: string, attrs: Record<string, string>, pos: "head" | "body" = "head") { const el = doc!.createElement(tag); for (const [k, v] of Object.entries(attrs)) { el[k] = v; } doc![pos].appendChild(el);}
/** wait for the given IDBRequest. */function waitIDBRequest<T>(req: IDBRequest): Promise<T> { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });}
export const hot = new Hot();export default hot;
Version Info