@stacksjs/ts-cloud 0.5.17 → 0.5.19

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.
Files changed (34) hide show
  1. package/dist/bin/cli.js +639 -601
  2. package/dist/deploy/dashboard-data.d.ts +1 -1
  3. package/dist/deploy/hooks.d.ts +25 -0
  4. package/dist/deploy/management-dashboard.d.ts +4 -4
  5. package/dist/deploy/quick-deploy.d.ts +25 -0
  6. package/dist/drivers/hetzner/driver.d.ts +1 -1
  7. package/dist/drivers/shared/backups.d.ts +12 -0
  8. package/dist/drivers/shared/compute-ops.d.ts +10 -1
  9. package/dist/drivers/shared/laravel-deploy.d.ts +8 -0
  10. package/dist/drivers/shared/monitoring.d.ts +15 -7
  11. package/dist/drivers/shared/nginx-vhost.d.ts +19 -0
  12. package/dist/drivers/shared/php-fpm-pool.d.ts +34 -0
  13. package/dist/index.js +225 -41
  14. package/dist/security/pre-deploy-scanner.d.ts +1 -1
  15. package/dist/ui/index.html +205 -68
  16. package/dist/ui/server/backups.html +205 -68
  17. package/dist/ui/server/deployments.html +205 -68
  18. package/dist/ui/server/metrics.html +205 -68
  19. package/dist/ui/server/services.html +205 -68
  20. package/dist/ui/server/sites.html +205 -68
  21. package/dist/ui/server/ssh-keys.html +205 -68
  22. package/dist/ui/server/workers.html +205 -68
  23. package/dist/ui/serverless/assets.html +205 -68
  24. package/dist/ui/serverless/cost.html +205 -68
  25. package/dist/ui/serverless/data.html +205 -68
  26. package/dist/ui/serverless/deployments.html +205 -68
  27. package/dist/ui/serverless/firewall.html +205 -68
  28. package/dist/ui/serverless/functions.html +205 -68
  29. package/dist/ui/serverless/metrics.html +205 -68
  30. package/dist/ui/serverless/queues.html +205 -68
  31. package/dist/ui/serverless/scheduler.html +205 -68
  32. package/dist/ui/serverless/secrets.html +205 -68
  33. package/dist/ui/serverless.html +205 -68
  34. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -7642,11 +7642,11 @@ function createServerlessLaravelPreset(options) {
7642
7642
  }
7643
7643
  function createDashboardSite(options) {
7644
7644
  return {
7645
- root: options.root ?? "ui/dist",
7645
+ root: options.root ?? "packages/ui/dist",
7646
7646
  deploy: "server",
7647
7647
  type: "static",
7648
7648
  domain: options.domain,
7649
- build: options.build ?? "cd ui && bun install && bun run build",
7649
+ build: options.build ?? "cd packages/ui && bun install && bun run build",
7650
7650
  ssl: { provider: "letsencrypt" },
7651
7651
  auth: {
7652
7652
  username: options.username ?? "admin",
@@ -8440,18 +8440,19 @@ class S3Client {
8440
8440
  stream = blob.stream();
8441
8441
  totalSize = blob.size;
8442
8442
  }
8443
- const uploadId = await this.initiateMultipartUpload(bucket, key, options);
8443
+ const { uploadId, baseUrl } = await this.initiateMultipartUpload(bucket, key, options);
8444
8444
  try {
8445
- const parts = await this.uploadParts(bucket, key, uploadId, stream, partSize, credentials, totalSize, options.concurrency || 4, options.onProgress);
8446
- return await this.completeMultipartUpload(bucket, key, uploadId, parts);
8445
+ const parts = await this.uploadParts(bucket, key, baseUrl, uploadId, stream, partSize, credentials, totalSize, options.concurrency || 4, options.onProgress);
8446
+ return await this.completeMultipartUpload(bucket, key, baseUrl, uploadId, parts);
8447
8447
  } catch (error) {
8448
- await this.abortMultipartUpload(bucket, key, uploadId).catch(() => {});
8448
+ await this.abortMultipartUpload(bucket, key, uploadId, baseUrl).catch(() => {});
8449
8449
  throw error;
8450
8450
  }
8451
8451
  }
8452
8452
  async initiateMultipartUpload(bucket, key, options) {
8453
8453
  const credentials = await this.getCredentials();
8454
- const url = `${this.buildUrl(bucket, key)}?uploads`;
8454
+ const baseUrl = this.buildUrl(bucket, key);
8455
+ const url = `${baseUrl}?uploads`;
8455
8456
  const headers = {
8456
8457
  "Content-Type": options.contentType || detectContentType(key)
8457
8458
  };
@@ -8487,18 +8488,26 @@ class S3Client {
8487
8488
  if (!uploadIdMatch) {
8488
8489
  throw new S3Error("Failed to parse upload ID from response", 0, bucket, key);
8489
8490
  }
8490
- return uploadIdMatch[1];
8491
+ let resolvedBaseUrl = baseUrl;
8492
+ if (response.url) {
8493
+ try {
8494
+ const resolved = new URL(response.url);
8495
+ resolved.search = "";
8496
+ resolvedBaseUrl = resolved.toString();
8497
+ } catch {}
8498
+ }
8499
+ return { uploadId: uploadIdMatch[1], baseUrl: resolvedBaseUrl };
8491
8500
  }
8492
- async uploadParts(bucket, key, uploadId, stream, partSize, credentials, totalSize, concurrency, onProgress) {
8501
+ async uploadParts(bucket, key, baseUrl, uploadId, stream, partSize, credentials, totalSize, concurrency, onProgress) {
8493
8502
  const parts = [];
8494
8503
  const reader = stream.getReader();
8495
8504
  let partNumber = 1;
8496
8505
  let buffer = new Uint8Array(0);
8497
8506
  let loaded = 0;
8498
8507
  const totalParts = totalSize ? Math.ceil(totalSize / partSize) : undefined;
8499
- const uploadQueue = [];
8508
+ const inFlight = new Map;
8500
8509
  const uploadPart = async (data, num) => {
8501
- const url = `${this.buildUrl(bucket, key)}?partNumber=${num}&uploadId=${encodeURIComponent(uploadId)}`;
8510
+ const url = `${baseUrl}?partNumber=${num}&uploadId=${encodeURIComponent(uploadId)}`;
8502
8511
  const headers = {
8503
8512
  "Content-Length": String(data.byteLength),
8504
8513
  "x-amz-content-sha256": "UNSIGNED-PAYLOAD"
@@ -8514,15 +8523,24 @@ class S3Client {
8514
8523
  const response = await fetch(signed.url, {
8515
8524
  method: signed.method,
8516
8525
  headers: signed.headers,
8517
- body: data
8526
+ body: data,
8527
+ redirect: "error"
8518
8528
  });
8519
8529
  if (!response.ok) {
8520
8530
  const error = await response.text();
8521
8531
  throw new S3Error(`Failed to upload part ${num}: ${error}`, response.status, bucket, key);
8522
8532
  }
8523
8533
  const etag = (response.headers.get("ETag") || "").replace(/"/g, "");
8534
+ if (!etag) {
8535
+ throw new S3Error(`Missing ETag for part ${num}`, response.status, bucket, key);
8536
+ }
8524
8537
  return { partNumber: num, etag };
8525
8538
  };
8539
+ const drainOne = async () => {
8540
+ const completed = await Promise.race(inFlight.values());
8541
+ inFlight.delete(completed.partNumber);
8542
+ parts.push(completed);
8543
+ };
8526
8544
  while (true) {
8527
8545
  const { done, value } = await reader.read();
8528
8546
  if (value) {
@@ -8535,13 +8553,10 @@ class S3Client {
8535
8553
  const partData = buffer.slice(0, partSize);
8536
8554
  buffer = buffer.slice(partSize);
8537
8555
  const currentPartNumber = partNumber++;
8538
- if (uploadQueue.length >= concurrency) {
8539
- const completed = await Promise.race(uploadQueue);
8540
- parts.push(completed);
8541
- uploadQueue.splice(uploadQueue.indexOf(Promise.resolve(completed)), 1);
8556
+ if (inFlight.size >= concurrency) {
8557
+ await drainOne();
8542
8558
  }
8543
- const uploadPromise = uploadPart(partData, currentPartNumber);
8544
- uploadQueue.push(uploadPromise);
8559
+ inFlight.set(currentPartNumber, uploadPart(partData, currentPartNumber));
8545
8560
  loaded += partData.byteLength;
8546
8561
  if (onProgress) {
8547
8562
  onProgress({
@@ -8555,14 +8570,15 @@ class S3Client {
8555
8570
  if (done)
8556
8571
  break;
8557
8572
  }
8558
- const remaining = await Promise.all(uploadQueue);
8559
- parts.push(...remaining);
8573
+ while (inFlight.size > 0) {
8574
+ await drainOne();
8575
+ }
8560
8576
  parts.sort((a, b) => a.partNumber - b.partNumber);
8561
8577
  return parts;
8562
8578
  }
8563
- async completeMultipartUpload(bucket, key, uploadId, parts) {
8579
+ async completeMultipartUpload(bucket, key, baseUrl, uploadId, parts) {
8564
8580
  const credentials = await this.getCredentials();
8565
- const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
8581
+ const url = `${baseUrl}?uploadId=${encodeURIComponent(uploadId)}`;
8566
8582
  const partsXml = parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>"${p.etag}"</ETag></Part>`).join("");
8567
8583
  const body = `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`;
8568
8584
  const signed = signRequest({
@@ -8588,9 +8604,9 @@ class S3Client {
8588
8604
  const etag = etagMatch ? etagMatch[1] : "";
8589
8605
  return { etag };
8590
8606
  }
8591
- async abortMultipartUpload(bucket, key, uploadId) {
8607
+ async abortMultipartUpload(bucket, key, uploadId, baseUrl) {
8592
8608
  const credentials = await this.getCredentials();
8593
- const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
8609
+ const url = `${baseUrl ?? this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
8594
8610
  const signed = signRequest({
8595
8611
  method: "DELETE",
8596
8612
  url,
@@ -84074,6 +84090,31 @@ function resolveNginxSnippet(nginx, templates) {
84074
84090
  out.push(...nginx.serverSnippet);
84075
84091
  return out;
84076
84092
  }
84093
+ function hstsHeader(hsts) {
84094
+ if (!hsts)
84095
+ return "";
84096
+ const o = typeof hsts === "object" ? hsts : {};
84097
+ const maxAge = o.maxAge ?? 31536000;
84098
+ const parts = [`max-age=${maxAge}`];
84099
+ if (o.includeSubDomains ?? true)
84100
+ parts.push("includeSubDomains");
84101
+ if (o.preload)
84102
+ parts.push("preload");
84103
+ return ` add_header Strict-Transport-Security "${parts.join("; ")}" always;`;
84104
+ }
84105
+ function securityRules(security) {
84106
+ if (!security || !security.allow?.length && !security.deny?.length)
84107
+ return [];
84108
+ const lines = [];
84109
+ for (const ip of security.deny || [])
84110
+ lines.push(` deny ${ip};`);
84111
+ if (security.allow?.length) {
84112
+ for (const ip of security.allow)
84113
+ lines.push(` allow ${ip};`);
84114
+ lines.push(" deny all;");
84115
+ }
84116
+ return lines;
84117
+ }
84077
84118
  function htpasswdPath(siteName) {
84078
84119
  return `/etc/nginx/.htpasswd-${siteName}`;
84079
84120
  }
@@ -84082,7 +84123,7 @@ function isPhpSiteType(type) {
84082
84123
  return PHP_TYPES.has(type);
84083
84124
  }
84084
84125
  function defaultWebDirectory(type) {
84085
- return type === "laravel" || type === "statamic" || type === "wordpress" ? "public" : "";
84126
+ return type === "laravel" || type === "statamic" ? "public" : "";
84086
84127
  }
84087
84128
  function resolveRoot(appDir, webDirectory) {
84088
84129
  const base = appDir.replace(/\/+$/, "");
@@ -84099,13 +84140,14 @@ function vhostBody(options) {
84099
84140
  ` root ${root};`,
84100
84141
  "",
84101
84142
  ' add_header X-Frame-Options "SAMEORIGIN";',
84102
- ' add_header X-Content-Type-Options "nosniff";',
84103
- "",
84104
- ` index ${isPhp ? "index.php index.html" : "index.html index.htm"};`,
84105
- "",
84106
- " charset utf-8;",
84107
- ""
84143
+ ' add_header X-Content-Type-Options "nosniff";'
84108
84144
  ];
84145
+ const hsts = hstsHeader(options.hsts);
84146
+ if (hsts)
84147
+ lines.push(hsts);
84148
+ for (const rule of securityRules(options.security))
84149
+ lines.push(rule);
84150
+ lines.push("", ` index ${isPhp ? "index.php index.html" : "index.html index.htm"};`, "", " charset utf-8;", "");
84109
84151
  if (options.clientMaxBodySize) {
84110
84152
  lines.push(` client_max_body_size ${options.clientMaxBodySize};`, "");
84111
84153
  }
@@ -84118,7 +84160,10 @@ function vhostBody(options) {
84118
84160
  if (Object.keys(options.redirects || {}).length > 0)
84119
84161
  lines.push("");
84120
84162
  if (isPhp) {
84121
- lines.push(" location / {", " try_files $uri $uri/ /index.php?$query_string;", " }", "", " location = /favicon.ico { access_log off; log_not_found off; }", " location = /robots.txt { access_log off; log_not_found off; }", "", " error_page 404 /index.php;", "", " location ~ \\.php$ {", ` fastcgi_pass ${phpFpmSocketPath(phpVersion)};`, " fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;", " include fastcgi_params;", " }", "", " location ~ /\\.(?!well-known).* {", " deny all;", " }");
84163
+ lines.push(" location / {", " try_files $uri $uri/ /index.php?$query_string;", " }", "", " location = /favicon.ico { access_log off; log_not_found off; }", " location = /robots.txt { access_log off; log_not_found off; }", "", " error_page 404 /index.php;", "", " location ~ \\.php$ {", ` fastcgi_pass ${options.fastcgiPass ?? phpFpmSocketPath(phpVersion)};`, " fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;", " include fastcgi_params;", " }", "", " location ~ /\\.(?!well-known).* {", " deny all;", " }");
84164
+ if (type === "wordpress") {
84165
+ lines.push("", " location = /xmlrpc.php { deny all; }", " location ~* /(?:wp-config\\.php|readme\\.html|license\\.txt)$ { deny all; }", " location ~* /wp-content/uploads/.*\\.php$ { deny all; }");
84166
+ }
84122
84167
  } else if (type === "spa") {
84123
84168
  lines.push(" location / {", " try_files $uri $uri/ /index.html;", " }");
84124
84169
  } else {
@@ -84150,6 +84195,7 @@ function buildNginxVhost(options) {
84150
84195
  ` server_name ${serverNames};`,
84151
84196
  ` ssl_certificate ${options.ssl.certPath};`,
84152
84197
  ` ssl_certificate_key ${options.ssl.keyPath};`,
84198
+ ...options.tlsProtocols?.length ? [` ssl_protocols ${options.tlsProtocols.join(" ")};`] : [],
84153
84199
  "",
84154
84200
  ...body,
84155
84201
  "}"
@@ -84323,22 +84369,71 @@ function buildAutoUpdatesScript(enabled2 = true) {
84323
84369
 
84324
84370
  // src/drivers/shared/monitoring.ts
84325
84371
  var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
84326
- function buildMonitoringScript(enabled2 = true) {
84372
+ var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
84373
+ var DEFAULT_CPU_LOAD_PER_CORE = 2;
84374
+ var DEFAULT_MEM_PERCENT = 90;
84375
+ var DEFAULT_DISK_PERCENT = 90;
84376
+ var SERVICE_PROBES = [
84377
+ ["nginx", 80],
84378
+ ["phpFpm", 9074],
84379
+ ["mysql", 3306],
84380
+ ["postgres", 5432],
84381
+ ["redis", 6379],
84382
+ ["meilisearch", 7700]
84383
+ ];
84384
+ function resolveMonitoring(monitoring = true) {
84385
+ const obj = typeof monitoring === "object" ? monitoring : {};
84386
+ const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
84387
+ return {
84388
+ enabled: enabled2,
84389
+ cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
84390
+ memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
84391
+ diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
84392
+ };
84393
+ }
84394
+ function buildMonitoringScript(monitoring = true) {
84395
+ const { enabled: enabled2, cpuLoadPerCore, memPercent, diskPercent } = resolveMonitoring(monitoring);
84327
84396
  if (!enabled2)
84328
84397
  return [];
84398
+ const probeLines = SERVICE_PROBES.map(([name, port]) => `SVC_${name.toUpperCase()}=$(probe ${port})`);
84399
+ const servicesJson = SERVICE_PROBES.map(([name]) => `"${name}":"$SVC_${name.toUpperCase()}"`).join(",");
84329
84400
  return [
84330
84401
  "mkdir -p /var/lib/ts-cloud",
84331
84402
  "cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
84332
84403
  "#!/bin/bash",
84333
- "set -euo pipefail",
84404
+ "set -uo pipefail",
84334
84405
  "LOAD=$(cut -d' ' -f1 /proc/loadavg)",
84406
+ "CPUS=$(nproc)",
84335
84407
  "MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
84336
84408
  "MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
84409
+ "SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
84410
+ "SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
84337
84411
  `DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
84338
- "CPUS=$(nproc)",
84339
- "cat > " + METRICS_PATH + " <<JSON",
84340
- '{"load":$LOAD,"cpus":$CPUS,"memTotalMb":$MEM_TOTAL,"memUsedMb":$MEM_USED,"diskUsedPct":$DISK_PCT}',
84412
+ "UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
84413
+ `RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
84414
+ `TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
84415
+ "probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
84416
+ ...probeLines,
84417
+ "LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
84418
+ "SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
84419
+ "UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
84420
+ "MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
84421
+ `cat > ${METRICS_PATH}.tmp <<JSON`,
84422
+ '{"load":$LOAD,"cpus":$CPUS,"memTotalMb":$MEM_TOTAL,"memUsedMb":$MEM_USED,"memUsedPct":$MEM_PCT,"swapTotalMb":$SWAP_TOTAL,"swapUsedMb":$SWAP_USED,"diskUsedPct":$DISK_PCT,"uptimeSec":$UPTIME_SEC,"network":{"rxBytes":$RX_BYTES,"txBytes":$TX_BYTES},"services":{' + servicesJson + "}}",
84341
84423
  "JSON",
84424
+ `mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
84425
+ 'ALERTS=""',
84426
+ `if awk -v l="$LOAD" -v c="$CPUS" -v t=${cpuLoadPerCore} 'BEGIN{exit !(c>0 && l/c > t)}'; then ALERTS="$ALERTS load=$LOAD/${cpuLoadPerCore}xCPU"; fi`,
84427
+ `if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
84428
+ `if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
84429
+ `PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
84430
+ 'if [ -n "$ALERTS" ]; then',
84431
+ ' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
84432
+ ` echo alert > ${ALERT_STATE_PATH}`,
84433
+ "else",
84434
+ ' if [ "$PREV" = alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "✅ $(hostname): resource usage back to normal" || true; fi',
84435
+ ` echo ok > ${ALERT_STATE_PATH}`,
84436
+ "fi",
84342
84437
  "TS_CLOUD_METRICS_EOF",
84343
84438
  "chmod +x /usr/local/bin/ts-cloud-metrics.sh",
84344
84439
  "cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
@@ -85379,6 +85474,10 @@ function buildRpxProvisionScript(options) {
85379
85474
  const version2 = proxy.version ?? "latest";
85380
85475
  const certsDir = config6.productionCerts.certsDir;
85381
85476
  const launcher = renderRpxLauncher(config6);
85477
+ const upstreamTimeout = proxy.upstreamTimeout ?? 60;
85478
+ const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
85479
+ if (typeof proxy.maxUpstreamConns === "number")
85480
+ poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
85382
85481
  return [
85383
85482
  "set -euo pipefail",
85384
85483
  `mkdir -p ${RPX_DIR} ${certsDir}`,
@@ -85394,6 +85493,7 @@ function buildRpxProvisionScript(options) {
85394
85493
  "Type=simple",
85395
85494
  `ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
85396
85495
  `Environment=BUN_INSTALL=/root/.bun`,
85496
+ ...poolEnv,
85397
85497
  "Restart=always",
85398
85498
  "RestartSec=5",
85399
85499
  "LimitNOFILE=1048576",
@@ -86433,12 +86533,12 @@ function defaultDeployScriptFor(type) {
86433
86533
  MACRO_RESTART_QUEUES
86434
86534
  ];
86435
86535
  case "php":
86536
+ case "wordpress":
86436
86537
  return [
86437
86538
  MACRO_CREATE_RELEASE,
86438
86539
  `${COMPOSER_INSTALL} || true`,
86439
86540
  MACRO_ACTIVATE_RELEASE
86440
86541
  ];
86441
- case "wordpress":
86442
86542
  case "static":
86443
86543
  case "spa":
86444
86544
  return [
@@ -86474,6 +86574,19 @@ function buildCredentialFiles(releaseDir, creds) {
86474
86574
  out.push(...writeFileHeredoc2(`${releaseDir}/.npmrc`, creds.npmrc, "TS_CLOUD_NPMRC_EOF"));
86475
86575
  return out;
86476
86576
  }
86577
+ function buildHealthCheckScript(site) {
86578
+ const path = site.healthCheck?.path;
86579
+ if (!path || !site.domain)
86580
+ return [];
86581
+ const p = path.startsWith("/") ? path : `/${path}`;
86582
+ const url = `http://127.0.0.1${p}`;
86583
+ return [
86584
+ `echo "[ts-cloud] health check ${url} (Host: ${site.domain})"`,
86585
+ "TS_CLOUD_HC_OK=0",
86586
+ `for i in $(seq 1 10); do TS_CLOUD_HC=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: ${site.domain}" -m 10 "${url}" 2>/dev/null || echo 000); case "$TS_CLOUD_HC" in 2*|3*) TS_CLOUD_HC_OK=1; break ;; esac; sleep 3; done`,
86587
+ '[ "$TS_CLOUD_HC_OK" = 1 ] && echo "health check passed ($TS_CLOUD_HC)" || { echo "health check FAILED (last=$TS_CLOUD_HC)" >&2; exit 1; }'
86588
+ ];
86589
+ }
86477
86590
  function buildLaravelDeployScript(options) {
86478
86591
  const { siteName, site, releaseId, commit } = options;
86479
86592
  if (!site.repository?.url)
@@ -86655,6 +86768,68 @@ function siteHasServices(site) {
86655
86768
  return !!(site.queues?.length || site.daemons?.length || site.scheduler);
86656
86769
  }
86657
86770
 
86771
+ // src/drivers/shared/php-fpm-pool.ts
86772
+ var PHP_FPM_POOL_DIR = "/var/lib/pantry/php-fpm/pool.d";
86773
+ var POOL_PORT_BASE = 9100;
86774
+ var POOL_PORT_SPAN = 400;
86775
+ function siteToken(siteName) {
86776
+ const base = siteName.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
86777
+ return (base || "site").slice(0, 28);
86778
+ }
86779
+ function siteUser(siteName) {
86780
+ return `web_${siteToken(siteName)}`;
86781
+ }
86782
+ function phpFpmPoolPort(siteName) {
86783
+ const token = siteToken(siteName);
86784
+ let h = 0;
86785
+ for (let i = 0;i < token.length; i++)
86786
+ h = h * 31 + token.charCodeAt(i) >>> 0;
86787
+ return POOL_PORT_BASE + h % POOL_PORT_SPAN;
86788
+ }
86789
+ function phpFpmPoolListen(siteName) {
86790
+ return `127.0.0.1:${phpFpmPoolPort(siteName)}`;
86791
+ }
86792
+ function buildPhpFpmPoolConf(options) {
86793
+ const pool = siteToken(options.siteName);
86794
+ const user = siteUser(options.siteName);
86795
+ const maxChildren = options.maxChildren ?? 10;
86796
+ return [
86797
+ `; ts-cloud-managed pool for ${options.siteName} (site isolation)`,
86798
+ `[${pool}]`,
86799
+ `user = ${user}`,
86800
+ `group = ${user}`,
86801
+ `listen = ${phpFpmPoolListen(options.siteName)}`,
86802
+ "pm = dynamic",
86803
+ `pm.max_children = ${maxChildren}`,
86804
+ "pm.start_servers = 2",
86805
+ "pm.min_spare_servers = 1",
86806
+ "pm.max_spare_servers = 3",
86807
+ "pm.max_requests = 500",
86808
+ "catch_workers_output = yes",
86809
+ `php_admin_value[open_basedir] = ${options.appBase}:/tmp`,
86810
+ ""
86811
+ ].join(`
86812
+ `);
86813
+ }
86814
+ function buildPhpFpmPoolScript(options) {
86815
+ const user = siteUser(options.siteName);
86816
+ const pool = siteToken(options.siteName);
86817
+ const conf = `${PHP_FPM_POOL_DIR}/${pool}.conf`;
86818
+ return [
86819
+ `getent group ${user} >/dev/null || groupadd --system ${user}`,
86820
+ `id -u ${user} >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin -g ${user} ${user}`,
86821
+ `usermod -aG ${user} www-data 2>/dev/null || true`,
86822
+ `chown -R ${user}:${user} ${options.appBase} || true`,
86823
+ `chmod -R g+rX ${options.appBase} || true`,
86824
+ `chmod 600 ${options.appBase}/shared/.env 2>/dev/null || true`,
86825
+ `mkdir -p ${PHP_FPM_POOL_DIR}`,
86826
+ `cat > ${conf} <<'TS_CLOUD_FPMPOOL_EOF'`,
86827
+ buildPhpFpmPoolConf(options).replace(/\n$/, ""),
86828
+ "TS_CLOUD_FPMPOOL_EOF",
86829
+ `(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`
86830
+ ];
86831
+ }
86832
+
86658
86833
  // src/drivers/shared/compute-deploy.ts
86659
86834
  var noopLogger = {
86660
86835
  info: () => {},
@@ -86702,6 +86877,7 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86702
86877
  const useNginx = compute2?.webServer !== "rpx";
86703
86878
  const sslProvider = resolveSslProvider(site);
86704
86879
  const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
86880
+ const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase }) : [];
86705
86881
  const vhostScript = useNginx ? buildNginxVhostScript({
86706
86882
  siteName,
86707
86883
  domain: site.domain || siteName,
@@ -86710,18 +86886,23 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86710
86886
  appDir: `${appBase}/current`,
86711
86887
  webDirectory: site.webDirectory,
86712
86888
  phpVersion,
86889
+ fastcgiPass: site.isolation ? phpFpmPoolListen(siteName) : undefined,
86713
86890
  redirects: site.redirects,
86714
86891
  ssl: customCert,
86715
86892
  auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86716
86893
  serverSnippet: resolveNginxSnippet(site.nginx, compute2?.nginxTemplates),
86717
- clientMaxBodySize: site.nginx?.clientMaxBodySize
86894
+ clientMaxBodySize: site.nginx?.clientMaxBodySize,
86895
+ hsts: site.ssl?.hsts,
86896
+ tlsProtocols: site.ssl?.tlsProtocols,
86897
+ security: site.security
86718
86898
  }) : [];
86719
86899
  const sslScript = useNginx ? buildSslScript(site) : [];
86720
86900
  const servicesScript = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, phpVersion, appBase }) : [];
86901
+ const healthCheckScript = useNginx ? buildHealthCheckScript(site) : [];
86721
86902
  logger4.step(`Deploying PHP site '${siteName}' to ${targets.length} target(s)...`);
86722
86903
  const phpResult = await driver.runRemoteDeploy({
86723
86904
  targets,
86724
- commands: [...deployScript, ...vhostScript, ...sslScript, ...servicesScript],
86905
+ commands: [...deployScript, ...poolScript, ...vhostScript, ...sslScript, ...servicesScript, ...healthCheckScript],
86725
86906
  comment: `ts-cloud deploy ${slug}/${siteName}@${sha}`,
86726
86907
  tags: { Project: slug, Environment: environment, Role: "app" }
86727
86908
  });
@@ -86779,7 +86960,10 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86779
86960
  redirects: site.redirects,
86780
86961
  auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86781
86962
  serverSnippet: resolveNginxSnippet(site.nginx, compute?.nginxTemplates),
86782
- clientMaxBodySize: site.nginx?.clientMaxBodySize
86963
+ clientMaxBodySize: site.nginx?.clientMaxBodySize,
86964
+ hsts: site.ssl?.hsts,
86965
+ tlsProtocols: site.ssl?.tlsProtocols,
86966
+ security: site.security
86783
86967
  }) : [];
86784
86968
  const staticSsl = wantsNginxStatic ? buildSslScript(site) : [];
86785
86969
  const remoteScript = [...baseScript, ...staticVhost, ...staticSsl];
@@ -67,7 +67,7 @@ export declare class PreDeployScanner {
67
67
  */
68
68
  private isLikelyPlaceholder;
69
69
  /**
70
- * Heuristic for "this match has way too little entropy to be a real secret."
70
+ * Heuristic for `this match has way too little entropy to be a real secret`.
71
71
  * Real cryptographic keys spread their characters across the alphabet; ASCII
72
72
  * art dividers, padding placeholders, and runs of repeated chars do not.
73
73
  *