Implementation
@override
Future<void> run() => guarded(() async {
final root = await findProjectRoot();
if (root == null) {
throw UsageException('Could not locate a pubspec.yaml.', usage);
}
final target = results?['target'] as String? ?? 'cloudflare';
if (target != 'cloudflare' && target != 'netlify' && target != 'vercel') {
throw UsageException('Unsupported deployment target: $target', usage);
}
final cloudflareFactory =
results?['cloudflare-factory'] as String? ?? 'engine';
if (target != 'cloudflare' && cloudflareFactory != 'engine') {
throw UsageException(
'--cloudflare-factory is only supported for Cloudflare deployments.',
usage,
);
}
final ssrEntry = (results?['ssr-entry'] as String?)?.trim();
if (ssrEntry != null && ssrEntry.isEmpty) {
throw UsageException('--ssr-entry cannot be empty.', usage);
}
if (ssrEntry != null && target != 'cloudflare') {
throw UsageException(
'--ssr-entry is only supported for Cloudflare deployments.',
usage,
);
}
final dartDurableObjects = _parseDurableObjectBindings(target);
final variables = _parseCloudflareVariables(target);
final containers = _parseContainerBindings(target);
final durableObjects = _mergeDurableObjectBindings(
dartDurableObjects,
containers,
);
final d1Bindings = _parseD1Bindings(target);
final r2Bindings = _parseSimpleCloudflareBindings(
target,
option: 'r2',
resource: 'R2 bucket',
valueHelp: 'BINDING=BUCKET_NAME',
);
final queueBindings = _parseSimpleCloudflareBindings(
target,
option: 'queue',
resource: 'Queue',
valueHelp: 'BINDING=QUEUE_NAME',
);
final serviceBindings = _parseSimpleCloudflareBindings(
target,
option: 'service',
resource: 'Service',
valueHelp: 'BINDING=SERVICE_NAME',
);
final workflowBindings = _parseWorkflowBindings(target);
final secretsStoreBindings = _parseSecretsStoreBindings(target);
_validateCloudflareBindingNames(
variables: variables.keys,
bindingNames: [
...durableObjects.map((binding) => binding.bindingName),
...d1Bindings.map((binding) => binding.bindingName),
...r2Bindings.map((binding) => binding.bindingName),
...queueBindings.map((binding) => binding.bindingName),
...serviceBindings.map((binding) => binding.bindingName),
...workflowBindings.map((binding) => binding.bindingName),
...secretsStoreBindings.map((binding) => binding.bindingName),
],
);
final packageName = await readPackageName(root);
if (packageName == null) {
throw UsageException(
'pubspec.yaml does not contain a package name.',
usage,
);
}
await _ensureRoutedNode(root);
if (target == 'netlify') {
await _deployNetlify(root, packageName);
return;
}
if (target == 'vercel') {
await _deployVercel(root, packageName);
return;
}
final requestedName = (results?['name'] as String?)?.trim();
final workerName = _sanitizeWorkerName(
requestedName == null || requestedName.isEmpty
? packageName
: requestedName,
);
final date =
results?['compatibility-date'] as String? ??
DateTime.now().toIso8601String().substring(0, 10);
final dryRun = results?['dry-run'] as bool? ?? false;
final keepVars = results?['keep-vars'] as bool? ?? false;
final entry = _resolveImport(packageName, results?['entry'] as String?);
final buildRoot = root.fileSystem.directory(
p.join(root.path, '.dart_tool', 'routed', 'deploy', target),
);
await buildRoot.create(recursive: true);
final dartEntry = root.fileSystem.file(
p.join(buildRoot.path, 'worker_entry.dart'),
);
final jsOutput = root.fileSystem.file(
p.join(buildRoot.path, 'worker.dart.js'),
);
final workerOutput = root.fileSystem.file(
p.join(buildRoot.path, 'worker.js'),
);
final configOutput = root.fileSystem.file(
p.join(buildRoot.path, 'wrangler.jsonc'),
);
String? bundledSsrEntry;
if (ssrEntry != null) {
final source = root.fileSystem.file(
p.isAbsolute(ssrEntry) ? ssrEntry : p.join(root.path, ssrEntry),
);
if (!source.existsSync()) {
throw UsageException(
'SSR entry does not exist: ${source.path}',
usage,
);
}
final buildDirectory = source.parent;
final destination = root.fileSystem.directory(
p.join(buildRoot.path, 'frontend'),
);
if (_pathsOverlap(buildDirectory.path, destination.path)) {
throw UsageException(
'SSR build directory cannot overlap the generated frontend '
'directory: ${destination.path}',
usage,
);
}
_copyFrontendBuild(buildDirectory, destination);
bundledSsrEntry = p.join('frontend', p.basename(source.path));
}
await dartEntry.writeAsString(
generateCloudflareWorkerEntry(
importPath: entry,
factory: cloudflareFactory,
durableObjectClasses: dartDurableObjects.map(
(binding) => binding.className,
),
),
);
await _runDart(root, [
'compile',
'js',
dartEntry.path,
'-o',
jsOutput.path,
'-O2',
], label: 'Compiling Cloudflare Worker');
await workerOutput.writeAsString(
generateCloudflareWorkerWrapper(
jsOutput.path,
dartDurableObjects.map((binding) => binding.className),
containerPorts: {
for (final container in containers)
container.className: container.port,
},
ssrEntry: bundledSsrEntry,
),
);
final config = <String, Object?>{
r'$schema':
'https://developers.cloudflare.com/workers/wrangler/config-schema.json',
'name': workerName,
'main': p.basename(workerOutput.path),
'compatibility_date': date,
'compatibility_flags': [
'nodejs_compat',
if (bundledSsrEntry != null) 'global_fetch_strictly_public',
],
};
if (durableObjects.isNotEmpty) {
config['durable_objects'] = {
'bindings': [
for (final binding in durableObjects)
{'name': binding.bindingName, 'class_name': binding.className},
],
};
config['migrations'] = [
{
'tag': 'routed-v1',
'new_sqlite_classes': [
for (final binding in durableObjects) binding.className,
],
},
];
}
if (containers.isNotEmpty) {
config['containers'] = [
for (final container in containers)
{
'class_name': container.className,
'image': _containerImageReference(root, buildRoot, container.image),
if (container.maxInstances != null)
'max_instances': container.maxInstances,
},
];
}
if (d1Bindings.isNotEmpty) {
config['d1_databases'] = [
for (final binding in d1Bindings)
{
'binding': binding.bindingName,
'database_name': binding.databaseName,
'database_id': binding.databaseId,
},
];
}
if (r2Bindings.isNotEmpty) {
config['r2_buckets'] = [
for (final binding in r2Bindings)
{'binding': binding.bindingName, 'bucket_name': binding.resourceName},
];
}
if (queueBindings.isNotEmpty) {
config['queues'] = {
'producers': [
for (final binding in queueBindings)
{'binding': binding.bindingName, 'queue': binding.resourceName},
],
};
}
if (serviceBindings.isNotEmpty) {
config['services'] = [
for (final binding in serviceBindings)
{'binding': binding.bindingName, 'service': binding.resourceName},
];
}
if (workflowBindings.isNotEmpty) {
config['workflows'] = [
for (final binding in workflowBindings)
{
'name': binding.workflowName,
'binding': binding.bindingName,
'class_name': binding.className,
if (binding.scriptName != null) 'script_name': binding.scriptName,
},
];
}
if (secretsStoreBindings.isNotEmpty) {
config['secrets_store_secrets'] = [
for (final binding in secretsStoreBindings)
{
'binding': binding.bindingName,
'store_id': binding.storeId,
'secret_name': binding.secretName,
},
];
}
if (bundledSsrEntry != null) {
config['assets'] = {
'directory': 'frontend',
'binding': 'ASSETS',
'run_worker_first': true,
};
}
if (variables.isNotEmpty) {
config['vars'] = variables;
}
await configOutput.writeAsString(
const JsonEncoder.withIndent(' ').convert(config),
);
final wranglerArgs = <String>['wrangler@latest', 'deploy'];
if (dryRun) wranglerArgs.add('--dry-run');
if (keepVars) wranglerArgs.add('--keep-vars');
wranglerArgs
..add('--config')
..add(configOutput.path);
await _runNpx(root, wranglerArgs, label: 'Deploying Cloudflare Worker');
logger.info('Routed Cloudflare deployment complete: $workerName');
});