@stacksjs/ts-cloud 0.2.20 → 0.2.22

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
@@ -80931,6 +80931,77 @@ class AcmeClient {
80931
80931
  return this.accountKey;
80932
80932
  }
80933
80933
  }
80934
+ // src/deploy/site-target.ts
80935
+ function resolveSiteDeployTarget(site) {
80936
+ if (site.deploy)
80937
+ return site.deploy;
80938
+ if (site.start)
80939
+ return "server";
80940
+ return "bucket";
80941
+ }
80942
+ function resolveSiteKind(site) {
80943
+ const target = resolveSiteDeployTarget(site);
80944
+ if (target === "bucket")
80945
+ return "bucket";
80946
+ return site.start ? "server-app" : "server-static";
80947
+ }
80948
+ function hasComputeConfigured(config6) {
80949
+ return config6.infrastructure?.compute != null;
80950
+ }
80951
+ function validateDeploymentConfig(config6) {
80952
+ const errors = [];
80953
+ const warnings = [];
80954
+ const sites = config6.sites || {};
80955
+ const computeConfigured = hasComputeConfigured(config6);
80956
+ const portOwners = new Map;
80957
+ for (const [name, site] of Object.entries(sites)) {
80958
+ if (!site) {
80959
+ continue;
80960
+ }
80961
+ const target = resolveSiteDeployTarget(site);
80962
+ const kind = resolveSiteKind(site);
80963
+ if (target === "server" && !site.start && !site.root) {
80964
+ errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
80965
+ continue;
80966
+ }
80967
+ if (kind === "server-app") {
80968
+ if (!computeConfigured) {
80969
+ 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).`);
80970
+ }
80971
+ if (typeof site.port === "number") {
80972
+ const existing = portOwners.get(site.port);
80973
+ if (existing) {
80974
+ errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
80975
+ } else {
80976
+ portOwners.set(site.port, name);
80977
+ }
80978
+ }
80979
+ } else if (kind === "server-static") {
80980
+ if (!site.root) {
80981
+ errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
80982
+ }
80983
+ if (!computeConfigured) {
80984
+ 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).`);
80985
+ }
80986
+ } else {
80987
+ if (!site.root) {
80988
+ errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
80989
+ }
80990
+ const serverOnly = [];
80991
+ if (site.start)
80992
+ serverOnly.push("start");
80993
+ if (typeof site.port === "number")
80994
+ serverOnly.push("port");
80995
+ if (site.preStart && site.preStart.length > 0)
80996
+ serverOnly.push("preStart");
80997
+ if (serverOnly.length > 0) {
80998
+ 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.`);
80999
+ }
81000
+ }
81001
+ }
81002
+ return { errors, warnings };
81003
+ }
81004
+
80934
81005
  // src/deploy/index.ts
80935
81006
  init_static_site();
80936
81007
  init_static_site_external_dns();
@@ -81159,46 +81230,202 @@ import { join as join13 } from "node:path";
81159
81230
  import { execSync } from "node:child_process";
81160
81231
 
81161
81232
  // 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)
81233
+ function isOnDemandDomain(domain) {
81234
+ return domain === "*" || domain.includes("*");
81235
+ }
81236
+ function isStaticApp(app) {
81237
+ return typeof app.root === "string" && app.root.length > 0;
81238
+ }
81239
+ function isProxyApp(app) {
81240
+ return typeof app.port === "number";
81241
+ }
81242
+ function staticSiteServerRoot(name) {
81243
+ return `/var/www/${name}`;
81244
+ }
81245
+ function normalizeOnDemandTls(onDemandTls) {
81246
+ if (!onDemandTls)
81247
+ return;
81248
+ if (onDemandTls === true)
81249
+ return {};
81250
+ return onDemandTls;
81251
+ }
81252
+ function wrapInHandle(body, path, indent) {
81253
+ const inner = body.split(`
81254
+ `).map((line) => ` ${line}`).join(`
81255
+ `);
81256
+ return `${indent}handle ${path} {
81257
+ ${inner}
81258
+ ${indent}}`;
81259
+ }
81260
+ function renderUpstreamBlock(app, indent) {
81261
+ const host = app.upstreamHost || "localhost";
81262
+ const upstream = `${host}:${app.port}`;
81263
+ const directives = app.reverseProxyDirectives ?? [];
81264
+ const proxyLine = directives.length === 0 ? `${indent}reverse_proxy ${upstream}` : [
81265
+ `${indent}reverse_proxy ${upstream} {`,
81266
+ ...directives.map((d) => `${indent} ${d}`),
81267
+ `${indent}}`
81268
+ ].join(`
81269
+ `);
81270
+ const isCatchAll = !app.path || app.path === "/";
81271
+ if (isCatchAll)
81272
+ return proxyLine;
81273
+ return wrapInHandle(proxyLine, app.path, indent);
81274
+ }
81275
+ function renderStaticBlock(app, indent) {
81276
+ const root = app.root;
81277
+ const lines = [`${indent}root * ${root}`];
81278
+ if (app.cache?.enabled) {
81279
+ const maxAge = app.cache.maxAge ?? 3600;
81280
+ lines.push(`${indent}header Cache-Control "public, max-age=${maxAge}"`);
81281
+ }
81282
+ if (app.spa) {
81283
+ lines.push(`${indent}try_files {path} /index.html`);
81284
+ } else if (app.pathRewriteStyle === "flat") {
81285
+ lines.push(`${indent}try_files {path} {path}.html {path}/index.html`);
81286
+ } else {
81287
+ lines.push(`${indent}try_files {path} {path}/index.html {path}.html`);
81288
+ }
81289
+ lines.push(`${indent}file_server`);
81290
+ const body = lines.join(`
81291
+ `);
81292
+ const isCatchAll = !app.path || app.path === "/";
81293
+ if (isCatchAll)
81294
+ return body;
81295
+ return wrapInHandle(body, app.path, indent);
81296
+ }
81297
+ function renderAppBlock(app, indent) {
81298
+ return isStaticApp(app) ? renderStaticBlock(app, indent) : renderUpstreamBlock(app, indent);
81299
+ }
81300
+ function groupAppsByDomains(apps) {
81301
+ const groups = new Map;
81302
+ for (const app of apps) {
81303
+ const domains = [...app.domains].filter(Boolean);
81304
+ if (domains.length === 0)
81305
+ continue;
81306
+ const key = [...domains].sort().join(" ");
81307
+ const group = groups.get(key) ?? { domains, apps: [] };
81308
+ group.apps.push(app);
81309
+ groups.set(key, group);
81310
+ }
81311
+ return [...groups.values()];
81312
+ }
81313
+ function sortAppsByPath(apps) {
81314
+ return [...apps].sort((a, b) => {
81315
+ const aCatchAll = !a.path || a.path === "/";
81316
+ const bCatchAll = !b.path || b.path === "/";
81317
+ if (aCatchAll && !bCatchAll)
81318
+ return 1;
81319
+ if (!aCatchAll && bCatchAll)
81320
+ return -1;
81321
+ return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81322
+ });
81323
+ }
81324
+ function buildCaddyfileFromProxy(proxy) {
81325
+ if (proxy.raw && proxy.raw.trim())
81326
+ return proxy.raw.trim();
81327
+ const apps = (proxy.apps ?? []).filter((app) => app.domains.length > 0 && (isProxyApp(app) || isStaticApp(app)));
81328
+ if (apps.length === 0)
81166
81329
  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);
81330
+ const onDemand = normalizeOnDemandTls(proxy.onDemandTls);
81331
+ const hasOnDemandDomain = apps.some((app) => app.domains.some(isOnDemandDomain));
81332
+ const globalLines = [];
81333
+ if (proxy.email)
81334
+ globalLines.push(`email ${proxy.email}`);
81335
+ if (proxy.staging)
81336
+ globalLines.push("acme_ca https://acme-staging-v02.api.letsencrypt.org/directory");
81337
+ if (onDemand) {
81338
+ if (onDemand.ask || onDemand.interval || onDemand.burst != null) {
81339
+ const inner = [];
81340
+ if (onDemand.ask)
81341
+ inner.push(` ask ${onDemand.ask}`);
81342
+ if (onDemand.interval || onDemand.burst != null) {
81343
+ const rl = [];
81344
+ if (onDemand.interval)
81345
+ rl.push(` interval ${onDemand.interval}`);
81346
+ if (onDemand.burst != null)
81347
+ rl.push(` burst ${onDemand.burst}`);
81348
+ inner.push(` rate_limit {
81349
+ ${rl.join(`
81350
+ `)}
81351
+ }`);
81352
+ }
81353
+ globalLines.push(`on_demand_tls {
81354
+ ${inner.join(`
81355
+ `)}
81356
+ }`);
81357
+ } else {
81358
+ globalLines.push(`on_demand_tls {
81359
+ }`);
81360
+ }
81172
81361
  }
81362
+ for (const directive of proxy.globalDirectives ?? [])
81363
+ globalLines.push(directive);
81173
81364
  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(`
81365
+ if (globalLines.length > 0)
81366
+ blocks.push(`{
81367
+ ${globalLines.map((line) => ` ${line}`).join(`
81195
81368
  `)}
81196
81369
  }`);
81370
+ for (const group of groupAppsByDomains(apps)) {
81371
+ const sorted = sortAppsByPath(group.apps);
81372
+ const body = sorted.map((app) => renderAppBlock(app, " ")).join(`
81373
+ `);
81374
+ const needsOnDemand = onDemand && group.domains.some(isOnDemandDomain);
81375
+ const tlsBlock = needsOnDemand ? `
81376
+ tls {
81377
+ on_demand
81378
+ }` : "";
81379
+ blocks.push(`${group.domains.join(", ")} {
81380
+ ${body}${tlsBlock}
81381
+ }`);
81382
+ }
81383
+ if (hasOnDemandDomain && !onDemand) {
81384
+ blocks.unshift(`# WARNING: wildcard/catch-all domain present but on_demand_tls is not enabled.
81385
+ ` + "# Caddy cannot provision TLS for these hosts. Set compute.proxy.onDemandTls.");
81197
81386
  }
81198
81387
  return blocks.join(`
81199
81388
 
81200
81389
  `);
81201
81390
  }
81391
+ function proxyConfigFromSites(sites) {
81392
+ const apps = [];
81393
+ for (const [name, site] of Object.entries(sites)) {
81394
+ if (typeof site.domain !== "string" || !site.domain)
81395
+ continue;
81396
+ if (typeof site.port === "number" && site.deploy !== "bucket") {
81397
+ apps.push({
81398
+ name,
81399
+ domains: [site.domain],
81400
+ port: site.port,
81401
+ path: site.path
81402
+ });
81403
+ } else if (resolveSiteKind(site) === "server-static") {
81404
+ apps.push({
81405
+ name,
81406
+ domains: [site.domain],
81407
+ root: staticSiteServerRoot(name),
81408
+ path: site.path,
81409
+ spa: site.spa,
81410
+ pathRewriteStyle: site.pathRewriteStyle,
81411
+ cache: site.cache
81412
+ });
81413
+ }
81414
+ }
81415
+ return { apps };
81416
+ }
81417
+ function resolveCaddyfile(sites, proxy) {
81418
+ if (proxy) {
81419
+ if (proxy.raw && proxy.raw.trim())
81420
+ return proxy.raw.trim();
81421
+ const resolved = proxy.apps && proxy.apps.length > 0 ? proxy : { ...proxy, apps: proxyConfigFromSites(sites).apps };
81422
+ return buildCaddyfileFromProxy(resolved);
81423
+ }
81424
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81425
+ }
81426
+ function buildCaddyfile(sites) {
81427
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81428
+ }
81202
81429
 
81203
81430
  // src/drivers/hetzner/client.ts
81204
81431
  var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
@@ -81223,10 +81450,20 @@ class HetznerClient {
81223
81450
  body: body === undefined ? undefined : JSON.stringify(body)
81224
81451
  });
81225
81452
  const text = await response.text();
81226
- const data = text ? JSON.parse(text) : {};
81453
+ let data;
81454
+ try {
81455
+ data = text ? JSON.parse(text) : {};
81456
+ } catch {
81457
+ if (!response.ok) {
81458
+ const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
81459
+ throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
81460
+ }
81461
+ throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
81462
+ }
81227
81463
  if (!response.ok) {
81228
81464
  const message = data.error?.message || response.statusText || "Hetzner API error";
81229
- throw new Error(`Hetzner API ${method} ${path}: ${message}`);
81465
+ const code = data.error?.code ? ` [${data.error.code}]` : "";
81466
+ throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
81230
81467
  }
81231
81468
  return data;
81232
81469
  }
@@ -81270,6 +81507,12 @@ class HetznerClient {
81270
81507
  });
81271
81508
  return { firewall: data.firewall, actions: data.actions };
81272
81509
  }
81510
+ async setFirewallRules(firewallId, rules) {
81511
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
81512
+ rules
81513
+ });
81514
+ return data.actions ?? [];
81515
+ }
81273
81516
  async applyFirewallToResources(firewallId, applyTo) {
81274
81517
  const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
81275
81518
  apply_to: applyTo
@@ -81435,12 +81678,19 @@ echo "ts-cloud bootstrap complete — instance is ready for site deploys"
81435
81678
  return script;
81436
81679
  }
81437
81680
  function wrapCloudInitUserData(bootstrapScript) {
81681
+ const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
81682
+ const indented = bootstrapScript.split(`
81683
+ `).map((line) => ` ${line}`).join(`
81684
+ `);
81438
81685
  return `#cloud-config
81686
+ write_files:
81687
+ - path: ${scriptPath}
81688
+ permissions: '0755'
81689
+ owner: root:root
81690
+ content: |
81691
+ ${indented}
81439
81692
  runcmd:
81440
- - |
81441
- ${bootstrapScript.split(`
81442
- `).join(`
81443
- `)}
81693
+ - [ bash, ${scriptPath} ]
81444
81694
  `;
81445
81695
  }
81446
81696
 
@@ -81464,10 +81714,10 @@ function buildHetznerFirewallRules(config6) {
81464
81714
  // src/drivers/hetzner/instance-sizes.ts
81465
81715
  var HETZNER_INSTANCE_TYPES = {
81466
81716
  micro: "cpx11",
81467
- small: "cx22",
81468
- medium: "cx32",
81469
- large: "cx42",
81470
- xlarge: "cx52",
81717
+ small: "cx23",
81718
+ medium: "cx33",
81719
+ large: "cx43",
81720
+ xlarge: "cx53",
81471
81721
  "2xlarge": "ccx33"
81472
81722
  };
81473
81723
  function resolveHetznerServerType(size) {
@@ -81516,6 +81766,7 @@ async function writeDriverState(stackName, state) {
81516
81766
  }
81517
81767
 
81518
81768
  // src/drivers/hetzner/driver.ts
81769
+ var SSH_MAX_BUFFER = 1024 * 1024 * 256;
81519
81770
  function expandHome(path) {
81520
81771
  return path.startsWith("~/") ? join13(homedir7(), path.slice(2)) : path;
81521
81772
  }
@@ -81528,6 +81779,8 @@ class HetznerDriver {
81528
81779
  sshPublicKeyPath;
81529
81780
  sshUser;
81530
81781
  location;
81782
+ waitForBoot;
81783
+ bootWait;
81531
81784
  constructor(options = {}) {
81532
81785
  this.client = options.client ?? new HetznerClient({
81533
81786
  apiToken: resolveHetznerApiToken(options.apiToken)
@@ -81536,6 +81789,13 @@ class HetznerDriver {
81536
81789
  this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
81537
81790
  this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
81538
81791
  this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
81792
+ this.waitForBoot = options.waitForBoot ?? true;
81793
+ this.bootWait = {
81794
+ sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
81795
+ sshTimeoutMs: options.bootWait?.sshTimeoutMs ?? 300000,
81796
+ cloudInitIntervalMs: options.bootWait?.cloudInitIntervalMs ?? 5000,
81797
+ cloudInitTimeoutMs: options.bootWait?.cloudInitTimeoutMs ?? 600000
81798
+ };
81539
81799
  }
81540
81800
  async provisionComputeInfrastructure(options) {
81541
81801
  const { config: config6, environment } = options;
@@ -81545,16 +81805,32 @@ class HetznerDriver {
81545
81805
  throw new Error("infrastructure.compute is required to provision Hetzner compute");
81546
81806
  }
81547
81807
  const stackName = resolveProjectStackName(config6, environment);
81808
+ const serverName = `${slug}-${environment}-app`;
81548
81809
  const existing = await readDriverState(stackName);
81549
81810
  if (existing?.serverId) {
81550
- const server2 = await this.client.getServer(existing.serverId);
81551
- if (server2.status !== "off") {
81811
+ const server2 = await this.tryGetServer(existing.serverId);
81812
+ if (server2 && server2.status !== "off") {
81552
81813
  return this.outputsFromState(existing, server2);
81553
81814
  }
81554
81815
  }
81816
+ const labels = tsCloudLabels(slug, environment, "app");
81817
+ const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
81818
+ if (alreadyRunning && alreadyRunning.status !== "off") {
81819
+ const rehydrated = {
81820
+ provider: "hetzner",
81821
+ stackName,
81822
+ serverId: alreadyRunning.id,
81823
+ serverName: alreadyRunning.name,
81824
+ publicIp: alreadyRunning.public_net.ipv4?.ip,
81825
+ deployStoragePath: "/var/ts-cloud/staging",
81826
+ sshUser: this.sshUser
81827
+ };
81828
+ await writeDriverState(stackName, rehydrated);
81829
+ return this.outputsFromState(rehydrated, alreadyRunning);
81830
+ }
81555
81831
  const sites = config6.sites || {};
81556
- const sitePorts = Object.values(sites).map((site) => site.port).filter((port) => typeof port === "number" && ![80, 443].includes(port));
81557
- const caddyfile = buildCaddyfile(sites);
81832
+ const caddyfile = resolveCaddyfile(sites, compute.proxy);
81833
+ const sitePorts = caddyfile ? [] : this.collectUpstreamPorts(sites, compute.proxy);
81558
81834
  const bootstrap = generateUbuntuAppCloudInit({
81559
81835
  runtime: compute.runtime || "bun",
81560
81836
  runtimeVersion: compute.runtimeVersion || "latest",
@@ -81563,19 +81839,13 @@ class HetznerDriver {
81563
81839
  caddyfile
81564
81840
  });
81565
81841
  const userData = wrapCloudInitUserData(bootstrap);
81566
- const serverName = `${slug}-${environment}-app`;
81567
81842
  const serverType = resolveHetznerServerType(compute.size);
81568
81843
  const image = compute.image || config6.hetzner?.image || "ubuntu-24.04";
81569
- const labels = tsCloudLabels(slug, environment, "app");
81570
81844
  const firewallName = `${slug}-${environment}-app-fw`;
81571
- const { firewall } = await this.client.createFirewall({
81572
- name: firewallName,
81573
- labels,
81574
- rules: buildHetznerFirewallRules({
81575
- allowSsh: compute.allowSsh !== false,
81576
- sitePorts
81577
- })
81578
- });
81845
+ const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
81846
+ allowSsh: compute.allowSsh !== false,
81847
+ sitePorts
81848
+ }));
81579
81849
  const sshKeyId = await this.ensureSshKey(slug, environment, labels);
81580
81850
  const { server, action } = await this.client.createServer({
81581
81851
  name: serverName,
@@ -81600,6 +81870,11 @@ class HetznerDriver {
81600
81870
  sshUser: this.sshUser
81601
81871
  };
81602
81872
  await writeDriverState(stackName, state);
81873
+ const ip = running.public_net.ipv4?.ip;
81874
+ if (ip && this.waitForBoot) {
81875
+ await this.waitForSshReady(ip);
81876
+ await this.waitForCloudInit(ip);
81877
+ }
81603
81878
  return this.outputsFromState(state, running);
81604
81879
  }
81605
81880
  async getComputeOutputs(options) {
@@ -81706,6 +81981,79 @@ class HetznerDriver {
81706
81981
  });
81707
81982
  return created.id;
81708
81983
  }
81984
+ async tryGetServer(id) {
81985
+ try {
81986
+ return await this.client.getServer(id);
81987
+ } catch {
81988
+ return null;
81989
+ }
81990
+ }
81991
+ async findExistingServer(slug, environment, serverName) {
81992
+ const servers = await this.client.listServers();
81993
+ return servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
81994
+ }
81995
+ async ensureFirewall(name, labels, rules) {
81996
+ const existing = await this.client.listFirewalls();
81997
+ const match = existing.find((fw) => fw.name === name);
81998
+ if (match) {
81999
+ await this.client.setFirewallRules(match.id, rules);
82000
+ return { firewall: match };
82001
+ }
82002
+ const { firewall } = await this.client.createFirewall({ name, labels, rules });
82003
+ return { firewall };
82004
+ }
82005
+ collectUpstreamPorts(sites, proxy) {
82006
+ const ports = new Set;
82007
+ for (const app of proxy?.apps ?? []) {
82008
+ if (typeof app.port === "number")
82009
+ ports.add(app.port);
82010
+ }
82011
+ for (const site of Object.values(sites)) {
82012
+ if (typeof site.port === "number")
82013
+ ports.add(site.port);
82014
+ }
82015
+ return [...ports].filter((port) => ![80, 443].includes(port));
82016
+ }
82017
+ async sleep(ms) {
82018
+ await new Promise((resolve13) => setTimeout(resolve13, ms));
82019
+ }
82020
+ async waitForSshReady(host) {
82021
+ const { sshIntervalMs, sshTimeoutMs } = this.bootWait;
82022
+ const start = Date.now();
82023
+ let lastErr;
82024
+ while (Date.now() - start < sshTimeoutMs) {
82025
+ try {
82026
+ execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} true`, {
82027
+ stdio: "pipe",
82028
+ maxBuffer: SSH_MAX_BUFFER
82029
+ });
82030
+ return;
82031
+ } catch (err) {
82032
+ lastErr = err;
82033
+ await this.sleep(sshIntervalMs);
82034
+ }
82035
+ }
82036
+ throw new Error(`Timed out waiting for SSH on ${host} after ${sshTimeoutMs}ms: ${lastErr?.message ?? "unknown error"}`);
82037
+ }
82038
+ async waitForCloudInit(host) {
82039
+ const { cloudInitIntervalMs, cloudInitTimeoutMs } = this.bootWait;
82040
+ const start = Date.now();
82041
+ while (Date.now() - start < cloudInitTimeoutMs) {
82042
+ try {
82043
+ const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
82044
+ if (/status:\s*done/.test(out))
82045
+ return;
82046
+ if (/status:\s*error/.test(out))
82047
+ throw new Error(`cloud-init reported an error on ${host}:
82048
+ ${out}`);
82049
+ } catch (err) {
82050
+ if (err instanceof Error && /cloud-init reported an error/.test(err.message))
82051
+ throw err;
82052
+ }
82053
+ await this.sleep(cloudInitIntervalMs);
82054
+ }
82055
+ throw new Error(`Timed out waiting for cloud-init to finish on ${host} after ${cloudInitTimeoutMs}ms`);
82056
+ }
81709
82057
  outputsFromState(state, server) {
81710
82058
  return {
81711
82059
  deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
@@ -81714,7 +82062,7 @@ class HetznerDriver {
81714
82062
  sshUser: state.sshUser || this.sshUser
81715
82063
  };
81716
82064
  }
81717
- sshBaseArgs(host) {
82065
+ sshBaseArgs(host, extra = []) {
81718
82066
  return [
81719
82067
  "-i",
81720
82068
  this.sshPrivateKeyPath,
@@ -81722,6 +82070,7 @@ class HetznerDriver {
81722
82070
  "StrictHostKeyChecking=accept-new",
81723
82071
  "-o",
81724
82072
  "BatchMode=yes",
82073
+ ...extra,
81725
82074
  `${this.sshUser}@${host}`
81726
82075
  ];
81727
82076
  }
@@ -81736,13 +82085,14 @@ class HetznerDriver {
81736
82085
  "BatchMode=yes",
81737
82086
  localPath,
81738
82087
  `${this.sshUser}@${host}:${remotePath}`
81739
- ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe" });
82088
+ ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
81740
82089
  }
81741
82090
  sshExec(host, script) {
81742
82091
  const escaped = script.replace(/'/g, `'\\''`);
81743
82092
  return execSync(`ssh ${this.sshBaseArgs(host).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} '${escaped}'`, {
81744
82093
  encoding: "utf8",
81745
- stdio: ["pipe", "pipe", "pipe"]
82094
+ stdio: ["pipe", "pipe", "pipe"],
82095
+ maxBuffer: SSH_MAX_BUFFER
81746
82096
  });
81747
82097
  }
81748
82098
  }
@@ -81792,12 +82142,14 @@ function buildSiteDeployScript(options) {
81792
82142
  artifactFetch,
81793
82143
  execStart,
81794
82144
  envEntries,
81795
- port
82145
+ port,
82146
+ preStartCommands = []
81796
82147
  } = options;
81797
82148
  const appDir = options.appDir ?? `/var/www/${siteName}`;
81798
82149
  const serviceName = `${slug}-${siteName}.service`;
81799
82150
  const envFile = Object.entries(envEntries).map(([k, v]) => `${k}=${JSON.stringify(String(v))}`).join(`
81800
82151
  `);
82152
+ const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
81801
82153
  return [
81802
82154
  "set -euo pipefail",
81803
82155
  ...artifactFetch,
@@ -81808,6 +82160,7 @@ function buildSiteDeployScript(options) {
81808
82160
  envFile,
81809
82161
  "TS_CLOUD_ENV_EOF",
81810
82162
  `chmod 600 ${appDir}/.env`,
82163
+ ...preStart,
81811
82164
  `cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
81812
82165
  "[Unit]",
81813
82166
  `Description=${siteName} (managed by ts-cloud)`,
@@ -81831,6 +82184,19 @@ function buildSiteDeployScript(options) {
81831
82184
  `systemctl is-active ${serviceName}`
81832
82185
  ];
81833
82186
  }
82187
+ function buildStaticSiteDeployScript(options) {
82188
+ const { siteName, artifactFetch, preStartCommands = [] } = options;
82189
+ const appDir = options.appDir ?? `/var/www/${siteName}`;
82190
+ const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
82191
+ return [
82192
+ "set -euo pipefail",
82193
+ ...artifactFetch,
82194
+ `mkdir -p ${appDir}`,
82195
+ `find ${appDir} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`,
82196
+ `tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
82197
+ ...preStart
82198
+ ];
82199
+ }
81834
82200
  function buildAwsArtifactFetch(bucket, key, region, siteName) {
81835
82201
  return [
81836
82202
  `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
@@ -81880,13 +82246,18 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
81880
82246
  targets
81881
82247
  });
81882
82248
  const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config6.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
81883
- const remoteScript = buildSiteDeployScript({
82249
+ const remoteScript = resolveSiteKind(site) === "server-static" ? buildStaticSiteDeployScript({
82250
+ siteName,
82251
+ artifactFetch,
82252
+ preStartCommands: site.preStart
82253
+ }) : buildSiteDeployScript({
81884
82254
  siteName,
81885
82255
  slug,
81886
82256
  artifactFetch,
81887
82257
  execStart: resolveExecStart(site.start, runtime),
81888
82258
  envEntries: site.env || {},
81889
- port: site.port
82259
+ port: site.port,
82260
+ preStartCommands: site.preStart
81890
82261
  });
81891
82262
  logger4.step(`Deploying to ${targets.length} target(s)...`);
81892
82263
  const result = await driver.runRemoteDeploy({
@@ -81918,8 +82289,11 @@ async function deployAllComputeSites(options) {
81918
82289
  const slug = config6.project.slug;
81919
82290
  const sites = config6.sites || {};
81920
82291
  const deployable = Object.entries(sites).filter(([name, site]) => {
81921
- if (!site?.start) {
81922
- logger4.warn(`Site '${name}' has no \`start\` command — skipping (compute mode requires every site to declare how to run).`);
82292
+ if (!site)
82293
+ return false;
82294
+ const kind = resolveSiteKind(site);
82295
+ if (kind === "bucket") {
82296
+ logger4.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
81923
82297
  return false;
81924
82298
  }
81925
82299
  return true;
@@ -81971,6 +82345,7 @@ export {
81971
82345
  validateTemplateSize,
81972
82346
  validateTemplate,
81973
82347
  validateResourceLimits,
82348
+ validateDeploymentConfig,
81974
82349
  validateCredentials,
81975
82350
  validateConfiguration,
81976
82351
  validateCommand,
@@ -81986,6 +82361,7 @@ export {
81986
82361
  suggestFlags,
81987
82362
  suggestCommand,
81988
82363
  storageAdvancedManager,
82364
+ staticSiteServerRoot,
81989
82365
  staticSiteManager,
81990
82366
  stackDependencyManager,
81991
82367
  signRequestAsync,
@@ -82006,6 +82382,8 @@ export {
82006
82382
  resolveStorageBucketName,
82007
82383
  resolveSiteStackName,
82008
82384
  resolveSiteResourceName,
82385
+ resolveSiteKind,
82386
+ resolveSiteDeployTarget,
82009
82387
  resolveSiteBucketName,
82010
82388
  resolveRegion,
82011
82389
  resolveProjectStackName,
@@ -82015,11 +82393,13 @@ export {
82015
82393
  resolveDeployBucketName,
82016
82394
  resolveCredentials,
82017
82395
  resolveCloudProvider,
82396
+ resolveCaddyfile,
82018
82397
  requiresReplacement,
82019
82398
  replicaManager,
82020
82399
  regionPairManager,
82021
82400
  quickHash,
82022
82401
  queueManagementManager,
82402
+ proxyConfigFromSites,
82023
82403
  providerEndpoint,
82024
82404
  progressiveDeploymentManager,
82025
82405
  processInChunks,
@@ -82052,6 +82432,7 @@ export {
82052
82432
  lambdaConcurrencyManager,
82053
82433
  isWebCryptoAvailable,
82054
82434
  isValidRegion,
82435
+ isOnDemandDomain,
82055
82436
  isNodeCryptoAvailable,
82056
82437
  isLocalDevelopment,
82057
82438
  isLikelyTypo,
@@ -82200,9 +82581,11 @@ export {
82200
82581
  certificateManager,
82201
82582
  categorizeChanges,
82202
82583
  canaryManager,
82584
+ buildStaticSiteDeployScript,
82203
82585
  buildSiteDeployScript,
82204
82586
  buildOptimizationManager,
82205
82587
  buildCloudFormationTemplate,
82588
+ buildCaddyfileFromProxy,
82206
82589
  buildCaddyfile,
82207
82590
  bounceComplaintHandler,
82208
82591
  blueGreenManager,