| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| // ignore_for_file: implementation_imports | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'package:analyzer/file_system/file_system.dart'; | |
| import 'package:analyzer/file_system/memory_file_system.dart'; | |
| import 'package:analyzer/file_system/overlay_file_system.dart'; | |
| import 'package:async/async.dart'; | |
| import 'package:json_rpc_2/json_rpc_2.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:stream_channel/stream_channel.dart'; | |
| import 'resource_provider/resource_provider_ext.dart'; | |
| import 'resource_provider/resource_provider_wrap_cwd.dart'; | |
| import 'shared.dart' hide FileSystemException; | |
| import 'tools/hot_reload_compiler.dart' show HotReloadCompiler; | |
| import 'tools/language_server.dart'; | |
| import 'tools/pub.dart'; | |
| final class Worker { | |
| final _rp = MemoryResourceProvider(context: p.posix); | |
| var _config = DartPadConfig(); | |
| int _nextLanguageServerId = 1; | |
| int _nextHotReloadCompilerId = 1; | |
| int _nextWorkspaceId = 1; | |
| Worker._(); | |
| static Future<Worker> create( | |
| Stream<List<int>> sdkTarStream, { | |
| String? pubHostedUrl, | |
| }) async { | |
| final w = Worker._(); | |
| await w._rp.getFolder('/').extractTarStream(sdkTarStream); | |
| final configFile = w._rp.getFile(DartPadConfig.defaultDartPadConfigPath); | |
| if (configFile.exists) { | |
| try { | |
| w._config = DartPadConfig.fromJson( | |
| jsonDecode(configFile.readAsStringSync()) as Map<String, Object?>, | |
| ); | |
| } catch (e) { | |
| // TODO(jonasfj): Find a better way to propogate this error. | |
| // This is only relevant for people making their own | |
| // sdk.tar files. But it'd also make general debugging | |
| // easier. To report it better we might also want to | |
| // report progress updates while loading. | |
| print('Error reading dartpad-config.json: $e'); | |
| } | |
| } | |
| w._config = w._config.copyWith(pubHostedUrl: pubHostedUrl); | |
| return w; | |
| } | |
| void connect(StreamChannel<String> channel) { | |
| _Session(channel, this); | |
| } | |
| } | |
| class _Session { | |
| final Worker _worker; | |
| late final Peer _rpc; | |
| final _workspaces = <int, _Workspace>{}; | |
| _Session(StreamChannel<String> channel, this._worker) { | |
| _rpc = Peer(channel, onUnhandledError: _onUnhandledError); | |
| _rpc.registerMethod('createWorkspace', _createWorkspace); | |
| _rpc.registerMethod('workspace/dispose', _disposeWorkspace); | |
| _rpc.registerMethod( | |
| 'workspace/writeFileFromText', | |
| _forwardToWorkspace((ws) => ws._writeFileFromText), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/writeFileFromBytes', | |
| _forwardToWorkspace((ws) => ws._writeFileFromBytes), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/readFileAsText', | |
| _forwardToWorkspace((ws) => ws._readFileAsText), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/readFileAsBytes', | |
| _forwardToWorkspace((ws) => ws._readFileAsBytes), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/deleteFileSystemEntity', | |
| _forwardToWorkspace((ws) => ws._deleteFileSystemEntity), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/stat', | |
| _forwardToWorkspace((ws) => ws._stat), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/listDirectory', | |
| _forwardToWorkspace((ws) => ws._listDirectory), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/importTarArchive', | |
| _forwardToWorkspace((ws) => ws._importTarArchive), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/exportTarArchive', | |
| _forwardToWorkspace((ws) => ws._exportTarArchive), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/createFolder', | |
| _forwardToWorkspace((ws) => ws._createFolder), | |
| ); | |
| _rpc.registerMethod('workspace/pub', _forwardToWorkspace((ws) => ws._pub)); | |
| _rpc.registerMethod( | |
| 'workspace/startHotReloadCompiler', | |
| _forwardToWorkspace((ws) => ws._startHotReloadCompiler), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/hotReloadCompiler/compile', | |
| _forwardToWorkspace((ws) => ws._hotReloadCompilerCompile), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/hotReloadCompiler/close', | |
| _forwardToWorkspace((ws) => ws._hotReloadCompilerClose), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/startLanguageServer', | |
| _forwardToWorkspace((ws) => ws._startLanguageServer), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/languageServer/message', | |
| _forwardToWorkspace((ws) => ws._languageServerMessage), | |
| ); | |
| _rpc.registerMethod( | |
| 'workspace/languageServer/stop', | |
| _forwardToWorkspace((ws) => ws._stopLanguageServer), | |
| ); | |
| unawaited(() async { | |
| await _rpc.listen(); | |
| // Delete all workspaces to cleanup resources | |
| await Future.wait( | |
| _workspaces.values.toList().map((ws) => ws._deleteWorkspace()), | |
| ); | |
| }()); | |
| } | |
| Object? _createWorkspace(Parameters params) async { | |
| final workspaceId = _worker._nextWorkspaceId++; | |
| final workspaceFolder = '/workspace/pad_$workspaceId'; | |
| _worker._rp.getFolder(workspaceFolder).create(); | |
| _workspaces[workspaceId] = _Workspace( | |
| _worker, | |
| this, | |
| workspaceId, | |
| workspaceFolder, | |
| resourceProviderWithCurrentWorkingDirectory(_worker._rp, workspaceFolder), | |
| ); | |
| return { | |
| 'workspaceId': workspaceId, | |
| 'workspaceFolder': Uri.directory(workspaceFolder).toString(), | |
| }; | |
| } | |
| Object? _disposeWorkspace(Parameters params) async { | |
| final workspace = _workspaces.remove(params['workspaceId'].asNum.toInt()); | |
| if (workspace != null) { | |
| await workspace._deleteWorkspace(); | |
| } | |
| // Deleting a workspace that doesn't exist is a no-op | |
| // This ensures that deletion is an idempotent operation! | |
| return <String, Object?>{}; | |
| } | |
| Object? Function(Parameters) _forwardToWorkspace( | |
| Object? Function(Parameters params) Function(_Workspace ws) resolveHandler, | |
| ) { | |
| return (Parameters params) async { | |
| final workspaceId = params['workspaceId'].asNum.toInt(); | |
| final workspace = _workspaces[workspaceId]; | |
| if (workspace == null) { | |
| throw WorkspaceNotFoundException( | |
| 'Invalid "workspaceId", no such workspace exists', | |
| data: {'workspaceId': workspaceId}, | |
| ); | |
| } | |
| return resolveHandler(workspace)(params); | |
| }; | |
| } | |
| void _onUnhandledError(Object? e, Object? st) { | |
| print('Unhandled error not forwarded to the client: $e, $st'); | |
| } | |
| } | |
| class _Workspace { | |
| final Worker _worker; | |
| final _Session _session; | |
| final int _workspaceId; | |
| final String _workspaceFolder; | |
| final ResourceProvider _rp; | |
| final _languageServers = <int, LanguageServer>{}; | |
| final _hotReloadCompilers = <int, HotReloadCompiler>{}; | |
| _Workspace( | |
| this._worker, | |
| this._session, | |
| this._workspaceId, | |
| this._workspaceFolder, | |
| this._rp, | |
| ); | |
| String _resolvePath(Uri u) { | |
| final path = _rp.pathContext.fromUri(u); | |
| if (_rp.pathContext.isAbsolute(path)) { | |
| return _rp.pathContext.normalize(path); | |
| } | |
| return _rp.pathContext.normalize( | |
| _rp.pathContext.join(_workspaceFolder, path), | |
| ); | |
| } | |
| Object? _writeFileFromText(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final text = params['text'].asString; | |
| try { | |
| final file = _rp.getFile(path); | |
| file.parent.createRecursively(); | |
| file.writeAsStringSync(text); | |
| } on FileSystemException catch (e) { | |
| throw FileWriteConflictException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| return <String, Object?>{}; | |
| } | |
| Object? _writeFileFromBytes(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final bytes = base64.decode(params['base64'].asString); | |
| try { | |
| final file = _rp.getFile(path); | |
| file.parent.createRecursively(); | |
| file.writeAsBytesSync(bytes); | |
| } on FileSystemException catch (e) { | |
| throw FileWriteConflictException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| return <String, Object?>{}; | |
| } | |
| Object? _readFileAsText(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| try { | |
| return {'text': _rp.getFile(path).readAsStringSync()}; | |
| } on FileSystemException catch (e) { | |
| throw FileNotFoundException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| } | |
| Object? _readFileAsBytes(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| try { | |
| return {'base64': base64.encode(_rp.getFile(path).readAsBytesSync())}; | |
| } on FileSystemException catch (e) { | |
| throw FileNotFoundException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| } | |
| Object? _deleteFileSystemEntity(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| try { | |
| _rp.getResource(path).delete(); | |
| } on FileSystemException catch (e) { | |
| throw FileDeletionFailedException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| return <String, Object?>{}; | |
| } | |
| Object? _stat(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final resource = _rp.getResource(path); | |
| if (!resource.exists) { | |
| throw FileNotFoundException( | |
| 'File or directory not found', | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| if (resource is File) { | |
| return {'type': 'file', 'size': resource.lengthSync}; | |
| } else if (resource is Folder) { | |
| return {'type': 'folder'}; | |
| } else { | |
| return {'type': 'other'}; | |
| } | |
| } | |
| Object? _createFolder(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| try { | |
| _rp.getFolder(path).createRecursively(); | |
| } on FileSystemException catch (e) { | |
| throw FileWriteConflictException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| return <String, Object?>{}; | |
| } | |
| Object? _listDirectory(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final recursive = params['recursive'].asBoolOr(false); | |
| final ignoreHidden = params['ignoreHidden'].asBoolOr(false); | |
| try { | |
| final folder = _rp.getFolder(path); | |
| if (!folder.exists) { | |
| throw FileSystemException('Directory not found', path); | |
| } | |
| final entries = <Map<String, String>>[]; | |
| void traverse(Folder dir) { | |
| for (final child in dir.getChildren()) { | |
| if (ignoreHidden && child.shortName.startsWith('.')) continue; | |
| final relativePath = _rp.pathContext.relative(child.path, from: path); | |
| if (child is File) { | |
| entries.add({'path': relativePath, 'type': 'file'}); | |
| } else if (child is Folder) { | |
| entries.add({'path': relativePath, 'type': 'folder'}); | |
| if (recursive) { | |
| traverse(child); | |
| } | |
| } | |
| } | |
| } | |
| traverse(folder); | |
| return {'entries': entries}; | |
| } on FileSystemException catch (e) { | |
| throw FileNotFoundException( | |
| e.message, | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| } | |
| Object? _importTarArchive(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final bytes = base64.decode(params['base64'].asString); | |
| await _rp.getFolder(path).extractTarStream(Stream.value(bytes)); | |
| return <String, Object?>{}; | |
| } | |
| Object? _exportTarArchive(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final folder = _rp.getFolder(path); | |
| if (!folder.exists) { | |
| throw FileNotFoundException( | |
| 'Directory not found', | |
| data: {'resolvedUri': Uri.file(path).toString()}, | |
| ); | |
| } | |
| return { | |
| 'base64': base64.encode(await collectBytes(folder.createTarStream())), | |
| }; | |
| } | |
| String _findPackageConfigFromEntrypoint(String entrypoint) { | |
| var parent = _rp.getFile(entrypoint).parent; | |
| do { | |
| final pkgConfig = parent | |
| .getFolder('.dart_tool') | |
| .getFile('package_config.json'); | |
| if (pkgConfig.exists) { | |
| return pkgConfig.path; | |
| } | |
| parent = parent.parent; | |
| } while (!parent.isRoot); | |
| throw PackageConfigNotFoundException( | |
| 'Unable to find `.dart_tool/package_config.json` in any ' | |
| 'parent directory of `$entrypoint`.', | |
| data: {'entrypoint': entrypoint}, | |
| ); | |
| } | |
| Object? _pub(Parameters params) async { | |
| final path = _resolvePath(params['uri'].asUri); | |
| final command = params['command'].asString; | |
| final args = params['args'].asListOr(const <String>[]); | |
| if (!supportedPubCommands.contains(command)) { | |
| throw RpcException.invalidParams( | |
| '`command` must be one of: ${supportedPubCommands.join(', ')}', | |
| ); | |
| } | |
| if (args.any((a) => a is! String)) { | |
| throw RpcException.invalidParams('args must be a list of strings'); | |
| } | |
| final (:log) = await pub( | |
| resourceProvider: _rp, | |
| currentWorkingDirectory: path, | |
| command: command, | |
| args: args.whereType<String>().toList(), | |
| config: _worker._config, | |
| ); | |
| return {'log': log}; | |
| } | |
| Object? _startHotReloadCompiler(Parameters params) async { | |
| var entrypoint = _resolvePath(params['uri'].asUri); | |
| // Test if the file we're compiling exists. | |
| // Otherwise, we get really ugly errors if there is a bootstrap file in play | |
| if (!_rp.getFile(entrypoint).exists) { | |
| throw CompilationFailedException( | |
| 'Compilation entrypoint "$entrypoint" not found', | |
| data: {'entrypoint': entrypoint}, | |
| ); | |
| } | |
| var rp = _rp; | |
| final bootstrapCodeTemplate = _worker._config.bootstrapCode; | |
| if (bootstrapCodeTemplate != null) { | |
| final originalEntrypoint = entrypoint; | |
| entrypoint = '$originalEntrypoint.virtual-bootstrap-wrapper.dart'; | |
| final overlay = rp = OverlayResourceProvider(_rp); | |
| overlay.setOverlay( | |
| entrypoint, | |
| content: bootstrapCodeTemplate.replaceAll( | |
| '{{entrypoint}}', | |
| originalEntrypoint, | |
| ), | |
| modificationStamp: 0, | |
| ); | |
| } | |
| final id = _worker._nextHotReloadCompilerId++; | |
| _hotReloadCompilers[id] = HotReloadCompiler( | |
| resourceProvider: rp, | |
| packageConfig: _findPackageConfigFromEntrypoint(entrypoint), | |
| targetPath: entrypoint, | |
| config: _worker._config, | |
| ); | |
| return {'hotReloadCompilerId': id}; | |
| } | |
| HotReloadCompiler _getHotReloadCompiler(Parameters params) { | |
| final id = params['hotReloadCompilerId'].asNum.toInt(); | |
| final c = _hotReloadCompilers[id]; | |
| if (c == null) { | |
| throw HotReloadCompilerNotFoundException( | |
| 'HotReloadCompiler not found, check the "hotReloadCompilerId"', | |
| data: {'workspaceId': _workspaceId, 'hotReloadCompilerId': id}, | |
| ); | |
| } | |
| return c; | |
| } | |
| Object? _hotReloadCompilerCompile(Parameters params) async { | |
| final c = _getHotReloadCompiler(params); | |
| final (:code, :compiledLibraryUris, :log) = await c.compile(); | |
| return { | |
| 'code': code, | |
| 'compiledLibraryUris': compiledLibraryUris, | |
| 'log': log, | |
| }; | |
| } | |
| Object? _hotReloadCompilerClose(Parameters params) async { | |
| final id = params['hotReloadCompilerId'].asNum.toInt(); | |
| final c = _getHotReloadCompiler(params); | |
| _hotReloadCompilers.remove(id); | |
| await c.close(); | |
| return <String, Object?>{}; | |
| } | |
| Object? _startLanguageServer(Parameters params) async { | |
| final languageServerId = _worker._nextLanguageServerId++; | |
| final ls = _languageServers[languageServerId] = LanguageServer( | |
| resourceProvider: _rp, | |
| config: _worker._config, | |
| ); | |
| ls.messages.listen((m) { | |
| _session._rpc.sendNotification('workspace/languageServer/message', { | |
| 'workspaceId': _workspaceId, | |
| 'languageServerId': languageServerId, | |
| 'message': m, | |
| }); | |
| }); | |
| unawaited( | |
| ls.closed.whenComplete(() { | |
| _session._rpc.sendNotification('workspace/languageServer/exited', { | |
| 'workspaceId': _workspaceId, | |
| 'languageServerId': languageServerId, | |
| }); | |
| }), | |
| ); | |
| return {'languageServerId': languageServerId}; | |
| } | |
| Object? _languageServerMessage(Parameters params) async { | |
| final languageServerId = params['languageServerId'].asNum.toInt(); | |
| final languageServer = _languageServers[languageServerId]; | |
| if (languageServer == null) { | |
| throw LanguageServerNotFoundException( | |
| 'Language server not found, check the "languageServerId"', | |
| data: { | |
| 'workspaceId': _workspaceId, | |
| 'languageServerId': languageServerId, | |
| }, | |
| ); | |
| } | |
| await languageServer.handle( | |
| params['message'].asMap as Map<String, Object?>, | |
| ); | |
| return <String, Object?>{}; | |
| } | |
| Object? _stopLanguageServer(Parameters params) async { | |
| final languageServerId = params['languageServerId'].asNum.toInt(); | |
| final languageServer = _languageServers.remove(languageServerId); | |
| if (languageServer != null) { | |
| await languageServer.close(); | |
| } | |
| return <String, Object?>{}; | |
| } | |
| Future<void> _deleteWorkspace() async { | |
| try { | |
| await Future.wait([ | |
| ..._languageServers.values.map((ls) => ls.close()), | |
| ..._hotReloadCompilers.values.map((cs) => cs.close()), | |
| ]); | |
| } finally { | |
| _rp.getFolder(_workspaceFolder).delete(); | |
| } | |
| } | |
| } |