Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions builder_pkgs/build_web_compilers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

- Make `fes_manager` config directory and file user-private.
- Use a token for socket authentication in `fes_manager`.
- By default, prefer entrypoints from the `web` directory over
entrypoints in other directories such as `test` or `example`.
- Prefer entrypoints closer to the root of a searched directory,
and `main.dart` over other entrypoints alongside it.
- Accept a list of directories for `web-assets-path`.

## 4.8.10

Expand Down
36 changes: 33 additions & 3 deletions builder_pkgs/build_web_compilers/lib/builders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Builder webEntrypointMarkerBuilder(BuilderOptions options) {
_ensureSameDdcHotReloadOptions(options);
return WebEntrypointMarkerBuilder(
usesWebHotReload: _readWebHotReloadOption(options),
webAssetsPath: _readWebAssetsPathOption(options),
webAssetPaths: _readWebAssetPathsOption(options),
);
}

Expand Down Expand Up @@ -283,8 +283,38 @@ String? _readUsePrebuiltSdkFromPathOption(BuilderOptions options) {
return options.config[_usePrebuiltSdkFromPathOption] as String?;
}

String? _readWebAssetsPathOption(BuilderOptions options) {
return options.config[_webAssetsPathOption] as String?;
/// Returns the web asset paths configured in [options], or `null` if omitted.
///
/// The option accepts either a comma-separated string or a YAML list.
/// Blank entries are ignored.
List<String>? _readWebAssetPathsOption(BuilderOptions options) {
final specifiedOption = options.config[_webAssetsPathOption];
if (specifiedOption == null) return null;

final paths = switch (specifiedOption) {
final String option => option.split(','),
final List<Object?> option => option.map((path) => '$path'),
_ => throw ArgumentError.value(
specifiedOption,
_webAssetsPathOption,
'Must be a comma-separated string or a list of directories.',
),
};

final trimmedPaths = [
for (final path in paths)
if (path.trim() case final trimmed when trimmed.isNotEmpty) trimmed,
];
if (trimmedPaths.isEmpty) {
throw ArgumentError.value(
specifiedOption,
_webAssetsPathOption,
'Must specify at least one directory. '
'Omit the option to search the defaults.',
);
}

return trimmedPaths;
}

String? _readScratchSpaceDirOption(BuilderOptions options) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@ class WebEntrypointMarkerBuilder implements Builder {
/// A no-op if [usesWebHotReload] is not set.
final bool usesWebHotReload;

/// The directory containing the 'main' entrypoint for the web target.
/// The directories to search in for the web target's `main` entrypoint.
///
/// If null, searches all standard entrypoint directories
/// ('web', 'test', 'example', 'benchmark').
final String? webAssetsPath;
/// Directories are searched in the specified order.
/// Defaults to [defaultWebDirs].
final List<String> webAssetPaths;

/// Creates a builder that marks the web target's `main` entrypoint.
///
/// Does nothing unless [usesWebHotReload] is `true`.
///
/// The entrypoint is searched for in [webAssetPaths],
/// which defaults to [defaultWebDirs] if not specified.
WebEntrypointMarkerBuilder({
this.usesWebHotReload = false,
this.webAssetsPath,
});
List<String>? webAssetPaths,
}) : webAssetPaths = webAssetPaths ?? defaultWebDirs;

@override
final buildExtensions = const {
Expand All @@ -54,28 +60,13 @@ class WebEntrypointMarkerBuilder implements Builder {
webEntrypointJson['entrypoint'] = asset.toString();
webEntrypointJson['canonicalUri'] = sourceArg(asset);
} else {
final searchGlob = webAssetsPath == null
? '{${defaultWebDirs.join(',')}}/**'
: webAssetsPath!.contains(',')
? '{$webAssetsPath}/**'
: '$webAssetsPath/**';
final webAssets = await buildStep.findAssets(Glob(searchGlob)).toList();

for (final asset in webAssets) {
if (asset.extension == '.dart') {
final moduleLibrary = ModuleLibrary.fromSource(
asset,
await buildStep.readAsString(asset),
);
if (moduleLibrary.hasMain && moduleLibrary.isEntryPoint) {
// We must save the main entrypoint as the recompilation target for
// the Frontend Server before any JS files are emitted.
frontendServerState.entrypointAssetId = asset;
webEntrypointJson['entrypoint'] = asset.toString();
webEntrypointJson['canonicalUri'] = sourceArg(asset);
break;
}
}
final asset = await _findEntrypoint(buildStep);
if (asset != null) {
// We must save the main entrypoint as the recompilation target for
// the Frontend Server before any JS files are emitted.
frontendServerState.entrypointAssetId = asset;
webEntrypointJson['entrypoint'] = asset.toString();
webEntrypointJson['canonicalUri'] = sourceArg(asset);
}
}

Expand All @@ -90,4 +81,51 @@ class WebEntrypointMarkerBuilder implements Builder {
jsonEncode(webEntrypointJson),
);
}

/// Searches for and returns the highest-priority web app entrypoint,
/// or `null` if no entrypoint is found.
///
/// The directories in [webAssetPaths] are searched in order,
/// with the candidates within each ranked by [_compareEntrypointPriority].
Future<AssetId?> _findEntrypoint(BuildStep buildStep) async {
for (final searchPath in webAssetPaths) {
final candidates =
await buildStep
.findAssets(Glob('$searchPath/**'))
.where((asset) => asset.extension == '.dart')
.toList()
..sort(_compareEntrypointPriority);

for (final asset in candidates) {
final moduleLibrary = ModuleLibrary.fromSource(
asset,
await buildStep.readAsString(asset),
);
if (moduleLibrary.hasMain && moduleLibrary.isEntryPoint) return asset;
}
}
return null;
}
}

/// Compares [a] and [b] by how likely each is to be a web app's entrypoint.
///
/// Assets closer to the root of the searched directory sort first,
/// then those named `main.dart`, then those that sort first alphabetically.
int _compareEntrypointPriority(AssetId a, AssetId b) {
// A top-level entrypoint is usually the app itself,
// while a nested one is more often a secondary target,
// such as a debug or example page.
if (a.pathSegments.length.compareTo(b.pathSegments.length)
case final depthComparison when depthComparison != 0) {
return depthComparison;
}

// Among entrypoints alongside each other, `main.dart` is the convention.
final aIsMain = p.url.basename(a.path) == 'main.dart';
final bIsMain = p.url.basename(b.path) == 'main.dart';
if (aIsMain != bIsMain) return aIsMain ? -1 : 1;

// Fall back to a stable order so the entrypoint doesn't vary between builds.
return a.path.compareTo(b.path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// 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 'package:build/build.dart';
import 'package:build_test/build_test.dart';
import 'package:build_web_compilers/builders.dart';
import 'package:build_web_compilers/src/build_modules/build_modules.dart';
import 'package:build_web_compilers/src/web_entrypoint_marker_builder.dart';
import 'package:test/test.dart';

void main() {
setUp(_resetFrontendServerState);
tearDown(_resetFrontendServerState);

test('prefers a web entrypoint over a test entrypoint', () async {
final generateEntrypoint = TestBuilder(
buildExtensions: replaceExtension('.template', '.dart'),
);

await testBuilders(
[generateEntrypoint, WebEntrypointMarkerBuilder(usesWebHotReload: true)],
{
'a|web/\$web\$': '',
'a|web/generated_main.template': 'void main() {}',
'a|test/app_test.dart': 'void main() {}',
},
outputs: {
'a|web/generated_main.dart': 'void main() {}',
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|web/generated_main.dart'),
),
},
);
});

test(
'falls back to a test entrypoint when there is no web entrypoint',
() async {
await testBuilders(
[WebEntrypointMarkerBuilder(usesWebHotReload: true)],
{'a|web/\$web\$': '', 'a|test/app_test.dart': 'void main() {}'},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|test/app_test.dart'),
),
},
);
},
);

test('uses configured entrypoint directory order', () async {
await testBuilders(
[
webEntrypointMarkerBuilder(
const BuilderOptions({
'web-hot-reload': true,
'web-assets-path': 'test,web',
}),
),
],
{
'a|web/\$web\$': '',
'a|web/main.dart': 'void main() {}',
'a|test/app_test.dart': 'void main() {}',
},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|test/app_test.dart'),
),
},
);
});

test('prefers `main.dart` over an earlier alphabetical entrypoint', () async {
await testBuilders(
[WebEntrypointMarkerBuilder(usesWebHotReload: true)],
{
'a|web/\$web\$': '',
'a|web/a_main.dart': 'void main() {}',
'a|web/main.dart': 'void main() {}',
},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|web/main.dart'),
),
},
);
});

test('prefers an entrypoint closer to the searched directory', () async {
await testBuilders(
[WebEntrypointMarkerBuilder(usesWebHotReload: true)],
{
'a|web/\$web\$': '',
'a|web/debug/main.dart': 'void main() {}',
'a|web/zzz.dart': 'void main() {}',
},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|web/zzz.dart'),
),
},
);
});

test('ignores whitespace around configured entrypoint directories', () async {
await testBuilders(
[
webEntrypointMarkerBuilder(
const BuilderOptions({
'web-hot-reload': true,
'web-assets-path': ' test , web ',
}),
),
],
{
'a|web/\$web\$': '',
'a|web/main.dart': 'void main() {}',
'a|test/app_test.dart': 'void main() {}',
},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|test/app_test.dart'),
),
},
);
});

test('accepts entrypoint directories specified as a list', () async {
await testBuilders(
[
webEntrypointMarkerBuilder(
const BuilderOptions({
'web-hot-reload': true,
'web-assets-path': ['test', 'web'],
}),
),
],
{
'a|web/\$web\$': '',
'a|web/main.dart': 'void main() {}',
'a|test/app_test.dart': 'void main() {}',
},
outputs: {
'a|web/.web.entrypoint.json': decodedMatches(
contains('a|test/app_test.dart'),
),
},
);
});

test('rejects an unusable entrypoint directory configuration', () {
for (final invalidValue in [42, '', ' , ', <String>[]]) {
expect(
() => webEntrypointMarkerBuilder(
BuilderOptions({
'web-hot-reload': true,
'web-assets-path': invalidValue,
}),
),
throwsA(isA<ArgumentError>()),
reason: 'Expected `$invalidValue` to be rejected.',
);
}
});
}

void _resetFrontendServerState() {
frontendServerState
..entrypointAssetId = null
..needsRecompileRestart = false
..fesScratchSpace = null;
}
Loading