/*--------------------------------------------------------------------------------------------- * 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 => { const path = fileServer.filePathToUrlPath(fsPath); if (path) { res.write(JSON.stringify({ changedPath: path, moduleId: moduleIdMapper.getModuleId(fsPath) }) + '\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;' ); const ___globalModuleManager: any = undefined; // This code will be appended to loader.js function $watchChanges(fileChangesUrl: string) { 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()!; for (const line of lines) { const data = JSON.parse(line); let handled = false; if (data.changedPath.endsWith('.css')) { if (typeof document !== 'undefined') { console.log('css changed', data.changedPath); const styleSheet = [...document.querySelectorAll(`link[rel='stylesheet']`)].find((l: any) => new URL(l.href, document.location.href).pathname.endsWith(data.changedPath)) as any; if (styleSheet) { styleSheet.href = styleSheet.href.replace(/\?.*/, '') + '?' + Date.now(); } } handled = true; } else if (data.changedPath.endsWith('.js') && data.moduleId) { console.log('js changed', data.changedPath); const moduleId = ___globalModuleManager._moduleIdProvider.getModuleId(data.moduleId); if (___globalModuleManager._modules2[moduleId]) { const srcUrl = ___globalModuleManager._config.moduleIdToPaths(data.moduleId); const newSrc = await (await fetch(srcUrl)).text(); (new Function('define', newSrc))(function (deps, callback) { // CodeQL [SM01632] This code is only executed during development (as part of the dev-only playground-server). It is required for the hot-reload functionality. const oldModule = ___globalModuleManager._modules2[moduleId]; delete ___globalModuleManager._modules2[moduleId]; ___globalModuleManager.defineModule(data.moduleId, deps, callback); const newModule = ___globalModuleManager._modules2[moduleId]; const oldExports = { ...oldModule.exports }; Object.assign(oldModule.exports, newModule.exports); newModule.exports = oldModule.exports; handled = true; for (const cb of [...globalThis.$hotReload_deprecateExports]) { cb(oldExports, newModule.exports); } if (handled) { console.log('hot reloaded', data.moduleId); } }); } } if (!handled) { reloadFn(); } } } }).catch(err => { console.error(err); setTimeout(() => $watchChanges(fileChangesUrl), 1000); }); } 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