/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as fsPromise from 'fs/promises'; import path from 'path'; import * as http from 'http'; import * as parcelWatcher from '@parcel/watcher'; /** * Launches the server for the monaco editor playground */ function main() { const server = new HttpServer({ host: 'localhost', port: 5001, cors: true }); server.use('/', redirectToMonacoEditorPlayground()); const rootDir = path.join(__dirname, '..'); const fileServer = new FileServer(rootDir); server.use(fileServer.handleRequest); const moduleIdMapper = new SimpleModuleIdPathMapper(path.join(rootDir, 'out')); const editorMainBundle = new CachedBundle('vs/editor/editor.main', moduleIdMapper); fileServer.overrideFileContent(editorMainBundle.entryModulePath, () => editorMainBundle.bundle()); const loaderPath = path.join(rootDir, 'out/vs/loader.js'); fileServer.overrideFileContent(loaderPath, async () => Buffer.from(new TextEncoder().encode(makeLoaderJsHotReloadable(await fsPromise.readFile(loaderPath, 'utf8'), new URL('/file-changes', server.url)))) ); const watcher = DirWatcher.watchRecursively(moduleIdMapper.rootDir); watcher.onDidChange((path, newContent) => { editorMainBundle.setModuleContent(path, newContent); editorMainBundle.bundle(); console.log(`${new Date().toLocaleTimeString()}, file change: ${path}`); }); server.use('/file-changes', handleGetFileChangesRequest(watcher, fileServer, moduleIdMapper)); console.log(`Server listening on ${server.url}`); } setTimeout(main, 0); // #region Http/File Server type RequestHandler = (req: http.IncomingMessage, res: http.ServerResponse) => Promise; type ChainableRequestHandler = (req: http.IncomingMessage, res: http.ServerResponse, next: RequestHandler) => Promise; class HttpServer { private readonly server: http.Server; public readonly url: URL; private handler: ChainableRequestHandler[] = []; constructor(options: { host: string; port: number; cors: boolean }) { this.server = http.createServer(async (req, res) => { if (options.cors) { res.setHeader('Access-Control-Allow-Origin', '*'); } let i = 0; const next = async (req: http.IncomingMessage, res: http.ServerResponse) => { if (i >= this.handler.length) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('404 Not Found'); return; } const handler = this.handler[i]; i++; await handler(req, res, next); }; await next(req, res); }); this.server.listen(options.port, options.host); this.url = new URL(`http://${options.host}:${options.port}`); } use(handler: ChainableRequestHandler); use(path: string, handler: ChainableRequestHandler); use(...args: [path: string, handler: ChainableRequestHandler] | [handler: ChainableRequestHandler]) { const handler = args.length === 1 ? args[0] : (req, res, next) => { const path = args[0]; const requestedUrl = new URL(req.url, this.url); if (requestedUrl.pathname === path) { return args[1](req, res, next); } else { return next(req, res); } }; this.handler.push(handler); } } function redirectToMonacoEditorPlayground(): ChainableRequestHandler { return async (req, res) => { const url = new URL('https://microsoft.github.io/monaco-editor/playground.html'); url.searchParams.append('source', `http://${req.headers.host}/out/vs`); res.writeHead(302, { Location: url.toString() }); res.end(); }; } class FileServer { private readonly overrides = new Map Promise>(); constructor(public readonly publicDir: string) { } public readonly handleRequest: ChainableRequestHandler = async (req, res, next) => { const requestedUrl = new URL(req.url!, `http://${req.headers.host}`); const pathName = requestedUrl.pathname; const filePath = path.join(this.publicDir, pathName); if (!filePath.startsWith(this.publicDir)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('403 Forbidden'); return; } try { const override = this.overrides.get(filePath); let content: Buffer; if (override) { content = await override(); } else { content = await fsPromise.readFile(filePath); } const contentType = getContentType(filePath); res.writeHead(200, { 'Content-Type': contentType }); res.end(content); } catch (err) { if (err.code === 'ENOENT') { next(req, res); } else { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('500 Internal Server Error'); } } }; public filePathToUrlPath(filePath: string): string | undefined { const relative = path.relative(this.publicDir, filePath); const isSubPath = !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); if (!isSubPath) { return undefined; } const relativePath = relative.replace(/\\/g, '/'); return `/${relativePath}`; } public overrideFileContent(filePath: string, content: () => Promise): void { this.overrides.set(filePath, content); } } function getContentType(filePath: string): string { const extname = path.extname(filePath); switch (extname) { case '.js': return 'text/javascript'; case '.css': return 'text/css'; case '.json': return 'application/json'; case '.png': return 'image/png'; case '.jpg': return 'image/jpg'; case '.svg': return 'image/svg+xml'; case '.html': return 'text/html'; case '.wasm': return 'application/wasm'; default: return 'text/plain'; } } // #endregion // #region File Watching interface IDisposable { dispose(): void; } class DirWatcher { public static watchRecursively(dir: string): DirWatcher { const listeners: ((path: string, newContent: string) => void)[] = []; const fileContents = new Map(); const event = (handler: (path: string, newContent: string) => void) => { listeners.push(handler); return { dispose: () => { const idx = listeners.indexOf(handler); if (idx >= 0) { listeners.splice(idx, 1); } } }; }; 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); listeners.forEach(l => l(e.path, newContent)); } } } }); return new DirWatcher(event); } constructor(public readonly onDidChange: (handler: (path: string, newContent: string) => void) => IDisposable) { } } function handleGetFileChangesRequest(watcher: DirWatcher, fileServer: FileServer, moduleIdMapper: SimpleModuleIdPathMapper): ChainableRequestHandler { return async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); const d = watcher.onDidChange((fsPath, newContent) => { const path = fileServer.filePathToUrlPath(fsPath); if (path) { res.write(JSON.stringify({ changedPath: path, moduleId: moduleIdMapper.getModuleId(fsPath), newContent }) + '\n'); } }); res.on('close', () => d.dispose()); }; } function makeLoaderJsHotReloadable(loaderJsCode: string, fileChangesUrl: URL): string { loaderJsCode = loaderJsCode.replace( /constructor\(env, scriptLoader, defineFunc, requireFunc, loaderAvailableTimestamp = 0\) {/, '$&globalThis.___globalModuleManager = this; globalThis.vscode = { process: { env: { VSCODE_DEV: true } } }' ); const ___globalModuleManager: any = undefined; // This code will be appended to loader.js function $watchChanges(fileChangesUrl: string) { interface HotReloadConfig { } let reloadFn; if (globalThis.$sendMessageToParent) { reloadFn = () => globalThis.$sendMessageToParent({ kind: 'reload' }); } else if (typeof window !== 'undefined') { reloadFn = () => window.location.reload(); } else { reloadFn = () => { }; } console.log('Connecting to server to watch for changes...'); (fetch as any)(fileChangesUrl) .then(async request => { const reader = request.body.getReader(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += new TextDecoder().decode(value); const lines = buffer.split('\n'); buffer = lines.pop()!; const changes: { relativePath: string; config: HotReloadConfig | undefined; path: string; newContent: string }[] = []; for (const line of lines) { const data = JSON.parse(line); const relativePath = data.changedPath.replace(/\\/g, '/').split('/out/')[1]; changes.push({ config: {}, path: data.changedPath, relativePath, newContent: data.newContent }); } const result = handleChanges(changes, 'playground-server'); if (result.reloadFailedJsFiles.length > 0) { reloadFn(); } } }).catch(err => { console.error(err); setTimeout(() => $watchChanges(fileChangesUrl), 1000); }); function handleChanges(changes: { relativePath: string; config: HotReloadConfig | undefined; path: string; newContent: string; }[], debugSessionName: string) { // This function is stringified and injected into the debuggee. const hotReloadData: { count: number; originalWindowTitle: any; timeout: any; shouldReload: boolean } = globalThis.$hotReloadData || (globalThis.$hotReloadData = { count: 0, messageHideTimeout: undefined, shouldReload: false }); const reloadFailedJsFiles: { relativePath: string; path: string }[] = []; for (const change of changes) { handleChange(change.relativePath, change.path, change.newContent, change.config); } return { reloadFailedJsFiles }; function handleChange(relativePath: string, path: string, newSrc: string, config: any) { if (relativePath.endsWith('.css')) { handleCssChange(relativePath); } else if (relativePath.endsWith('.js')) { handleJsChange(relativePath, path, newSrc, config); } } function handleCssChange(relativePath: string) { if (typeof document === 'undefined') { return; } const styleSheet = (([...document.querySelectorAll(`link[rel='stylesheet']`)] as HTMLLinkElement[])) .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); } } function handleJsChange(relativePath: string, path: string, newSrc: string, config: any) { const moduleIdStr = trimEnd(relativePath, '.js'); const requireFn: any = globalThis.require; const moduleManager = (requireFn as any).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 = globalThis as any; // 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()}`); }); } function setMessage(message: string) { const domElem = (document.querySelector('.titlebar-center .window-title')) as HTMLDivElement | undefined; 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); } function formatPath(path: string): string { 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; } } } const additionalJsCode = ` (${(function () { globalThis.$hotReload_deprecateExports = new Set<(oldExports: any, newExports: any) => void>(); }).toString()})(); ${$watchChanges.toString()} $watchChanges(${JSON.stringify(fileChangesUrl)}); `; return `${loaderJsCode}\n${additionalJsCode}`; } // #endregion // #region Bundling class CachedBundle { public readonly entryModulePath = this.mapper.resolveRequestToPath(this.moduleId)!; constructor( private readonly moduleId: string, private readonly mapper: SimpleModuleIdPathMapper, ) { } private loader: ModuleLoader | undefined = undefined; private bundlePromise: Promise | undefined = undefined; public async bundle(): Promise { if (!this.bundlePromise) { this.bundlePromise = (async () => { if (!this.loader) { this.loader = new ModuleLoader(this.mapper); await this.loader.addModuleAndDependencies(this.entryModulePath); } const editorEntryPoint = await this.loader.getModule(this.entryModulePath); const content = bundleWithDependencies(editorEntryPoint!); return content; })(); } return this.bundlePromise; } public async setModuleContent(path: string, newContent: string): Promise { if (!this.loader) { return; } const module = await this.loader!.getModule(path); if (module) { if (!this.loader.updateContent(module, newContent)) { this.loader = undefined; } } this.bundlePromise = undefined; } } function bundleWithDependencies(module: IModule): Buffer { const visited = new Set(); const builder = new SourceMapBuilder(); function visit(module: IModule) { if (visited.has(module)) { return; } visited.add(module); for (const dep of module.dependencies) { visit(dep); } builder.addSource(module.source); } visit(module); const sourceMap = builder.toSourceMap(); sourceMap.sourceRoot = module.source.sourceMap.sourceRoot; const sourceMapBase64Str = Buffer.from(JSON.stringify(sourceMap)).toString('base64'); builder.addLine(`//# sourceMappingURL=data:application/json;base64,${sourceMapBase64Str}`); return builder.toContent(); } class ModuleLoader { private readonly modules = new Map>(); constructor(private readonly mapper: SimpleModuleIdPathMapper) { } public getModule(path: string): Promise { return Promise.resolve(this.modules.get(path)); } public updateContent(module: IModule, newContent: string): boolean { const parsedModule = parseModule(newContent, module.path, this.mapper); if (!parsedModule) { return false; } if (!arrayEquals(parsedModule.dependencyRequests, module.dependencyRequests)) { return false; } module.dependencyRequests = parsedModule.dependencyRequests; module.source = parsedModule.source; return true; } async addModuleAndDependencies(path: string): Promise { if (this.modules.has(path)) { return this.modules.get(path)!; } const promise = (async () => { const content = await fsPromise.readFile(path, { encoding: 'utf-8' }); const parsedModule = parseModule(content, path, this.mapper); if (!parsedModule) { return undefined; } const dependencies = (await Promise.all(parsedModule.dependencyRequests.map(async r => { if (r === 'require' || r === 'exports' || r === 'module') { return null; } const depPath = this.mapper.resolveRequestToPath(r, path); if (!depPath) { return null; } return await this.addModuleAndDependencies(depPath); }))).filter((d): d is IModule => !!d); const module: IModule = { id: this.mapper.getModuleId(path)!, dependencyRequests: parsedModule.dependencyRequests, dependencies, path, source: parsedModule.source, }; return module; })(); this.modules.set(path, promise); return promise; } } function arrayEquals(a: T[], b: T[]): boolean { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } const encoder = new TextEncoder(); function parseModule(content: string, path: string, mapper: SimpleModuleIdPathMapper): { source: Source; dependencyRequests: string[] } | undefined { const m = content.match(/define\((\[.*?\])/); if (!m) { return undefined; } const dependencyRequests = JSON.parse(m[1].replace(/'/g, '"')) as string[]; const sourceMapHeader = '//# sourceMappingURL=data:application/json;base64,'; const idx = content.indexOf(sourceMapHeader); let sourceMap: any = null; if (idx !== -1) { const sourceMapJsonStr = Buffer.from(content.substring(idx + sourceMapHeader.length), 'base64').toString('utf-8'); sourceMap = JSON.parse(sourceMapJsonStr); content = content.substring(0, idx); } content = content.replace('define([', `define("${mapper.getModuleId(path)}", [`); const contentBuffer = Buffer.from(encoder.encode(content)); const source = new Source(contentBuffer, sourceMap); return { dependencyRequests, source }; } class SimpleModuleIdPathMapper { constructor(public readonly rootDir: string) { } public getModuleId(path: string): string | null { if (!path.startsWith(this.rootDir) || !path.endsWith('.js')) { return null; } const moduleId = path.substring(this.rootDir.length + 1); return moduleId.replace(/\\/g, '/').substring(0, moduleId.length - 3); } public resolveRequestToPath(request: string, requestingModulePath?: string): string | null { if (request.indexOf('css!') !== -1) { return null; } if (request.startsWith('.')) { return path.join(path.dirname(requestingModulePath!), request + '.js'); } else { return path.join(this.rootDir, request + '.js'); } } } interface IModule { id: string; dependencyRequests: string[]; dependencies: IModule[]; path: string; source: Source; } // #endregion // #region SourceMapBuilder // From https://stackoverflow.com/questions/29905373/how-to-create-sourcemaps-for-concatenated-files with modifications class Source { // Ends with \n public readonly content: Buffer; public readonly sourceMap: SourceMap; public readonly sourceLines: number; public readonly sourceMapMappings: Buffer; constructor(content: Buffer, sourceMap: SourceMap | undefined) { if (!sourceMap) { sourceMap = SourceMapBuilder.emptySourceMap; } let sourceLines = countNL(content); if (content.length > 0 && content[content.length - 1] !== 10) { sourceLines++; content = Buffer.concat([content, Buffer.from([10])]); } this.content = content; this.sourceMap = sourceMap; this.sourceLines = sourceLines; this.sourceMapMappings = typeof this.sourceMap.mappings === 'string' ? Buffer.from(encoder.encode(sourceMap.mappings as string)) : this.sourceMap.mappings; } } class SourceMapBuilder { public static emptySourceMap: SourceMap = { version: 3, sources: [], mappings: Buffer.alloc(0) }; private readonly outputBuffer = new DynamicBuffer(); private readonly sources: string[] = []; private readonly mappings = new DynamicBuffer(); private lastSourceIndex = 0; private lastSourceLine = 0; private lastSourceCol = 0; addLine(text: string) { this.outputBuffer.addString(text); this.outputBuffer.addByte(10); this.mappings.addByte(59); // ; } addSource(source: Source) { const sourceMap = source.sourceMap; this.outputBuffer.addBuffer(source.content); const sourceRemap: number[] = []; for (const v of sourceMap.sources) { let pos = this.sources.indexOf(v); if (pos < 0) { pos = this.sources.length; this.sources.push(v); } sourceRemap.push(pos); } let lastOutputCol = 0; const inputMappings = source.sourceMapMappings; let outputLine = 0; let ip = 0; let inOutputCol = 0; let inSourceIndex = 0; let inSourceLine = 0; let inSourceCol = 0; let shift = 0; let value = 0; let valpos = 0; const commit = () => { if (valpos === 0) { return; } this.mappings.addVLQ(inOutputCol - lastOutputCol); lastOutputCol = inOutputCol; if (valpos === 1) { valpos = 0; return; } const outSourceIndex = sourceRemap[inSourceIndex]; this.mappings.addVLQ(outSourceIndex - this.lastSourceIndex); this.lastSourceIndex = outSourceIndex; this.mappings.addVLQ(inSourceLine - this.lastSourceLine); this.lastSourceLine = inSourceLine; this.mappings.addVLQ(inSourceCol - this.lastSourceCol); this.lastSourceCol = inSourceCol; valpos = 0; }; while (ip < inputMappings.length) { let b = inputMappings[ip++]; if (b === 59) { // ; commit(); this.mappings.addByte(59); inOutputCol = 0; lastOutputCol = 0; outputLine++; } else if (b === 44) { // , commit(); this.mappings.addByte(44); } else { b = charToInteger[b]; if (b === 255) { throw new Error('Invalid sourceMap'); } value += (b & 31) << shift; if (b & 32) { shift += 5; } else { const shouldNegate = value & 1; value >>= 1; if (shouldNegate) { value = -value; } switch (valpos) { case 0: inOutputCol += value; break; case 1: inSourceIndex += value; break; case 2: inSourceLine += value; break; case 3: inSourceCol += value; break; } valpos++; value = shift = 0; } } } commit(); while (outputLine < source.sourceLines) { this.mappings.addByte(59); outputLine++; } } toContent(): Buffer { return this.outputBuffer.toBuffer(); } toSourceMap(sourceRoot?: string): SourceMap { return { version: 3, sourceRoot, sources: this.sources, mappings: this.mappings.toBuffer().toString() }; } } export interface SourceMap { version: number; // always 3 file?: string; sourceRoot?: string; sources: string[]; sourcesContent?: string[]; names?: string[]; mappings: string | Buffer; } const charToInteger = Buffer.alloc(256); const integerToChar = Buffer.alloc(64); charToInteger.fill(255); 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('').forEach((char, i) => { charToInteger[char.charCodeAt(0)] = i; integerToChar[i] = char.charCodeAt(0); }); class DynamicBuffer { private buffer: Buffer; private size: number; constructor() { this.buffer = Buffer.alloc(512); this.size = 0; } ensureCapacity(capacity: number) { if (this.buffer.length >= capacity) { return; } const oldBuffer = this.buffer; this.buffer = Buffer.alloc(Math.max(oldBuffer.length * 2, capacity)); oldBuffer.copy(this.buffer); } addByte(b: number) { this.ensureCapacity(this.size + 1); this.buffer[this.size++] = b; } addVLQ(num: number) { let clamped: number; if (num < 0) { num = (-num << 1) | 1; } else { num <<= 1; } do { clamped = num & 31; num >>= 5; if (num > 0) { clamped |= 32; } this.addByte(integerToChar[clamped]); } while (num > 0); } addString(s: string) { const l = Buffer.byteLength(s); this.ensureCapacity(this.size + l); this.buffer.write(s, this.size); this.size += l; } addBuffer(b: Buffer) { this.ensureCapacity(this.size + b.length); b.copy(this.buffer, this.size); this.size += b.length; } toBuffer(): Buffer { return this.buffer.slice(0, this.size); } } function countNL(b: Buffer): number { let res = 0; for (let i = 0; i < b.length; i++) { if (b[i] === 10) { res++; } } return res; } // #endregion