@stacksjs/ts-cloud 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2123,6 +2123,67 @@ class S3Client2 {
2123
2123
  });
2124
2124
  return result;
2125
2125
  }
2126
+ async getObjectBytes(bucket, key) {
2127
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials();
2128
+ const host = this.s3VirtualHost(bucket);
2129
+ const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2130
+ const canonicalUri = this.forcePathStyle ? `/${bucket}/${encodedKey}` : `/${encodedKey}`;
2131
+ const url = `https://${host}${canonicalUri}`;
2132
+ const now = new Date;
2133
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
2134
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
2135
+ const payloadHash = crypto3.createHash("sha256").update("").digest("hex");
2136
+ const requestHeaders = {
2137
+ host,
2138
+ "x-amz-date": amzDate,
2139
+ "x-amz-content-sha256": payloadHash
2140
+ };
2141
+ if (sessionToken) {
2142
+ requestHeaders["x-amz-security-token"] = sessionToken;
2143
+ }
2144
+ const canonicalHeaders = Object.keys(requestHeaders).sort().map((k) => `${k.toLowerCase()}:${requestHeaders[k].trim()}
2145
+ `).join("");
2146
+ const signedHeaders = Object.keys(requestHeaders).sort().map((k) => k.toLowerCase()).join(";");
2147
+ const canonicalRequest = [
2148
+ "GET",
2149
+ canonicalUri,
2150
+ "",
2151
+ canonicalHeaders,
2152
+ signedHeaders,
2153
+ payloadHash
2154
+ ].join(`
2155
+ `);
2156
+ const algorithm = "AWS4-HMAC-SHA256";
2157
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
2158
+ const stringToSign = [
2159
+ algorithm,
2160
+ amzDate,
2161
+ credentialScope,
2162
+ crypto3.createHash("sha256").update(canonicalRequest).digest("hex")
2163
+ ].join(`
2164
+ `);
2165
+ const kDate = crypto3.createHmac("sha256", `AWS4${secretAccessKey}`).update(dateStamp).digest();
2166
+ const kRegion = crypto3.createHmac("sha256", kDate).update(this.region).digest();
2167
+ const kService = crypto3.createHmac("sha256", kRegion).update("s3").digest();
2168
+ const kSigning = crypto3.createHmac("sha256", kService).update("aws4_request").digest();
2169
+ const signature = crypto3.createHmac("sha256", kSigning).update(stringToSign).digest("hex");
2170
+ const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
2171
+ const response = await fetch(url, {
2172
+ method: "GET",
2173
+ headers: { ...requestHeaders, Authorization: authorizationHeader }
2174
+ });
2175
+ if (!response.ok) {
2176
+ const errorText = await response.text();
2177
+ throw new Error(`S3 GET failed: ${response.status} ${errorText}`);
2178
+ }
2179
+ const buffer = await response.arrayBuffer();
2180
+ const contentLengthHeader = response.headers.get("content-length");
2181
+ return {
2182
+ body: new Uint8Array(buffer),
2183
+ contentType: response.headers.get("content-type") ?? undefined,
2184
+ contentLength: contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined
2185
+ };
2186
+ }
2126
2187
  async copyObject(options) {
2127
2188
  const headers = {
2128
2189
  "x-amz-copy-source": `/${options.sourceBucket}/${options.sourceKey}`
@@ -65570,6 +65631,165 @@ init_client();
65570
65631
 
65571
65632
  // src/object-storage/index.ts
65572
65633
  init_s3();
65634
+
65635
+ // src/object-storage/migrate.ts
65636
+ function stripPrefix(key, prefix) {
65637
+ if (!prefix)
65638
+ return key;
65639
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
65640
+ }
65641
+ function remapKey(sourceKey, fromPrefix, toPrefix) {
65642
+ const stripped = stripPrefix(sourceKey, fromPrefix);
65643
+ return `${toPrefix ?? ""}${stripped}`;
65644
+ }
65645
+ function keyMatchesFilters(key, include, exclude) {
65646
+ if (exclude && exclude.some((p) => key.startsWith(p)))
65647
+ return false;
65648
+ if (include && include.length > 0)
65649
+ return include.some((p) => key.startsWith(p));
65650
+ return true;
65651
+ }
65652
+ async function mapWithConcurrency(items, limit, worker) {
65653
+ let cursor = 0;
65654
+ const runners = [];
65655
+ const size = Math.max(1, Math.min(limit, items.length || 1));
65656
+ for (let i = 0;i < size; i++) {
65657
+ runners.push((async () => {
65658
+ while (true) {
65659
+ const index = cursor++;
65660
+ if (index >= items.length)
65661
+ return;
65662
+ await worker(items[index], index);
65663
+ }
65664
+ })());
65665
+ }
65666
+ await Promise.all(runners);
65667
+ }
65668
+ function clientFor(endpoint) {
65669
+ if (endpoint.client)
65670
+ return endpoint.client;
65671
+ return createObjectStorageClient({
65672
+ provider: endpoint.provider,
65673
+ region: endpoint.region,
65674
+ endpoint: endpoint.endpoint,
65675
+ forcePathStyle: endpoint.forcePathStyle,
65676
+ credentials: endpoint.credentials
65677
+ });
65678
+ }
65679
+ async function migrateObjectStorage(options) {
65680
+ const concurrency = options.concurrency ?? 8;
65681
+ const fromClient = clientFor(options.from);
65682
+ const toClient = clientFor(options.to);
65683
+ const sourceObjects = await fromClient.listAllObjects({
65684
+ bucket: options.from.bucket,
65685
+ prefix: options.from.prefix
65686
+ });
65687
+ const result = {
65688
+ copied: 0,
65689
+ skipped: 0,
65690
+ excluded: 0,
65691
+ bytesCopied: 0,
65692
+ errors: [],
65693
+ excludedKeys: [],
65694
+ deleted: []
65695
+ };
65696
+ const toCopy = [];
65697
+ for (const obj of sourceObjects) {
65698
+ if (!keyMatchesFilters(obj.Key, options.include, options.exclude)) {
65699
+ result.excluded++;
65700
+ result.excludedKeys.push(obj.Key);
65701
+ continue;
65702
+ }
65703
+ toCopy.push({ source: obj, destKey: remapKey(obj.Key, options.from.prefix, options.to.prefix) });
65704
+ }
65705
+ if (options.dryRun) {
65706
+ result.plan = toCopy.map(({ source, destKey }) => ({ key: source.Key, destKey, size: source.Size }));
65707
+ const total2 = sourceObjects.length;
65708
+ let index = 0;
65709
+ for (const obj of sourceObjects) {
65710
+ index++;
65711
+ const excluded = !keyMatchesFilters(obj.Key, options.include, options.exclude);
65712
+ options.onProgress?.({
65713
+ key: obj.Key,
65714
+ destKey: excluded ? "" : remapKey(obj.Key, options.from.prefix, options.to.prefix),
65715
+ size: obj.Size,
65716
+ action: excluded ? "excluded" : "planned",
65717
+ index,
65718
+ total: total2
65719
+ });
65720
+ }
65721
+ return result;
65722
+ }
65723
+ const total = toCopy.length;
65724
+ let processed = 0;
65725
+ await mapWithConcurrency(toCopy, concurrency, async ({ source, destKey }) => {
65726
+ const myIndex = ++processed;
65727
+ try {
65728
+ if (!options.force) {
65729
+ const head = await toClient.headObject(options.to.bucket, destKey);
65730
+ if (head && head.ContentLength === source.Size) {
65731
+ result.skipped++;
65732
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "skipped", index: myIndex, total });
65733
+ return;
65734
+ }
65735
+ }
65736
+ const { body, contentType } = await fromClient.getObjectBytes(options.from.bucket, source.Key);
65737
+ await toClient.putObject({
65738
+ bucket: options.to.bucket,
65739
+ key: destKey,
65740
+ body,
65741
+ contentType
65742
+ });
65743
+ result.copied++;
65744
+ result.bytesCopied += body.byteLength;
65745
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "copied", index: myIndex, total });
65746
+ } catch (err) {
65747
+ result.errors.push({ key: source.Key, message: err?.message ?? String(err) });
65748
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "error", index: myIndex, total });
65749
+ }
65750
+ });
65751
+ const copiedDestKeys = new Set(toCopy.map((c) => c.destKey));
65752
+ if (options.deleteExtraneous) {
65753
+ const destObjects = await toClient.listAllObjects({ bucket: options.to.bucket, prefix: options.to.prefix });
65754
+ const extraneous = destObjects.filter((o) => !copiedDestKeys.has(o.Key)).map((o) => o.Key);
65755
+ for (const key of extraneous) {
65756
+ try {
65757
+ await toClient.deleteObject(options.to.bucket, key);
65758
+ result.deleted.push(key);
65759
+ } catch (err) {
65760
+ result.errors.push({ key, message: `delete failed: ${err?.message ?? String(err)}` });
65761
+ }
65762
+ }
65763
+ }
65764
+ if (options.verify) {
65765
+ const destObjects = await toClient.listAllObjects({ bucket: options.to.bucket, prefix: options.to.prefix });
65766
+ const destBySizeKey = new Map(destObjects.map((o) => [o.Key, o.Size]));
65767
+ const missing = [];
65768
+ const sizeMismatches = [];
65769
+ let matched = 0;
65770
+ for (const { source, destKey } of toCopy) {
65771
+ if (!destBySizeKey.has(destKey)) {
65772
+ missing.push(destKey);
65773
+ continue;
65774
+ }
65775
+ const actual = destBySizeKey.get(destKey);
65776
+ if (actual !== source.Size) {
65777
+ sizeMismatches.push({ key: destKey, expected: source.Size, actual });
65778
+ continue;
65779
+ }
65780
+ matched++;
65781
+ }
65782
+ result.verification = {
65783
+ ok: missing.length === 0 && sizeMismatches.length === 0,
65784
+ matched,
65785
+ missing,
65786
+ sizeMismatches
65787
+ };
65788
+ }
65789
+ return result;
65790
+ }
65791
+
65792
+ // src/object-storage/index.ts
65573
65793
  var DEFAULT_REGION = {
65574
65794
  aws: "us-east-1",
65575
65795
  backblaze: "us-west-004",
@@ -80931,6 +81151,77 @@ class AcmeClient {
80931
81151
  return this.accountKey;
80932
81152
  }
80933
81153
  }
81154
+ // src/deploy/site-target.ts
81155
+ function resolveSiteDeployTarget(site) {
81156
+ if (site.deploy)
81157
+ return site.deploy;
81158
+ if (site.start)
81159
+ return "server";
81160
+ return "bucket";
81161
+ }
81162
+ function resolveSiteKind(site) {
81163
+ const target = resolveSiteDeployTarget(site);
81164
+ if (target === "bucket")
81165
+ return "bucket";
81166
+ return site.start ? "server-app" : "server-static";
81167
+ }
81168
+ function hasComputeConfigured(config6) {
81169
+ return config6.infrastructure?.compute != null;
81170
+ }
81171
+ function validateDeploymentConfig(config6) {
81172
+ const errors = [];
81173
+ const warnings = [];
81174
+ const sites = config6.sites || {};
81175
+ const computeConfigured = hasComputeConfigured(config6);
81176
+ const portOwners = new Map;
81177
+ for (const [name, site] of Object.entries(sites)) {
81178
+ if (!site) {
81179
+ continue;
81180
+ }
81181
+ const target = resolveSiteDeployTarget(site);
81182
+ const kind = resolveSiteKind(site);
81183
+ if (target === "server" && !site.start && !site.root) {
81184
+ errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
81185
+ continue;
81186
+ }
81187
+ if (kind === "server-app") {
81188
+ if (!computeConfigured) {
81189
+ errors.push(`Site '${name}' deploys to a server (deploy:'server'${site.deploy ? "" : " inferred from `start`"}) but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
81190
+ }
81191
+ if (typeof site.port === "number") {
81192
+ const existing = portOwners.get(site.port);
81193
+ if (existing) {
81194
+ errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
81195
+ } else {
81196
+ portOwners.set(site.port, name);
81197
+ }
81198
+ }
81199
+ } else if (kind === "server-static") {
81200
+ if (!site.root) {
81201
+ errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
81202
+ }
81203
+ if (!computeConfigured) {
81204
+ errors.push(`Site '${name}' deploys to a server (deploy:'server') but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
81205
+ }
81206
+ } else {
81207
+ if (!site.root) {
81208
+ errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
81209
+ }
81210
+ const serverOnly = [];
81211
+ if (site.start)
81212
+ serverOnly.push("start");
81213
+ if (typeof site.port === "number")
81214
+ serverOnly.push("port");
81215
+ if (site.preStart && site.preStart.length > 0)
81216
+ serverOnly.push("preStart");
81217
+ if (serverOnly.length > 0) {
81218
+ warnings.push(`Site '${name}' deploys to a bucket but sets server-only field(s): ${serverOnly.join(", ")}. These are ignored. Set deploy:'server' to use them.`);
81219
+ }
81220
+ }
81221
+ }
81222
+ return { errors, warnings };
81223
+ }
81224
+
80934
81225
  // src/deploy/index.ts
80935
81226
  init_static_site();
80936
81227
  init_static_site_external_dns();
@@ -81159,46 +81450,202 @@ import { join as join13 } from "node:path";
81159
81450
  import { execSync } from "node:child_process";
81160
81451
 
81161
81452
  // src/drivers/shared/caddyfile.ts
81162
- function buildCaddyfile(sites) {
81163
- const allSites = Object.entries(sites);
81164
- const sitesWithDomain = allSites.filter(([, s]) => typeof s.domain === "string" && s.domain && typeof s.port === "number");
81165
- if (sitesWithDomain.length === 0)
81453
+ function isOnDemandDomain(domain) {
81454
+ return domain === "*" || domain.includes("*");
81455
+ }
81456
+ function isStaticApp(app) {
81457
+ return typeof app.root === "string" && app.root.length > 0;
81458
+ }
81459
+ function isProxyApp(app) {
81460
+ return typeof app.port === "number";
81461
+ }
81462
+ function staticSiteServerRoot(name) {
81463
+ return `/var/www/${name}`;
81464
+ }
81465
+ function normalizeOnDemandTls(onDemandTls) {
81466
+ if (!onDemandTls)
81166
81467
  return;
81167
- const byDomain = new Map;
81168
- for (const [, site] of sitesWithDomain) {
81169
- const list = byDomain.get(site.domain) ?? [];
81170
- list.push({ port: site.port, path: site.path });
81171
- byDomain.set(site.domain, list);
81468
+ if (onDemandTls === true)
81469
+ return {};
81470
+ return onDemandTls;
81471
+ }
81472
+ function wrapInHandle(body, path, indent) {
81473
+ const inner = body.split(`
81474
+ `).map((line) => ` ${line}`).join(`
81475
+ `);
81476
+ return `${indent}handle ${path} {
81477
+ ${inner}
81478
+ ${indent}}`;
81479
+ }
81480
+ function renderUpstreamBlock(app, indent) {
81481
+ const host = app.upstreamHost || "localhost";
81482
+ const upstream = `${host}:${app.port}`;
81483
+ const directives = app.reverseProxyDirectives ?? [];
81484
+ const proxyLine = directives.length === 0 ? `${indent}reverse_proxy ${upstream}` : [
81485
+ `${indent}reverse_proxy ${upstream} {`,
81486
+ ...directives.map((d) => `${indent} ${d}`),
81487
+ `${indent}}`
81488
+ ].join(`
81489
+ `);
81490
+ const isCatchAll = !app.path || app.path === "/";
81491
+ if (isCatchAll)
81492
+ return proxyLine;
81493
+ return wrapInHandle(proxyLine, app.path, indent);
81494
+ }
81495
+ function renderStaticBlock(app, indent) {
81496
+ const root = app.root;
81497
+ const lines = [`${indent}root * ${root}`];
81498
+ if (app.cache?.enabled) {
81499
+ const maxAge = app.cache.maxAge ?? 3600;
81500
+ lines.push(`${indent}header Cache-Control "public, max-age=${maxAge}"`);
81501
+ }
81502
+ if (app.spa) {
81503
+ lines.push(`${indent}try_files {path} /index.html`);
81504
+ } else if (app.pathRewriteStyle === "flat") {
81505
+ lines.push(`${indent}try_files {path} {path}.html {path}/index.html`);
81506
+ } else {
81507
+ lines.push(`${indent}try_files {path} {path}/index.html {path}.html`);
81172
81508
  }
81509
+ lines.push(`${indent}file_server`);
81510
+ const body = lines.join(`
81511
+ `);
81512
+ const isCatchAll = !app.path || app.path === "/";
81513
+ if (isCatchAll)
81514
+ return body;
81515
+ return wrapInHandle(body, app.path, indent);
81516
+ }
81517
+ function renderAppBlock(app, indent) {
81518
+ return isStaticApp(app) ? renderStaticBlock(app, indent) : renderUpstreamBlock(app, indent);
81519
+ }
81520
+ function groupAppsByDomains(apps) {
81521
+ const groups = new Map;
81522
+ for (const app of apps) {
81523
+ const domains = [...app.domains].filter(Boolean);
81524
+ if (domains.length === 0)
81525
+ continue;
81526
+ const key = [...domains].sort().join(" ");
81527
+ const group = groups.get(key) ?? { domains, apps: [] };
81528
+ group.apps.push(app);
81529
+ groups.set(key, group);
81530
+ }
81531
+ return [...groups.values()];
81532
+ }
81533
+ function sortAppsByPath(apps) {
81534
+ return [...apps].sort((a, b) => {
81535
+ const aCatchAll = !a.path || a.path === "/";
81536
+ const bCatchAll = !b.path || b.path === "/";
81537
+ if (aCatchAll && !bCatchAll)
81538
+ return 1;
81539
+ if (!aCatchAll && bCatchAll)
81540
+ return -1;
81541
+ return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81542
+ });
81543
+ }
81544
+ function buildCaddyfileFromProxy(proxy) {
81545
+ if (proxy.raw && proxy.raw.trim())
81546
+ return proxy.raw.trim();
81547
+ const apps = (proxy.apps ?? []).filter((app) => app.domains.length > 0 && (isProxyApp(app) || isStaticApp(app)));
81548
+ if (apps.length === 0)
81549
+ return;
81550
+ const onDemand = normalizeOnDemandTls(proxy.onDemandTls);
81551
+ const hasOnDemandDomain = apps.some((app) => app.domains.some(isOnDemandDomain));
81552
+ const globalLines = [];
81553
+ if (proxy.email)
81554
+ globalLines.push(`email ${proxy.email}`);
81555
+ if (proxy.staging)
81556
+ globalLines.push("acme_ca https://acme-staging-v02.api.letsencrypt.org/directory");
81557
+ if (onDemand) {
81558
+ if (onDemand.ask || onDemand.interval || onDemand.burst != null) {
81559
+ const inner = [];
81560
+ if (onDemand.ask)
81561
+ inner.push(` ask ${onDemand.ask}`);
81562
+ if (onDemand.interval || onDemand.burst != null) {
81563
+ const rl = [];
81564
+ if (onDemand.interval)
81565
+ rl.push(` interval ${onDemand.interval}`);
81566
+ if (onDemand.burst != null)
81567
+ rl.push(` burst ${onDemand.burst}`);
81568
+ inner.push(` rate_limit {
81569
+ ${rl.join(`
81570
+ `)}
81571
+ }`);
81572
+ }
81573
+ globalLines.push(`on_demand_tls {
81574
+ ${inner.join(`
81575
+ `)}
81576
+ }`);
81577
+ } else {
81578
+ globalLines.push(`on_demand_tls {
81579
+ }`);
81580
+ }
81581
+ }
81582
+ for (const directive of proxy.globalDirectives ?? [])
81583
+ globalLines.push(directive);
81173
81584
  const blocks = [];
81174
- for (const [domain, domainSites] of byDomain) {
81175
- const sorted = [...domainSites].sort((a, b) => {
81176
- const aIsCatchAll = !a.path || a.path === "/";
81177
- const bIsCatchAll = !b.path || b.path === "/";
81178
- if (aIsCatchAll && !bIsCatchAll)
81179
- return 1;
81180
- if (!aIsCatchAll && bIsCatchAll)
81181
- return -1;
81182
- return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81183
- });
81184
- const handles = sorted.map((s) => {
81185
- const isCatchAll = !s.path || s.path === "/";
81186
- const inner = `reverse_proxy localhost:${s.port}`;
81187
- return isCatchAll ? ` handle {
81188
- ${inner}
81189
- }` : ` handle ${s.path} {
81190
- ${inner}
81191
- }`;
81192
- });
81193
- blocks.push(`${domain} {
81194
- ${handles.join(`
81585
+ if (globalLines.length > 0)
81586
+ blocks.push(`{
81587
+ ${globalLines.map((line) => ` ${line}`).join(`
81195
81588
  `)}
81196
81589
  }`);
81590
+ for (const group of groupAppsByDomains(apps)) {
81591
+ const sorted = sortAppsByPath(group.apps);
81592
+ const body = sorted.map((app) => renderAppBlock(app, " ")).join(`
81593
+ `);
81594
+ const needsOnDemand = onDemand && group.domains.some(isOnDemandDomain);
81595
+ const tlsBlock = needsOnDemand ? `
81596
+ tls {
81597
+ on_demand
81598
+ }` : "";
81599
+ blocks.push(`${group.domains.join(", ")} {
81600
+ ${body}${tlsBlock}
81601
+ }`);
81602
+ }
81603
+ if (hasOnDemandDomain && !onDemand) {
81604
+ blocks.unshift(`# WARNING: wildcard/catch-all domain present but on_demand_tls is not enabled.
81605
+ ` + "# Caddy cannot provision TLS for these hosts. Set compute.proxy.onDemandTls.");
81197
81606
  }
81198
81607
  return blocks.join(`
81199
81608
 
81200
81609
  `);
81201
81610
  }
81611
+ function proxyConfigFromSites(sites) {
81612
+ const apps = [];
81613
+ for (const [name, site] of Object.entries(sites)) {
81614
+ if (typeof site.domain !== "string" || !site.domain)
81615
+ continue;
81616
+ if (typeof site.port === "number" && site.deploy !== "bucket") {
81617
+ apps.push({
81618
+ name,
81619
+ domains: [site.domain],
81620
+ port: site.port,
81621
+ path: site.path
81622
+ });
81623
+ } else if (resolveSiteKind(site) === "server-static") {
81624
+ apps.push({
81625
+ name,
81626
+ domains: [site.domain],
81627
+ root: staticSiteServerRoot(name),
81628
+ path: site.path,
81629
+ spa: site.spa,
81630
+ pathRewriteStyle: site.pathRewriteStyle,
81631
+ cache: site.cache
81632
+ });
81633
+ }
81634
+ }
81635
+ return { apps };
81636
+ }
81637
+ function resolveCaddyfile(sites, proxy) {
81638
+ if (proxy) {
81639
+ if (proxy.raw && proxy.raw.trim())
81640
+ return proxy.raw.trim();
81641
+ const resolved = proxy.apps && proxy.apps.length > 0 ? proxy : { ...proxy, apps: proxyConfigFromSites(sites).apps };
81642
+ return buildCaddyfileFromProxy(resolved);
81643
+ }
81644
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81645
+ }
81646
+ function buildCaddyfile(sites) {
81647
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81648
+ }
81202
81649
 
81203
81650
  // src/drivers/hetzner/client.ts
81204
81651
  var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
@@ -81223,10 +81670,20 @@ class HetznerClient {
81223
81670
  body: body === undefined ? undefined : JSON.stringify(body)
81224
81671
  });
81225
81672
  const text = await response.text();
81226
- const data = text ? JSON.parse(text) : {};
81673
+ let data;
81674
+ try {
81675
+ data = text ? JSON.parse(text) : {};
81676
+ } catch {
81677
+ if (!response.ok) {
81678
+ const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
81679
+ throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
81680
+ }
81681
+ throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
81682
+ }
81227
81683
  if (!response.ok) {
81228
81684
  const message = data.error?.message || response.statusText || "Hetzner API error";
81229
- throw new Error(`Hetzner API ${method} ${path}: ${message}`);
81685
+ const code = data.error?.code ? ` [${data.error.code}]` : "";
81686
+ throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
81230
81687
  }
81231
81688
  return data;
81232
81689
  }
@@ -81270,6 +81727,12 @@ class HetznerClient {
81270
81727
  });
81271
81728
  return { firewall: data.firewall, actions: data.actions };
81272
81729
  }
81730
+ async setFirewallRules(firewallId, rules) {
81731
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
81732
+ rules
81733
+ });
81734
+ return data.actions ?? [];
81735
+ }
81273
81736
  async applyFirewallToResources(firewallId, applyTo) {
81274
81737
  const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
81275
81738
  apply_to: applyTo
@@ -81536,6 +81999,8 @@ class HetznerDriver {
81536
81999
  sshPublicKeyPath;
81537
82000
  sshUser;
81538
82001
  location;
82002
+ waitForBoot;
82003
+ bootWait;
81539
82004
  constructor(options = {}) {
81540
82005
  this.client = options.client ?? new HetznerClient({
81541
82006
  apiToken: resolveHetznerApiToken(options.apiToken)
@@ -81544,6 +82009,13 @@ class HetznerDriver {
81544
82009
  this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
81545
82010
  this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
81546
82011
  this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
82012
+ this.waitForBoot = options.waitForBoot ?? true;
82013
+ this.bootWait = {
82014
+ sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
82015
+ sshTimeoutMs: options.bootWait?.sshTimeoutMs ?? 300000,
82016
+ cloudInitIntervalMs: options.bootWait?.cloudInitIntervalMs ?? 5000,
82017
+ cloudInitTimeoutMs: options.bootWait?.cloudInitTimeoutMs ?? 600000
82018
+ };
81547
82019
  }
81548
82020
  async provisionComputeInfrastructure(options) {
81549
82021
  const { config: config6, environment } = options;
@@ -81553,16 +82025,32 @@ class HetznerDriver {
81553
82025
  throw new Error("infrastructure.compute is required to provision Hetzner compute");
81554
82026
  }
81555
82027
  const stackName = resolveProjectStackName(config6, environment);
82028
+ const serverName = `${slug}-${environment}-app`;
81556
82029
  const existing = await readDriverState(stackName);
81557
82030
  if (existing?.serverId) {
81558
- const server2 = await this.client.getServer(existing.serverId);
81559
- if (server2.status !== "off") {
82031
+ const server2 = await this.tryGetServer(existing.serverId);
82032
+ if (server2 && server2.status !== "off") {
81560
82033
  return this.outputsFromState(existing, server2);
81561
82034
  }
81562
82035
  }
82036
+ const labels = tsCloudLabels(slug, environment, "app");
82037
+ const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
82038
+ if (alreadyRunning && alreadyRunning.status !== "off") {
82039
+ const rehydrated = {
82040
+ provider: "hetzner",
82041
+ stackName,
82042
+ serverId: alreadyRunning.id,
82043
+ serverName: alreadyRunning.name,
82044
+ publicIp: alreadyRunning.public_net.ipv4?.ip,
82045
+ deployStoragePath: "/var/ts-cloud/staging",
82046
+ sshUser: this.sshUser
82047
+ };
82048
+ await writeDriverState(stackName, rehydrated);
82049
+ return this.outputsFromState(rehydrated, alreadyRunning);
82050
+ }
81563
82051
  const sites = config6.sites || {};
81564
- const sitePorts = Object.values(sites).map((site) => site.port).filter((port) => typeof port === "number" && ![80, 443].includes(port));
81565
- const caddyfile = buildCaddyfile(sites);
82052
+ const caddyfile = resolveCaddyfile(sites, compute.proxy);
82053
+ const sitePorts = caddyfile ? [] : this.collectUpstreamPorts(sites, compute.proxy);
81566
82054
  const bootstrap = generateUbuntuAppCloudInit({
81567
82055
  runtime: compute.runtime || "bun",
81568
82056
  runtimeVersion: compute.runtimeVersion || "latest",
@@ -81571,19 +82059,13 @@ class HetznerDriver {
81571
82059
  caddyfile
81572
82060
  });
81573
82061
  const userData = wrapCloudInitUserData(bootstrap);
81574
- const serverName = `${slug}-${environment}-app`;
81575
82062
  const serverType = resolveHetznerServerType(compute.size);
81576
82063
  const image = compute.image || config6.hetzner?.image || "ubuntu-24.04";
81577
- const labels = tsCloudLabels(slug, environment, "app");
81578
82064
  const firewallName = `${slug}-${environment}-app-fw`;
81579
- const { firewall } = await this.client.createFirewall({
81580
- name: firewallName,
81581
- labels,
81582
- rules: buildHetznerFirewallRules({
81583
- allowSsh: compute.allowSsh !== false,
81584
- sitePorts
81585
- })
81586
- });
82065
+ const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
82066
+ allowSsh: compute.allowSsh !== false,
82067
+ sitePorts
82068
+ }));
81587
82069
  const sshKeyId = await this.ensureSshKey(slug, environment, labels);
81588
82070
  const { server, action } = await this.client.createServer({
81589
82071
  name: serverName,
@@ -81608,6 +82090,11 @@ class HetznerDriver {
81608
82090
  sshUser: this.sshUser
81609
82091
  };
81610
82092
  await writeDriverState(stackName, state);
82093
+ const ip = running.public_net.ipv4?.ip;
82094
+ if (ip && this.waitForBoot) {
82095
+ await this.waitForSshReady(ip);
82096
+ await this.waitForCloudInit(ip);
82097
+ }
81611
82098
  return this.outputsFromState(state, running);
81612
82099
  }
81613
82100
  async getComputeOutputs(options) {
@@ -81714,6 +82201,79 @@ class HetznerDriver {
81714
82201
  });
81715
82202
  return created.id;
81716
82203
  }
82204
+ async tryGetServer(id) {
82205
+ try {
82206
+ return await this.client.getServer(id);
82207
+ } catch {
82208
+ return null;
82209
+ }
82210
+ }
82211
+ async findExistingServer(slug, environment, serverName) {
82212
+ const servers = await this.client.listServers();
82213
+ return servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
82214
+ }
82215
+ async ensureFirewall(name, labels, rules) {
82216
+ const existing = await this.client.listFirewalls();
82217
+ const match = existing.find((fw) => fw.name === name);
82218
+ if (match) {
82219
+ await this.client.setFirewallRules(match.id, rules);
82220
+ return { firewall: match };
82221
+ }
82222
+ const { firewall } = await this.client.createFirewall({ name, labels, rules });
82223
+ return { firewall };
82224
+ }
82225
+ collectUpstreamPorts(sites, proxy) {
82226
+ const ports = new Set;
82227
+ for (const app of proxy?.apps ?? []) {
82228
+ if (typeof app.port === "number")
82229
+ ports.add(app.port);
82230
+ }
82231
+ for (const site of Object.values(sites)) {
82232
+ if (typeof site.port === "number")
82233
+ ports.add(site.port);
82234
+ }
82235
+ return [...ports].filter((port) => ![80, 443].includes(port));
82236
+ }
82237
+ async sleep(ms) {
82238
+ await new Promise((resolve13) => setTimeout(resolve13, ms));
82239
+ }
82240
+ async waitForSshReady(host) {
82241
+ const { sshIntervalMs, sshTimeoutMs } = this.bootWait;
82242
+ const start = Date.now();
82243
+ let lastErr;
82244
+ while (Date.now() - start < sshTimeoutMs) {
82245
+ try {
82246
+ execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} true`, {
82247
+ stdio: "pipe",
82248
+ maxBuffer: SSH_MAX_BUFFER
82249
+ });
82250
+ return;
82251
+ } catch (err) {
82252
+ lastErr = err;
82253
+ await this.sleep(sshIntervalMs);
82254
+ }
82255
+ }
82256
+ throw new Error(`Timed out waiting for SSH on ${host} after ${sshTimeoutMs}ms: ${lastErr?.message ?? "unknown error"}`);
82257
+ }
82258
+ async waitForCloudInit(host) {
82259
+ const { cloudInitIntervalMs, cloudInitTimeoutMs } = this.bootWait;
82260
+ const start = Date.now();
82261
+ while (Date.now() - start < cloudInitTimeoutMs) {
82262
+ try {
82263
+ const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
82264
+ if (/status:\s*done/.test(out))
82265
+ return;
82266
+ if (/status:\s*error/.test(out))
82267
+ throw new Error(`cloud-init reported an error on ${host}:
82268
+ ${out}`);
82269
+ } catch (err) {
82270
+ if (err instanceof Error && /cloud-init reported an error/.test(err.message))
82271
+ throw err;
82272
+ }
82273
+ await this.sleep(cloudInitIntervalMs);
82274
+ }
82275
+ throw new Error(`Timed out waiting for cloud-init to finish on ${host} after ${cloudInitTimeoutMs}ms`);
82276
+ }
81717
82277
  outputsFromState(state, server) {
81718
82278
  return {
81719
82279
  deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
@@ -81722,7 +82282,7 @@ class HetznerDriver {
81722
82282
  sshUser: state.sshUser || this.sshUser
81723
82283
  };
81724
82284
  }
81725
- sshBaseArgs(host) {
82285
+ sshBaseArgs(host, extra = []) {
81726
82286
  return [
81727
82287
  "-i",
81728
82288
  this.sshPrivateKeyPath,
@@ -81730,6 +82290,7 @@ class HetznerDriver {
81730
82290
  "StrictHostKeyChecking=accept-new",
81731
82291
  "-o",
81732
82292
  "BatchMode=yes",
82293
+ ...extra,
81733
82294
  `${this.sshUser}@${host}`
81734
82295
  ];
81735
82296
  }
@@ -81843,6 +82404,19 @@ function buildSiteDeployScript(options) {
81843
82404
  `systemctl is-active ${serviceName}`
81844
82405
  ];
81845
82406
  }
82407
+ function buildStaticSiteDeployScript(options) {
82408
+ const { siteName, artifactFetch, preStartCommands = [] } = options;
82409
+ const appDir = options.appDir ?? `/var/www/${siteName}`;
82410
+ const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
82411
+ return [
82412
+ "set -euo pipefail",
82413
+ ...artifactFetch,
82414
+ `mkdir -p ${appDir}`,
82415
+ `find ${appDir} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`,
82416
+ `tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
82417
+ ...preStart
82418
+ ];
82419
+ }
81846
82420
  function buildAwsArtifactFetch(bucket, key, region, siteName) {
81847
82421
  return [
81848
82422
  `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
@@ -81892,7 +82466,11 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
81892
82466
  targets
81893
82467
  });
81894
82468
  const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config6.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
81895
- const remoteScript = buildSiteDeployScript({
82469
+ const remoteScript = resolveSiteKind(site) === "server-static" ? buildStaticSiteDeployScript({
82470
+ siteName,
82471
+ artifactFetch,
82472
+ preStartCommands: site.preStart
82473
+ }) : buildSiteDeployScript({
81896
82474
  siteName,
81897
82475
  slug,
81898
82476
  artifactFetch,
@@ -81931,8 +82509,11 @@ async function deployAllComputeSites(options) {
81931
82509
  const slug = config6.project.slug;
81932
82510
  const sites = config6.sites || {};
81933
82511
  const deployable = Object.entries(sites).filter(([name, site]) => {
81934
- if (!site?.start) {
81935
- logger4.warn(`Site '${name}' has no \`start\` command — skipping (compute mode requires every site to declare how to run).`);
82512
+ if (!site)
82513
+ return false;
82514
+ const kind = resolveSiteKind(site);
82515
+ if (kind === "bucket") {
82516
+ logger4.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
81936
82517
  return false;
81937
82518
  }
81938
82519
  return true;
@@ -81984,6 +82565,7 @@ export {
81984
82565
  validateTemplateSize,
81985
82566
  validateTemplate,
81986
82567
  validateResourceLimits,
82568
+ validateDeploymentConfig,
81987
82569
  validateCredentials,
81988
82570
  validateConfiguration,
81989
82571
  validateCommand,
@@ -81999,6 +82581,7 @@ export {
81999
82581
  suggestFlags,
82000
82582
  suggestCommand,
82001
82583
  storageAdvancedManager,
82584
+ staticSiteServerRoot,
82002
82585
  staticSiteManager,
82003
82586
  stackDependencyManager,
82004
82587
  signRequestAsync,
@@ -82019,6 +82602,8 @@ export {
82019
82602
  resolveStorageBucketName,
82020
82603
  resolveSiteStackName,
82021
82604
  resolveSiteResourceName,
82605
+ resolveSiteKind,
82606
+ resolveSiteDeployTarget,
82022
82607
  resolveSiteBucketName,
82023
82608
  resolveRegion,
82024
82609
  resolveProjectStackName,
@@ -82028,11 +82613,14 @@ export {
82028
82613
  resolveDeployBucketName,
82029
82614
  resolveCredentials,
82030
82615
  resolveCloudProvider,
82616
+ resolveCaddyfile,
82031
82617
  requiresReplacement,
82032
82618
  replicaManager,
82619
+ remapKey,
82033
82620
  regionPairManager,
82034
82621
  quickHash,
82035
82622
  queueManagementManager,
82623
+ proxyConfigFromSites,
82036
82624
  providerEndpoint,
82037
82625
  progressiveDeploymentManager,
82038
82626
  processInChunks,
@@ -82050,6 +82638,7 @@ export {
82050
82638
  multiRegionManager,
82051
82639
  multiAccountManager,
82052
82640
  migrationManager,
82641
+ migrateObjectStorage,
82053
82642
  metricsManager,
82054
82643
  mergeInfrastructure,
82055
82644
  makeAWSRequestOnce,
@@ -82063,8 +82652,10 @@ export {
82063
82652
  lambdaDestinationsManager,
82064
82653
  lambdaDLQManager,
82065
82654
  lambdaConcurrencyManager,
82655
+ keyMatchesFilters,
82066
82656
  isWebCryptoAvailable,
82067
82657
  isValidRegion,
82658
+ isOnDemandDomain,
82068
82659
  isNodeCryptoAvailable,
82069
82660
  isLocalDevelopment,
82070
82661
  isLikelyTypo,
@@ -82213,9 +82804,11 @@ export {
82213
82804
  certificateManager,
82214
82805
  categorizeChanges,
82215
82806
  canaryManager,
82807
+ buildStaticSiteDeployScript,
82216
82808
  buildSiteDeployScript,
82217
82809
  buildOptimizationManager,
82218
82810
  buildCloudFormationTemplate,
82811
+ buildCaddyfileFromProxy,
82219
82812
  buildCaddyfile,
82220
82813
  bounceComplaintHandler,
82221
82814
  blueGreenManager,