Skip to content

Commit

Permalink
Improve hot script and plugins
Browse files Browse the repository at this point in the history
ije committed Dec 9, 2023
1 parent 1b44786 commit 9eee2e9
Showing 12 changed files with 273 additions and 221 deletions.
113 changes: 61 additions & 52 deletions hot.ts
Original file line number Diff line number Diff line change
@@ -23,10 +23,10 @@ interface Loader {
map?: string;
headers?: Record<string, string>;
}>;
varyUA?: boolean; // for the loaders that checks build target by `user-agent` header
}

interface ImportMap {
$support?: boolean;
imports?: Record<string, string>;
scopes?: Record<string, Record<string, string>>;
}
@@ -50,11 +50,10 @@ const VERSION = 135;
const plugins: Plugin[] = [];
const doc = globalThis.document;
const enc = new TextEncoder();
const kJsxImportSource = "@jsxImportSource";
const kSkipWaiting = "SKIP_WAITING";
const kVfs = "vfs";
const kContentType = "content-type";
const tsQuery = /\?t=[a-z0-9]+$/;
const kImportmapJson = "internal:importmap.json";

/** virtual file system using indexed database */
class VFS {
@@ -162,7 +161,7 @@ class Hot {
| T
| Response
| Promise<T | Response>,
transform: (input: T) =>
transform?: (input: T) =>
| T
| Response
| Promise<T | Response>,
@@ -186,7 +185,7 @@ class Hot {
if (cached && cached.hash === hash) {
return cached;
}
let data = transform(input);
let data = transform?.(input) ?? input;
if (data instanceof Promise) {
data = await data;
}
@@ -209,9 +208,9 @@ class Hot {
return this;
}

onLoad(test: RegExp, load: Loader["load"], varyUA = false) {
onLoad(test: RegExp, load: Loader["load"]) {
if (!doc) {
this.#loaders.push({ test, load, varyUA });
this.#loaders.push({ test, load });
}
return this;
}
@@ -259,7 +258,7 @@ class Hot {
waiting.addEventListener("statechange", () => {
const { active } = reg;
if (active) {
this.#fireApp(active);
this.reload();
}
});
}
@@ -367,30 +366,49 @@ class Hot {
if (!res.ok) {
return res;
}
const [im, source] = await Promise.all([
vfs.get("importmap.json"),
res.text(),
]);
const importMap: ImportMap = (im?.data as unknown) ?? {};
const jsxImportSource = isJSX(url.pathname)
? importMap.imports?.[kJsxImportSource]
: undefined;
const cacheKey = this.#isDev && url.host === location.host
? url.href.replace(tsQuery, "")
: url.href;
const resHeaders = res.headers;
let etag = resHeaders.get("etag");
if (!etag) {
const size = resHeaders.get("content-length");
const modtime = resHeaders.get("last-modified");
if (size && modtime) {
etag = "W/" + JSON.stringify(
parseInt(size).toString(36) + "-" +
(new Date(modtime).getTime() / 1000).toString(36),
);
}
}
let buffer: string | null = null;
const source = async () => {
if (buffer === null) {
buffer = await res.text();
}
return buffer;
};
let cacheKey = url.href;
if (url.host === location.host) {
url.searchParams.delete("t");
cacheKey = url.pathname + url.search.replaceAll(/=(&|$)/g, "");
}
const ret = await vfs.get(kImportmapJson);
const importMap: ImportMap = (ret?.data as unknown) ?? {};
const cached = await vfs.get(cacheKey);
const hash = await computeHash(enc.encode(
jsxImportSource + source + (loader.varyUA ? navigator.userAgent : ""),
));
const hash = await computeHash(enc.encode([
JSON.stringify(importMap),
etag ?? await source(),
].join("")));
if (cached && cached.hash === hash) {
if (!res.bodyUsed) {
res.body?.cancel();
}
return new Response(cached.data, {
headers: cached.headers ?? jsHeaders,
});
}
try {
const { code, map, headers } = await loader.load(
url,
source,
await source(),
{ importMap },
);
let body = code;
@@ -414,11 +432,6 @@ class Hot {
);
});

self.addEventListener("activate", (event) => {
// @ts-ignore
event.waitUntil(clients.claim());
});

self.addEventListener("fetch", (event) => {
const evt = event as FetchEvent;
const { request } = evt;
@@ -470,40 +483,41 @@ class Hot {
}

async #syncVFS() {
const script = doc.querySelector("head>script[type=importmap]");
const importMap: ImportMap = {
$support: HTMLScriptElement.supports?.("importmap"),
imports: Object.fromEntries(this.#customImports.entries()),
};
const script = doc.querySelector("head>script[type=importmap]");
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];
try {
const v = JSON.parse(script.innerHTML);
if (typeof v === "object" && v !== null) {
const { imports, scopes } = v;
for (const k in imports) {
importMap.imports![k] = imports[k];
}
if (typeof scopes === "object" && scopes !== null) {
importMap.scopes = scopes;
}
}
}
if (!supported && "scopes" in v) {
importMap.scopes = v.scopes;
} catch (err) {
console.warn("Failed to parse importmap:", err);
}
}
await this.#vfs.put(
"importmap.json",
await computeHash(enc.encode(JSON.stringify(importMap))),
importMap as unknown as string,
);
await this.#vfs.put(kImportmapJson, "", importMap as any);
await Promise.all(
Object.values(this.#vfsRegisters).map((handler) => handler()),
);
}

async #fireApp(sw: ServiceWorker) {
if (this.#isDev) {
const hmr = await import(`./hot-plugins/hmr`);
hmr.default.setup(this);
const { setup } = await import(`./hot-plugins/dev`);
setup(this);
}
await this.#syncVFS();
for (const handler of this.#fireListeners) {
handler(sw);
for (const onfire of this.#fireListeners) {
onfire(sw);
}
doc.querySelectorAll("script[type='module/hot']").forEach((el) => {
const copy = el.cloneNode(true) as HTMLScriptElement;
@@ -541,7 +555,7 @@ class Hot {
}
};
// @ts-ignore
if (hot.hmr) hot.hmrCallbacks.set(url.pathname, load);
if (hot.hmr) __hot_hmr_callbacks.set(url.pathname, load);
load();
}
},
@@ -555,11 +569,6 @@ function isString(v: unknown): v is string {
return typeof v === "string";
}

/** check if the pathname is a jsx file */
function isJSX(pathname: string): boolean {
return pathname.endsWith(".jsx") || pathname.endsWith(".tsx");
}

/** get the extension name of the given path */
function getExtname(path: string): string {
const i = path.lastIndexOf(".");
98 changes: 98 additions & 0 deletions server/embed/hot-plugins/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { setup as setupDevtools } from "./devtools";

declare global {
interface Window {
__hot_hmr_modules: Set<string>;
__hot_hmr_callbacks: Map<string, (module: any) => void>;
}
}

function setupHMR(hot: any) {
hot.hmr = true;
window.__hot_hmr_modules = new Set();
window.__hot_hmr_callbacks = new Map();

hot.customImports.set(
"@hmrRuntime",
"https://esm.sh/hot/_hmr.js",
);
hot.register(
"_hmr.js",
() => `
export default (path) => ({
decline() {
__hot_hmr_modules.delete(path);
__hot_hmr_callbacks.set(path, () => location.reload());
},
accept(cb) {
if (!__hot_hmr_modules.has(path)) {
__hot_hmr_modules.add(path);
typeof cb === "function" && __hot_hmr_callbacks.set(path, cb);
}
},
invalidate() {
location.reload();
}
})
`,
);

const logPrefix = ["🔥 %c[HMR]", "color:#999"];
const eventColors = {
modify: "#056CF0",
create: "#20B44B",
remove: "#F00C08",
};

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 = window.__hot_hmr_modules.has(name);
const callback = window.__hot_hmr_callbacks.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 (callback) {
callback(module);
}
} else if (callback) {
callback(null);
}
}
if (module || callback) {
console.log(
logPrefix[0] + " %c" + type,
logPrefix[1],
`color:${eventColors[type as keyof typeof eventColors]}`,
`${JSON.stringify(name)}`,
);
}
});
let connected = false;
source.onopen = () => {
connected = true;
console.log(
...logPrefix,
"connected, listening for file changes...",
);
};
source.onerror = (err) => {
if (err.eventPhase === EventSource.CLOSED) {
if (!connected) {
console.warn(...logPrefix, "failed to connect.");
} else {
console.log(...logPrefix, "connection lost, reconnecting...");
}
}
};
}

export function setup(hot: any) {
setupHMR(hot);
setupDevtools(hot);
}
57 changes: 57 additions & 0 deletions server/embed/hot-plugins/devtools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const html = String.raw;

const template = html`
<button class="popup" aria-label="Hot Devtools" onclick="alert('Hot Devtools')">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path fill="currentColor" d="m8.468 8.395l-.002.001l-.003.002Zm9.954-.187a1.237 1.237 0 0 0-.23-.175a1 1 0 0 0-1.4.411a5.782 5.782 0 0 1-1.398 1.778a8.664 8.664 0 0 0 .134-1.51a8.714 8.714 0 0 0-4.4-7.582a1 1 0 0 0-1.492.806a7.017 7.017 0 0 1-2.471 4.942l-.23.187a8.513 8.513 0 0 0-1.988 1.863a8.983 8.983 0 0 0 3.656 13.908a1 1 0 0 0 1.377-.926a1.05 1.05 0 0 0-.05-.312a6.977 6.977 0 0 1-.19-2.581a9.004 9.004 0 0 0 4.313 4.016a.997.997 0 0 0 .715.038a8.995 8.995 0 0 0 3.654-14.863Zm-3.905 12.831a6.964 6.964 0 0 1-3.577-4.402a8.908 8.908 0 0 1-.18-.964a1 1 0 0 0-.799-.845a.982.982 0 0 0-.191-.018a1 1 0 0 0-.867.5a8.959 8.959 0 0 0-1.205 4.718a6.985 6.985 0 0 1-1.176-9.868a6.555 6.555 0 0 1 1.562-1.458a.745.745 0 0 0 .075-.055s.296-.245.306-.25a8.968 8.968 0 0 0 2.9-4.633a6.736 6.736 0 0 1 1.385 8.088a1 1 0 0 0 1.184 1.418a7.856 7.856 0 0 0 3.862-2.688a7 7 0 0 1-3.279 10.457Z"/>
</svg>
</button>
<style>
button.popup {
box-sizing: border-box;
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid #eee;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
color: rgba(255, 165, 0, 0.9);
transition: all 0.3s ease;
cursor: pointer;
}
button.popup:focus,
button.popup:hover {
outline: none;
color: rgba(255, 165, 0, 1);
border-color: rgba(255, 165, 0, 0.5);
background-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 4px 10px 0 rgba(50, 25, 0, 0.1);
}
button.popup svg {
width: 18px;
height: 18px;
}
</style>
`;

class DevTools extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = template;
}
}
customElements.define("hot-devtools", DevTools);

export function setup(hot: any) {
hot.onFire((_sw: ServiceWorker) => {
const d = document;
d.body.appendChild(d.createElement("hot-devtools"));
});
}
132 changes: 0 additions & 132 deletions server/embed/hot-plugins/hmr.ts

This file was deleted.

5 changes: 1 addition & 4 deletions server/embed/hot-plugins/md.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
init as initWasm,
parse,
} from "https://esm.sh/v135/markdown-wasm-es@1.2.1";
import { init as initWasm, parse } from "https://esm.sh/markdown-wasm-es@1.2.1";

const deafultStyle = `
h1 > a.anchor,
30 changes: 30 additions & 0 deletions server/embed/hot-plugins/react-root.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,36 @@ function importAll(...urls: (string | URL)[]) {
export default {
name: "react-root",
setup(hot: any) {
hot.customImports.set(
"@reactRefreshRuntime",
"https://esm.sh/hot/_hmr_react_refresh.js",
);
hot.register(
"_hmr_react_refresh.js",
() => `
// react-refresh
// @link https://github.com/facebook/react/issues/16604#issuecomment-528663101
import runtime from "https://esm.sh/v135/react-refresh@0.14.0/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) => {
customElements.define(
"react-root",
8 changes: 6 additions & 2 deletions server/embed/hot-plugins/svelte.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/** @version: 4.2.7 */

import { compile } from "https://esm.sh/v135/svelte@4.2.7/compiler";
import { compile } from "https://esm.sh/svelte@4.2.7/compiler";

export default {
name: "svelte",
setup(hot: any) {
hot.onLoad(
/\.svelte$/,
(url: URL, source: string, _options: Record<string, any> = {}) => {
(url: URL, source: string, options: Record<string, any> = {}) => {
const { isDev } = hot;
const { importMap } = options;
const { js } = compile(source, {
filename: url.pathname,
sveltePath: importMap.imports?.["svelte/"] && importMap.$support
? "svelte"
: (importMap.imports?.["svelte"] ?? "https://esm.sh/svelte@4.2.7"),
generate: "dom",
enableSourcemap: !!isDev,
dev: !!isDev,
36 changes: 12 additions & 24 deletions server/embed/hot-plugins/tsx.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import initWasm, {
type Targets,
transform,
transformCSS,
} from "https://esm.sh/v135/esm-compiler@0.3.3";
} from "https://esm.sh/esm-compiler@0.3.3";

let waiting: Promise<any> | null = null;
const init = async () => {
@@ -21,23 +21,6 @@ export default {
setup(hot: any) {
const { stringify } = JSON;

const targets: Targets = {
chrome: 95 << 16, // default to chrome 95
};
if (!globalThis.document) {
const { userAgent } = navigator;
if (userAgent.includes("Safari/")) {
// safari: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15
// chrome: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
let m = userAgent.match(/Version\/(\d+)\.(\d)+/);
if (m) {
targets.safari = parseInt(m[1]) << 16 | parseInt(m[2]) << 8;
} else if ((m = userAgent.match(/Chrome\/(\d+)\./))) {
targets.chrome = parseInt(m[1]) << 16;
}
}
}

// add `?dev` to react/react-dom import url in development mode
if (hot.isDev) {
hot.onFetch((url: URL, req: Request) => {
@@ -68,7 +51,12 @@ export default {
const hmrRuntime = imports?.["@hmrRuntime"];
await init();
if (pathname.endsWith(".css")) {
// todo: check more browsers
const targets: Targets = {
chrome: 95 << 16, // default to chrome 95
safari: 15 << 16, // default to safari 15
firefox: 114 << 16, // default to firefox 114
opera: 77 << 16, // default to opera 77
};
const { code, map, exports } = transformCSS(pathname, source, {
targets,
minify: !isDev,
@@ -115,21 +103,21 @@ export default {
return { code, map };
}
const jsxImportSource = imports?.["@jsxImportSource"];
const reactRefreshRuntime = imports?.["@reactRefreshRuntime"];
return transform(pathname, source, {
isDev,
sourceMap: Boolean(isDev),
sourceMap: !!isDev,
jsxImportSource: jsxImportSource,
importMap: stringify(importMap ?? {}),
minify: !isDev ? { compress: true, keepNames: true } : undefined,
target: "es2020", // TODO: check user agent
target: "es2020",
hmr: hmrRuntime && {
runtime: hmrRuntime,
reactRefresh: jsxImportSource?.includes("/react"),
reactRefreshRuntime: imports?.["@reactRefreshRuntime"],
reactRefresh: !!reactRefreshRuntime,
reactRefreshRuntime: reactRefreshRuntime,
},
});
},
true, // varyUA
);
},
};
2 changes: 1 addition & 1 deletion server/embed/hot-plugins/vue-root.ts
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ export default {
const src = this.getAttribute("src");
if (src) {
importAll(
"https://esm.sh/vue@3.3.9",
"vue",
new URL(src, location.href),
).then(([
{ createApp },
9 changes: 5 additions & 4 deletions server/embed/hot-plugins/vue.ts
Original file line number Diff line number Diff line change
@@ -7,11 +7,11 @@ import {
parse,
rewriteDefault,
type SFCTemplateCompileOptions,
} from "https://esm.sh/v135/@vue/compiler-sfc@3.3.9";
} from "https://esm.sh/@vue/compiler-sfc@3.3.9";

interface Options {
hmr?: { runtime: string };
importMap?: { imports?: Record<string, string> };
importMap?: { $support?: boolean; imports?: Record<string, string> };
isDev?: boolean;
}

@@ -48,8 +48,9 @@ const compileSFC = async (
ssr: false,
ssrCssVars: descriptor.cssVars,
compilerOptions: {
runtimeModuleName: importMap?.imports?.["vue"] ??
"https://esm.sh/vue@3.3.9",
runtimeModuleName: importMap?.imports?.["vue"]
? importMap.$support ? "vue" : importMap.imports["vue"]
: "https://esm.sh/vue@3.3.9",
expressionPlugins,
},
};
2 changes: 1 addition & 1 deletion server/embed/hot.html
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
"imports": {
"@jsxImportSource": "https://esm.sh/react@18.2.0",
"vue": "https://esm.sh/vue@3.3.9",
"svelte/": "https://esm.sh/svelte@4.2.7/"
"svelte": "https://esm.sh/svelte@4.2.7"
}
}
</script>
2 changes: 1 addition & 1 deletion server/esm_handler.go
Original file line number Diff line number Diff line change
@@ -357,7 +357,7 @@ func esmHandler() rex.Handle {
}
id := fmt.Sprintf("p%d", i)
plugins = append(plugins, id)
imports = append(imports, fmt.Sprintf(`import %s from "%s%s/v%d/hot-plugins/%s%s";`, id, cdnOrigin, cfg.CdnBasePath, CTX_BUILD_VERSION, name, query))
imports = append(imports, fmt.Sprintf(`import %s from "./hot-plugins/%s%s";`, id, name, query))
}
}
}

0 comments on commit 9eee2e9

Please sign in to comment.