Skip to content

Commit 1f6644b

Browse files
committed
protocol discovery, gzip
1 parent b834b84 commit 1f6644b

13 files changed

Lines changed: 183 additions & 147 deletions

‎lib/models/project.dart‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class Site {
8282
Site({
8383
required this.name,
8484
required this.domain,
85-
this.service = 'https://xmit.co',
85+
this.service = 'xmit.co',
8686
this.status = TaskStatus.idle,
8787
this.steps = const [],
8888
});
@@ -166,10 +166,17 @@ class Project {
166166
final sites = sm.entries
167167
.map((entry) {
168168
final siteConfig = entry.value as Map<String, dynamic>;
169+
var service = siteConfig['service'] as String? ?? 'xmit.co';
170+
// Remove protocol if present for backwards compatibility
171+
if (service.startsWith('https://')) {
172+
service = service.substring(8);
173+
} else if (service.startsWith('http://')) {
174+
service = service.substring(7);
175+
}
169176
return Site(
170177
name: entry.key,
171178
domain: siteConfig['domain'] as String? ?? '',
172-
service: siteConfig['service'] as String? ?? 'https://xmit.co',
179+
service: service,
173180
);
174181
})
175182
.toList();

‎lib/services/launch_service.dart‎

Lines changed: 121 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,84 @@
11
import 'dart:io';
22
import 'dart:async';
3+
import 'dart:convert';
34
import 'dart:typed_data';
45
import 'package:http/http.dart' as http;
56
import 'package:path/path.dart' as path;
67
import 'package:cbor/cbor.dart';
7-
import 'package:zstandard/zstandard.dart';
88
import 'package:thirds/blake3.dart';
99
import '../models/project.dart';
1010
import 'task_service.dart';
1111

1212
class LaunchService {
1313
final TaskService _taskService = TaskService();
14-
static const String _apiPrefix = '/api/cli/0';
14+
static const String _apiPrefix = '/api/0';
1515
final Map<String, bool> _cancellationTokens = {};
1616
final http.Client _httpClient = http.Client();
1717
static const Duration _httpTimeout = Duration(seconds: 30);
1818

19+
/// Normalize service domain to full URL with https://
20+
String _normalizeServiceUrl(String service) {
21+
if (service.startsWith('http://') || service.startsWith('https://')) {
22+
return service;
23+
}
24+
return 'https://$service';
25+
}
26+
27+
/// Discover the hosting provider's protocol and base URL
28+
/// Returns a record of (protocol, baseUrl)
29+
Future<({String protocol, String baseUrl})> _discoverProtocol(
30+
String serviceDomain,
31+
) async {
32+
final serviceUrl = _normalizeServiceUrl(serviceDomain);
33+
final wellKnownUrl = '$serviceUrl/.well-known/web-publication-protocol';
34+
35+
try {
36+
final response = await _httpClient
37+
.get(Uri.parse(wellKnownUrl))
38+
.timeout(_httpTimeout);
39+
40+
if (response.statusCode != 200) {
41+
throw Exception(
42+
'Failed to discover protocol: HTTP ${response.statusCode}',
43+
);
44+
}
45+
46+
// Parse JSON response
47+
final Map<String, dynamic> json;
48+
try {
49+
json = jsonDecode(response.body) as Map<String, dynamic>;
50+
} catch (e) {
51+
throw Exception('Failed to parse protocol discovery response: $e');
52+
}
53+
54+
final protocol = json['protocol'] as String?;
55+
final baseUrl = json['url'] as String?;
56+
57+
if (protocol == null || protocol.isEmpty) {
58+
throw Exception(
59+
'Protocol field missing or empty in discovery response',
60+
);
61+
}
62+
63+
// Validate protocol
64+
if (protocol != 'xmit/0') {
65+
throw Exception(
66+
'Unknown protocol: $protocol. Expected xmit/0',
67+
);
68+
}
69+
70+
if (baseUrl == null || baseUrl.isEmpty) {
71+
throw Exception(
72+
'URL field missing or empty in discovery response',
73+
);
74+
}
75+
76+
return (protocol: protocol, baseUrl: baseUrl);
77+
} catch (e) {
78+
throw Exception('Protocol discovery failed: $e');
79+
}
80+
}
81+
1982
/// Cancel a launch by its identifier
2083
void cancelLaunch(String launchId) {
2184
_cancellationTokens[launchId] = true;
@@ -253,7 +316,39 @@ class LaunchService {
253316
return LaunchResult.failure('Launch cancelled');
254317
}
255318

256-
// Step 1: Create bundle node structure from project files
319+
// Step 1: Discover protocol and base URL
320+
_currentStep = LaunchStep(
321+
title: 'Discovering protocol',
322+
status: LaunchStepStatus.running,
323+
);
324+
onStepUpdate?.call(_currentStep!);
325+
_logToStep('Discovering protocol from ${target.service}', onStepUpdate);
326+
327+
String serviceUrl;
328+
try {
329+
final discovery = await _discoverProtocol(target.service);
330+
serviceUrl = discovery.baseUrl;
331+
_logToStep('✅ Protocol: ${discovery.protocol}', onStepUpdate);
332+
_logToStep('✅ Base URL: $serviceUrl', onStepUpdate);
333+
} catch (e) {
334+
_currentStep = _currentStep!.copyWith(
335+
status: LaunchStepStatus.failed,
336+
message: e.toString(),
337+
);
338+
onStepUpdate?.call(_currentStep!);
339+
return LaunchResult.failure('Protocol discovery failed: $e');
340+
}
341+
342+
if (_isCancelled(launchId)) {
343+
return LaunchResult.failure('Launch cancelled');
344+
}
345+
346+
_currentStep = _currentStep!.copyWith(
347+
status: LaunchStepStatus.completed,
348+
);
349+
onStepUpdate?.call(_currentStep!);
350+
351+
// Step 2: Create bundle node structure from project files
257352
_currentStep = LaunchStep(
258353
title: 'Creating bundle',
259354
status: LaunchStepStatus.running,
@@ -274,7 +369,7 @@ class LaunchService {
274369
);
275370
onStepUpdate?.call(_currentStep!);
276371

277-
// Step 2: Suggest bundle to check if already present
372+
// Step 3: Suggest bundle to check if already present
278373
_currentStep = LaunchStep(
279374
title: 'Suggesting bundle',
280375
status: LaunchStepStatus.running,
@@ -285,8 +380,7 @@ class LaunchService {
285380
return LaunchResult.failure('Launch cancelled');
286381
}
287382

288-
final suggestUrl = '${target.service}$_apiPrefix/suggest';
289-
_logToStep('Connecting to ${target.service}', onStepUpdate);
383+
final suggestUrl = '$serviceUrl$_apiPrefix/suggest';
290384

291385
var suggestResponse = await _suggestBundle(
292386
launchId,
@@ -320,7 +414,7 @@ class LaunchService {
320414

321415
teamId = await _handleTeamSelection(
322416
launchId,
323-
target.service,
417+
serviceUrl,
324418
authKey,
325419
onStepUpdate,
326420
onTeamSelectionRequired,
@@ -420,7 +514,7 @@ class LaunchService {
420514
message: '${suggestResponse.missing.length} parts',
421515
);
422516
onStepUpdate?.call(_currentStep!);
423-
final missingUrl = '${target.service}$_apiPrefix/missing';
517+
final missingUrl = '$serviceUrl$_apiPrefix/missing';
424518
await _uploadMissingParts(
425519
launchId,
426520
missingUrl,
@@ -444,7 +538,7 @@ class LaunchService {
444538
status: LaunchStepStatus.running,
445539
);
446540
onStepUpdate?.call(_currentStep!);
447-
final finalizeUrl = '${target.service}$_apiPrefix/finalize';
541+
final finalizeUrl = '$serviceUrl$_apiPrefix/finalize';
448542
final finalizeResponse = await _finalizeLaunch(
449543
launchId,
450544
finalizeUrl,
@@ -496,7 +590,7 @@ class LaunchService {
496590
);
497591
}
498592

499-
// Step 3: Upload bundle
593+
// Step 4: Upload bundle
500594
_currentStep = LaunchStep(
501595
title: 'Uploading bundle',
502596
status: LaunchStepStatus.running,
@@ -512,7 +606,7 @@ class LaunchService {
512606
return LaunchResult.failure('Launch cancelled');
513607
}
514608

515-
final uploadUrl = '${target.service}$_apiPrefix/bundle';
609+
final uploadUrl = '$serviceUrl$_apiPrefix/bundle';
516610
final bundleSizeKb = (bundleBytes.length / 1024).toStringAsFixed(2);
517611
_logToStep('Uploading ${bundleSizeKb}KB bundle', onStepUpdate);
518612

@@ -572,15 +666,15 @@ class LaunchService {
572666
),
573667
);
574668

575-
// Step 4: Upload missing parts if any
669+
// Step 5: Upload missing parts if any
576670
if (uploadResponse.missing.isNotEmpty) {
577671
_currentStep = LaunchStep(
578672
title: 'Uploading missing parts',
579673
status: LaunchStepStatus.running,
580674
message: '${uploadResponse.missing.length} parts',
581675
);
582676
onStepUpdate?.call(_currentStep!);
583-
final missingUrl = '${target.service}$_apiPrefix/missing';
677+
final missingUrl = '$serviceUrl$_apiPrefix/missing';
584678
await _uploadMissingParts(
585679
launchId,
586680
missingUrl,
@@ -597,15 +691,15 @@ class LaunchService {
597691
onStepUpdate?.call(_currentStep!);
598692
}
599693

600-
// Step 5: Finalize launch
694+
// Step 6: Finalize launch
601695
_currentStep = LaunchStep(
602696
title: 'Finalizing launch',
603697
status: LaunchStepStatus.running,
604698
);
605699
onStepUpdate?.call(_currentStep!);
606700
_logToStep('Requesting finalization', onStepUpdate);
607701

608-
final finalizeUrl = '${target.service}$_apiPrefix/finalize';
702+
final finalizeUrl = '$serviceUrl$_apiPrefix/finalize';
609703
final finalizeResponse = await _finalizeLaunch(
610704
launchId,
611705
finalizeUrl,
@@ -866,8 +960,8 @@ class LaunchService {
866960
fields.map((k, v) => MapEntry(CborSmallInt(k), v)),
867961
);
868962
final encoded = cbor.encode(requestCbor);
869-
final compressed = await Uint8List.fromList(encoded).compress();
870-
return compressed ?? Uint8List(0);
963+
final compressed = gzip.encode(encoded);
964+
return Uint8List.fromList(compressed);
871965
}
872966

873967
/// Make a CBOR POST request and decode the response
@@ -881,7 +975,10 @@ class LaunchService {
881975
launchId,
882976
() => _httpClient.post(
883977
Uri.parse(url),
884-
headers: {'Content-Type': 'application/cbor+zstd'},
978+
headers: {
979+
'Content-Type': 'application/cbor+gzip',
980+
'Accept': 'application/cbor+gzip',
981+
},
885982
body: compressedBody,
886983
),
887984
);
@@ -891,10 +988,7 @@ class LaunchService {
891988
}
892989

893990
// Decompress and decode response
894-
final decompressed = await response.bodyBytes.decompress();
895-
if (decompressed == null) {
896-
throw Exception('Failed to decompress response');
897-
}
991+
final decompressed = gzip.decode(response.bodyBytes);
898992
final decoded = cbor.decode(decompressed) as CborMap;
899993
return (decoded, response.statusCode);
900994
}
@@ -1083,7 +1177,10 @@ class LaunchService {
10831177
final response = await _httpClient
10841178
.post(
10851179
Uri.parse(url),
1086-
headers: {'Content-Type': 'application/cbor+zstd'},
1180+
headers: {
1181+
'Content-Type': 'application/cbor+gzip',
1182+
'Accept': 'application/cbor+gzip',
1183+
},
10871184
body: compressed,
10881185
)
10891186
.timeout(
@@ -1099,10 +1196,7 @@ class LaunchService {
10991196
throw Exception('List teams failed: ${response.statusCode}');
11001197
}
11011198

1102-
final decompressed = await response.bodyBytes.decompress();
1103-
if (decompressed == null) {
1104-
throw Exception('Failed to decompress response');
1105-
}
1199+
final decompressed = gzip.decode(response.bodyBytes);
11061200
final decoded = cbor.decode(decompressed) as CborMap;
11071201

11081202
// Extract management URL (key 6)

0 commit comments

Comments
 (0)