@ts-cloud/core 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.
package/dist/aws/s3.d.ts CHANGED
@@ -235,7 +235,7 @@ export declare class S3Client {
235
235
  /**
236
236
  * Abort a multipart upload
237
237
  */
238
- abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise<void>;
238
+ abortMultipartUpload(bucket: string, key: string, uploadId: string, baseUrl?: string): Promise<void>;
239
239
  /**
240
240
  * Empty all objects in a bucket and then delete the bucket
241
241
  */
package/dist/index.js CHANGED
@@ -22295,11 +22295,11 @@ function createServerlessLaravelPreset(options) {
22295
22295
  // src/presets/dashboard.ts
22296
22296
  function createDashboardSite(options) {
22297
22297
  return {
22298
- root: options.root ?? "ui/dist",
22298
+ root: options.root ?? "packages/ui/dist",
22299
22299
  deploy: "server",
22300
22300
  type: "static",
22301
22301
  domain: options.domain,
22302
- build: options.build ?? "cd ui && bun install && bun run build",
22302
+ build: options.build ?? "cd packages/ui && bun install && bun run build",
22303
22303
  ssl: { provider: "letsencrypt" },
22304
22304
  auth: {
22305
22305
  username: options.username ?? "admin",
@@ -23532,18 +23532,19 @@ class S3Client {
23532
23532
  stream = blob.stream();
23533
23533
  totalSize = blob.size;
23534
23534
  }
23535
- const uploadId = await this.initiateMultipartUpload(bucket, key, options);
23535
+ const { uploadId, baseUrl } = await this.initiateMultipartUpload(bucket, key, options);
23536
23536
  try {
23537
- const parts = await this.uploadParts(bucket, key, uploadId, stream, partSize, credentials, totalSize, options.concurrency || 4, options.onProgress);
23538
- return await this.completeMultipartUpload(bucket, key, uploadId, parts);
23537
+ const parts = await this.uploadParts(bucket, key, baseUrl, uploadId, stream, partSize, credentials, totalSize, options.concurrency || 4, options.onProgress);
23538
+ return await this.completeMultipartUpload(bucket, key, baseUrl, uploadId, parts);
23539
23539
  } catch (error) {
23540
- await this.abortMultipartUpload(bucket, key, uploadId).catch(() => {});
23540
+ await this.abortMultipartUpload(bucket, key, uploadId, baseUrl).catch(() => {});
23541
23541
  throw error;
23542
23542
  }
23543
23543
  }
23544
23544
  async initiateMultipartUpload(bucket, key, options) {
23545
23545
  const credentials = await this.getCredentials();
23546
- const url = `${this.buildUrl(bucket, key)}?uploads`;
23546
+ const baseUrl = this.buildUrl(bucket, key);
23547
+ const url = `${baseUrl}?uploads`;
23547
23548
  const headers = {
23548
23549
  "Content-Type": options.contentType || detectContentType(key)
23549
23550
  };
@@ -23579,18 +23580,26 @@ class S3Client {
23579
23580
  if (!uploadIdMatch) {
23580
23581
  throw new S3Error("Failed to parse upload ID from response", 0, bucket, key);
23581
23582
  }
23582
- return uploadIdMatch[1];
23583
+ let resolvedBaseUrl = baseUrl;
23584
+ if (response.url) {
23585
+ try {
23586
+ const resolved = new URL(response.url);
23587
+ resolved.search = "";
23588
+ resolvedBaseUrl = resolved.toString();
23589
+ } catch {}
23590
+ }
23591
+ return { uploadId: uploadIdMatch[1], baseUrl: resolvedBaseUrl };
23583
23592
  }
23584
- async uploadParts(bucket, key, uploadId, stream, partSize, credentials, totalSize, concurrency, onProgress) {
23593
+ async uploadParts(bucket, key, baseUrl, uploadId, stream, partSize, credentials, totalSize, concurrency, onProgress) {
23585
23594
  const parts = [];
23586
23595
  const reader = stream.getReader();
23587
23596
  let partNumber = 1;
23588
23597
  let buffer = new Uint8Array(0);
23589
23598
  let loaded = 0;
23590
23599
  const totalParts = totalSize ? Math.ceil(totalSize / partSize) : undefined;
23591
- const uploadQueue = [];
23600
+ const inFlight = new Map;
23592
23601
  const uploadPart = async (data, num) => {
23593
- const url = `${this.buildUrl(bucket, key)}?partNumber=${num}&uploadId=${encodeURIComponent(uploadId)}`;
23602
+ const url = `${baseUrl}?partNumber=${num}&uploadId=${encodeURIComponent(uploadId)}`;
23594
23603
  const headers = {
23595
23604
  "Content-Length": String(data.byteLength),
23596
23605
  "x-amz-content-sha256": "UNSIGNED-PAYLOAD"
@@ -23606,15 +23615,24 @@ class S3Client {
23606
23615
  const response = await fetch(signed.url, {
23607
23616
  method: signed.method,
23608
23617
  headers: signed.headers,
23609
- body: data
23618
+ body: data,
23619
+ redirect: "error"
23610
23620
  });
23611
23621
  if (!response.ok) {
23612
23622
  const error = await response.text();
23613
23623
  throw new S3Error(`Failed to upload part ${num}: ${error}`, response.status, bucket, key);
23614
23624
  }
23615
23625
  const etag = (response.headers.get("ETag") || "").replace(/"/g, "");
23626
+ if (!etag) {
23627
+ throw new S3Error(`Missing ETag for part ${num}`, response.status, bucket, key);
23628
+ }
23616
23629
  return { partNumber: num, etag };
23617
23630
  };
23631
+ const drainOne = async () => {
23632
+ const completed = await Promise.race(inFlight.values());
23633
+ inFlight.delete(completed.partNumber);
23634
+ parts.push(completed);
23635
+ };
23618
23636
  while (true) {
23619
23637
  const { done, value } = await reader.read();
23620
23638
  if (value) {
@@ -23627,13 +23645,10 @@ class S3Client {
23627
23645
  const partData = buffer.slice(0, partSize);
23628
23646
  buffer = buffer.slice(partSize);
23629
23647
  const currentPartNumber = partNumber++;
23630
- if (uploadQueue.length >= concurrency) {
23631
- const completed = await Promise.race(uploadQueue);
23632
- parts.push(completed);
23633
- uploadQueue.splice(uploadQueue.indexOf(Promise.resolve(completed)), 1);
23648
+ if (inFlight.size >= concurrency) {
23649
+ await drainOne();
23634
23650
  }
23635
- const uploadPromise = uploadPart(partData, currentPartNumber);
23636
- uploadQueue.push(uploadPromise);
23651
+ inFlight.set(currentPartNumber, uploadPart(partData, currentPartNumber));
23637
23652
  loaded += partData.byteLength;
23638
23653
  if (onProgress) {
23639
23654
  onProgress({
@@ -23647,14 +23662,15 @@ class S3Client {
23647
23662
  if (done)
23648
23663
  break;
23649
23664
  }
23650
- const remaining = await Promise.all(uploadQueue);
23651
- parts.push(...remaining);
23665
+ while (inFlight.size > 0) {
23666
+ await drainOne();
23667
+ }
23652
23668
  parts.sort((a, b) => a.partNumber - b.partNumber);
23653
23669
  return parts;
23654
23670
  }
23655
- async completeMultipartUpload(bucket, key, uploadId, parts) {
23671
+ async completeMultipartUpload(bucket, key, baseUrl, uploadId, parts) {
23656
23672
  const credentials = await this.getCredentials();
23657
- const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
23673
+ const url = `${baseUrl}?uploadId=${encodeURIComponent(uploadId)}`;
23658
23674
  const partsXml = parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>"${p.etag}"</ETag></Part>`).join("");
23659
23675
  const body = `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`;
23660
23676
  const signed = signRequest({
@@ -23680,9 +23696,9 @@ class S3Client {
23680
23696
  const etag = etagMatch ? etagMatch[1] : "";
23681
23697
  return { etag };
23682
23698
  }
23683
- async abortMultipartUpload(bucket, key, uploadId) {
23699
+ async abortMultipartUpload(bucket, key, uploadId, baseUrl) {
23684
23700
  const credentials = await this.getCredentials();
23685
- const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
23701
+ const url = `${baseUrl ?? this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`;
23686
23702
  const signed = signRequest({
23687
23703
  method: "DELETE",
23688
23704
  url,
@@ -19,7 +19,7 @@ export declare function createDashboardSite(options: {
19
19
  password?: string;
20
20
  /** Basic-auth username. @default 'admin' */
21
21
  username?: string;
22
- /** Built UI output directory shipped to the box. @default 'ui/dist' */
22
+ /** Built UI output directory shipped to the box. @default 'packages/ui/dist' */
23
23
  root?: string;
24
24
  /** Build command producing {@link root}. @default builds @ts-cloud/ui */
25
25
  build?: string;
package/dist/types.d.ts CHANGED
@@ -932,9 +932,11 @@ export interface SiteConfig {
932
932
  redirects?: Record<string, string>;
933
933
  /**
934
934
  * Give this site a dedicated php-fpm pool (isolated user/process) rather than
935
- * sharing the default pool.
935
+ * sharing the default pool (Forge's "User Isolation").
936
936
  */
937
937
  isolation?: boolean;
938
+ /** Per-site nginx access control (IP allow/deny — Forge "Security Rules"). */
939
+ security?: SiteSecurityConfig;
938
940
  /** Post-deploy health check (Forge-style) pinged after `current` is flipped. */
939
941
  healthCheck?: {
940
942
  path?: string;
@@ -1059,6 +1061,33 @@ export interface SiteSslConfig {
1059
1061
  * domain resolves to the box. The plugin + credentials are wired on the box.
1060
1062
  */
1061
1063
  dns?: SslDnsConfig;
1064
+ /**
1065
+ * Emit an HSTS (`Strict-Transport-Security`) header so browsers force HTTPS.
1066
+ * `true` uses a 1-year max-age with `includeSubDomains`; an object customizes
1067
+ * it. Only meaningful when the site serves TLS.
1068
+ */
1069
+ hsts?: boolean | {
1070
+ maxAge?: number;
1071
+ includeSubDomains?: boolean;
1072
+ preload?: boolean;
1073
+ };
1074
+ /**
1075
+ * `ssl_protocols` for the vhost (e.g. `['TLSv1.2', 'TLSv1.3']`). Applied to
1076
+ * the `custom`-cert :443 block; for Let's Encrypt the protocols are managed by
1077
+ * certbot's options file.
1078
+ */
1079
+ tlsProtocols?: string[];
1080
+ }
1081
+ /**
1082
+ * Per-site access control at the nginx layer (Forge's "Security Rules"). IPs/
1083
+ * CIDRs in {@link allow} are permitted and everything else denied; {@link deny}
1084
+ * blocks specific IPs/CIDRs. `allow` takes precedence (allow-list mode).
1085
+ */
1086
+ export interface SiteSecurityConfig {
1087
+ /** Allow only these IPs/CIDRs (everything else gets 403). */
1088
+ allow?: string[];
1089
+ /** Block these IPs/CIDRs. */
1090
+ deny?: string[];
1062
1091
  }
1063
1092
  /** certbot DNS-01 plugin configuration for DNS-validated / wildcard certs. */
1064
1093
  export interface SslDnsConfig {
@@ -2169,10 +2198,12 @@ export interface ComputeConfig {
2169
2198
  */
2170
2199
  sshKey?: string;
2171
2200
  /**
2172
- * Enable detailed monitoring
2173
- * @default false
2201
+ * On-box server monitoring (Forge-style). `true`/`false` toggles the metrics
2202
+ * collector; pass a {@link ComputeMonitoringConfig} to also set resource
2203
+ * alert thresholds that notify the configured channels on breach.
2204
+ * @default true for PHP boxes
2174
2205
  */
2175
- monitoring?: boolean;
2206
+ monitoring?: boolean | ComputeMonitoringConfig;
2176
2207
  /**
2177
2208
  * Spot/preemptible instance settings (when using fleet)
2178
2209
  */
@@ -2287,6 +2318,25 @@ export interface SshKeyConfig {
2287
2318
  /** The public key line (e.g. `ssh-ed25519 AAAA… user@host`). */
2288
2319
  publicKey: string;
2289
2320
  }
2321
+ /**
2322
+ * On-box monitoring + resource alerts. See {@link ComputeConfig.monitoring}.
2323
+ * The collector writes a metrics snapshot every minute; when a threshold is
2324
+ * breached it calls the on-box notifier (Slack/Discord/Telegram/webhook) once
2325
+ * per OK→alert transition (and again on recovery), so channels aren't spammed.
2326
+ */
2327
+ export interface ComputeMonitoringConfig {
2328
+ /** Enable the metrics collector. @default true */
2329
+ enabled?: boolean;
2330
+ /** Alert thresholds; omit a field to keep its default. */
2331
+ alerts?: {
2332
+ /** Alert when 1-min load average per CPU exceeds this. @default 2 */
2333
+ cpuLoadPerCore?: number;
2334
+ /** Alert when used memory percentage is ≥ this. @default 90 */
2335
+ memPercent?: number;
2336
+ /** Alert when root-filesystem usage percentage is ≥ this. @default 90 */
2337
+ diskPercent?: number;
2338
+ };
2339
+ }
2290
2340
  /** Host firewall (UFW) configuration. See {@link ComputeConfig.firewall}. */
2291
2341
  export interface ComputeFirewallConfig {
2292
2342
  /** Enable UFW. @default true */
@@ -2393,6 +2443,27 @@ export interface ComputeProxyConfig {
2393
2443
  * served per-SNI by rpx. @default '/etc/rpx/certs'
2394
2444
  */
2395
2445
  certsDir?: string;
2446
+ /**
2447
+ * Inactivity timeout (seconds) for rpx's pooled upstream connections, surfaced
2448
+ * to the gateway as `RPX_UPSTREAM_TIMEOUT`. rpx leaves this **off by default**
2449
+ * because it commonly fronts dev servers doing SSE/HMR/long-poll, but a
2450
+ * production gateway fronting real apps must bound it: the pool caps
2451
+ * connections per upstream and queues requests for a free slot, so a single
2452
+ * stalled upstream socket with no timeout holds its slot forever — enough of
2453
+ * them leak the pool and the gateway wedges (TLS handshakes succeed but no
2454
+ * request is ever answered). The timer resets on every byte, so a stream that
2455
+ * emits data periodically never trips it; only a fully-stalled upstream does.
2456
+ * Set `0` to disable (match rpx's dev default for streaming-heavy upstreams).
2457
+ * @default 60
2458
+ */
2459
+ upstreamTimeout?: number;
2460
+ /**
2461
+ * Max open connections rpx keeps per upstream `host:port`, surfaced as
2462
+ * `RPX_MAX_UPSTREAM_CONNS`. Requests beyond this queue for a free slot rather
2463
+ * than churning sockets into TIME_WAIT. Raise for higher peak parallelism per
2464
+ * upstream; omit to use rpx's built-in default (256).
2465
+ */
2466
+ maxUpstreamConns?: number;
2396
2467
  /**
2397
2468
  * Enable rpx on-demand TLS: lazily issue a real (Let's Encrypt) cert for an
2398
2469
  * approved host the first time it's needed. The site domains are used as the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ts-cloud/core",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "type": "module",
5
5
  "description": "Core CloudFormation generation library for ts-cloud",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
@@ -31,7 +31,7 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@ts-cloud/aws-types": "0.5.17"
34
+ "@ts-cloud/aws-types": "0.5.19"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.9.3"