diff --git a/src/modules/client/mod.d.ts b/src/modules/client/mod.d.ts new file mode 100644 index 0000000..3f7713d --- /dev/null +++ b/src/modules/client/mod.d.ts @@ -0,0 +1,6 @@ +import { HotModuleState } from '../modules/client/mod.ts' +declare global { + interface ImportMeta { + hot?: HotModuleState + } +} diff --git a/src/modules/client/mod.ts b/src/modules/client/mod.ts new file mode 100644 index 0000000..061f93d --- /dev/null +++ b/src/modules/client/mod.ts @@ -0,0 +1,197 @@ +/** + * A client-side implementation of the ESM-HMR spec, for real. + * See https://github.com/FredKSchott/esm-hmr + */ + +type DisposeCallback = () => void; +// TODO better typing +// deno-lint-ignore no-explicit-any +type AcceptCallback = (args: { module: any; deps: any[] }) => void; +type AcceptCallbackObject = { + deps: string[]; + callback: AcceptCallback; +}; + +// deno-lint-ignore no-explicit-any +function debug(...args: any[]) { + console.log(`[ESM-HMR]`, ...args); +} +function reload() { + location.reload(); +} + +// deno-lint-ignore no-explicit-any +let SOCKET_MESSAGE_QUEUE: any[] = []; +// deno-lint-ignore no-explicit-any +function _sendSocketMessage(socket: WebSocket, msg: any) { + socket.send(JSON.stringify(msg)); +} +// deno-lint-ignore no-explicit-any +function sendSocketMessage(socket: WebSocket, msg: any) { + if (socket.readyState !== socket.OPEN) { + SOCKET_MESSAGE_QUEUE.push(msg); + } else { + _sendSocketMessage(socket, msg); + } +} + +const socketURL = + // deno-lint-ignore no-explicit-any + (window as any).HMR_WEBSOCKET_URL || + // TODO make common function + (location.protocol === "http:" ? "ws://" : "wss://") + location.host + "/"; + +const REGISTERED_MODULES: { [key: string]: HotModuleState } = {}; + +export class HotModuleState { + id: string; + #socket: WebSocket; + // deno-lint-ignore no-explicit-any + data: any = {}; + isLocked = false; + isDeclined = false; + isAccepted = false; + acceptCallbacks: AcceptCallbackObject[] = []; + disposeCallbacks: DisposeCallback[] = []; + + constructor(id: string, socket: WebSocket) { + this.id = id; + this.#socket = socket; + } + + lock(): void { + this.isLocked = true; + } + + dispose(callback: DisposeCallback): void { + this.disposeCallbacks.push(callback); + } + + invalidate(): void { + reload(); + } + + decline(): void { + this.isDeclined = true; + } + + accept(_deps: string[], callback: true | AcceptCallback = true): void { + if (this.isLocked) { + return; + } + if (!this.isAccepted) { + sendSocketMessage(this.#socket, { id: this.id, type: "hotAccept" }); + this.isAccepted = true; + } + if (!Array.isArray(_deps)) { + callback = _deps || callback; + _deps = []; + } + if (callback === true) { + callback = () => {}; + } + const deps = _deps.map((dep) => { + return new URL(dep, `${window.location.origin}${this.id}`).pathname; + }); + this.acceptCallbacks.push({ + deps, + callback, + }); + } +} + +function createHotContext(fullUrl: string, socket: WebSocket) { + const id = new URL(fullUrl).pathname; + const existing = REGISTERED_MODULES[id]; + if (existing) { + existing.lock(); + return existing; + } + const state = new HotModuleState(id, socket); + REGISTERED_MODULES[id] = state; + return state; +} + +function installHotContext(importMeta: ImportMeta, socket: WebSocket) { + // TODO conditionally inject this in build + // this condition is a temporary workaround until I figure out how to inject config/env vars, + // or accomplish the above TODO + if (location.hostname === "localhost") { + importMeta.hot = createHotContext(importMeta.url, socket); + } +} + +async function applyUpdate(id: string) { + const state = REGISTERED_MODULES[id]; + if (!state) { + return false; + } + if (state.isDeclined) { + return false; + } + + const acceptCallbacks = state.acceptCallbacks; + const disposeCallbacks = state.disposeCallbacks; + state.disposeCallbacks = []; + state.data = {}; + + disposeCallbacks.map((callback) => callback()); + const updateID = Date.now(); + for (const { deps, callback: acceptCallback } of acceptCallbacks) { + const [module, ...depModules] = await Promise.all([ + import(id + `?mtime=${updateID}`), + ...deps.map((d) => import(d + `?mtime=${updateID}`)), + ]); + acceptCallback({ module, deps: depModules }); + } + + return true; +} + +let isHmrClientRunning = false +function startHmrClient(socket: WebSocket) { + socket.addEventListener("open", () => { + SOCKET_MESSAGE_QUEUE.forEach((msg) => _sendSocketMessage(socket, msg)); + SOCKET_MESSAGE_QUEUE = []; + }); + socket.addEventListener("message", async ({ data: _data }) => { + if (!_data) { + return; + } + const data = JSON.parse(_data); + debug("message", data); + if (data.type === "reload") { + debug("message: reload"); + reload(); + return; + } + if (data.type !== "update") { + debug("message: unknown", data); + return; + } + debug("message: update", data); + debug(data.url, Object.keys(REGISTERED_MODULES)); + try { + const ok = await applyUpdate(data.url) + if (!ok) { + reload(); + } + } catch (err) { + console.error(err); + reload(); + } + }); + + isHmrClientRunning = true + debug("listening for file changes..."); +} + +export function useHmr(importMeta: ImportMeta) { + if(!isHmrClientRunning) { + // Seems like Deno cannot handle subprotocols + // const socket = new WebSocket(socketURL, "esm-hmr"); + const socket = new WebSocket(socketURL); + startHmrClient(socket) + installHotContext(importMeta, socket) + } +} diff --git a/src/modules/server/mod.ts b/src/modules/server/mod.ts new file mode 100644 index 0000000..f63291b --- /dev/null +++ b/src/modules/server/mod.ts @@ -0,0 +1,147 @@ +/** + * A server-side implementation of the ESM-HMR spec, for real. + * See https://github.com/FredKSchott/esm-hmr + * + * TODO there's some confusing naming conventions going on in this code + * The names "id" and "url" are used to refer to the same things, and those + * things are actually both path(name)s. + */ + +interface Dependency { + dependents: Set; + dependencies: Set; + isHmrEnabled: boolean; + isHmrAccepted: boolean; + needsReplacement: boolean; +} + +interface ModuleEventSource { + (emitModuleModifiedEvent: (moduleId: string) => void): void +} + +export class EsmHmrEngine { + clients: Set = new Set(); + dependencyTree = new Map(); + + constructor(watcher: ModuleEventSource) { + watcher((url) => { + this.broadcastMessage({ type: "update", url }); + }) + } + + addClient(client: WebSocket): void { + const onopen = () => { + this.connectClient(client); + this.registerListener(client); + }; + client.onopen = onopen; + if (client.readyState === WebSocket.OPEN) { + onopen(); + } + client.onerror = () => { + console.warn(`Socket error!`); + }; + client.onclose = (e) => { + console.info("socket closed", e.reason, e.code, e.wasClean); + this.clients.delete(client); + }; + } + + registerListener(client: WebSocket) { + client.onmessage = (event) => { + console.info("received message:", event.data); + const message = JSON.parse(event.data.toString()); + if (message.type === "hotAccept") { + const entry = this.getEntry(message.id, true) as Dependency; + entry.isHmrAccepted = true; + } + }; + } + + createEntry(sourceUrl: string) { + const newEntry: Dependency = { + dependencies: new Set(), + dependents: new Set(), + needsReplacement: false, + isHmrEnabled: false, + isHmrAccepted: false, + }; + this.dependencyTree.set(sourceUrl, newEntry); + return newEntry; + } + + getEntry(sourceUrl: string, createIfNotFound = false) { + const result = this.dependencyTree.get(sourceUrl); + if (result) { + return result; + } + if (createIfNotFound) { + return this.createEntry(sourceUrl); + } + return null; + } + + setEntry(sourceUrl: string, imports: string[], isHmrEnabled = false) { + const result = this.getEntry(sourceUrl, true)!; + const outdatedDependencies = new Set(result.dependencies); + result.isHmrEnabled = isHmrEnabled; + for (const importUrl of imports) { + this.addRelationship(sourceUrl, importUrl); + outdatedDependencies.delete(importUrl); + } + for (const importUrl of outdatedDependencies) { + this.removeRelationship(sourceUrl, importUrl); + } + } + + removeRelationship(sourceUrl: string, importUrl: string) { + const importResult = this.getEntry(importUrl); + importResult && importResult.dependents.delete(sourceUrl); + const sourceResult = this.getEntry(sourceUrl); + sourceResult && sourceResult.dependencies.delete(importUrl); + } + + addRelationship(sourceUrl: string, importUrl: string) { + if (importUrl !== sourceUrl) { + const importResult = this.getEntry(importUrl, true)!; + importResult.dependents.add(sourceUrl); + const sourceResult = this.getEntry(sourceUrl, true)!; + sourceResult.dependencies.add(importUrl); + } + } + + markEntryForReplacement(entry: Dependency, state: boolean) { + entry.needsReplacement = state; + } + + broadcastMessage(data: Record) { + console.log("broadcast:", data); + this.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(data)); + } else { + this.disconnectClient(client); + } + }); + } + + connectClient(client: WebSocket) { + console.info("client connected"); + this.clients.add(client); + } + + disconnectClient(client: WebSocket) { + console.log("disconnecting client") + client.close(); + this.clients.delete(client); + } + + disconnectAllClients() { + for (const client of this.clients) { + this.disconnectClient(client); + } + } +} + + +