UNPKG

6.97 kBJavaScriptView Raw
1/**
2 * esm-hmr/runtime.ts
3 * A client-side implementation of the ESM-HMR spec, for reference.
4 */
5
6const isWindowDefined = typeof window !== 'undefined';
7
8function log(...args) {
9 console.log('[ESM-HMR]', ...args);
10}
11function reload() {
12 if (!isWindowDefined) {
13 return;
14 }
15 location.reload(true);
16}
17/** Clear all error overlays from the page */
18function clearErrorOverlay() {
19 if (!isWindowDefined) {
20 return;
21 }
22 document.querySelectorAll('hmr-error-overlay').forEach((el) => el.remove());
23}
24/** Create an error overlay (if custom element exists on the page). */
25function createNewErrorOverlay(data) {
26 if (!isWindowDefined) {
27 return;
28 }
29 const HmrErrorOverlay = customElements.get('hmr-error-overlay');
30 if (HmrErrorOverlay) {
31 const overlay = new HmrErrorOverlay(data);
32 clearErrorOverlay();
33 document.body.appendChild(overlay);
34 }
35}
36
37let SOCKET_MESSAGE_QUEUE = [];
38function _sendSocketMessage(msg) {
39 socket.send(JSON.stringify(msg));
40}
41function sendSocketMessage(msg) {
42 if (socket.readyState !== socket.OPEN) {
43 SOCKET_MESSAGE_QUEUE.push(msg);
44 } else {
45 _sendSocketMessage(msg);
46 }
47}
48
49let socketURL = isWindowDefined && window.HMR_WEBSOCKET_URL;
50if (!socketURL) {
51 const socketHost =
52 isWindowDefined && window.HMR_WEBSOCKET_PORT
53 ? `${location.hostname}:${window.HMR_WEBSOCKET_PORT}`
54 : location.host;
55 socketURL = (location.protocol === 'http:' ? 'ws://' : 'wss://') + socketHost + '/';
56}
57
58const socket = new WebSocket(socketURL, 'esm-hmr');
59socket.addEventListener('open', () => {
60 SOCKET_MESSAGE_QUEUE.forEach(_sendSocketMessage);
61 SOCKET_MESSAGE_QUEUE = [];
62});
63const REGISTERED_MODULES = {};
64class HotModuleState {
65 constructor(id) {
66 this.data = {};
67 this.isLocked = false;
68 this.isDeclined = false;
69 this.isAccepted = false;
70 this.acceptCallbacks = [];
71 this.disposeCallbacks = [];
72 this.id = id;
73 }
74 lock() {
75 this.isLocked = true;
76 }
77 dispose(callback) {
78 this.disposeCallbacks.push(callback);
79 }
80 invalidate() {
81 reload();
82 }
83 decline() {
84 this.isDeclined = true;
85 }
86 accept(_deps, callback = true) {
87 if (this.isLocked) {
88 return;
89 }
90 if (!this.isAccepted) {
91 sendSocketMessage({id: this.id, type: 'hotAccept'});
92 this.isAccepted = true;
93 }
94 if (!Array.isArray(_deps)) {
95 callback = _deps || callback;
96 _deps = [];
97 }
98 if (callback === true) {
99 callback = () => {};
100 }
101 const deps = _deps.map((dep) => {
102 const ext = dep.split('.').pop();
103 if (!ext) {
104 dep += '.js';
105 } else if (ext !== 'js') {
106 dep += '.proxy.js';
107 }
108 return new URL(dep, `${window.location.origin}${this.id}`).pathname;
109 });
110 this.acceptCallbacks.push({
111 deps,
112 callback,
113 });
114 }
115}
116export function createHotContext(fullUrl) {
117 const id = new URL(fullUrl).pathname;
118 const existing = REGISTERED_MODULES[id];
119 if (existing) {
120 existing.lock();
121 runModuleDispose(id);
122 return existing;
123 }
124 const state = new HotModuleState(id);
125 REGISTERED_MODULES[id] = state;
126 return state;
127}
128
129/** Called when any CSS file is loaded. */
130async function runCssStyleAccept({url: id}) {
131 const nonce = Date.now();
132 const oldLinkEl =
133 document.head.querySelector(`link[data-hmr="${id}"]`) ||
134 document.head.querySelector(`link[href="${id}"]`);
135 if (!oldLinkEl) {
136 return true;
137 }
138 const linkEl = oldLinkEl.cloneNode(false);
139 linkEl.dataset.hmr = id;
140 linkEl.type = 'text/css';
141 linkEl.rel = 'stylesheet';
142 linkEl.href = id + '?mtime=' + nonce;
143 linkEl.addEventListener(
144 'load',
145 // Once loaded, remove the old link element (with some delay, to avoid FOUC)
146 () => setTimeout(() => document.head.removeChild(oldLinkEl), 30),
147 false,
148 );
149 oldLinkEl.parentNode.insertBefore(linkEl, oldLinkEl)
150 return true;
151}
152
153/** Called when a new module is loaded, to pass the updated module to the "active" module */
154async function runJsModuleAccept({url: id, bubbled}) {
155 const state = REGISTERED_MODULES[id];
156 if (!state) {
157 return false;
158 }
159 if (state.isDeclined) {
160 return false;
161 }
162 const acceptCallbacks = state.acceptCallbacks;
163 const updateID = Date.now();
164 for (const {deps, callback: acceptCallback} of acceptCallbacks) {
165 const [module, ...depModules] = await Promise.all([
166 import(id + `?mtime=${updateID}`),
167 ...deps.map((d) => import(d + `?mtime=${updateID}`)),
168 ]);
169 acceptCallback({module, bubbled, deps: depModules});
170 }
171 return true;
172}
173
174/** Called when a new module is loaded, to run cleanup on the old module (if needed) */
175async function runModuleDispose(id) {
176 const state = REGISTERED_MODULES[id];
177 if (!state) {
178 return false;
179 }
180 if (state.isDeclined) {
181 return false;
182 }
183 const disposeCallbacks = state.disposeCallbacks;
184 state.disposeCallbacks = [];
185 state.data = {};
186 disposeCallbacks.map((callback) => callback());
187 return true;
188}
189socket.addEventListener('message', ({data: _data}) => {
190 if (!_data) {
191 return;
192 }
193 const data = JSON.parse(_data);
194 if (data.type === 'reload') {
195 log('message: reload');
196 reload();
197 return;
198 }
199 if (data.type === 'error') {
200 console.error(
201 `[ESM-HMR] ${data.fileLoc ? data.fileLoc + '\n' : ''}`,
202 data.title + '\n' + data.errorMessage,
203 );
204 createNewErrorOverlay(data);
205 return;
206 }
207 if (data.type === 'update') {
208 log('message: update', data);
209 (data.url.endsWith('.css') ? runCssStyleAccept(data) : runJsModuleAccept(data))
210 .then((ok) => {
211 if (ok) {
212 clearErrorOverlay();
213 } else {
214 reload();
215 }
216 })
217 .catch((err) => {
218 console.error('[ESM-HMR] Hot Update Error', err);
219 // A failed import gives a TypeError, but invalid ESM imports/exports give a SyntaxError.
220 // Failed build results already get reported via a better WebSocket update.
221 // We only want to report invalid code like a bad import that doesn't exist.
222 if (err instanceof SyntaxError) {
223 createNewErrorOverlay({
224 title: 'Hot Update Error',
225 fileLoc: data.url,
226 errorMessage: err.message,
227 errorStackTrace: err.stack,
228 });
229 }
230 });
231 return;
232 }
233 log('message: unknown', data);
234});
235log('listening for file changes...');
236
237/** Runtime error reporting: If a runtime error occurs, show it in an overlay. */
238isWindowDefined && window.addEventListener('error', function (event) {
239 // Generate an "error location" string
240 let fileLoc;
241 if (event.filename) {
242 fileLoc = event.filename;
243 if (event.lineno !== undefined) {
244 fileLoc += ` [:${event.lineno}`;
245 if (event.colno !== undefined) {
246 fileLoc += `:${event.colno}`;
247 }
248 fileLoc += `]`;
249 }
250 }
251 createNewErrorOverlay({
252 title: 'Unhandled Runtime Error',
253 fileLoc,
254 errorMessage: event.message,
255 errorStackTrace: event.error ? event.error.stack : undefined,
256 });
257});