Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
patreeceeo committed Mar 11, 2023
1 parent 5c50208 commit 70bcc34
Show file tree
Hide file tree
Showing 3 changed files with 350 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/modules/client/mod.d.ts
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
}
}
197 changes: 197 additions & 0 deletions src/modules/client/mod.ts
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)
}
}
147 changes: 147 additions & 0 deletions src/modules/server/mod.ts
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);
}
}
}



0 comments on commit 70bcc34

Please sign in to comment.