Initial commit
This commit is contained in:
6
packages/hmr/lib/consts.ts
Normal file
6
packages/hmr/lib/consts.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const LOCAL_RELOAD_SOCKET_PORT = 8081;
|
||||
export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`;
|
||||
|
||||
export const DO_UPDATE = 'do_update';
|
||||
export const DONE_UPDATE = 'done_update';
|
||||
export const BUILD_COMPLETE = 'build_complete';
|
||||
17
packages/hmr/lib/initializers/init-client.ts
Normal file
17
packages/hmr/lib/initializers/init-client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
|
||||
export default ({ id, onUpdate }: { id: string; onUpdate: () => void }) => {
|
||||
const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.addEventListener('message', event => {
|
||||
const message = MessageInterpreter.receive(String(event.data));
|
||||
|
||||
if (message.type === DO_UPDATE && message.id === id) {
|
||||
onUpdate();
|
||||
ws.send(MessageInterpreter.send({ type: DONE_UPDATE }));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
53
packages/hmr/lib/initializers/init-reload-server.ts
Normal file
53
packages/hmr/lib/initializers/init-reload-server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
BUILD_COMPLETE,
|
||||
DO_UPDATE,
|
||||
DONE_UPDATE,
|
||||
LOCAL_RELOAD_SOCKET_PORT,
|
||||
LOCAL_RELOAD_SOCKET_URL,
|
||||
} from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
const clientsThatNeedToUpdate: Set<WebSocket> = new Set();
|
||||
|
||||
(() => {
|
||||
const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT });
|
||||
|
||||
wss.on('listening', () => {
|
||||
console.log(`[HMR] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
});
|
||||
|
||||
wss.on('connection', ws => {
|
||||
clientsThatNeedToUpdate.add(ws);
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
clientsThatNeedToUpdate.delete(ws);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', event => {
|
||||
if (typeof event.data !== 'string') return;
|
||||
|
||||
const message = MessageInterpreter.receive(event.data);
|
||||
|
||||
if (message.type === DONE_UPDATE) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
if (message.type === BUILD_COMPLETE) {
|
||||
clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
|
||||
ws.send(MessageInterpreter.send({ type: DO_UPDATE, id: message.id })),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('error', (error: Error & { code: string }) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.info(`[HMR] Server already running at ${LOCAL_RELOAD_SOCKET_URL}, skipping reload server initialization`);
|
||||
} else {
|
||||
console.error(`[HMR] Failed to start server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
})();
|
||||
33
packages/hmr/lib/injections/refresh.ts
Normal file
33
packages/hmr/lib/injections/refresh.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import initClient from '../initializers/init-client.js';
|
||||
|
||||
(() => {
|
||||
let pendingReload = false;
|
||||
|
||||
initClient({
|
||||
// @ts-expect-error That's because of the dynamic code loading
|
||||
id: __HMR_ID,
|
||||
onUpdate: () => {
|
||||
// disable reload when tab is hidden
|
||||
if (document.hidden) {
|
||||
pendingReload = true;
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
},
|
||||
});
|
||||
|
||||
// reload
|
||||
const reload = (): void => {
|
||||
pendingReload = false;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// reload when tab is visible
|
||||
const reloadWhenTabIsVisible = (): void => {
|
||||
if (!document.hidden && pendingReload) {
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', reloadWhenTabIsVisible);
|
||||
})();
|
||||
13
packages/hmr/lib/injections/reload.ts
Normal file
13
packages/hmr/lib/injections/reload.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import initClient from '../initializers/init-client.js';
|
||||
|
||||
(() => {
|
||||
const reload = () => {
|
||||
chrome.runtime.reload();
|
||||
};
|
||||
|
||||
initClient({
|
||||
// @ts-expect-error That's because of the dynamic code loading
|
||||
id: __HMR_ID,
|
||||
onUpdate: reload,
|
||||
});
|
||||
})();
|
||||
6
packages/hmr/lib/interpreter/index.ts
Normal file
6
packages/hmr/lib/interpreter/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { WebSocketMessageType } from '../types.js';
|
||||
|
||||
export default {
|
||||
send: (message: WebSocketMessageType): string => JSON.stringify(message),
|
||||
receive: (serializedMessage: string): WebSocketMessageType => JSON.parse(serializedMessage),
|
||||
};
|
||||
3
packages/hmr/lib/plugins/index.ts
Normal file
3
packages/hmr/lib/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './watch-rebuild-plugin.js';
|
||||
export * from './watch-public-plugin.js';
|
||||
export * from './make-entry-point-plugin.js';
|
||||
73
packages/hmr/lib/plugins/make-entry-point-plugin.ts
Normal file
73
packages/hmr/lib/plugins/make-entry-point-plugin.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { IS_FIREFOX } from '@extension/env';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { basename, resolve, sep } from 'node:path';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
/**
|
||||
* Extract content directory from output directory for Firefox
|
||||
* @param outputDir
|
||||
*/
|
||||
const extractContentDir = (outputDir: string) => {
|
||||
const parts = outputDir.split(sep);
|
||||
const distIndex = parts.indexOf('dist');
|
||||
|
||||
if (distIndex !== -1 && distIndex < parts.length - 1) {
|
||||
return parts.slice(distIndex + 1);
|
||||
}
|
||||
|
||||
throw new Error('Output directory does not contain "dist"');
|
||||
};
|
||||
|
||||
const safeWriteFileSync = (path: string, data: string) => {
|
||||
const folder = path.split(sep).slice(0, -1).join(sep);
|
||||
|
||||
if (!existsSync(folder)) {
|
||||
mkdirSync(folder, { recursive: true });
|
||||
}
|
||||
writeFileSync(path, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make an entry point file for content script cache busting
|
||||
*/
|
||||
export const makeEntryPointPlugin = (): PluginOption => ({
|
||||
name: 'make-entry-point-plugin',
|
||||
generateBundle(options, bundle) {
|
||||
const outputDir = options.dir;
|
||||
|
||||
if (!outputDir) {
|
||||
throw new Error('Output directory not found');
|
||||
}
|
||||
|
||||
for (const module of Object.values(bundle)) {
|
||||
const fileName = module.fileName;
|
||||
const newFileName = fileName.replace('.js', '_dev.js');
|
||||
|
||||
switch (module.type) {
|
||||
case 'asset':
|
||||
if (fileName.endsWith('.map')) {
|
||||
const originalFileName = fileName.replace('.map', '');
|
||||
const replacedSource = String(module.source).replaceAll(originalFileName, newFileName);
|
||||
|
||||
module.source = '';
|
||||
safeWriteFileSync(resolve(outputDir, newFileName), replacedSource);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk': {
|
||||
safeWriteFileSync(resolve(outputDir, newFileName), module.code);
|
||||
const newFileNameBase = basename(newFileName);
|
||||
|
||||
if (IS_FIREFOX) {
|
||||
const contentDirectory = extractContentDir(outputDir);
|
||||
module.code = `import(browser.runtime.getURL("${contentDirectory}/${newFileNameBase}"));`;
|
||||
} else {
|
||||
module.code = `import('./${newFileNameBase}');`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
13
packages/hmr/lib/plugins/watch-public-plugin.ts
Normal file
13
packages/hmr/lib/plugins/watch-public-plugin.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import fg from 'fast-glob';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
export const watchPublicPlugin = (): PluginOption => ({
|
||||
name: 'watch-public-plugin',
|
||||
async buildStart() {
|
||||
const files = await fg(['public/**/*']);
|
||||
|
||||
for (const file of files) {
|
||||
this.addWatchFile(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
68
packages/hmr/lib/plugins/watch-rebuild-plugin.ts
Normal file
68
packages/hmr/lib/plugins/watch-rebuild-plugin.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { BUILD_COMPLETE, LOCAL_RELOAD_SOCKET_URL } from '../consts.js';
|
||||
import MessageInterpreter from '../interpreter/index.js';
|
||||
import { WebSocket } from 'ws';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { PluginConfigType } from '../types.js';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
const injectionsPath = resolve(import.meta.dirname, '..', 'injections');
|
||||
|
||||
const refreshCode = readFileSync(resolve(injectionsPath, 'refresh.js'), 'utf-8');
|
||||
const reloadCode = readFileSync(resolve(injectionsPath, 'reload.js'), 'utf-8');
|
||||
|
||||
export const watchRebuildPlugin = (config: PluginConfigType): PluginOption => {
|
||||
const { refresh, reload, id: _id, onStart } = config;
|
||||
const hmrCode = (refresh ? refreshCode : '') + (reload ? reloadCode : '');
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const id = _id ?? Math.random().toString(36);
|
||||
let reconnectTries = 0;
|
||||
|
||||
const initializeWebSocket = () => {
|
||||
ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`[HMR] Connected to dev-server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
console.error(`[HMR] Failed to connect server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
console.warn('Retrying in 3 seconds...');
|
||||
ws = null;
|
||||
|
||||
if (reconnectTries <= 2) {
|
||||
setTimeout(() => {
|
||||
reconnectTries++;
|
||||
initializeWebSocket();
|
||||
}, 3_000);
|
||||
} else {
|
||||
console.error(`[HMR] Cannot establish connection to server at ${LOCAL_RELOAD_SOCKET_URL}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'watch-rebuild',
|
||||
closeBundle() {
|
||||
onStart?.();
|
||||
if (!ws) {
|
||||
initializeWebSocket();
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* When the build is complete, send a message to the reload server.
|
||||
* The reload server will send a message to the client to reload or refresh the extension.
|
||||
*/
|
||||
ws.send(MessageInterpreter.send({ type: BUILD_COMPLETE, id }));
|
||||
},
|
||||
generateBundle(_options, bundle) {
|
||||
for (const module of Object.values(bundle)) {
|
||||
if (module.type === 'chunk') {
|
||||
module.code = `(function() {let __HMR_ID = "${id}";\n` + hmrCode + '\n' + '})();' + '\n' + module.code;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
18
packages/hmr/lib/types.ts
Normal file
18
packages/hmr/lib/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE } from './consts.js';
|
||||
|
||||
type UpdateRequestMessageType = {
|
||||
type: typeof DO_UPDATE;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type UpdateCompleteMessageType = { type: typeof DONE_UPDATE };
|
||||
type BuildCompletionMessageType = { type: typeof BUILD_COMPLETE; id: string };
|
||||
|
||||
export type WebSocketMessageType = UpdateCompleteMessageType | UpdateRequestMessageType | BuildCompletionMessageType;
|
||||
|
||||
export type PluginConfigType = {
|
||||
onStart?: () => void;
|
||||
reload?: boolean;
|
||||
refresh?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user