| // 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. |
| |
| import 'dart:async'; |
| import 'dart:collection'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'dart:typed_data'; |
| |
| import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; |
| import 'package:logging/logging.dart'; |
| import 'package:shelf/shelf.dart'; |
| import 'package:vm_service/vm_service.dart' hide Response; |
| |
| import 'dart_runtime_service.dart'; |
| import 'dart_runtime_service_rpcs.dart'; |
| import 'rpc_exceptions.dart'; |
| |
| /// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file |
| /// system access to clients. |
| abstract base class DevelopmentFileSystem { |
| DevelopmentFileSystem({required this.name, required this.rootUri}); |
| |
| final String name; |
| final Uri rootUri; |
| |
| static Never throwInvalidUriParameter({ |
| required String method, |
| required Object? uri, |
| }) => RpcException.invalidParams.throwExceptionWithDetails( |
| details: "$method: invalid 'uri' parameter: $uri", |
| ); |
| |
| static Never throwMissingUriParameter({required String method}) => |
| RpcException.invalidParams.throwExceptionWithDetails( |
| details: "$method: expects the 'uri' parameter", |
| ); |
| |
| /// Reads the contents of the file at [uri]. |
| /// |
| /// If the file does not exist, a [RpcException.fileDoesNotExist] exception |
| /// is thrown. |
| Future<RpcResponse> readFile({required String uri}); |
| |
| /// Writes [bytes] to [uri]. |
| Future<void> writeFile({required String uri, required List<int> bytes}); |
| |
| /// Writes a stream of [bytes] to [uri]. |
| Future<void> writeStreamFile({ |
| required String uri, |
| required Stream<List<int>> bytes, |
| }); |
| |
| /// Lists all files contained in the [DevelopmentFileSystem]. |
| /// |
| /// Each file is reported with its size in bytes and last modified timestamp |
| /// in milliseconds since epoch. |
| Future<RpcResponse> listFiles(); |
| |
| /// Resolves the [uri] against the [rootUri] of the [DevelopmentFileSystem]. |
| /// |
| /// [uri] must be a valid file URI with an optional leading `/`. If the |
| /// resolved URI is not within the file systems [rootUri], an |
| /// [RpcException.invalidParams] exception is thrown. |
| Uri resolve({required String method, required String uri}) { |
| // The leading '/' is optional but must be removed before resolving the |
| // URI, otherwise it will be treated as the file system root. |
| if (uri.startsWith('/')) { |
| uri = uri.substring(1); |
| } |
| final parsedUri = Uri.tryParse(uri); |
| if (parsedUri == null) { |
| throwInvalidUriParameter(method: method, uri: uri); |
| } |
| try { |
| // Make sure that this pathUri can be converted to a file path. |
| parsedUri.toFilePath(); |
| // ignore: avoid_catching_errors |
| } on UnsupportedError { |
| throwInvalidUriParameter(method: method, uri: uri); |
| } |
| |
| final resolvedUri = rootUri.resolveUri(parsedUri); |
| if (!resolvedUri.toString().startsWith(rootUri.toString())) { |
| // Resolved uri must be within the filesystem's base uri. |
| throwInvalidUriParameter(method: method, uri: uri); |
| } |
| return resolvedUri; |
| } |
| |
| Map<String, String> toJson() => { |
| 'type': 'FileSystem', |
| 'name': name, |
| 'uri': rootUri.toString(), |
| }; |
| } |
| |
| /// A collection of [DevelopmentFileSystem]s. |
| abstract base class DevelopmentFileSystemCollection { |
| List<String> get fsNames; |
| |
| /// Destroys all [DevelopmentFileSystem]s in the collection. |
| Future<void> cleanup(); |
| |
| /// Creates a new [DevelopmentFileSystem] named [name]. |
| /// |
| /// Throws a [RpcException.fileSystemAlreadyExists] if the file system has |
| /// already been created. |
| Future<DevelopmentFileSystem> createFileSystem({required String name}); |
| |
| /// Destroys the [DevelopmentFileSystem] with name [name]. |
| /// |
| /// Throws a [RpcException.fileSystemDoesNotExist] if the file system does |
| /// not exist. |
| Future<void> deleteFileSystem({required String name}); |
| |
| /// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC |
| /// request. |
| /// |
| /// Throws a [RpcException.fileSystemDoesNotExist] if the file system does |
| /// not exist. |
| DevelopmentFileSystem getFileSystem({required String name}); |
| } |
| |
| /// A development file system used by service clients to upload compilation |
| /// artifacts and assets for use by the runtime. |
| class DevFS<DevFSBackend extends DevelopmentFileSystemCollection> { |
| DevFS({required this._fileSystems}); |
| |
| final DevFSBackend _fileSystems; |
| final _logger = Logger('$DevFS'); |
| |
| static const _kFsName = 'fsName'; |
| static const _kUri = 'uri'; |
| static const _kFiles = 'files'; |
| static const _kFileContents = 'fileContents'; |
| |
| late final rpcs = UnmodifiableListView<ServiceRpcHandler>([ |
| ('_listDevFS', listDevFS), |
| ('_createDevFS', createDevFS), |
| ('_deleteDevFS', deleteDevFS), |
| ('_readDevFSFile', readDevFSFile), |
| ('_writeDevFSFile', writeDevFSFile), |
| ('_writeDevFSFiles', writeDevFSFiles), |
| ('_listDevFSFiles', listDevFSFiles), |
| ]); |
| |
| // Destroy the development file systems. |
| Future<void> cleanup() => _fileSystems.cleanup(); |
| |
| /// Responsible for processing file system writes initiated via an HTTP PUT |
| /// request. |
| /// |
| /// In order to write a file, the HTTP PUT request must include the following |
| /// query parameters: |
| /// - `dev_fs_name`: the name of the [DevelopmentFileSystem] to write to. |
| /// - `dev_fs_uri_b64`: the base-64 encoded URI for the file to be written. |
| /// |
| /// The request body will be treated as the contents of the file and written |
| /// to the provided URI rooted in the [DevelopmentFileSystem]. |
| Future<Response?> handlePutStreamRequest(Request request) async { |
| if (request.method != 'PUT') { |
| return null; |
| } |
| _logger.info('Handling DevFS PUT request: ${request.headers}'); |
| String? fsUri; |
| |
| const kDevFsName = 'dev_fs_name'; |
| const kDevFsUriBase64 = 'dev_fs_uri_b64'; |
| |
| // Extract the fs name and fs path from the request headers. |
| final fsName = request.headers[kDevFsName]; |
| if (fsName == null) { |
| _logger.info('Invalid $kDevFsName. Returning.'); |
| // TODO(bkonyi): this is wrong |
| return Response.internalServerError(body: 'Invalid $kDevFsName.'); |
| } |
| if (request.headers[kDevFsUriBase64] case final String base64Uri) { |
| fsUri = utf8.decode(base64.decode(base64Uri)); |
| } |
| |
| if (fsUri == null) { |
| DevelopmentFileSystem.throwMissingUriParameter(method: '_writeDevFSFile'); |
| } |
| |
| _logger.info('Invoking handlePutStream.'); |
| |
| final result = await _handlePutStream( |
| fsName: fsName, |
| uri: fsUri, |
| bytes: request.read().cast<List<int>>().transform(gzip.decoder), |
| ); |
| _logger.info('handlePutStream response: $result'); |
| |
| return Response.ok( |
| json.encode({'result': result}), |
| headers: { |
| // We closed the connection for bad origins earlier. |
| 'Access-Control-Allow-Origin': '*', |
| 'content-type': ContentType.json.mimeType, |
| }, |
| ); |
| } |
| |
| Future<RpcResponse> _handlePutStream({ |
| required String fsName, |
| required String uri, |
| required Stream<List<int>> bytes, |
| }) async { |
| _logger.info('Handling PUT write to $uri in $fsName'); |
| final fs = _fileSystems.getFileSystem(name: fsName); |
| await fs.writeStreamFile(uri: uri, bytes: bytes); |
| return Success().toJson(); |
| } |
| |
| /// Lists the names of all active [DevelopmentFileSystem]s. |
| RpcResponse listDevFS() => |
| // TODO(bkonyi): create package:vm_service type if we make this public. |
| {'type': 'FileSystemList', 'fsNames': _fileSystems.fsNames}; |
| |
| /// Creates a new [DevelopmentFileSystem] with a given `fsName`. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` already exists, an error is |
| /// returned. |
| Future<RpcResponse> createDevFS(json_rpc.Parameters parameters) async { |
| final fs = await _fileSystems.createFileSystem( |
| name: parameters[_kFsName].asString, |
| ); |
| return fs.toJson(); |
| } |
| |
| /// Deletes the [DevelopmentFileSystem] with name `fsName`. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is |
| /// returned. |
| Future<RpcResponse> deleteDevFS(json_rpc.Parameters parameters) async { |
| await _fileSystems.deleteFileSystem(name: parameters[_kFsName].asString); |
| return Success().toJson(); |
| } |
| |
| /// Reads a file from `uri` within the [DevelopmentFileSystem] `fsName`. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` does not exist, or `uri` is |
| /// does not point to a valid file, an error is returned. |
| Future<RpcResponse> readDevFSFile(json_rpc.Parameters parameters) async { |
| final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); |
| final uri = parameters[_kUri].asString; |
| return await fs.readFile(uri: uri); |
| } |
| |
| /// Writes `fileContents` to `uri` within the [DevelopmentFileSystem] |
| /// `fsName`. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is |
| /// returned. |
| Future<RpcResponse> writeDevFSFile(json_rpc.Parameters parameters) async { |
| final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); |
| final path = parameters[_kUri].asString; |
| final fileContents = parameters[_kFileContents].asString; |
| final decodedFileContents = base64.decode(fileContents); |
| await fs.writeFile(uri: path, bytes: decodedFileContents); |
| return Success().toJson(); |
| } |
| |
| /// Writes multiple `files` within the [DevelopmentFileSystem] `fsName`. |
| /// |
| /// Each entry in `files` is a list with two entries: |
| /// - The URI of the file to be written to. |
| /// - The contents of the file. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is |
| /// returned. |
| Future<RpcResponse> writeDevFSFiles(json_rpc.Parameters parameters) async { |
| final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); |
| final files = parameters[_kFiles].asList.cast<Object?>(); |
| final processed = <(String, Uint8List)>[]; |
| |
| Never throwInvalidFiles({required int index, required Object? fileInfo}) => |
| RpcException.invalidParams.throwExceptionWithDetails( |
| details: |
| "_writeDevFSFiles: invalid '$_kFiles' parameter at index $index: " |
| '$fileInfo', |
| ); |
| |
| for (var i = 0; i < files.length; i++) { |
| final fileInfo = files[i]; |
| if (fileInfo case [final String uriString, final String contents]) { |
| try { |
| fs.resolve(method: '_writeDevFSFiles', uri: uriString); |
| processed.add((uriString, base64.decode(contents))); |
| } catch (_) { |
| throwInvalidFiles(index: i, fileInfo: fileInfo); |
| } |
| } else { |
| throwInvalidFiles(index: i, fileInfo: fileInfo); |
| } |
| } |
| final pendingWrites = <Future<void>>[]; |
| for (final (path, decodedContents) in processed) { |
| pendingWrites.add(fs.writeFile(uri: path, bytes: decodedContents)); |
| } |
| await Future.wait(pendingWrites); |
| return Success().toJson(); |
| } |
| |
| /// Lists the set of files contained within the [DevelopmentFileSystem], |
| /// `fsName`. |
| /// |
| /// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is |
| /// returned. |
| Future<RpcResponse> listDevFSFiles(json_rpc.Parameters parameters) async { |
| final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); |
| return await fs.listFiles(); |
| } |
| } |