@stacksjs/ts-cloud 0.5.0 → 0.5.2

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
@@ -25946,15 +25946,21 @@ function composeServerlessAppTemplate(opts) {
25946
25946
  TSCLOUD_LAMBDA_MODE: mode,
25947
25947
  TSCLOUD_ENV: environment,
25948
25948
  ...app.octane ? { TSCLOUD_OCTANE: "1" } : {},
25949
+ ...app.scheduler === "sub-minute" ? { TSCLOUD_SCHEDULER: "sub-minute" } : {},
25949
25950
  ...cacheEnabled ? { TSCLOUD_CACHE_TABLE: `${slug}-${environment}-cache` } : {},
25950
25951
  ...hasQueue ? { TSCLOUD_QUEUE: queueNames[0] } : {},
25951
25952
  ...app.env ?? {}
25952
25953
  });
25954
+ const efsEnabled = Boolean(app.efs);
25955
+ const efsOpts = typeof app.efs === "object" ? app.efs : {};
25956
+ const efsMountPath = efsOpts.mountPath ?? "/mnt/local";
25957
+ const efsProvision = efsEnabled && !efsOpts.accessPointArn;
25958
+ const efsAccessPoint = efsOpts.accessPointArn ?? (efsProvision ? Fn2.getAtt("EfsAccessPoint", "Arn") : undefined);
25953
25959
  const subnets = app.vpc?.subnets ?? [];
25954
25960
  const hasVpc = subnets.length > 0;
25955
- const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy);
25961
+ const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy) || efsEnabled;
25956
25962
  if (needsDataVpc && !hasVpc) {
25957
- throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy require app.vpc.subnets (private subnets) to be set.");
25963
+ throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy / efs require app.vpc.subnets (private subnets) to be set.");
25958
25964
  }
25959
25965
  const vpcConfig = hasVpc ? {
25960
25966
  VpcConfig: {
@@ -25965,7 +25971,16 @@ function composeServerlessAppTemplate(opts) {
25965
25971
  ]
25966
25972
  }
25967
25973
  } : {};
25968
- function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency) {
25974
+ const efsDependsOn = efsProvision ? subnets.map((_, i) => `EfsMountTarget${i}`) : [];
25975
+ const efsConfig = efsEnabled ? { FileSystemConfigs: [{ Arn: efsAccessPoint, LocalMountPath: efsMountPath }] } : {};
25976
+ if (efsEnabled) {
25977
+ inlinePolicies[0].PolicyDocument.Statement.push({
25978
+ Effect: "Allow",
25979
+ Action: ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite", "elasticfilesystem:ClientRootAccess", "elasticfilesystem:DescribeMountTargets"],
25980
+ Resource: "*"
25981
+ });
25982
+ }
25983
+ function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency, tmp = tmpStorage) {
25969
25984
  resources[`${logicalId}LogGroup`] = {
25970
25985
  Type: "AWS::Logs::LogGroup",
25971
25986
  Properties: { LogGroupName: `/aws/lambda/${name}`, RetentionInDays: 14 }
@@ -25982,7 +25997,7 @@ function composeServerlessAppTemplate(opts) {
25982
25997
  };
25983
25998
  resources[logicalId] = {
25984
25999
  Type: "AWS::Lambda::Function",
25985
- DependsOn: [`${logicalId}LogGroup`],
26000
+ DependsOn: [`${logicalId}LogGroup`, ...efsDependsOn],
25986
26001
  Properties: {
25987
26002
  FunctionName: name,
25988
26003
  Architectures: [architecture],
@@ -25990,17 +26005,18 @@ function composeServerlessAppTemplate(opts) {
25990
26005
  Timeout: timeout,
25991
26006
  Role: Fn2.getAtt("AppRole", "Arn"),
25992
26007
  Environment: { Variables: baseEnv(mode) },
25993
- EphemeralStorage: { Size: tmpStorage },
26008
+ EphemeralStorage: { Size: tmp },
25994
26009
  ...codeProps,
25995
26010
  ...reservedConcurrency !== undefined ? { ReservedConcurrentExecutions: reservedConcurrency } : {},
25996
- ...vpcConfig
26011
+ ...vpcConfig,
26012
+ ...efsConfig
25997
26013
  }
25998
26014
  };
25999
26015
  }
26000
- addFunction("HttpFunction", functionNames.http, handlers.http, "http", app.memory ?? 1024, app.timeout ?? 28, app.concurrency);
26001
- addFunction("CliFunction", functionNames.cli, handlers.cli, "cli", app.cliMemory ?? 1024, app.cliTimeout ?? 900);
26016
+ addFunction("HttpFunction", functionNames.http, handlers.http, "http", app.memory ?? 1024, app.timeout ?? 28, app.concurrency, tmpStorage);
26017
+ addFunction("CliFunction", functionNames.cli, handlers.cli, "cli", app.cliMemory ?? 1024, app.cliTimeout ?? 900, undefined, app.cliTmpStorage ?? tmpStorage);
26002
26018
  if (hasQueue)
26003
- addFunction("QueueFunction", functionNames.queue, handlers.queue, "queue", app.queueMemory ?? 1024, app.queueTimeout ?? 120);
26019
+ addFunction("QueueFunction", functionNames.queue, handlers.queue, "queue", app.queueMemory ?? 1024, app.queueTimeout ?? 120, undefined, app.queueTmpStorage ?? tmpStorage);
26004
26020
  resources.HttpApi = {
26005
26021
  Type: "AWS::ApiGatewayV2::Api",
26006
26022
  Properties: {
@@ -26047,6 +26063,59 @@ function composeServerlessAppTemplate(opts) {
26047
26063
  Value: Fn2.getAtt("HttpApi", "ApiEndpoint")
26048
26064
  };
26049
26065
  outputs.HttpApiId = { Description: "HTTP API id", Value: Fn2.ref("HttpApi") };
26066
+ const domains = (Array.isArray(app.domain) ? app.domain : app.domain ? [app.domain] : []).filter(Boolean);
26067
+ if (domains.length) {
26068
+ if (!app.certificateArn && !app.hostedZoneId) {
26069
+ throw new Error("serverless app: a custom `domain` needs either `certificateArn` (pre-issued, regional) or `hostedZoneId` (to auto-issue + validate an ACM cert).");
26070
+ }
26071
+ let certRef = app.certificateArn;
26072
+ if (!certRef) {
26073
+ resources.HttpCertificate = {
26074
+ Type: "AWS::CertificateManager::Certificate",
26075
+ Properties: {
26076
+ DomainName: domains[0],
26077
+ ...domains.length > 1 ? { SubjectAlternativeNames: domains.slice(1) } : {},
26078
+ ValidationMethod: "DNS",
26079
+ DomainValidationOptions: domains.map((d) => ({ DomainName: d, HostedZoneId: app.hostedZoneId }))
26080
+ }
26081
+ };
26082
+ certRef = Fn2.ref("HttpCertificate");
26083
+ }
26084
+ domains.forEach((d, i) => {
26085
+ const dn = `HttpDomain${i}`;
26086
+ resources[dn] = {
26087
+ Type: "AWS::ApiGatewayV2::DomainName",
26088
+ Properties: {
26089
+ DomainName: d,
26090
+ DomainNameConfigurations: [{ CertificateArn: certRef, EndpointType: "REGIONAL" }]
26091
+ }
26092
+ };
26093
+ resources[`HttpApiMapping${i}`] = {
26094
+ Type: "AWS::ApiGatewayV2::ApiMapping",
26095
+ DependsOn: ["HttpStage"],
26096
+ Properties: { ApiId: Fn2.ref("HttpApi"), DomainName: Fn2.ref(dn), Stage: "$default" }
26097
+ };
26098
+ if (app.hostedZoneId) {
26099
+ resources[`HttpDomainRecord${i}`] = {
26100
+ Type: "AWS::Route53::RecordSet",
26101
+ Properties: {
26102
+ HostedZoneId: app.hostedZoneId,
26103
+ Name: d,
26104
+ Type: "A",
26105
+ AliasTarget: {
26106
+ DNSName: Fn2.getAtt(dn, "RegionalDomainName"),
26107
+ HostedZoneId: Fn2.getAtt(dn, "RegionalHostedZoneId")
26108
+ }
26109
+ }
26110
+ };
26111
+ }
26112
+ outputs[`CustomDomain${i}`] = { Description: `Custom domain ${d}`, Value: d };
26113
+ outputs[`CustomDomainTarget${i}`] = {
26114
+ Description: `Point ${d} (CNAME/alias) at this APIGW regional domain`,
26115
+ Value: Fn2.getAtt(dn, "RegionalDomainName")
26116
+ };
26117
+ });
26118
+ }
26050
26119
  if (hasQueue) {
26051
26120
  resources.AppQueueDlq = {
26052
26121
  Type: "AWS::SQS::Queue",
@@ -26189,10 +26258,37 @@ function composeServerlessAppTemplate(opts) {
26189
26258
  DomainName: Fn2.getAtt("AssetsBucket", "RegionalDomainName"),
26190
26259
  OriginAccessControlId: Fn2.ref("AssetsOAC"),
26191
26260
  S3OriginConfig: { OriginAccessIdentity: "" }
26192
- }]
26261
+ }],
26262
+ ...app.assetDomain ? {
26263
+ Aliases: [app.assetDomain],
26264
+ ViewerCertificate: {
26265
+ AcmCertificateArn: app.assetCertificateArn,
26266
+ SslSupportMethod: "sni-only",
26267
+ MinimumProtocolVersion: "TLSv1.2_2021"
26268
+ }
26269
+ } : {}
26193
26270
  }
26194
26271
  }
26195
26272
  };
26273
+ if (app.assetDomain) {
26274
+ if (!app.assetCertificateArn)
26275
+ throw new Error("serverless app: `assetDomain` requires `assetCertificateArn` (a us-east-1 ACM cert — CloudFront only accepts certs from us-east-1).");
26276
+ if (app.hostedZoneId) {
26277
+ resources.AssetsDomainRecord = {
26278
+ Type: "AWS::Route53::RecordSet",
26279
+ Properties: {
26280
+ HostedZoneId: app.hostedZoneId,
26281
+ Name: app.assetDomain,
26282
+ Type: "A",
26283
+ AliasTarget: {
26284
+ DNSName: Fn2.getAtt("AssetsDistribution", "DomainName"),
26285
+ HostedZoneId: "Z2FDTNDATAQYW2"
26286
+ }
26287
+ }
26288
+ };
26289
+ }
26290
+ outputs.AssetDomain = { Description: "Custom asset CDN host", Value: app.assetDomain };
26291
+ }
26196
26292
  resources.AssetsBucketPolicy = {
26197
26293
  Type: "AWS::S3::BucketPolicy",
26198
26294
  Properties: {
@@ -26271,6 +26367,37 @@ function composeServerlessAppTemplate(opts) {
26271
26367
  }
26272
26368
  };
26273
26369
  }
26370
+ if (efsProvision) {
26371
+ resources.EfsFileSystem = {
26372
+ Type: "AWS::EFS::FileSystem",
26373
+ Properties: {
26374
+ Encrypted: true,
26375
+ FileSystemTags: [{ Key: "Name", Value: `${slug}-${environment}-efs` }]
26376
+ }
26377
+ };
26378
+ subnets.forEach((subnetId, i) => {
26379
+ resources[`EfsMountTarget${i}`] = {
26380
+ Type: "AWS::EFS::MountTarget",
26381
+ Properties: {
26382
+ FileSystemId: Fn2.ref("EfsFileSystem"),
26383
+ SubnetId: subnetId,
26384
+ SecurityGroups: [Fn2.getAtt("DataSecurityGroup", "GroupId")]
26385
+ }
26386
+ };
26387
+ });
26388
+ resources.EfsAccessPoint = {
26389
+ Type: "AWS::EFS::AccessPoint",
26390
+ Properties: {
26391
+ FileSystemId: Fn2.ref("EfsFileSystem"),
26392
+ PosixUser: { Uid: 1001, Gid: 1001 },
26393
+ RootDirectory: {
26394
+ Path: "/lambda",
26395
+ CreationInfo: { OwnerUid: 1001, OwnerGid: 1001, Permissions: "0755" }
26396
+ }
26397
+ }
26398
+ };
26399
+ outputs.EfsFileSystemId = { Description: "EFS file system id", Value: Fn2.ref("EfsFileSystem") };
26400
+ }
26274
26401
  if (app.cache?.driver === "elasticache") {
26275
26402
  resources.CacheSubnetGroup = {
26276
26403
  Type: "AWS::ElastiCache::SubnetGroup",
@@ -83078,6 +83205,8 @@ function buildFunctionEnv(app, ctx, environment, mode, secrets, assetUrl, queueN
83078
83205
  TSCLOUD_ENV: environment,
83079
83206
  MAINTENANCE_MODE: "0",
83080
83207
  ...app.octane ? { TSCLOUD_OCTANE: "1" } : {},
83208
+ ...app.serveAssets ? { TSCLOUD_SERVE_ASSETS: "1" } : {},
83209
+ ...app.redirectRobotsTxt === false ? { TSCLOUD_REDIRECT_ROBOTS_TXT: "0" } : {},
83081
83210
  ...laravelDefaults,
83082
83211
  ...infraEnv,
83083
83212
  ...ctx.app.cache?.driver !== "elasticache" ? { TSCLOUD_CACHE_TABLE: `${ctx.slug}-${environment}-cache` } : {},
@@ -83118,13 +83247,15 @@ function* walk3(dir) {
83118
83247
  yield full;
83119
83248
  }
83120
83249
  }
83121
- async function uploadAssets(s32, bucket, dir, prefix) {
83250
+ async function uploadAssets(s32, bucket, dir, prefix, includeDotfiles = false) {
83122
83251
  let count = 0;
83123
83252
  for (const file of walk3(dir)) {
83124
- const key = `${prefix}/${relative6(dir, file).replace(/\\/g, "/")}`;
83253
+ const rel = relative6(dir, file).replace(/\\/g, "/");
83254
+ if (!includeDotfiles && rel.split("/").some((seg) => seg.startsWith(".")))
83255
+ continue;
83125
83256
  await s32.putObject({
83126
83257
  bucket,
83127
- key,
83258
+ key: `${prefix}/${rel}`,
83128
83259
  body: readFileSync13(file),
83129
83260
  contentType: contentType(file),
83130
83261
  cacheControl: "public, max-age=31536000, immutable"
@@ -83230,10 +83361,10 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83230
83361
  if (app.assets) {
83231
83362
  const assetsDir3 = join16(projectRoot, app.assets);
83232
83363
  if (existsSync17(assetsDir3)) {
83233
- const cdn = outputs.AssetsCdnDomain;
83364
+ const cdn = app.assetDomain || outputs.AssetsCdnDomain;
83234
83365
  assetUrl = cdn ? `https://${cdn}/${artifactSha}` : undefined;
83235
83366
  step(`Syncing assets from ${app.assets}`);
83236
- const n = await uploadAssets(s32, ctx.assetsBucket, assetsDir3, artifactSha);
83367
+ const n = await uploadAssets(s32, ctx.assetsBucket, assetsDir3, artifactSha, app.dotFilesAsAssets);
83237
83368
  info(`Uploaded ${n} asset(s)${assetUrl ? ` → ${assetUrl}` : ""}`);
83238
83369
  } else {
83239
83370
  warn(`Assets directory not found: ${assetsDir3}`);
@@ -83487,31 +83618,63 @@ function buildDatabaseSetupScript(database, services = {}) {
83487
83618
  if (usePostgres && !useMysql && !useMariadb) {
83488
83619
  const pgIdent = (v) => `"${v.replace(/"/g, '""')}"`;
83489
83620
  const pgLit = (v) => `'${v.replace(/'/g, "''")}'`;
83621
+ const pgEnsureRole = (u, p) => [
83622
+ "DO $$ BEGIN",
83623
+ ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(u)}) THEN`,
83624
+ ` CREATE ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
83625
+ " ELSE",
83626
+ ` ALTER ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
83627
+ " END IF;",
83628
+ "END $$;"
83629
+ ];
83630
+ const pgGrant = (u) => {
83631
+ const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
83632
+ const lines = [];
83633
+ for (const db of dbs) {
83634
+ if (u.access === "readonly") {
83635
+ lines.push(`GRANT CONNECT ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`, `\\connect ${pgIdent(db)}`, `GRANT USAGE ON SCHEMA public TO ${pgIdent(u.username)};`, `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${pgIdent(u.username)};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
83636
+ } else {
83637
+ lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
83638
+ }
83639
+ }
83640
+ return lines;
83641
+ };
83642
+ const extraUsers2 = database.users || [];
83490
83643
  return [
83491
83644
  pantryEnvActivation(),
83492
83645
  "for i in $(seq 1 30); do pg_isready -h 127.0.0.1 -p 5432 -q && break; sleep 2; done",
83493
83646
  "psql -h 127.0.0.1 -p 5432 -U postgres <<'TS_CLOUD_PG_EOF'",
83494
- "DO $$ BEGIN",
83495
- ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(user)}) THEN`,
83496
- ` CREATE ROLE ${pgIdent(user)} LOGIN PASSWORD ${pgLit(pass)};`,
83497
- " END IF;",
83498
- "END $$;",
83647
+ ...pgEnsureRole(user, pass),
83499
83648
  `SELECT 'CREATE DATABASE ${pgIdent(name)} OWNER ${pgIdent(user)}' ` + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`,
83649
+ ...extraUsers2.flatMap((u) => [...pgEnsureRole(u.username, u.password), ...pgGrant(u)]),
83500
83650
  "TS_CLOUD_PG_EOF"
83501
83651
  ];
83502
83652
  }
83503
83653
  const sock = useMariadb ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
83504
83654
  const ident = (v) => v.replace(/`/g, "``");
83505
83655
  const lit = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83656
+ const mysqlUser = (u) => {
83657
+ const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
83658
+ const priv = u.access === "readonly" ? "SELECT" : "ALL PRIVILEGES";
83659
+ const lines = [
83660
+ `CREATE USER IF NOT EXISTS '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
83661
+ `CREATE USER IF NOT EXISTS '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`,
83662
+ `ALTER USER '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
83663
+ `ALTER USER '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`
83664
+ ];
83665
+ for (const db of dbs) {
83666
+ lines.push(`GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'%';`, `GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'localhost';`);
83667
+ }
83668
+ return lines;
83669
+ };
83670
+ const extraUsers = database.users || [];
83506
83671
  return [
83507
83672
  pantryEnvActivation(),
83508
83673
  `for i in $(seq 1 30); do mysqladmin --socket=${sock} -u root ping 2>/dev/null | grep -q alive && break; sleep 2; done`,
83509
83674
  `mysql --socket=${sock} -u root <<'TS_CLOUD_SQL_EOF'`,
83510
83675
  `CREATE DATABASE IF NOT EXISTS \`${ident(name)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
83511
- `CREATE USER IF NOT EXISTS '${lit(user)}'@'%' IDENTIFIED BY '${lit(pass)}';`,
83512
- `CREATE USER IF NOT EXISTS '${lit(user)}'@'localhost' IDENTIFIED BY '${lit(pass)}';`,
83513
- `GRANT ALL PRIVILEGES ON \`${ident(name)}\`.* TO '${lit(user)}'@'%';`,
83514
- `GRANT ALL PRIVILEGES ON \`${ident(name)}\`.* TO '${lit(user)}'@'localhost';`,
83676
+ ...mysqlUser({ username: user, password: pass }),
83677
+ ...extraUsers.flatMap(mysqlUser),
83515
83678
  "FLUSH PRIVILEGES;",
83516
83679
  "TS_CLOUD_SQL_EOF"
83517
83680
  ];
@@ -83535,6 +83698,19 @@ function buildManagedDbEnv(database) {
83535
83698
  }
83536
83699
 
83537
83700
  // src/drivers/shared/php-provision.ts
83701
+ var PRODUCTION_PHP_INI = {
83702
+ "opcache.enable": "1",
83703
+ "opcache.enable_cli": "1",
83704
+ "opcache.memory_consumption": "256",
83705
+ "opcache.interned_strings_buffer": "16",
83706
+ "opcache.max_accelerated_files": "20000",
83707
+ "opcache.validate_timestamps": "0",
83708
+ "opcache.revalidate_freq": "0",
83709
+ "opcache.save_comments": "1",
83710
+ "opcache.fast_shutdown": "1",
83711
+ realpath_cache_size: "4096K",
83712
+ realpath_cache_ttl: "600"
83713
+ };
83538
83714
  var PHP_FPM_LISTEN = "127.0.0.1:9074";
83539
83715
  function phpFpmSocketPath(_version) {
83540
83716
  return PHP_FPM_LISTEN;
@@ -83543,6 +83719,45 @@ function resolveDefaultPhpVersion(options = {}) {
83543
83719
  const versions = options.versions?.length ? options.versions : ["8.3"];
83544
83720
  return options.default && versions.includes(options.default) ? options.default : versions[0];
83545
83721
  }
83722
+ function resolvePhpIni(options = {}) {
83723
+ const base = options.optimizeForProduction === false ? {} : { ...PRODUCTION_PHP_INI };
83724
+ return { ...base, ...options.ini || {} };
83725
+ }
83726
+ function buildPhpTuningScript(options = {}) {
83727
+ const ini = resolvePhpIni(options);
83728
+ const keys = Object.keys(ini);
83729
+ if (keys.length === 0)
83730
+ return [];
83731
+ const body = keys.map((k) => `${k}=${ini[k]}`);
83732
+ const marker = "ts-cloud-managed";
83733
+ return [
83734
+ pantryEnvActivation(),
83735
+ "TS_CLOUD_SCAN_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Scan this dir for additional .ini files/{print $2}' | head -1)",
83736
+ `TS_CLOUD_LOADED_INI=$(php -r 'echo php_ini_loaded_file() ?: "";' 2>/dev/null)`,
83737
+ 'if [ -n "$TS_CLOUD_SCAN_DIR" ] && [ "$TS_CLOUD_SCAN_DIR" != "(none)" ]; then',
83738
+ ' mkdir -p "$TS_CLOUD_SCAN_DIR"',
83739
+ ' TS_CLOUD_PHP_INI="$TS_CLOUD_SCAN_DIR/zz-ts-cloud.ini"',
83740
+ ` cat > "$TS_CLOUD_PHP_INI" <<'TS_CLOUD_PHPINI_EOF'`,
83741
+ `; ${marker} — production PHP tuning`,
83742
+ ...body,
83743
+ "TS_CLOUD_PHPINI_EOF",
83744
+ "else",
83745
+ ' if [ -z "$TS_CLOUD_LOADED_INI" ]; then',
83746
+ " TS_CLOUD_INI_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Configuration File \\(php.ini\\) Path/{print $2}' | head -1)",
83747
+ ' TS_CLOUD_LOADED_INI="$TS_CLOUD_INI_DIR/php.ini"',
83748
+ ' mkdir -p "$TS_CLOUD_INI_DIR"',
83749
+ ' touch "$TS_CLOUD_LOADED_INI"',
83750
+ " fi",
83751
+ ` sed -i '/; ${marker} BEGIN/,/; ${marker} END/d' "$TS_CLOUD_LOADED_INI"`,
83752
+ ` cat >> "$TS_CLOUD_LOADED_INI" <<'TS_CLOUD_PHPINI_EOF'`,
83753
+ `; ${marker} BEGIN`,
83754
+ ...body,
83755
+ `; ${marker} END`,
83756
+ "TS_CLOUD_PHPINI_EOF",
83757
+ "fi",
83758
+ `(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`
83759
+ ];
83760
+ }
83546
83761
  function buildPhpProvisionScript(options = {}) {
83547
83762
  const defaultVersion = resolveDefaultPhpVersion(options);
83548
83763
  const installNginx = options.installNginx !== false;
@@ -83554,11 +83769,22 @@ function buildPhpProvisionScript(options = {}) {
83554
83769
  specs.push("nginx.org");
83555
83770
  return [
83556
83771
  ...buildPantryInstallScript(specs),
83557
- ...buildPantryServiceScript(["php-fpm"])
83772
+ ...buildPantryServiceScript(["php-fpm"]),
83773
+ ...buildPhpTuningScript(options)
83558
83774
  ];
83559
83775
  }
83560
83776
 
83561
83777
  // src/drivers/shared/nginx-vhost.ts
83778
+ function resolveNginxSnippet(nginx, templates) {
83779
+ if (!nginx)
83780
+ return [];
83781
+ const out = [];
83782
+ if (nginx.template && templates?.[nginx.template])
83783
+ out.push(...templates[nginx.template]);
83784
+ if (nginx.serverSnippet)
83785
+ out.push(...nginx.serverSnippet);
83786
+ return out;
83787
+ }
83562
83788
  function htpasswdPath(siteName) {
83563
83789
  return `/etc/nginx/.htpasswd-${siteName}`;
83564
83790
  }
@@ -83591,6 +83817,9 @@ function vhostBody(options) {
83591
83817
  " charset utf-8;",
83592
83818
  ""
83593
83819
  ];
83820
+ if (options.clientMaxBodySize) {
83821
+ lines.push(` client_max_body_size ${options.clientMaxBodySize};`, "");
83822
+ }
83594
83823
  if (options.auth) {
83595
83824
  lines.push(` auth_basic "${options.auth.realm || "Restricted"}";`, ` auth_basic_user_file ${htpasswdPath(options.siteName)};`, "");
83596
83825
  }
@@ -83606,6 +83835,11 @@ function vhostBody(options) {
83606
83835
  } else {
83607
83836
  lines.push(" location / {", " try_files $uri $uri/ =404;", " }");
83608
83837
  }
83838
+ if (options.serverSnippet && options.serverSnippet.length > 0) {
83839
+ lines.push("");
83840
+ for (const line of options.serverSnippet)
83841
+ lines.push(line ? ` ${line}` : "");
83842
+ }
83609
83843
  return lines;
83610
83844
  }
83611
83845
  function buildNginxVhost(options) {
@@ -83730,6 +83964,12 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
83730
83964
  " fastcgi_temp_path /var/lib/nginx/fastcgi;",
83731
83965
  " uwsgi_temp_path /var/lib/nginx/uwsgi;",
83732
83966
  " scgi_temp_path /var/lib/nginx/scgi;",
83967
+ " server {",
83968
+ " listen 80 default_server;",
83969
+ " listen [::]:80 default_server;",
83970
+ " server_name _;",
83971
+ " return 444;",
83972
+ " }",
83733
83973
  " include /etc/nginx/sites-enabled/*;",
83734
83974
  "}",
83735
83975
  "TS_CLOUD_NGINXCONF_EOF",
@@ -84057,7 +84297,9 @@ function buildComputeProvisionScripts(config6) {
84057
84297
  versions: compute.php?.versions,
84058
84298
  default: compute.php?.default,
84059
84299
  extensions: compute.php?.extensions,
84060
- installNginx: useNginx
84300
+ installNginx: useNginx,
84301
+ optimizeForProduction: compute.php?.optimizeForProduction,
84302
+ ini: compute.php?.ini
84061
84303
  }),
84062
84304
  ...useNginx ? buildNginxServiceScript() : []
84063
84305
  ] : undefined;
@@ -85177,7 +85419,9 @@ class HetznerDriver {
85177
85419
  versions: compute.php?.versions,
85178
85420
  default: compute.php?.default,
85179
85421
  extensions: compute.php?.extensions,
85180
- installNginx: compute.webServer !== "rpx"
85422
+ installNginx: compute.webServer !== "rpx",
85423
+ optimizeForProduction: compute.php?.optimizeForProduction,
85424
+ ini: compute.php?.ini
85181
85425
  });
85182
85426
  const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", phpProvision: appPhp, servicesProvision: appProvision, baked }));
85183
85427
  const existingApp = all.filter((s) => matchesTsCloudLabels(s.labels, slug, environment, "app"));
@@ -85673,10 +85917,22 @@ function resolveSslProvider(site) {
85673
85917
  return site.ssl.provider;
85674
85918
  return site.domain ? "letsencrypt" : "none";
85675
85919
  }
85676
- function buildCertbotInstallScript() {
85920
+ var DNS_PLUGINS = {
85921
+ cloudflare: { pkg: "python3-certbot-dns-cloudflare", plugin: "dns-cloudflare" },
85922
+ route53: { pkg: "python3-certbot-dns-route53", plugin: "dns-route53" },
85923
+ digitalocean: { pkg: "python3-certbot-dns-digitalocean", plugin: "dns-digitalocean" },
85924
+ google: { pkg: "python3-certbot-dns-google", plugin: "dns-google" }
85925
+ };
85926
+ function dnsCredentialsPath(provider) {
85927
+ return `/etc/letsencrypt/ts-cloud-${provider}.ini`;
85928
+ }
85929
+ function buildCertbotInstallScript(dns2) {
85930
+ const plugin = dns2 ? DNS_PLUGINS[dns2.provider] : undefined;
85931
+ const pkgs = ["certbot", "python3-certbot-nginx", ...plugin ? [plugin.pkg] : []].join(" ");
85932
+ const installLine = plugin ? `apt-get update -y && apt-get install -y ${pkgs}` : `command -v certbot >/dev/null 2>&1 || { apt-get update -y && apt-get install -y ${pkgs}; }`;
85677
85933
  return [
85678
85934
  "export DEBIAN_FRONTEND=noninteractive",
85679
- "command -v certbot >/dev/null 2>&1 || { apt-get update -y && apt-get install -y certbot python3-certbot-nginx; }",
85935
+ installLine,
85680
85936
  "mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
85681
85937
  "cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
85682
85938
  "#!/bin/sh",
@@ -85687,15 +85943,34 @@ function buildCertbotInstallScript() {
85687
85943
  "systemctl start certbot.timer 2>/dev/null || true"
85688
85944
  ];
85689
85945
  }
85690
- function buildCertbotIssueScript(options) {
85691
- const domains = [options.domain, ...options.aliases || []].filter(Boolean);
85692
- const args = [
85693
- "certbot --nginx",
85694
- "--non-interactive",
85695
- "--agree-tos",
85696
- "--keep-until-expiring",
85697
- options.redirect === false ? "--no-redirect" : "--redirect"
85946
+ function buildDnsCredentialsScript(dns2) {
85947
+ if (!dns2.credentials || Object.keys(dns2.credentials).length === 0)
85948
+ return [];
85949
+ const file = dnsCredentialsPath(dns2.provider);
85950
+ const lines = Object.entries(dns2.credentials).map(([k, v]) => `${k} = ${v}`).join(`
85951
+ `);
85952
+ return [
85953
+ `cat > ${file} <<'TS_CLOUD_DNSCREDS_EOF'`,
85954
+ lines,
85955
+ "TS_CLOUD_DNSCREDS_EOF",
85956
+ `chmod 600 ${file}`
85698
85957
  ];
85958
+ }
85959
+ function buildCertbotIssueScript(options) {
85960
+ const dns2 = options.dns;
85961
+ const domains = options.wildcard ? [`*.${options.domain}`, options.domain] : [options.domain, ...options.aliases || []].filter(Boolean);
85962
+ const args = ["certbot", "--non-interactive", "--agree-tos", "--keep-until-expiring"];
85963
+ if (dns2) {
85964
+ const plugin = DNS_PLUGINS[dns2.provider].plugin;
85965
+ args.push(`--${plugin}`);
85966
+ if (dns2.provider !== "route53" && dns2.credentials)
85967
+ args.push(`--${plugin}-credentials ${dnsCredentialsPath(dns2.provider)}`);
85968
+ if (typeof dns2.propagationSeconds === "number" && dns2.provider !== "route53")
85969
+ args.push(`--${plugin}-propagation-seconds ${dns2.propagationSeconds}`);
85970
+ args.push("certonly");
85971
+ } else {
85972
+ args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
85973
+ }
85699
85974
  if (options.email)
85700
85975
  args.push(`-m ${options.email}`);
85701
85976
  else
@@ -85707,12 +85982,20 @@ function buildCertbotIssueScript(options) {
85707
85982
  function buildSslScript(site) {
85708
85983
  if (resolveSslProvider(site) !== "letsencrypt" || !site.domain)
85709
85984
  return [];
85985
+ const ssl = site.ssl;
85986
+ const dns2 = ssl?.dns;
85987
+ const wildcard = ssl?.wildcard === true;
85988
+ if (wildcard && !dns2)
85989
+ return [];
85710
85990
  return [
85711
- ...buildCertbotInstallScript(),
85991
+ ...buildCertbotInstallScript(dns2),
85992
+ ...dns2 ? buildDnsCredentialsScript(dns2) : [],
85712
85993
  ...buildCertbotIssueScript({
85713
85994
  domain: site.domain,
85714
85995
  aliases: site.aliases,
85715
- email: site.ssl?.email
85996
+ email: ssl?.email,
85997
+ wildcard,
85998
+ dns: dns2
85716
85999
  })
85717
86000
  ];
85718
86001
  }
@@ -85752,6 +86035,16 @@ function shellQuote(value) {
85752
86035
  // src/drivers/shared/releases.ts
85753
86036
  var DEFAULT_SHARED_PATHS = ["storage", ".env"];
85754
86037
  var DEFAULT_KEEP_RELEASES = 4;
86038
+ var DEFAULT_KEEP_DEPLOY_LOGS = 20;
86039
+ function deployMetaDir(base) {
86040
+ return `${base.replace(/\/+$/, "")}/.ts-cloud`;
86041
+ }
86042
+ function deployHistoryPath(base) {
86043
+ return `${deployMetaDir(base)}/deploy-history.log`;
86044
+ }
86045
+ function deployLogPath(base, releaseId) {
86046
+ return `${deployMetaDir(base)}/deploys/${releaseId}.log`;
86047
+ }
85755
86048
  function releasePaths(base, releaseId) {
85756
86049
  const root = base.replace(/\/+$/, "");
85757
86050
  return {
@@ -85805,6 +86098,26 @@ function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
85805
86098
  "done"
85806
86099
  ];
85807
86100
  }
86101
+ function buildDeployHistoryHeader(base, options) {
86102
+ const meta = deployMetaDir(base);
86103
+ const log4 = deployLogPath(base, options.releaseId);
86104
+ const history = deployHistoryPath(base);
86105
+ const keepLogs = Math.max(1, options.keepLogs ?? DEFAULT_KEEP_DEPLOY_LOGS);
86106
+ const commit = options.commit || "";
86107
+ const branch = options.branch || "";
86108
+ return [
86109
+ `mkdir -p ${meta}/deploys`,
86110
+ `exec > >(tee -a ${log4}) 2>&1`,
86111
+ `echo "[ts-cloud] deploy ${options.releaseId} commit=${commit} branch=${branch} starting $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
86112
+ "ts_cloud_record_deploy() {",
86113
+ " TS_CLOUD_RC=$?",
86114
+ ' if [ "$TS_CLOUD_RC" -eq 0 ]; then TS_CLOUD_ST=success; else TS_CLOUD_ST=failed; fi',
86115
+ ` printf '%s\\t%s\\t%s\\t%s\\trc=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${options.releaseId}" "${commit}" "$TS_CLOUD_ST" "$TS_CLOUD_RC" >> ${history}`,
86116
+ "}",
86117
+ "trap ts_cloud_record_deploy EXIT",
86118
+ `ls -1t ${meta}/deploys/*.log 2>/dev/null | tail -n +${keepLogs + 1} | while read -r TS_CLOUD_OLDLOG; do rm -f "$TS_CLOUD_OLDLOG"; done`
86119
+ ];
86120
+ }
85808
86121
 
85809
86122
  // src/drivers/shared/laravel-deploy.ts
85810
86123
  var MACRO_CREATE_RELEASE = "$CREATE_RELEASE";
@@ -85857,6 +86170,21 @@ function writeSharedEnv(sharedEnvPath, env2) {
85857
86170
  `chmod 600 ${sharedEnvPath}`
85858
86171
  ];
85859
86172
  }
86173
+ function writeFileHeredoc2(path, body, marker) {
86174
+ return [`cat > ${path} <<'${marker}'`, body.replace(/\n$/, ""), marker, `chmod 600 ${path}`];
86175
+ }
86176
+ function buildCredentialFiles(releaseDir, creds) {
86177
+ if (!creds)
86178
+ return [];
86179
+ const out = [];
86180
+ if (creds.composerAuth) {
86181
+ const json = typeof creds.composerAuth === "string" ? creds.composerAuth : JSON.stringify(creds.composerAuth, null, 2);
86182
+ out.push(...writeFileHeredoc2(`${releaseDir}/auth.json`, json, "TS_CLOUD_AUTHJSON_EOF"));
86183
+ }
86184
+ if (creds.npmrc)
86185
+ out.push(...writeFileHeredoc2(`${releaseDir}/.npmrc`, creds.npmrc, "TS_CLOUD_NPMRC_EOF"));
86186
+ return out;
86187
+ }
85860
86188
  function buildLaravelDeployScript(options) {
85861
86189
  const { siteName, site, releaseId, commit } = options;
85862
86190
  if (!site.repository?.url)
@@ -85874,6 +86202,12 @@ function buildLaravelDeployScript(options) {
85874
86202
  "export COMPOSER_ALLOW_SUPERUSER=1",
85875
86203
  pantryEnvActivation()
85876
86204
  ];
86205
+ out.push(...buildDeployHistoryHeader(base, {
86206
+ releaseId,
86207
+ commit,
86208
+ branch: site.repository.branch,
86209
+ keepLogs: keepReleases
86210
+ }));
85877
86211
  out.push(...buildEnsureReleaseLayout(paths, sharedPaths));
85878
86212
  if (site.env && Object.keys(site.env).length > 0)
85879
86213
  out.push(...writeSharedEnv(`${paths.shared}/.env`, site.env));
@@ -85883,6 +86217,7 @@ function buildLaravelDeployScript(options) {
85883
86217
  out.push(...buildGitCheckoutScript({ repository: site.repository, releaseDir: paths.release, commit }));
85884
86218
  out.push(...buildLinkSharedPaths(paths, sharedPaths));
85885
86219
  out.push(`cd ${paths.release}`);
86220
+ out.push(...buildCredentialFiles(paths.release, site.credentials));
85886
86221
  out.push(`chown -R www-data:www-data ${paths.shared}/storage 2>/dev/null || true`, `[ -d ${paths.release}/bootstrap/cache ] && chown -R www-data:www-data ${paths.release}/bootstrap/cache 2>/dev/null || true`, `chmod -R ug+rwX ${paths.shared}/storage 2>/dev/null || true`);
85887
86222
  } else if (line === MACRO_ACTIVATE_RELEASE) {
85888
86223
  out.push(...buildActivateRelease(paths));
@@ -85905,6 +86240,9 @@ function pantryExec(cmd) {
85905
86240
  function reEscape(value) {
85906
86241
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85907
86242
  }
86243
+ function cronQuote(value) {
86244
+ return `'${value.split("'").join("'\\''")}'`;
86245
+ }
85908
86246
  function slugify(value) {
85909
86247
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
85910
86248
  }
@@ -86006,9 +86344,18 @@ function buildSiteServicesScript(options) {
86006
86344
  out.push(`systemctl enable ${name}.service`, `systemctl restart ${name}.service`);
86007
86345
  }
86008
86346
  const cronPath = schedulerCronPath(slug, siteName);
86009
- if (site.scheduler) {
86010
- const cron = `* * * * * root cd ${current} && ${PANTRY_ENV_EVAL} && ${phpBin} artisan schedule:run >> /dev/null 2>&1
86011
- `;
86347
+ const scheduler2 = site.scheduler;
86348
+ const schedulerEnabled = scheduler2 === true || typeof scheduler2 === "object" && scheduler2 !== null;
86349
+ if (schedulerEnabled) {
86350
+ const heartbeat = typeof scheduler2 === "object" && scheduler2 !== null ? scheduler2 : undefined;
86351
+ let command = `cd ${current} && ${PANTRY_ENV_EVAL} && ${phpBin} artisan schedule:run >> /dev/null 2>&1`;
86352
+ if (heartbeat?.heartbeatUrl) {
86353
+ const method = heartbeat.heartbeatMethod || "GET";
86354
+ const methodFlag = method === "GET" ? "" : `-X ${method} `;
86355
+ command += ` && curl -fsS -m 10 ${methodFlag}${cronQuote(heartbeat.heartbeatUrl)} >/dev/null 2>&1`;
86356
+ }
86357
+ const cron = `* * * * * root ${command}
86358
+ `.replace(/%/g, "\\%");
86012
86359
  out.push(`cat > ${cronPath} <<'TS_CLOUD_CRON_EOF'`, cron.replace(/\n$/, ""), "TS_CLOUD_CRON_EOF", `chmod 644 ${cronPath}`);
86013
86360
  } else {
86014
86361
  out.push(`rm -f ${cronPath}`);
@@ -86076,7 +86423,9 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86076
86423
  phpVersion,
86077
86424
  redirects: site.redirects,
86078
86425
  ssl: customCert,
86079
- auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined
86426
+ auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86427
+ serverSnippet: resolveNginxSnippet(site.nginx, compute2?.nginxTemplates),
86428
+ clientMaxBodySize: site.nginx?.clientMaxBodySize
86080
86429
  }) : [];
86081
86430
  const sslScript = useNginx ? buildSslScript(site) : [];
86082
86431
  const servicesScript = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, phpVersion, appBase }) : [];
@@ -86139,7 +86488,9 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86139
86488
  appDir: `/var/www/${siteName}`,
86140
86489
  webDirectory: "",
86141
86490
  redirects: site.redirects,
86142
- auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined
86491
+ auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86492
+ serverSnippet: resolveNginxSnippet(site.nginx, compute?.nginxTemplates),
86493
+ clientMaxBodySize: site.nginx?.clientMaxBodySize
86143
86494
  }) : [];
86144
86495
  const staticSsl = wantsNginxStatic ? buildSslScript(site) : [];
86145
86496
  const remoteScript = [...baseScript, ...staticVhost, ...staticSsl];