-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
5c50208
commit 70bcc34
Showing
3 changed files
with
350 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { HotModuleState } from '../modules/client/mod.ts' | ||
declare global { | ||
interface ImportMeta { | ||
hot?: HotModuleState | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string>; | ||
dependencies: Set<string>; | ||
isHmrEnabled: boolean; | ||
isHmrAccepted: boolean; | ||
needsReplacement: boolean; | ||
} | ||
|
||
interface ModuleEventSource { | ||
(emitModuleModifiedEvent: (moduleId: string) => void): void | ||
} | ||
|
||
export class EsmHmrEngine { | ||
clients: Set<WebSocket> = new Set(); | ||
dependencyTree = new Map<string, Dependency>(); | ||
|
||
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<string, unknown>) { | ||
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); | ||
} | ||
} | ||
} | ||
|
||
|
||
|