ras-stack 0.18.0 → 0.20.0

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/README.md CHANGED
@@ -396,6 +396,34 @@ e2e:
396
396
 
397
397
  The loaded image tag is also available to the command as `RAS_STACK_TEST_IMAGE`. Build and browser durations are written to the job summary, and failure artifacts remain configurable. Applications that need extra caches, services, registry publication, or a different PR/main topology can use `actions/build-container` and `actions/setup-playwright` inside their own job instead.
398
398
 
399
+ Dokploy previews can share the application/domain/image/environment/deploy/health/delete/prune lifecycle through `ras-stack/preview/dokploy`. Product-specific Stripe, storage, seed, and verification work stays around the manager's configure and cleanup hooks.
400
+
401
+ The reusable `build-preview-image.yml` workflow publishes same-repository pull requests directly but turns fork builds into one-day artifacts without exposing a token or secret. A trusted `workflow_run` job can publish that artifact with `actions/publish-preview-image` before running its repository-owned deployment command. The event wrapper and secret-to-environment mapping remain in each application so the trust boundary is visible locally.
402
+
403
+ Self-hosted images that run the app, Centrifugo, and Caddy together can share the lifecycle without sharing a Dockerfile:
404
+
405
+ ```ts
406
+ import { caddyRealtimeProxy, caddyRuntimeEnvironment, centrifugoEnvironment, superviseProcesses } from 'ras-stack/runtime'
407
+
408
+ await superviseProcesses([
409
+ { name: 'app', command: 'node', args: ['.output/server/index.mjs'], env: { ...process.env, PORT: '3001' } },
410
+ {
411
+ name: 'realtime',
412
+ command: 'centrifugo',
413
+ args: ['--config=/app/realtime.json'],
414
+ env: { ...process.env, ...centrifugoEnvironment(realtime) },
415
+ },
416
+ {
417
+ name: 'proxy',
418
+ command: 'caddy',
419
+ args: ['run', '--config', '/app/Caddyfile'],
420
+ env: { ...process.env, ...caddyRuntimeEnvironment() },
421
+ },
422
+ ])
423
+ ```
424
+
425
+ Any unexpected child exit stops its siblings; orchestrator signals receive a graceful window before remaining children are force-killed. `caddyRealtimeProxy()` generates the shared trusted-proxy and same-origin websocket guard. Applications retain binaries, base images, namespaces, ports, volumes, secrets, per-process environment inheritance, preview seeding, and distributed-mode policy.
426
+
399
427
  The workflow consumes pending changesets, commits the resulting versions and changelogs, pushes the commit and tag atomically, and creates a GitHub Release. It does nothing when no versioned changeset is present. The caller owns its checks, Changesets configuration, release policy, and any deployment that follows the release.
400
428
 
401
429
  Pin actions and reusable workflows to a release tag and let Dependabot propose upgrades.
@@ -0,0 +1,71 @@
1
+ export type DokployApplication = {
2
+ applicationId: string;
3
+ name: string;
4
+ };
5
+ export type DokployClientOptions = {
6
+ url: string;
7
+ apiKey: string;
8
+ environmentId: string;
9
+ fetch?: typeof fetch;
10
+ log?: (message: string) => void;
11
+ };
12
+ export declare class DokployClient {
13
+ private readonly options;
14
+ private readonly request;
15
+ private readonly log;
16
+ constructor(options: DokployClientOptions);
17
+ get environmentId(): string;
18
+ api<T = unknown>(procedure: string, options?: {
19
+ query?: Record<string, string>;
20
+ body?: unknown;
21
+ }): Promise<T>;
22
+ applications(): Promise<DokployApplication[]>;
23
+ application(name: string): Promise<DokployApplication | undefined>;
24
+ }
25
+ export type DokployPreviewOptions = {
26
+ client: DokployClient;
27
+ applicationName: (prNumber: string) => string;
28
+ hostname: (prNumber: string) => string;
29
+ port: number;
30
+ healthPath?: string;
31
+ deploymentTimeoutMs?: number;
32
+ healthTimeoutMs?: number;
33
+ pollIntervalMs?: number;
34
+ fetch?: typeof fetch;
35
+ sleep?: (milliseconds: number) => Promise<void>;
36
+ now?: () => number;
37
+ log?: (message: string) => void;
38
+ };
39
+ export type DeployPreviewOptions = {
40
+ prNumber: string;
41
+ image: string;
42
+ environment: string;
43
+ registry?: {
44
+ username: string;
45
+ password: string;
46
+ };
47
+ configure?: (context: {
48
+ applicationId: string;
49
+ client: DokployClient;
50
+ host: string;
51
+ }) => void | Promise<void>;
52
+ };
53
+ export declare class DokployPreviewManager {
54
+ private readonly options;
55
+ private readonly request;
56
+ private readonly pause;
57
+ private readonly log;
58
+ private readonly now;
59
+ constructor(options: DokployPreviewOptions);
60
+ deploy(options: DeployPreviewOptions): Promise<{
61
+ applicationId: string;
62
+ host: string;
63
+ url: string;
64
+ }>;
65
+ delete(prNumber: string, beforeDelete?: (application: DokployApplication | undefined) => void | Promise<void>): Promise<boolean>;
66
+ prune(openPullRequests: ReadonlySet<string>, beforeDelete?: (prNumber: string, application: DokployApplication) => void | Promise<void>): Promise<string[]>;
67
+ private waitForDeployment;
68
+ private waitForHealth;
69
+ }
70
+ export declare function pullRequestNumber(value: string): string;
71
+ export declare function previewHostname(value: string): string;
@@ -0,0 +1,192 @@
1
+ export class DokployClient {
2
+ options;
3
+ request;
4
+ log;
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.request = options.fetch ?? fetch;
8
+ this.log = options.log ?? console.log;
9
+ }
10
+ get environmentId() {
11
+ return this.options.environmentId;
12
+ }
13
+ async api(procedure, options = {}) {
14
+ const url = new URL(`${this.options.url.replace(/\/$/, '')}/api/${procedure}`);
15
+ for (const [key, value] of Object.entries(options.query ?? {}))
16
+ url.searchParams.set(key, value);
17
+ this.log(`→ ${procedure}`);
18
+ const response = await this.request(url, {
19
+ method: options.body === undefined ? 'GET' : 'POST',
20
+ headers: {
21
+ 'x-api-key': this.options.apiKey,
22
+ ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
23
+ },
24
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
25
+ });
26
+ const text = await response.text();
27
+ if (!response.ok)
28
+ throw new Error(`${procedure} failed with ${response.status}: ${text.slice(0, 500)}`);
29
+ if (!text)
30
+ return undefined;
31
+ try {
32
+ return JSON.parse(text);
33
+ }
34
+ catch {
35
+ throw new Error(`${procedure} returned ${response.status} with a non-JSON body: ${text.slice(0, 200)}`);
36
+ }
37
+ }
38
+ async applications() {
39
+ const environment = await this.api('environment.one', {
40
+ query: { environmentId: this.options.environmentId },
41
+ });
42
+ if (!environment)
43
+ throw new Error('environment.one returned an empty response');
44
+ return environment.applications ?? [];
45
+ }
46
+ async application(name) {
47
+ return (await this.applications()).find((application) => application.name === name);
48
+ }
49
+ }
50
+ export class DokployPreviewManager {
51
+ options;
52
+ request;
53
+ pause;
54
+ log;
55
+ now;
56
+ constructor(options) {
57
+ this.options = options;
58
+ this.request = options.fetch ?? fetch;
59
+ this.pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
60
+ this.log = options.log ?? console.log;
61
+ this.now = options.now ?? Date.now;
62
+ }
63
+ async deploy(options) {
64
+ const prNumber = pullRequestNumber(options.prNumber);
65
+ const name = this.options.applicationName(prNumber);
66
+ const host = previewHostname(this.options.hostname(prNumber));
67
+ let application = await this.options.client.application(name);
68
+ if (!application) {
69
+ await this.options.client.api('application.create', {
70
+ body: { name, appName: name, environmentId: this.options.client.environmentId },
71
+ });
72
+ application = await this.options.client.application(name);
73
+ if (!application)
74
+ throw new Error(`Dokploy did not report ${name} after creating it`);
75
+ }
76
+ const applicationId = application.applicationId;
77
+ const details = await this.options.client.api('application.one', {
78
+ query: { applicationId },
79
+ });
80
+ if (!details?.domains?.some((domain) => domain.host === host)) {
81
+ await this.options.client.api('domain.create', {
82
+ body: {
83
+ applicationId,
84
+ host,
85
+ path: '/',
86
+ port: this.options.port,
87
+ https: true,
88
+ certificateType: 'letsencrypt',
89
+ domainType: 'application',
90
+ },
91
+ });
92
+ }
93
+ await options.configure?.({ applicationId, client: this.options.client, host });
94
+ await this.options.client.api('application.saveDockerProvider', {
95
+ body: {
96
+ applicationId,
97
+ dockerImage: options.image,
98
+ username: options.registry?.username ?? null,
99
+ password: options.registry?.password ?? null,
100
+ registryUrl: options.registry ? options.image.split('/')[0] : null,
101
+ },
102
+ });
103
+ await this.options.client.api('application.saveEnvironment', {
104
+ body: { applicationId, env: options.environment, buildArgs: null, buildSecrets: null, createEnvFile: false },
105
+ });
106
+ await this.options.client.api('application.deploy', { body: { applicationId } });
107
+ await this.waitForDeployment(applicationId);
108
+ const url = `https://${host}`;
109
+ await this.waitForHealth(new URL(this.options.healthPath ?? '/api/health', url).toString());
110
+ this.log(`Preview ready at ${url}`);
111
+ return { applicationId, host, url };
112
+ }
113
+ async delete(prNumber, beforeDelete) {
114
+ const name = this.options.applicationName(pullRequestNumber(prNumber));
115
+ const application = await this.options.client.application(name);
116
+ await beforeDelete?.(application);
117
+ if (!application)
118
+ return false;
119
+ await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } });
120
+ return true;
121
+ }
122
+ async prune(openPullRequests, beforeDelete) {
123
+ const deleted = [];
124
+ for (const application of await this.options.client.applications()) {
125
+ const prNumber = previewApplicationPrNumber(application.name, this.options.applicationName);
126
+ if (!prNumber || openPullRequests.has(prNumber))
127
+ continue;
128
+ // Dokploy mutations are intentionally ordered to avoid overwhelming one environment.
129
+ // oxlint-disable-next-line no-await-in-loop
130
+ await beforeDelete?.(prNumber, application);
131
+ // oxlint-disable-next-line no-await-in-loop
132
+ await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } });
133
+ deleted.push(prNumber);
134
+ }
135
+ return deleted;
136
+ }
137
+ async waitForDeployment(applicationId) {
138
+ const deadline = this.now() + (this.options.deploymentTimeoutMs ?? 600_000);
139
+ while (this.now() < deadline) {
140
+ // Polling is intentionally sequential; each response determines whether another request is needed.
141
+ // oxlint-disable-next-line no-await-in-loop
142
+ await this.pause(this.options.pollIntervalMs ?? 5_000);
143
+ // oxlint-disable-next-line no-await-in-loop
144
+ const { applicationStatus } = await this.options.client.api('application.one', {
145
+ query: { applicationId },
146
+ });
147
+ if (applicationStatus === 'done')
148
+ return;
149
+ if (applicationStatus === 'error')
150
+ throw new Error('Dokploy reported a failed deployment');
151
+ }
152
+ throw new Error('Timed out waiting for the Dokploy deployment to finish');
153
+ }
154
+ async waitForHealth(url) {
155
+ const deadline = this.now() + (this.options.healthTimeoutMs ?? 300_000);
156
+ let lastFailure = 'no response';
157
+ while (this.now() < deadline) {
158
+ try {
159
+ // Polling is intentionally sequential; each response determines whether another request is needed.
160
+ // oxlint-disable-next-line no-await-in-loop
161
+ const response = await this.request(url);
162
+ if (response.status === 200)
163
+ return;
164
+ lastFailure = `status ${response.status}`;
165
+ }
166
+ catch (error) {
167
+ lastFailure = error instanceof Error ? error.message : String(error);
168
+ }
169
+ // oxlint-disable-next-line no-await-in-loop
170
+ await this.pause(this.options.pollIntervalMs ?? 5_000);
171
+ }
172
+ throw new Error(`Timed out waiting for ${url} (${lastFailure})`);
173
+ }
174
+ }
175
+ export function pullRequestNumber(value) {
176
+ if (!/^\d+$/.test(value))
177
+ throw new Error('pull request number must contain only digits');
178
+ return value;
179
+ }
180
+ export function previewHostname(value) {
181
+ const parsed = new URL(`https://${value}`);
182
+ if (parsed.hostname !== value || parsed.port || parsed.username || parsed.password || parsed.pathname !== '/') {
183
+ throw new Error('preview hostname must be a bare hostname');
184
+ }
185
+ return value;
186
+ }
187
+ function previewApplicationPrNumber(name, applicationName) {
188
+ const match = /^(\d+)$/.exec(name.match(/(\d+)$/)?.[1] ?? '');
189
+ const prNumber = match?.[1];
190
+ return prNumber && applicationName(prNumber) === name ? prNumber : undefined;
191
+ }
192
+ //# sourceMappingURL=dokploy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dokploy.js","sourceRoot":"","sources":["../../src/preview/dokploy.ts"],"names":[],"mappings":"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","sourcesContent":["export type DokployApplication = { applicationId: string; name: string }\n\nexport type DokployClientOptions = {\n url: string\n apiKey: string\n environmentId: string\n fetch?: typeof fetch\n log?: (message: string) => void\n}\n\nexport class DokployClient {\n private readonly request: typeof fetch\n private readonly log: (message: string) => void\n\n constructor(private readonly options: DokployClientOptions) {\n this.request = options.fetch ?? fetch\n this.log = options.log ?? console.log\n }\n\n get environmentId() {\n return this.options.environmentId\n }\n\n async api<T = unknown>(procedure: string, options: { query?: Record<string, string>; body?: unknown } = {}): Promise<T> {\n const url = new URL(`${this.options.url.replace(/\\/$/, '')}/api/${procedure}`)\n for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value)\n this.log(`→ ${procedure}`)\n const response = await this.request(url, {\n method: options.body === undefined ? 'GET' : 'POST',\n headers: {\n 'x-api-key': this.options.apiKey,\n ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),\n },\n ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),\n })\n const text = await response.text()\n if (!response.ok) throw new Error(`${procedure} failed with ${response.status}: ${text.slice(0, 500)}`)\n if (!text) return undefined as T\n try {\n return JSON.parse(text) as T\n } catch {\n throw new Error(`${procedure} returned ${response.status} with a non-JSON body: ${text.slice(0, 200)}`)\n }\n }\n\n async applications() {\n const environment = await this.api<{ applications?: DokployApplication[] } | undefined>('environment.one', {\n query: { environmentId: this.options.environmentId },\n })\n if (!environment) throw new Error('environment.one returned an empty response')\n return environment.applications ?? []\n }\n\n async application(name: string) {\n return (await this.applications()).find((application) => application.name === name)\n }\n}\n\nexport type DokployPreviewOptions = {\n client: DokployClient\n applicationName: (prNumber: string) => string\n hostname: (prNumber: string) => string\n port: number\n healthPath?: string\n deploymentTimeoutMs?: number\n healthTimeoutMs?: number\n pollIntervalMs?: number\n fetch?: typeof fetch\n sleep?: (milliseconds: number) => Promise<void>\n now?: () => number\n log?: (message: string) => void\n}\n\nexport type DeployPreviewOptions = {\n prNumber: string\n image: string\n environment: string\n registry?: { username: string; password: string }\n configure?: (context: { applicationId: string; client: DokployClient; host: string }) => void | Promise<void>\n}\n\nexport class DokployPreviewManager {\n private readonly request: typeof fetch\n private readonly pause: (milliseconds: number) => Promise<void>\n private readonly log: (message: string) => void\n private readonly now: () => number\n\n constructor(private readonly options: DokployPreviewOptions) {\n this.request = options.fetch ?? fetch\n this.pause = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)))\n this.log = options.log ?? console.log\n this.now = options.now ?? Date.now\n }\n\n async deploy(options: DeployPreviewOptions) {\n const prNumber = pullRequestNumber(options.prNumber)\n const name = this.options.applicationName(prNumber)\n const host = previewHostname(this.options.hostname(prNumber))\n let application = await this.options.client.application(name)\n if (!application) {\n await this.options.client.api('application.create', {\n body: { name, appName: name, environmentId: this.options.client.environmentId },\n })\n application = await this.options.client.application(name)\n if (!application) throw new Error(`Dokploy did not report ${name} after creating it`)\n }\n const applicationId = application.applicationId\n const details = await this.options.client.api<{ domains?: { host: string }[] } | undefined>('application.one', {\n query: { applicationId },\n })\n if (!details?.domains?.some((domain) => domain.host === host)) {\n await this.options.client.api('domain.create', {\n body: {\n applicationId,\n host,\n path: '/',\n port: this.options.port,\n https: true,\n certificateType: 'letsencrypt',\n domainType: 'application',\n },\n })\n }\n await options.configure?.({ applicationId, client: this.options.client, host })\n await this.options.client.api('application.saveDockerProvider', {\n body: {\n applicationId,\n dockerImage: options.image,\n username: options.registry?.username ?? null,\n password: options.registry?.password ?? null,\n registryUrl: options.registry ? options.image.split('/')[0] : null,\n },\n })\n await this.options.client.api('application.saveEnvironment', {\n body: { applicationId, env: options.environment, buildArgs: null, buildSecrets: null, createEnvFile: false },\n })\n await this.options.client.api('application.deploy', { body: { applicationId } })\n await this.waitForDeployment(applicationId)\n const url = `https://${host}`\n await this.waitForHealth(new URL(this.options.healthPath ?? '/api/health', url).toString())\n this.log(`Preview ready at ${url}`)\n return { applicationId, host, url }\n }\n\n async delete(prNumber: string, beforeDelete?: (application: DokployApplication | undefined) => void | Promise<void>) {\n const name = this.options.applicationName(pullRequestNumber(prNumber))\n const application = await this.options.client.application(name)\n await beforeDelete?.(application)\n if (!application) return false\n await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } })\n return true\n }\n\n async prune(\n openPullRequests: ReadonlySet<string>,\n beforeDelete?: (prNumber: string, application: DokployApplication) => void | Promise<void>,\n ) {\n const deleted: string[] = []\n for (const application of await this.options.client.applications()) {\n const prNumber = previewApplicationPrNumber(application.name, this.options.applicationName)\n if (!prNumber || openPullRequests.has(prNumber)) continue\n // Dokploy mutations are intentionally ordered to avoid overwhelming one environment.\n // oxlint-disable-next-line no-await-in-loop\n await beforeDelete?.(prNumber, application)\n // oxlint-disable-next-line no-await-in-loop\n await this.options.client.api('application.delete', { body: { applicationId: application.applicationId } })\n deleted.push(prNumber)\n }\n return deleted\n }\n\n private async waitForDeployment(applicationId: string) {\n const deadline = this.now() + (this.options.deploymentTimeoutMs ?? 600_000)\n while (this.now() < deadline) {\n // Polling is intentionally sequential; each response determines whether another request is needed.\n // oxlint-disable-next-line no-await-in-loop\n await this.pause(this.options.pollIntervalMs ?? 5_000)\n // oxlint-disable-next-line no-await-in-loop\n const { applicationStatus } = await this.options.client.api<{ applicationStatus: string }>('application.one', {\n query: { applicationId },\n })\n if (applicationStatus === 'done') return\n if (applicationStatus === 'error') throw new Error('Dokploy reported a failed deployment')\n }\n throw new Error('Timed out waiting for the Dokploy deployment to finish')\n }\n\n private async waitForHealth(url: string) {\n const deadline = this.now() + (this.options.healthTimeoutMs ?? 300_000)\n let lastFailure = 'no response'\n while (this.now() < deadline) {\n try {\n // Polling is intentionally sequential; each response determines whether another request is needed.\n // oxlint-disable-next-line no-await-in-loop\n const response = await this.request(url)\n if (response.status === 200) return\n lastFailure = `status ${response.status}`\n } catch (error) {\n lastFailure = error instanceof Error ? error.message : String(error)\n }\n // oxlint-disable-next-line no-await-in-loop\n await this.pause(this.options.pollIntervalMs ?? 5_000)\n }\n throw new Error(`Timed out waiting for ${url} (${lastFailure})`)\n }\n}\n\nexport function pullRequestNumber(value: string) {\n if (!/^\\d+$/.test(value)) throw new Error('pull request number must contain only digits')\n return value\n}\n\nexport function previewHostname(value: string) {\n const parsed = new URL(`https://${value}`)\n if (parsed.hostname !== value || parsed.port || parsed.username || parsed.password || parsed.pathname !== '/') {\n throw new Error('preview hostname must be a bare hostname')\n }\n return value\n}\n\nfunction previewApplicationPrNumber(name: string, applicationName: (prNumber: string) => string) {\n const match = /^(\\d+)$/.exec(name.match(/(\\d+)$/)?.[1] ?? '')\n const prNumber = match?.[1]\n return prNumber && applicationName(prNumber) === name ? prNumber : undefined\n}\n"]}
@@ -0,0 +1,32 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export type RuntimeProcess = {
3
+ name: string;
4
+ command: string;
5
+ args?: readonly string[];
6
+ cwd?: string;
7
+ env?: NodeJS.ProcessEnv;
8
+ };
9
+ type SignalSource = Pick<NodeJS.Process, 'off' | 'once'>;
10
+ type SpawnProcess = (process: RuntimeProcess) => ChildProcess;
11
+ export type SupervisorOptions = {
12
+ shutdownTimeoutMs?: number;
13
+ signalSource?: SignalSource;
14
+ spawn?: SpawnProcess;
15
+ };
16
+ export declare function superviseProcesses(processes: readonly RuntimeProcess[], options?: SupervisorOptions): Promise<number>;
17
+ export type CentrifugoEnvironmentOptions = {
18
+ apiKey: string;
19
+ clientTokenSecret?: string;
20
+ subscriptionTokenSecret?: string;
21
+ allowedOrigins?: string;
22
+ redisUrl?: string;
23
+ };
24
+ export declare function centrifugoEnvironment(options: CentrifugoEnvironmentOptions): NodeJS.ProcessEnv;
25
+ export declare function caddyRuntimeEnvironment(): NodeJS.ProcessEnv;
26
+ export declare function caddyRealtimeProxy(options?: {
27
+ publicPort?: number;
28
+ appPort?: number;
29
+ realtimePort?: number;
30
+ websocketPath?: string;
31
+ }): string;
32
+ export {};
@@ -0,0 +1,144 @@
1
+ import { spawn } from 'node:child_process';
2
+ export async function superviseProcesses(processes, options = {}) {
3
+ if (processes.length === 0)
4
+ throw new Error('at least one runtime process is required');
5
+ const names = new Set();
6
+ for (const process of processes) {
7
+ if (!process.name.trim())
8
+ throw new Error('runtime process names must not be empty');
9
+ if (!process.command.trim())
10
+ throw new Error(`runtime process ${process.name} must have a command`);
11
+ if (names.has(process.name))
12
+ throw new Error(`duplicate runtime process name: ${process.name}`);
13
+ names.add(process.name);
14
+ }
15
+ const signalSource = options.signalSource ?? process;
16
+ const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 10_000;
17
+ if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {
18
+ throw new Error('shutdownTimeoutMs must be a non-negative integer');
19
+ }
20
+ const spawnProcess = options.spawn ??
21
+ ((specification) => spawn(specification.command, [...(specification.args ?? [])], {
22
+ cwd: specification.cwd,
23
+ env: specification.env ?? process.env,
24
+ stdio: 'inherit',
25
+ }));
26
+ const children = new Map();
27
+ let settled = false;
28
+ let resolveResult;
29
+ const result = new Promise((resolve) => {
30
+ resolveResult = resolve;
31
+ });
32
+ const finish = async (status) => {
33
+ if (settled)
34
+ return;
35
+ settled = true;
36
+ signalSource.off('SIGINT', onSignal);
37
+ signalSource.off('SIGTERM', onSignal);
38
+ await stopChildren([...children.keys()], shutdownTimeoutMs);
39
+ resolveResult(status);
40
+ };
41
+ const onSignal = () => void finish(0);
42
+ signalSource.once('SIGINT', onSignal);
43
+ signalSource.once('SIGTERM', onSignal);
44
+ try {
45
+ for (const specification of processes) {
46
+ const child = spawnProcess(specification);
47
+ children.set(child, specification.name);
48
+ child.once('error', () => void finish(1));
49
+ child.once('exit', (code) => void finish(code && code > 0 ? code : 1));
50
+ }
51
+ }
52
+ catch (error) {
53
+ await finish(1);
54
+ throw error;
55
+ }
56
+ return result;
57
+ }
58
+ async function stopChildren(children, timeoutMs) {
59
+ const running = children.filter((child) => child.exitCode === null && child.signalCode === null);
60
+ if (running.length === 0)
61
+ return;
62
+ const exited = Promise.all(running.map((child) => new Promise((resolve) => child.once('exit', () => resolve())))).then(() => 'exited');
63
+ for (const child of running)
64
+ child.kill('SIGTERM');
65
+ let timer;
66
+ const timeout = new Promise((resolve) => {
67
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
68
+ });
69
+ const outcome = await Promise.race([exited, timeout]);
70
+ if (timer)
71
+ clearTimeout(timer);
72
+ if (outcome === 'timeout') {
73
+ for (const child of running)
74
+ if (child.exitCode === null && child.signalCode === null)
75
+ child.kill('SIGKILL');
76
+ }
77
+ }
78
+ export function centrifugoEnvironment(options) {
79
+ const apiKey = requiredValue(options.apiKey, 'apiKey');
80
+ const clientTokenSecret = options.clientTokenSecret?.trim();
81
+ const subscriptionTokenSecret = options.subscriptionTokenSecret?.trim();
82
+ return {
83
+ CENTRIFUGO_HTTP_API_KEY: apiKey,
84
+ CENTRIFUGO_CLIENT_ALLOWED_ORIGINS: options.allowedOrigins?.trim() || '*',
85
+ CENTRIFUGO_HTTP_SERVER_ADDRESS: '127.0.0.1',
86
+ CENTRIFUGO_HEALTH_ENABLED: 'true',
87
+ ...(clientTokenSecret ? { CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY: clientTokenSecret } : {}),
88
+ ...(subscriptionTokenSecret
89
+ ? {
90
+ CENTRIFUGO_CLIENT_SUBSCRIPTION_TOKEN_ENABLED: 'true',
91
+ CENTRIFUGO_CLIENT_SUBSCRIPTION_TOKEN_HMAC_SECRET_KEY: subscriptionTokenSecret,
92
+ }
93
+ : {}),
94
+ ...(options.redisUrl
95
+ ? { CENTRIFUGO_ENGINE_TYPE: 'redis', CENTRIFUGO_ENGINE_REDIS_ADDRESS: requiredValue(options.redisUrl, 'redisUrl') }
96
+ : {}),
97
+ };
98
+ }
99
+ export function caddyRuntimeEnvironment() {
100
+ return { XDG_CONFIG_HOME: '/tmp/caddy-config', XDG_DATA_HOME: '/tmp/caddy-data' };
101
+ }
102
+ export function caddyRealtimeProxy(options = {}) {
103
+ const publicPort = port(options.publicPort ?? 3000, 'publicPort');
104
+ const appPort = port(options.appPort ?? 3001, 'appPort');
105
+ const realtimePort = port(options.realtimePort ?? 8000, 'realtimePort');
106
+ const websocketPath = options.websocketPath ?? '/connection/';
107
+ if (!/^\/[A-Za-z0-9._~/-]+\/$/.test(websocketPath) || websocketPath.includes('//')) {
108
+ throw new Error('websocketPath must be a normalized absolute directory path');
109
+ }
110
+ return `{
111
+ \tservers {
112
+ \t\ttrusted_proxies static private_ranges
113
+ \t\ttrusted_proxies_strict
114
+ \t}
115
+ }
116
+
117
+ :${publicPort} {
118
+ \troute {
119
+ \t\t@foreignWebSocketOrigin \`{path}.startsWith('${websocketPath}') && {http.request.header.Origin} != '' && {http.request.header.Origin} != 'http://' + {http.request.hostport} && {http.request.header.Origin} != 'https://' + {http.request.hostport}\`
120
+ \t\trespond @foreignWebSocketOrigin 403
121
+
122
+ \t\thandle ${websocketPath}* {
123
+ \t\t\treverse_proxy 127.0.0.1:${realtimePort}
124
+ \t\t}
125
+
126
+ \t\thandle {
127
+ \t\t\treverse_proxy 127.0.0.1:${appPort}
128
+ \t\t}
129
+ \t}
130
+ }
131
+ `;
132
+ }
133
+ function requiredValue(value, name) {
134
+ const normalized = value.trim();
135
+ if (!normalized)
136
+ throw new Error(`${name} is required`);
137
+ return normalized;
138
+ }
139
+ function port(value, name) {
140
+ if (!Number.isInteger(value) || value < 1 || value > 65_535)
141
+ throw new Error(`${name} must be a valid TCP port`);
142
+ return value;
143
+ }
144
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, type ChildProcess } from 'node:child_process'\n\nexport type RuntimeProcess = {\n name: string\n command: string\n args?: readonly string[]\n cwd?: string\n env?: NodeJS.ProcessEnv\n}\n\ntype SignalSource = Pick<NodeJS.Process, 'off' | 'once'>\ntype SpawnProcess = (process: RuntimeProcess) => ChildProcess\n\nexport type SupervisorOptions = {\n shutdownTimeoutMs?: number\n signalSource?: SignalSource\n spawn?: SpawnProcess\n}\n\nexport async function superviseProcesses(processes: readonly RuntimeProcess[], options: SupervisorOptions = {}) {\n if (processes.length === 0) throw new Error('at least one runtime process is required')\n const names = new Set<string>()\n for (const process of processes) {\n if (!process.name.trim()) throw new Error('runtime process names must not be empty')\n if (!process.command.trim()) throw new Error(`runtime process ${process.name} must have a command`)\n if (names.has(process.name)) throw new Error(`duplicate runtime process name: ${process.name}`)\n names.add(process.name)\n }\n\n const signalSource = options.signalSource ?? process\n const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 10_000\n if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0) {\n throw new Error('shutdownTimeoutMs must be a non-negative integer')\n }\n const spawnProcess =\n options.spawn ??\n ((specification: RuntimeProcess) =>\n spawn(specification.command, [...(specification.args ?? [])], {\n cwd: specification.cwd,\n env: specification.env ?? process.env,\n stdio: 'inherit',\n }))\n const children = new Map<ChildProcess, string>()\n let settled = false\n let resolveResult!: (value: number) => void\n const result = new Promise<number>((resolve) => {\n resolveResult = resolve\n })\n\n const finish = async (status: number) => {\n if (settled) return\n settled = true\n signalSource.off('SIGINT', onSignal)\n signalSource.off('SIGTERM', onSignal)\n await stopChildren([...children.keys()], shutdownTimeoutMs)\n resolveResult(status)\n }\n const onSignal = () => void finish(0)\n signalSource.once('SIGINT', onSignal)\n signalSource.once('SIGTERM', onSignal)\n\n try {\n for (const specification of processes) {\n const child = spawnProcess(specification)\n children.set(child, specification.name)\n child.once('error', () => void finish(1))\n child.once('exit', (code) => void finish(code && code > 0 ? code : 1))\n }\n } catch (error) {\n await finish(1)\n throw error\n }\n return result\n}\n\nasync function stopChildren(children: ChildProcess[], timeoutMs: number) {\n const running = children.filter((child) => child.exitCode === null && child.signalCode === null)\n if (running.length === 0) return\n const exited = Promise.all(running.map((child) => new Promise<void>((resolve) => child.once('exit', () => resolve())))).then(\n () => 'exited' as const,\n )\n for (const child of running) child.kill('SIGTERM')\n let timer: ReturnType<typeof setTimeout> | undefined\n const timeout = new Promise<'timeout'>((resolve) => {\n timer = setTimeout(() => resolve('timeout'), timeoutMs)\n })\n const outcome = await Promise.race([exited, timeout])\n if (timer) clearTimeout(timer)\n if (outcome === 'timeout') {\n for (const child of running) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')\n }\n}\n\nexport type CentrifugoEnvironmentOptions = {\n apiKey: string\n clientTokenSecret?: string\n subscriptionTokenSecret?: string\n allowedOrigins?: string\n redisUrl?: string\n}\n\nexport function centrifugoEnvironment(options: CentrifugoEnvironmentOptions): NodeJS.ProcessEnv {\n const apiKey = requiredValue(options.apiKey, 'apiKey')\n const clientTokenSecret = options.clientTokenSecret?.trim()\n const subscriptionTokenSecret = options.subscriptionTokenSecret?.trim()\n return {\n CENTRIFUGO_HTTP_API_KEY: apiKey,\n CENTRIFUGO_CLIENT_ALLOWED_ORIGINS: options.allowedOrigins?.trim() || '*',\n CENTRIFUGO_HTTP_SERVER_ADDRESS: '127.0.0.1',\n CENTRIFUGO_HEALTH_ENABLED: 'true',\n ...(clientTokenSecret ? { CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY: clientTokenSecret } : {}),\n ...(subscriptionTokenSecret\n ? {\n CENTRIFUGO_CLIENT_SUBSCRIPTION_TOKEN_ENABLED: 'true',\n CENTRIFUGO_CLIENT_SUBSCRIPTION_TOKEN_HMAC_SECRET_KEY: subscriptionTokenSecret,\n }\n : {}),\n ...(options.redisUrl\n ? { CENTRIFUGO_ENGINE_TYPE: 'redis', CENTRIFUGO_ENGINE_REDIS_ADDRESS: requiredValue(options.redisUrl, 'redisUrl') }\n : {}),\n }\n}\n\nexport function caddyRuntimeEnvironment(): NodeJS.ProcessEnv {\n return { XDG_CONFIG_HOME: '/tmp/caddy-config', XDG_DATA_HOME: '/tmp/caddy-data' }\n}\n\nexport function caddyRealtimeProxy(options: { publicPort?: number; appPort?: number; realtimePort?: number; websocketPath?: string } = {}) {\n const publicPort = port(options.publicPort ?? 3000, 'publicPort')\n const appPort = port(options.appPort ?? 3001, 'appPort')\n const realtimePort = port(options.realtimePort ?? 8000, 'realtimePort')\n const websocketPath = options.websocketPath ?? '/connection/'\n if (!/^\\/[A-Za-z0-9._~/-]+\\/$/.test(websocketPath) || websocketPath.includes('//')) {\n throw new Error('websocketPath must be a normalized absolute directory path')\n }\n return `{\n\\tservers {\n\\t\\ttrusted_proxies static private_ranges\n\\t\\ttrusted_proxies_strict\n\\t}\n}\n\n:${publicPort} {\n\\troute {\n\\t\\t@foreignWebSocketOrigin \\`{path}.startsWith('${websocketPath}') && {http.request.header.Origin} != '' && {http.request.header.Origin} != 'http://' + {http.request.hostport} && {http.request.header.Origin} != 'https://' + {http.request.hostport}\\`\n\\t\\trespond @foreignWebSocketOrigin 403\n\n\\t\\thandle ${websocketPath}* {\n\\t\\t\\treverse_proxy 127.0.0.1:${realtimePort}\n\\t\\t}\n\n\\t\\thandle {\n\\t\\t\\treverse_proxy 127.0.0.1:${appPort}\n\\t\\t}\n\\t}\n}\n`\n}\n\nfunction requiredValue(value: string, name: string) {\n const normalized = value.trim()\n if (!normalized) throw new Error(`${name} is required`)\n return normalized\n}\n\nfunction port(value: number, name: string) {\n if (!Number.isInteger(value) || value < 1 || value > 65_535) throw new Error(`${name} must be a valid TCP port`)\n return value\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ras-stack",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Composable full-stack primitives shared across Richard Solomou's applications.",
5
5
  "keywords": [
6
6
  "authentication",
@@ -63,6 +63,10 @@
63
63
  "types": "./dist/policy/index.d.ts",
64
64
  "default": "./dist/policy/index.js"
65
65
  },
66
+ "./preview/dokploy": {
67
+ "types": "./dist/preview/dokploy.d.ts",
68
+ "default": "./dist/preview/dokploy.js"
69
+ },
66
70
  "./realtime": {
67
71
  "types": "./dist/realtime/index.d.ts",
68
72
  "default": "./dist/realtime/index.js"
@@ -79,6 +83,10 @@
79
83
  "types": "./dist/server/index.d.ts",
80
84
  "default": "./dist/server/index.js"
81
85
  },
86
+ "./runtime": {
87
+ "types": "./dist/runtime/index.d.ts",
88
+ "default": "./dist/runtime/index.js"
89
+ },
82
90
  "./tanstack/query": {
83
91
  "types": "./dist/tanstack/query.d.ts",
84
92
  "default": "./dist/tanstack/query.js"