Skip to content

Commit

Permalink
hot: Add hmr plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
ije committed Nov 29, 2023
1 parent 834a5e2 commit d96c7e0
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 94 deletions.
118 changes: 68 additions & 50 deletions hot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
/// <reference lib="webworker" />

interface Plugin {
name?: string;
name: string;
devOnly?: boolean;
setup: (hot: Hot) => void;
}

Expand All @@ -34,11 +35,15 @@ interface VfsRecord {
headers: [string, string][] | null;
}

interface ImportMap {
imports?: Record<string, string>;
scopes?: Record<string, Record<string, string>>;
}

const VERSION = 135;
const plugins: Plugin[] = [];
const doc = globalThis.document;
const enc = new TextEncoder();
const dec = new TextDecoder();
const kJsxImportSource = "@jsxImportSource";
const kSkipWaiting = "SKIP_WAITING";
const kVfs = "vfs";
Expand Down Expand Up @@ -106,6 +111,8 @@ class Hot {
fetcherListeners: { test: RegExp; handler: FetchHandler }[] = [];
swListeners: ((sw: ServiceWorker) => void)[] = [];
vfs: Record<string, (req?: Request) => Promise<VfsRecord>> = {};
customImports?: Record<string, string>;
isDev = location.hostname === "localhost";

register<T extends string | Uint8Array>(
name: string,
Expand All @@ -126,10 +133,14 @@ class Hot {
if (input instanceof Response) {
input = new Uint8Array(await input.arrayBuffer()) as T;
}
if (!isString(input) && !(input instanceof Uint8Array)) {
input = String(input) as T;
}
const hash = await computeHash(
isString(input) ? enc.encode(input) : input,
);
const cached = await vfs.get(name);
const url = `https://esm.sh/hot/${name}`;
const cached = await vfs.get(url);
if (cached && cached.hash === hash) {
return cached;
}
Expand All @@ -142,17 +153,15 @@ class Hot {
}
if (cached && doc) {
if (name.endsWith(".css")) {
const url = `https://esm.sh/hot/${name}`;
const el = doc.querySelector(`link[href="${url}"]`);
if (el) {
const copy = el.cloneNode(true) as HTMLLinkElement;
copy.href = url + "?" + hash;
el.replaceWith(copy);
}
}
console.log(`[hot] ${name} updated`);
}
await vfs.put(name, hash, data);
await vfs.put(url, hash, data);
return { name, hash, data, headers: null };
};
return this;
Expand All @@ -172,14 +181,14 @@ class Hot {
return this;
}

onActive(handler: (reg: ServiceWorker) => void) {
onFire(handler: (reg: ServiceWorker) => void) {
if (doc) {
this.swListeners.push(handler);
}
return this;
}

async fire(swUrl = "/sw.js") {
async fire(swName = "sw.js") {
if (!doc) {
throw new Error("Hot.fire() can't be called in Service Worker.");
}
Expand All @@ -189,33 +198,9 @@ class Hot {
throw new Error("Service Worker not supported.");
}

this.register(
"importmap.json",
() => {
const im = doc.querySelector("head>script[type=importmap]");
if (im) {
const v = JSON.parse(im.innerHTML);
const imports: Record<string, string> = {};
const supported = HTMLScriptElement.supports?.("importmap");
for (const k in v.imports) {
if (!supported || k === kJsxImportSource) {
imports[k] = v.imports[k];
}
}
if (supported && "scopes" in v) {
delete v.scopes;
}
return JSON.stringify({ ...v, imports });
}
return "{}";
},
(input) => input,
);
const updateVFS = Promise.all(
Object.values(this.vfs).map((handler) => handler()),
);

const reg = await sw.register(swUrl, { type: "module" });
const reg = await sw.register(new URL(swName, location.href), {
type: "module",
});
const { active, waiting } = reg;

// detect Service Worker update available and wait for it to become installed
Expand All @@ -230,7 +215,7 @@ class Hot {
// otherwise it's the first install
// invoke all vfs and store them to the database
// then reload the page
updateVFS.then(() => {
this.syncVFS().then(() => {
reload();
});
}
Expand All @@ -253,6 +238,12 @@ class Hot {
for (const handler of this.swListeners) {
handler(active);
}
await this.syncVFS();
doc.querySelectorAll("script[type='module/hot']").forEach((el) => {
const copy = el.cloneNode(true) as HTMLScriptElement;
copy.type = "module";
el.replaceWith(copy);
});
doc.querySelectorAll(
["iframe", "script", "link", "style"].map((t) => "hot-" + t).join(","),
).forEach(
Expand All @@ -261,17 +252,46 @@ class Hot {
el.getAttributeNames().forEach((name) => {
copy.setAttribute(name, el.getAttribute(name)!);
});
copy.textContent = el.textContent;
el.replaceWith(copy);
},
);
console.log("🔥 [hot] app fired.");
console.log("🔥 app fired.");
}
}

async syncVFS() {
if (doc) {
const script = doc.querySelector("head>script[type=importmap]");
const importMap: ImportMap = { imports: { ...this.customImports } };
if (script) {
const supported = HTMLScriptElement.supports?.("importmap");
const v = JSON.parse(script.innerHTML);
for (const k in v.imports) {
if (!supported || k === kJsxImportSource) {
importMap.imports![k] = v.imports[k];
}
}
if (!supported && "scopes" in v) {
importMap.scopes = v.scopes;
}
}
await vfs.put(
"importmap.json",
await computeHash(enc.encode(JSON.stringify(importMap))),
importMap as unknown as string,
);
await Promise.all(
Object.values(this.vfs).map((handler) => handler()),
);
}
}
}

// 🔥
const hot = new Hot();
plugins.forEach((plugin) => plugin.setup(hot));
Object.assign(globalThis, { HOT: hot });
export default hot;

// service worker environment
Expand Down Expand Up @@ -306,14 +326,14 @@ if (!doc) {
let hotCache: Cache | null = null;
const cacheFetch = async (req: Request) => {
if (req.method !== "GET") {
return fetch(req);
return fetch(req, { redirect: "manual" });
}
const cache = hotCache ?? (hotCache = await caches.open("hot/v" + VERSION));
let res = await cache.match(req);
if (res) {
return res;
}
res = await fetch(req);
res = await fetch(req, { redirect: "manual" });
if (!res.ok) {
return res;
}
Expand All @@ -327,25 +347,22 @@ if (!doc) {
const record = await hot.vfs[name](req);
return new Response(record.data, { headers });
}
const file = await vfs.get(name);
const file = await vfs.get(`https://esm.sh/hot/${name}`);
if (!file) {
return new Response("Not found", { status: 404 });
return fetch(req);
}
return new Response(file.data, { headers });
};

const isDev = new URL(import.meta.url).hostname === "localhost";
const jsHeaders = { "Content-Type": typesMap.get("js") + ";charset=utf-8" };
const noCacheHeaders = { "Cache-Control": "no-cache" };
const serveLoader = async (loader: Loader, url: URL) => {
const res = await fetch(url, { headers: isDev ? noCacheHeaders : {} });
const res = await fetch(url, { headers: hot.isDev ? noCacheHeaders : {} });
if (!res.ok) {
return res;
}
const im = await vfs.get("importmap.json");
const importMap: { imports?: Record<string, string> } = JSON.parse(
im?.data ? (isString(im.data) ? im.data : dec.decode(im.data)) : "{}",
);
const importMap: ImportMap = (im?.data as unknown) ?? {};
const jsxImportSource = isJsx(url.pathname)
? importMap.imports?.[kJsxImportSource]
: undefined;
Expand All @@ -360,10 +377,11 @@ if (!doc) {
});
}
try {
const { code, map, headers } = await loader.load(url, source, {
importMap,
isDev,
});
const { code, map, headers } = await loader.load(
url,
source,
{ importMap },
);
let body = code;
if (map) {
body +=
Expand Down
115 changes: 115 additions & 0 deletions server/embed/hot-plugins/hmr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/** @version: 0.57.7 */

const eventColors = {
modify: "#056CF0",
create: "#20B44B",
remove: "#F00C08",
};

export default {
name: "hmr",
devOnly: true,
setup(hot: any) {
hot.hmr = true;
hot.hmrModules = new Set<string>();
hot.hmrCallbacks = new Map<string, (module: any) => void>();
hot.customImports = {
...hot.customImports,
"@hmrRuntimeUrl": "https://esm.sh/hot/_hmr.js",
"@reactRefreshRuntimeUrl": "https://esm.sh/hot/_hmr_react.js",
};
hot.register("_hmr.js", () => "", () => {
return `
export default (path) => ({
decline() {
HOT.hmrModules.delete(path);
HOT.hmrCallbacks.set(path, () => location.reload());
},
accept(cb) {
const hmrModules = HOT.hmrModules ?? (HOT.hmrModules = new Set());
const hmrCallbacks = HOT.hmrCallbacks ?? (HOT.hmrCallbacks = new Map());
if (!HOT.hmrModules.has(path)) {
HOT.hmrModules.add(path);
HOT.hmrCallbacks.set(path, cb);
}
},
invalidate() {
location.reload();
}
})
`;
});
hot.register("_hmr_react.js", () => "", () => {
return `
// react-refresh
// @link https://github.com/facebook/react/issues/16604#issuecomment-528663101
import runtime from "https://esm.sh/v135/[email protected]/runtime";
let timer;
const refresh = () => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
runtime.performReactRefresh()
timer = null;
}, 30);
};
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => type => type;
export { refresh as __REACT_REFRESH__, runtime as __REACT_REFRESH_RUNTIME__ };
`;
});
hot.onFire((_sw: ServiceWorker) => {
const source = new EventSource(new URL("hot-notify", location.href));
source.addEventListener("fs-notify", async (ev) => {
const { type, name } = JSON.parse(ev.data);
const module = hot.hmrModules.has(name);
const handler = hot.hmrCallbacks.get(name);
if (type === "modify") {
if (module) {
const url = new URL(name, location.href);
url.searchParams.set("t", Date.now().toString(36));
if (url.pathname.endsWith(".css")) {
url.searchParams.set("module", "");
}
const module = await import(url.href);
if (handler) {
handler(module);
}
} else if (handler) {
handler();
}
}
if (module || handler) {
console.log(
`🔥 %c[HMR] %c${type}`,
"color:#999",
`color:${eventColors[type as keyof typeof eventColors]}`,
`${JSON.stringify(name)}`,
);
}
});
source.onopen = () => {
console.log(
"🔥 %c[HMR]",
"color:#999",
"listening for file changes...",
);
};
source.onerror = (err) => {
if (err.eventPhase === EventSource.CLOSED) {
console.log(
"🔥 %c[HMR]",
"color:#999",
"connection lost, reconnecting...",
);
}
};
});
},
};

0 comments on commit d96c7e0

Please sign in to comment.