diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a6be10d6c1..afd57934304 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -275,10 +275,6 @@ "presentation": { "hidden": true, }, - // This is read by the vscode-diagnostic-tools extension - "vscode-diagnostic-tools.debuggerScripts": [ - "${workspaceFolder}/scripts/hot-reload-injected-script.js" - ] }, { "type": "node", diff --git a/scripts/debugger-scripts-api.d.ts b/scripts/debugger-scripts-api.d.ts deleted file mode 100644 index b101855f4d0..00000000000 --- a/scripts/debugger-scripts-api.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -type RunFunction = - | ((debugSession: IDebugSession, context: Context) => IDisposable) - | ((debugSession: IDebugSession, context: Context) => Promise); - -interface IDebugSession { - name: string; - eval(expression: string): Promise; - evalJs( - bodyFn: (...args: T) => TResult, - ...args: T - ): Promise; -} - -interface Context { - vscode: typeof import('vscode'); -} - -interface IDisposable { - dispose(): void; -} - -interface HotReloadConfig { - mode?: 'patch-prototype' | undefined; -} - -interface GlobalThisAddition { - $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined; -} - -type AcceptNewExportsFn = (newExports: Record) => boolean; diff --git a/scripts/hot-reload-injected-script.js b/scripts/hot-reload-injected-script.js deleted file mode 100644 index 431f11b6a66..00000000000 --- a/scripts/hot-reload-injected-script.js +++ /dev/null @@ -1,483 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// @ts-check -/// -/// - -const path = require('path'); -const fsPromise = require('fs/promises'); -const parcelWatcher = require('@parcel/watcher'); - -// This file is loaded by the vscode-diagnostic-tools extension and injected into the debugger. - - -/** - * Represents a lazy evaluation container. - * @template T - * @template TArg - */ -class Lazy { - /** - * Creates a new instance of the Lazy class. - * @param {(arg: TArg) => T} _fn - The function to be lazily evaluated. - */ - constructor(_fn) { - this._fn = _fn; - this._value = undefined; - } - - /** - * Gets the lazily evaluated value. - * @param {TArg} arg - The argument passed in to the evaluation function. - * @return {T} - */ - getValue(arg) { - if (!this._value) { - this._value = this._fn(arg); - } - return this._value; - } -} - -/** - * @param {Context['vscode']} vscode - */ -function setupGlobals(vscode) { - /** @type {DisposableStore} */ - const store = globalThis['hot-reload-injected-script-disposables'] ?? (globalThis['hot-reload-injected-script-disposables'] = new DisposableStore()); - store.clear(); - - function getConfig() { - const config = vscode.workspace.getConfiguration('vscode-diagnostic-tools').get('debuggerScriptsConfig', { - 'hotReload.sources': {} - }); - if (!config['hotReload.sources']) { - config['hotReload.sources'] = {}; - } - return config; - } - - /** - * @type {Map void>>} - */ - const enabledRelativePaths = new Map(); - const api = { - /** - * @param {string} relativePath - * @param {() => void} forceReloadFn - */ - reloadFailed: (relativePath, forceReloadFn) => { - const set = enabledRelativePaths.get(relativePath) ?? new Set(); - set.add(forceReloadFn); - enabledRelativePaths.set(relativePath, set); - - update(); - }, - - /** - * @param {string} relativePath - * @returns {HotReloadConfig} - */ - getConfig: (relativePath) => { - const config = getConfig(); - return { mode: config['hotReload.sources'][relativePath] === 'patch-prototype' ? 'patch-prototype' : undefined }; - } - }; - - const item = store.add(vscode.window.createStatusBarItem(undefined, 10000)); - - function update() { - item.hide(); - const e = vscode.window.activeTextEditor; - if (!e) { return; } - - const part = e.document.fileName.replace(/\\/g, '/').replace(/\.ts/, '.js').split('/src/')[1]; - if (!part) { return; } - - const isEnabled = api.getConfig(part)?.mode === 'patch-prototype'; - - if (!enabledRelativePaths.has(part) && !isEnabled) { - return; - } - - if (!isEnabled) { - item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); - item.text = '$(sync-ignored) hot reload disabled'; - } else { - item.backgroundColor = undefined; - item.text = '$(sync) hot reload enabled'; - } - - item.command = { - command: 'vscode-diagnostic-tools.hotReload.toggle', - title: 'Toggle hot reload', - arguments: [part], - tooltip: 'Toggle hot reload' - }; - item.tooltip = 'Toggle hot reload'; - item.show(); - } - - store.add(vscode.window.onDidChangeActiveTextEditor(e => { - update(); - })); - - store.add(vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('vscode-diagnostic-tools.debuggerScriptsConfig')) { - update(); - } - })); - - update(); - - store.add(vscode.commands.registerCommand('vscode-diagnostic-tools.hotReload.toggle', async (relativePath) => { - let config = getConfig(); - const current = config['hotReload.sources'][relativePath]; - const newValue = current === 'patch-prototype' ? undefined : 'patch-prototype'; - config = { ...config, 'hotReload.sources': { ...config['hotReload.sources'], [relativePath]: newValue } }; - - await vscode.workspace.getConfiguration('vscode-diagnostic-tools').update('debuggerScriptsConfig', config, vscode.ConfigurationTarget.Global); - - if (newValue === 'patch-prototype') { - const reloadFns = enabledRelativePaths.get(relativePath); - console.log(reloadFns); - if (reloadFns) { - for (const fn of reloadFns) { - fn(); - } - } - } - })); - - return api; -} - -const g = new Lazy(setupGlobals); - -/** @type {RunFunction} */ -module.exports.run = async function (debugSession, ctx) { - const store = new DisposableStore(); - - const global = ctx.vscode ? g.getValue(ctx.vscode) : undefined; - - const watcher = store.add(await DirWatcher.watchRecursively(path.join(__dirname, '../out/'))); - - /** - * So that the same file always gets the same reload fn. - * @type {Map void>} - */ - const reloadFns = new Map(); - - store.add(watcher.onDidChange(async changes => { - const supportedChanges = changes - .filter(c => c.path.endsWith('.js') || c.path.endsWith('.css')) - .map(c => { - const relativePath = c.path.replace(/\\/g, '/').split('/out/')[1]; - return { ...c, relativePath, config: global?.getConfig(relativePath) }; - }); - - const result = await debugSession.evalJs(function (changes, debugSessionName) { - // This function is stringified and injected into the debuggee. - - /** @type {{ count: number; originalWindowTitle: any; timeout: any; shouldReload: boolean }} */ - const hotReloadData = globalThis.$hotReloadData || (globalThis.$hotReloadData = { count: 0, messageHideTimeout: undefined, shouldReload: false }); - - /** @type {{ relativePath: string, path: string }[]} */ - const reloadFailedJsFiles = []; - - for (const change of changes) { - handleChange(change.relativePath, change.path, change.newContent, change.config); - } - - return { reloadFailedJsFiles }; - - /** - * @param {string} relativePath - * @param {string} path - * @param {string} newSrc - * @param {HotReloadConfig | undefined} config - */ - function handleChange(relativePath, path, newSrc, config) { - if (relativePath.endsWith('.css')) { - handleCssChange(relativePath); - } else if (relativePath.endsWith('.js')) { - handleJsChange(relativePath, path, newSrc, config); - } - } - - /** - * @param {string} relativePath - */ - function handleCssChange(relativePath) { - if (typeof document === 'undefined') { - return; - } - - const styleSheet = (/** @type {HTMLLinkElement[]} */ ([...document.querySelectorAll(`link[rel='stylesheet']`)])) - .find(l => new URL(l.href, document.location.href).pathname.endsWith(relativePath)); - if (styleSheet) { - setMessage(`reload ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); - console.log(debugSessionName, 'css reloaded', relativePath); - styleSheet.href = styleSheet.href.replace(/\?.*/, '') + '?' + Date.now(); - } else { - setMessage(`could not reload ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); - console.log(debugSessionName, 'ignoring css change, as stylesheet is not loaded', relativePath); - } - } - - /** - * @param {string} relativePath - * @param {string} newSrc - * @param {HotReloadConfig | undefined} config - */ - function handleJsChange(relativePath, path, newSrc, config) { - const moduleIdStr = trimEnd(relativePath, '.js'); - - /** @type {any} */ - const requireFn = globalThis.require; - const moduleManager = requireFn.moduleManager; - if (!moduleManager) { - console.log(debugSessionName, 'ignoring js change, as moduleManager is not available', relativePath); - return; - } - - const moduleId = moduleManager._moduleIdProvider.getModuleId(moduleIdStr); - const oldModule = moduleManager._modules2[moduleId]; - - if (!oldModule) { - console.log(debugSessionName, 'ignoring js change, as module is not loaded', relativePath); - return; - } - - // Check if we can reload - const g = /** @type {GlobalThisAddition} */ (globalThis); - - // A frozen copy of the previous exports - const oldExports = Object.freeze({ ...oldModule.exports }); - const reloadFn = g.$hotReload_applyNewExports?.({ oldExports, newSrc, config }); - - if (!reloadFn) { - console.log(debugSessionName, 'ignoring js change, as module does not support hot-reload', relativePath); - hotReloadData.shouldReload = true; - - reloadFailedJsFiles.push({ relativePath, path }); - - setMessage(`hot reload not supported for ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); - return; - } - - // Eval maintains source maps - function newScript(/* this parameter is used by newSrc */ define) { - // eslint-disable-next-line no-eval - eval(newSrc); // CodeQL [SM01632] This code is only executed during development. It is required for the hot-reload functionality. - } - - newScript(/* define */ function (deps, callback) { - // Evaluating the new code was successful. - - // Redefine the module - delete moduleManager._modules2[moduleId]; - moduleManager.defineModule(moduleIdStr, deps, callback); - const newModule = moduleManager._modules2[moduleId]; - - - // Patch the exports of the old module, so that modules using the old module get the new exports - Object.assign(oldModule.exports, newModule.exports); - // We override the exports so that future reloads still patch the initial exports. - newModule.exports = oldModule.exports; - - const successful = reloadFn(newModule.exports); - if (!successful) { - hotReloadData.shouldReload = true; - setMessage(`hot reload failed ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); - console.log(debugSessionName, 'hot reload was not successful', relativePath); - return; - } - - console.log(debugSessionName, 'hot reloaded', moduleIdStr); - setMessage(`successfully reloaded ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); - }); - } - - /** - * @param {string} message - */ - function setMessage(message) { - const domElem = /** @type {HTMLDivElement | undefined} */ (document.querySelector('.titlebar-center .window-title')); - if (!domElem) { return; } - if (!hotReloadData.timeout) { - hotReloadData.originalWindowTitle = domElem.innerText; - } else { - clearTimeout(hotReloadData.timeout); - } - if (hotReloadData.shouldReload) { - message += ' (manual reload required)'; - } - - domElem.innerText = message; - hotReloadData.timeout = setTimeout(() => { - hotReloadData.timeout = undefined; - // If wanted, we can restore the previous title message - // domElem.replaceChildren(hotReloadData.originalWindowTitle); - }, 5000); - } - - /** - * @param {string} path - * @returns {string} - */ - function formatPath(path) { - const parts = path.split('/'); - parts.reverse(); - let result = parts[0]; - parts.shift(); - for (const p of parts) { - if (result.length + p.length > 40) { - break; - } - result = p + '/' + result; - if (result.length > 20) { - break; - } - } - return result; - } - - function trimEnd(str, suffix) { - if (str.endsWith(suffix)) { - return str.substring(0, str.length - suffix.length); - } - return str; - } - - }, supportedChanges, debugSession.name.substring(0, 25)); - - for (const failedFile of result.reloadFailedJsFiles) { - const reloadFn = reloadFns.get(failedFile.relativePath) ?? (() => { - console.log('force change'); - watcher.forceChange(failedFile.path); - }); - reloadFns.set(failedFile.relativePath, reloadFn); - global?.reloadFailed(failedFile.relativePath, reloadFn); - } - })); - - return store; -}; - -class DirWatcher { - /** - * - * @param {string} dir - * @returns {Promise} - */ - static async watchRecursively(dir) { - /** @type {((changes: { path: string, newContent: string }[]) => void)[]} */ - const listeners = []; - /** @type {Map } */ - const fileContents = new Map(); - /** @type {Map} */ - const changes = new Map(); - /** @type {(handler: (changes: { path: string, newContent: string }[]) => void) => IDisposable} */ - const event = (handler) => { - listeners.push(handler); - return { - dispose: () => { - const idx = listeners.indexOf(handler); - if (idx >= 0) { - listeners.splice(idx, 1); - } - } - }; - }; - const r = parcelWatcher.subscribe(dir, async (err, events) => { - for (const e of events) { - if (e.type === 'update') { - const newContent = await fsPromise.readFile(e.path, 'utf8'); - if (fileContents.get(e.path) !== newContent) { - fileContents.set(e.path, newContent); - changes.set(e.path, { path: e.path, newContent }); - } - } - } - if (changes.size > 0) { - debounce(() => { - const uniqueChanges = Array.from(changes.values()); - changes.clear(); - listeners.forEach(l => l(uniqueChanges)); - })(); - } - }); - const result = await r; - return new DirWatcher(event, () => result.unsubscribe(), path => { - const content = fileContents.get(path); - if (content !== undefined) { - listeners.forEach(l => l([{ path: path, newContent: content }])); - } - }); - } - - /** - * @param {(handler: (changes: { path: string, newContent: string }[]) => void) => IDisposable} onDidChange - * @param {() => void} unsub - * @param {(path: string) => void} forceChange - */ - constructor(onDidChange, unsub, forceChange) { - this.onDidChange = onDidChange; - this.unsub = unsub; - this.forceChange = forceChange; - } - - dispose() { - this.unsub(); - } -} - -/** - * Debounce function calls - * @param {() => void} fn - * @param {number} delay - */ -function debounce(fn, delay = 50) { - let timeoutId; - return function (...args) { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - fn.apply(this, args); - }, delay); - }; -} - -class DisposableStore { - constructor() { - this._toDispose = new Set(); - this._isDisposed = false; - } - - - /** - * Adds an item to the collection. - * - * @template T - * @param {T} t - The item to add. - * @returns {T} The added item. - */ - add(t) { - this._toDispose.add(t); - return t; - } - dispose() { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - this.clear(); - } - clear() { - this._toDispose.forEach(item => item.dispose()); - this._toDispose.clear(); - } -}