blob: 58e03b02862c2aeec9c8afc6dd490b3054c0a233 [file] [log] [blame]
// Copyright (c) 2024, 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:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dev_compiler/dev_compiler.dart' as ddc_names
show libraryUriToJsIdentifier;
/// A pseudo in-memory filesystem with helpers to aid the hot reload runner.
///
/// The Frontend Server outputs web sources and sourcemaps as concatenated
/// single files per-invocation. A manifest file contains the byte offsets
/// for resolving the individual files.
/// Adapted from:
/// https://github.com/flutter/flutter/blob/ac7879e2aa6de40afec1fe2af9730a8d55de3e06/packages/flutter_tools/lib/src/web/memory_fs.dart
class HotReloadMemoryFilesystem {
/// The root directory's URI from which JS file are being served.
final Uri jsRootUri;
final Map<String, Uint8List> files = {};
final Map<String, Uint8List> sourcemaps = {};
/// Maps generation numbers to a list of changed libraries.
final Map<String, List<LibraryInfo>> generationChanges = {};
final List<LibraryInfo> libraries = [];
final List<LibraryInfo> firstGenerationLibraries = [];
HotReloadMemoryFilesystem(this.jsRootUri);
/// Writes the entirety of this filesystem to [outputDirectoryUri].
///
/// [clearWritableState] clears generation-specific state so that old
/// generations' files aren't rewritten.
void writeToDisk(Uri outputDirectoryUri,
{required String generation, bool clearWritableState = true}) {
assert(Directory.fromUri(outputDirectoryUri).existsSync(),
'$outputDirectoryUri does not exist.');
files.forEach((path, content) {
final outputFileUri =
outputDirectoryUri.resolve('generation$generation/').resolve(path);
final outputFile = File.fromUri(outputFileUri);
outputFile.createSync(recursive: true);
outputFile.writeAsBytesSync(content);
});
if (clearWritableState) {
files.clear();
sourcemaps.clear();
}
}
/// Returns a map of generation number to modified files' paths.
///
/// Used to determine which JS files should be loaded per generation.
Map<String, List<String>> get generationsToModifiedFilePaths => {
for (var e in generationChanges.entries)
e.key: e.value.map((info) => info.jsSourcePath).toList()
};
/// Returns all scripts in the filesystem in a form that can be ingested by
/// the DDC module system's bootstrapper.
/// Files must only be in the first generation.
List<Map<String, String?>> get scriptDescriptorForBootstrap {
// TODO(markzipan): This currently isn't ordered, which may cause problems
// with cycles.
final scriptsJson = <Map<String, String?>>[];
for (var library in firstGenerationLibraries) {
final scriptDescriptor = <String, String?>{
'id': library.dartSourcePath,
'src': library.jsSourcePath,
};
scriptsJson.add(scriptDescriptor);
}
return scriptsJson;
}
/// Update the filesystem with the provided source and manifest files.
///
/// Returns the list of updated files. Also associates file info with a
/// generation label.
List<String> update(
File codeFile,
File manifestFile,
File sourcemapFile, {
required String generation,
}) {
final updatedFiles = <String>[];
final codeBytes = codeFile.readAsBytesSync();
final sourcemapBytes = sourcemapFile.readAsBytesSync();
final manifest = Map.castFrom<dynamic, dynamic, String, Object?>(
json.decode(manifestFile.readAsStringSync()) as Map);
generationChanges[generation] = [];
for (final filePath in manifest.keys) {
final fileUri = Uri.file(filePath);
final Map<String, dynamic> offsets =
Map.castFrom<dynamic, dynamic, String, Object?>(
manifest[filePath] as Map);
final codeOffsets = (offsets['code'] as List<dynamic>).cast<int>();
final sourcemapOffsets =
(offsets['sourcemap'] as List<dynamic>).cast<int>();
if (codeOffsets.length != 2 || sourcemapOffsets.length != 2) {
continue;
}
final codeStart = codeOffsets[0];
final codeEnd = codeOffsets[1];
if (codeStart < 0 || codeEnd > codeBytes.lengthInBytes) {
continue;
}
final byteView = Uint8List.view(
codeBytes.buffer,
codeStart,
codeEnd - codeStart,
);
final fileName =
filePath.startsWith('/') ? filePath.substring(1) : filePath;
files[fileName] = byteView;
final libraryName = ddc_names.libraryUriToJsIdentifier(fileUri);
// TODO(markzipan): This is an overly simple heuristic to resolve the
// original Dart file. Replace this if it no longer holds.
var dartFileName = fileName;
if (dartFileName.endsWith('.lib.js')) {
dartFileName =
fileName.substring(0, fileName.length - '.lib.js'.length);
}
final fullyResolvedFileUri =
jsRootUri.resolve('generation$generation/$fileName');
// TODO(markzipan): Update this if module and library names are no
// longer the same.
final libraryInfo = LibraryInfo(
moduleName: libraryName,
libraryName: libraryName,
dartSourcePath: dartFileName,
jsSourcePath: fullyResolvedFileUri.toFilePath());
libraries.add(libraryInfo);
if (generation == '0') {
firstGenerationLibraries.add(libraryInfo);
}
generationChanges[generation]!.add(libraryInfo);
updatedFiles.add(fileName);
final sourcemapStart = sourcemapOffsets[0];
final sourcemapEnd = sourcemapOffsets[1];
if (sourcemapStart < 0 || sourcemapEnd > sourcemapBytes.lengthInBytes) {
continue;
}
final sourcemapView = Uint8List.view(
sourcemapBytes.buffer,
sourcemapStart,
sourcemapEnd - sourcemapStart,
);
final sourcemapName = '$fileName.map';
sourcemaps[sourcemapName] = sourcemapView;
}
return updatedFiles;
}
}
/// Bundles information associated with a DDC library.
class LibraryInfo {
final String moduleName;
final String libraryName;
final String dartSourcePath;
final String jsSourcePath;
LibraryInfo(
{required this.moduleName,
required this.libraryName,
required this.dartSourcePath,
required this.jsSourcePath});
@override
String toString() =>
'LibraryInfo($moduleName, $libraryName, $dartSourcePath, $jsSourcePath)';
}