@stacksjs/ts-cloud 0.2.24 → 0.2.25

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.
@@ -9,6 +9,13 @@ export interface UbuntuBootstrapOptions {
9
9
  systemPackages?: string[];
10
10
  database?: 'sqlite' | 'mysql' | 'postgres';
11
11
  caddyfile?: string;
12
+ /**
13
+ * Shell commands that install + start the rpx reverse-proxy gateway, built by
14
+ * {@link import('../shared/rpx-gateway').buildRpxProvisionScript}. Appended
15
+ * after the runtime is installed so `bun add -g @stacksjs/rpx` works. Mutually
16
+ * exclusive with `caddyfile` (the box runs one gateway).
17
+ */
18
+ rpxProvision?: string[];
12
19
  }
13
20
  export declare function generateUbuntuAppCloudInit(options?: UbuntuBootstrapOptions): string;
14
21
  /**
@@ -4,4 +4,6 @@ export { HetznerDriver } from './hetzner/driver';
4
4
  export { HetznerClient, resolveHetznerApiToken } from './hetzner/client';
5
5
  export { generateUbuntuAppCloudInit, wrapCloudInitUserData } from './hetzner/cloud-init';
6
6
  export { buildAwsArtifactFetch, buildLocalArtifactFetch, buildSiteDeployScript, buildStaticSiteDeployScript, resolveExecStart, } from './shared/deploy-script';
7
- export { deployAllComputeSites, deploySiteRelease } from './shared/compute-deploy';
7
+ export { deployAllComputeSites, deploySiteRelease, reloadRpxGateway } from './shared/compute-deploy';
8
+ export { buildRpxConfig, buildRpxProvisionScript, deriveRouteId, normalizeRoutePath, renderRpxLauncher, DEFAULT_RPX_CERTS_DIR, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, } from './shared/rpx-gateway';
9
+ export type { BuildRpxConfigOptions, BuildRpxProvisionOptions, RpxGatewayConfig, RpxRoute, } from './shared/rpx-gateway';
@@ -25,3 +25,10 @@ export interface DeployAllSitesOptions {
25
25
  * without `start`, shipped to `/var/www/<site>`). Bucket sites are skipped.
26
26
  */
27
27
  export declare function deployAllComputeSites(options: DeployAllSitesOptions): Promise<boolean>;
28
+ /**
29
+ * Regenerate the rpx gateway config from the sites model and (re)start the
30
+ * gateway on the compute targets. No-op (returns `true`) unless
31
+ * `compute.proxy.engine === 'rpx'`. Re-runnable: the provision script writes the
32
+ * launcher + unit and `systemctl restart`s, which reloads the new routes.
33
+ */
34
+ export declare function reloadRpxGateway(options: DeployAllSitesOptions): Promise<boolean>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Generate the rpx reverse-proxy gateway config + provisioning from the `sites`
3
+ * model.
4
+ *
5
+ * ts-cloud's per-site deploy model resolves each site to one of three kinds
6
+ * (see {@link import('../../deploy/site-target').resolveSiteKind}):
7
+ * - `server-app` — a dynamic app running on a port (systemd service);
8
+ * - `server-static` — a static site shipped to `/var/www/<name>`;
9
+ * - `bucket` — object storage + CDN (not on the box; ignored here).
10
+ *
11
+ * The rpx gateway fronts :80/:443 on the box and routes by host **and path**:
12
+ * several sites can share one `domain` on different `path`s (e.g.
13
+ * `stacksjs.com/api/*` → app on :3000, `stacksjs.com/docs*` → `/var/www/docs`,
14
+ * `stacksjs.com/` → `/var/www/public`). This module maps the sites model to the
15
+ * rpx `proxies` array so `buddy deploy` can ship the config + wire the gateway.
16
+ *
17
+ * It replaces the old Caddyfile generation — pantry/stacks use rpx (their own
18
+ * tooling), so the gateway is rpx, not Caddy.
19
+ */
20
+ import type { ComputeProxyConfig, SiteConfig } from '@ts-cloud/core';
21
+ /** Default directory on the box that holds real per-domain TLS certs. */
22
+ export declare const DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
23
+ /** A single rpx proxy route, mapped from one site. */
24
+ export interface RpxRoute {
25
+ /** Public host this route is served under (the site's `domain`). */
26
+ to: string;
27
+ /** Path prefix within the host this route owns (e.g. `/api`). Omitted = `/`. */
28
+ path?: string;
29
+ /** Upstream `host:port` for a `server-app` route. */
30
+ from?: string;
31
+ /** Absolute directory served for a `server-static` route (`/var/www/<name>`). */
32
+ static?: string;
33
+ /** Strip `.html` and resolve clean URLs (set for static sites). */
34
+ cleanUrls?: boolean;
35
+ /** SPA fallback for static sites. */
36
+ spa?: boolean;
37
+ /** Stable id used when rpx registers the route. Derived from `to`+`path`. */
38
+ id: string;
39
+ }
40
+ /** The rpx daemon/proxy config produced from a sites model. */
41
+ export interface RpxGatewayConfig {
42
+ /** Multi-proxy route list (host + path keyed). */
43
+ proxies: RpxRoute[];
44
+ /**
45
+ * Production per-domain SNI certs: rpx serves a real PEM per server name from
46
+ * this directory (`<domain>.crt` / `<domain>.key`).
47
+ */
48
+ productionCerts: {
49
+ certsDir: string;
50
+ };
51
+ /**
52
+ * On-demand TLS (opt-in): lazily issue a real cert for an approved host the
53
+ * first time it's needed. The site domains form the allowlist.
54
+ */
55
+ onDemandTls?: {
56
+ enabled: true;
57
+ allowedSuffixes: string[];
58
+ email?: string;
59
+ certsDir: string;
60
+ };
61
+ /** Always `true` — the gateway terminates TLS on the box. */
62
+ https: true;
63
+ /** Never touch `/etc/hosts` on a real server with real DNS. */
64
+ hostsManagement: false;
65
+ /** Don't remove certs/hosts on exit. */
66
+ cleanup: {
67
+ hosts: false;
68
+ certs: false;
69
+ };
70
+ }
71
+ export interface BuildRpxConfigOptions {
72
+ /** Proxy config from `infrastructure.compute.proxy`. */
73
+ proxy: ComputeProxyConfig;
74
+ /** Directory static sites are shipped to. @default '/var/www' */
75
+ wwwRoot?: string;
76
+ }
77
+ /**
78
+ * Normalize a path prefix to a leading-slash, no-trailing-slash form, or
79
+ * `undefined` for the host default. Mirrors rpx's `normalizePathPrefix`.
80
+ */
81
+ export declare function normalizeRoutePath(path: string | undefined): string | undefined;
82
+ /** Derive a stable, filesystem/registry-safe id from a host (+ optional path). */
83
+ export declare function deriveRouteId(to: string, path?: string): string;
84
+ /**
85
+ * Map the sites model to an rpx gateway config. Each non-bucket site with a
86
+ * `domain` becomes a route:
87
+ * - `server-app` → `{ to: domain, path, from: 'localhost:<port>' }`
88
+ * - `server-static` → `{ to: domain, path, static: '<wwwRoot>/<name>' }`
89
+ *
90
+ * Routes are grouped by domain so rpx's path-based routing can serve an app +
91
+ * several static dirs under one host. Bucket sites and sites without a `domain`
92
+ * (or a `server-app` without a `port`) are skipped.
93
+ */
94
+ export declare function buildRpxConfig(sites: Record<string, SiteConfig | undefined>, options: BuildRpxConfigOptions): RpxGatewayConfig;
95
+ /**
96
+ * Render the rpx gateway config as a self-contained launcher TS module. The
97
+ * systemd unit runs `bun <file>`, which imports `startProxies` from the
98
+ * globally-installed `@stacksjs/rpx` and starts the gateway with the generated
99
+ * options. We ship a runnable launcher (not a bare config) because rpx's CLI
100
+ * resolves its own config from its install dir, not an arbitrary path.
101
+ */
102
+ export declare function renderRpxLauncher(config: RpxGatewayConfig): string;
103
+ /** Default install location for the gateway launcher + config on the box. */
104
+ export declare const RPX_DIR = "/etc/rpx";
105
+ export declare const RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
106
+ export declare const RPX_SERVICE_NAME = "rpx-gateway.service";
107
+ export interface BuildRpxProvisionOptions {
108
+ config: RpxGatewayConfig;
109
+ proxy: ComputeProxyConfig;
110
+ /** Absolute path to the `bun` binary on the box. @default '/usr/local/bin/bun' */
111
+ bunBin?: string;
112
+ }
113
+ /**
114
+ * Build the idempotent, re-runnable shell commands that install rpx as the
115
+ * gateway, write the generated launcher + ensure the certs dir, install the
116
+ * systemd unit, and enable + (re)start it on :80/:443.
117
+ *
118
+ * Safe to run at first boot (cloud-init) and again on every deploy — the unit
119
+ * write + `systemctl restart` reloads the regenerated routes so new
120
+ * server-app/server-static sites appear in the gateway automatically.
121
+ */
122
+ export declare function buildRpxProvisionScript(options: BuildRpxProvisionOptions): string[];
package/dist/index.js CHANGED
@@ -81600,7 +81600,8 @@ function generateUbuntuAppCloudInit(options = {}) {
81600
81600
  runtimeVersion = "latest",
81601
81601
  systemPackages = [],
81602
81602
  database,
81603
- caddyfile
81603
+ caddyfile,
81604
+ rpxProvision
81604
81605
  } = options;
81605
81606
  const packages = new Set(systemPackages);
81606
81607
  if (database === "sqlite")
@@ -81692,6 +81693,13 @@ CADDY_CONFIG_EOF
81692
81693
  systemctl daemon-reload
81693
81694
  systemctl enable caddy
81694
81695
  systemctl start caddy
81696
+ `;
81697
+ }
81698
+ if (rpxProvision && rpxProvision.length > 0) {
81699
+ const body = rpxProvision[0] === "set -euo pipefail" ? rpxProvision.slice(1) : rpxProvision;
81700
+ script += `
81701
+ ${body.join(`
81702
+ `)}
81695
81703
  `;
81696
81704
  }
81697
81705
  script += `
@@ -81716,6 +81724,129 @@ runcmd:
81716
81724
  `;
81717
81725
  }
81718
81726
 
81727
+ // src/drivers/shared/rpx-gateway.ts
81728
+ var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
81729
+ function normalizeRoutePath(path) {
81730
+ if (!path || path === "/")
81731
+ return;
81732
+ let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
81733
+ if (!p.startsWith("/"))
81734
+ p = `/${p}`;
81735
+ return p === "" || p === "/" ? undefined : p;
81736
+ }
81737
+ function deriveRouteId(to, path) {
81738
+ const base = path ? `${to}${path}` : to;
81739
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
81740
+ return cleaned.length > 0 ? cleaned : "rpx";
81741
+ }
81742
+ function buildRpxConfig(sites, options) {
81743
+ const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
81744
+ const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
81745
+ const proxies = [];
81746
+ const domains = new Set;
81747
+ for (const [name, site] of Object.entries(sites)) {
81748
+ if (!site || !site.domain)
81749
+ continue;
81750
+ const kind = resolveSiteKind(site);
81751
+ if (kind === "bucket")
81752
+ continue;
81753
+ const path = normalizeRoutePath(site.path);
81754
+ const id = deriveRouteId(site.domain, path);
81755
+ if (kind === "server-app") {
81756
+ if (typeof site.port !== "number")
81757
+ continue;
81758
+ proxies.push({ to: site.domain, path, from: `localhost:${site.port}`, id });
81759
+ } else {
81760
+ proxies.push({
81761
+ to: site.domain,
81762
+ path,
81763
+ static: `${wwwRoot}/${name}`,
81764
+ cleanUrls: site.pathRewriteStyle !== "flat",
81765
+ spa: site.spa ?? false,
81766
+ id
81767
+ });
81768
+ }
81769
+ domains.add(site.domain);
81770
+ }
81771
+ proxies.sort((a, b) => {
81772
+ if (a.to !== b.to)
81773
+ return a.to.localeCompare(b.to);
81774
+ return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81775
+ });
81776
+ const config6 = {
81777
+ proxies,
81778
+ productionCerts: { certsDir },
81779
+ https: true,
81780
+ hostsManagement: false,
81781
+ cleanup: { hosts: false, certs: false }
81782
+ };
81783
+ if (options.proxy.onDemandTls && domains.size > 0) {
81784
+ config6.onDemandTls = {
81785
+ enabled: true,
81786
+ allowedSuffixes: [...domains],
81787
+ email: options.proxy.onDemandTlsEmail,
81788
+ certsDir
81789
+ };
81790
+ }
81791
+ return config6;
81792
+ }
81793
+ function renderRpxLauncher(config6) {
81794
+ const json = JSON.stringify(config6, null, 2);
81795
+ return `// Generated by ts-cloud — rpx reverse-proxy gateway.
81796
+ // Routes are derived from the \`sites\` model on every \`buddy deploy\`.
81797
+ import { startProxies } from '@stacksjs/rpx'
81798
+
81799
+ const config = ${json} as const
81800
+
81801
+ await startProxies(config as any)
81802
+ `;
81803
+ }
81804
+ var RPX_DIR = "/etc/rpx";
81805
+ var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
81806
+ var RPX_SERVICE_NAME = "rpx-gateway.service";
81807
+ function writeFileHeredoc(path, content, delimiter) {
81808
+ return [
81809
+ `cat > ${path} <<'${delimiter}'`,
81810
+ content,
81811
+ delimiter
81812
+ ];
81813
+ }
81814
+ function buildRpxProvisionScript(options) {
81815
+ const { config: config6, proxy } = options;
81816
+ const bunBin = options.bunBin ?? "/usr/local/bin/bun";
81817
+ const version2 = proxy.version ?? "latest";
81818
+ const certsDir = config6.productionCerts.certsDir;
81819
+ const launcher = renderRpxLauncher(config6);
81820
+ return [
81821
+ "set -euo pipefail",
81822
+ `mkdir -p ${RPX_DIR} ${certsDir}`,
81823
+ `${bunBin} add -g @stacksjs/rpx@${version2}`,
81824
+ ...writeFileHeredoc(RPX_LAUNCHER_PATH, launcher, "TS_CLOUD_RPX_EOF"),
81825
+ ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
81826
+ "[Unit]",
81827
+ "Description=rpx reverse-proxy gateway (managed by ts-cloud)",
81828
+ "After=network.target network-online.target",
81829
+ "Wants=network-online.target",
81830
+ "",
81831
+ "[Service]",
81832
+ "Type=simple",
81833
+ `ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
81834
+ `Environment=BUN_INSTALL=/root/.bun`,
81835
+ "Restart=always",
81836
+ "RestartSec=5",
81837
+ "LimitNOFILE=1048576",
81838
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE",
81839
+ "",
81840
+ "[Install]",
81841
+ "WantedBy=multi-user.target"
81842
+ ].join(`
81843
+ `), "TS_CLOUD_RPX_UNIT_EOF"),
81844
+ "systemctl daemon-reload",
81845
+ `systemctl enable ${RPX_SERVICE_NAME}`,
81846
+ `systemctl restart ${RPX_SERVICE_NAME}`
81847
+ ];
81848
+ }
81849
+
81719
81850
  // src/drivers/hetzner/firewall-rules.ts
81720
81851
  function buildHetznerFirewallRules(config6) {
81721
81852
  const openPorts = new Set([80, 443, ...config6.sitePorts]);
@@ -81852,11 +81983,17 @@ class HetznerDriver {
81852
81983
  }
81853
81984
  const sites = config6.sites || {};
81854
81985
  const sitePorts = this.collectUpstreamPorts(sites);
81986
+ const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
81987
+ proxy: compute.proxy,
81988
+ config: buildRpxConfig(sites, { proxy: compute.proxy }),
81989
+ bunBin: compute.runtime === "node" || compute.runtime === "deno" ? undefined : "/usr/local/bin/bun"
81990
+ }) : undefined;
81855
81991
  const bootstrap = generateUbuntuAppCloudInit({
81856
81992
  runtime: compute.runtime || "bun",
81857
81993
  runtimeVersion: compute.runtimeVersion || "latest",
81858
81994
  systemPackages: compute.systemPackages,
81859
- database: config6.infrastructure?.database
81995
+ database: config6.infrastructure?.database,
81996
+ rpxProvision
81860
81997
  });
81861
81998
  const userData = wrapCloudInitUserData(bootstrap);
81862
81999
  const serverType = resolveHetznerServerType(compute.size);
@@ -82344,6 +82481,48 @@ async function deployAllComputeSites(options) {
82344
82481
  }
82345
82482
  }
82346
82483
  }
82484
+ const reloaded = await reloadRpxGateway(options);
82485
+ if (!reloaded)
82486
+ return false;
82487
+ return true;
82488
+ }
82489
+ async function reloadRpxGateway(options) {
82490
+ const { config: config6, environment, driver, logger: logger4 = noopLogger } = options;
82491
+ const proxy = config6.infrastructure?.compute?.proxy;
82492
+ if (proxy?.engine !== "rpx")
82493
+ return true;
82494
+ const sites = config6.sites || {};
82495
+ const rpxConfig = buildRpxConfig(sites, { proxy });
82496
+ if (rpxConfig.proxies.length === 0) {
82497
+ logger4.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
82498
+ return true;
82499
+ }
82500
+ const targets = await driver.findComputeTargets({
82501
+ slug: config6.project.slug,
82502
+ environment,
82503
+ role: "app"
82504
+ });
82505
+ if (targets.length === 0) {
82506
+ logger4.warn("rpx gateway: no compute targets found — skipping gateway reload.");
82507
+ return true;
82508
+ }
82509
+ logger4.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
82510
+ const script = buildRpxProvisionScript({ proxy, config: rpxConfig });
82511
+ const result = await driver.runRemoteDeploy({
82512
+ targets,
82513
+ commands: script,
82514
+ comment: `ts-cloud rpx gateway reload ${config6.project.slug}`,
82515
+ tags: {
82516
+ Project: config6.project.slug,
82517
+ Environment: environment,
82518
+ Role: "app"
82519
+ }
82520
+ });
82521
+ if (!result.success) {
82522
+ logger4.error(`rpx gateway reload failed: ${result.error || "unknown error"}`);
82523
+ return false;
82524
+ }
82525
+ logger4.success(`rpx gateway reloaded on ${result.instanceCount} target(s)`);
82347
82526
  return true;
82348
82527
  }
82349
82528
  // src/index.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.2.24",
4
+ "version": "0.2.25",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -89,8 +89,8 @@
89
89
  "test": "bun test"
90
90
  },
91
91
  "dependencies": {
92
- "@ts-cloud/aws-types": "0.2.24",
93
- "@ts-cloud/core": "0.2.24",
92
+ "@ts-cloud/aws-types": "0.2.25",
93
+ "@ts-cloud/core": "0.2.25",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {