Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,52 @@ 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.
final depthComparison = p
.split(a.path)
Comment thread
parlough marked this conversation as resolved.
Outdated
.length
.compareTo(p.split(b.path).length);
if (depthComparison != 0) return depthComparison;

// Among entrypoints alongside each other, `main.dart` is the convention.
final aIsMain = p.basename(a.path) == 'main.dart';
final bIsMain = p.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