ras-stack 0.19.0 → 0.21.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 +33 -0
- package/dist/conformance/index.d.ts +25 -0
- package/dist/conformance/index.js +125 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/runtime/index.d.ts +32 -0
- package/dist/runtime/index.js +144 -0
- package/dist/runtime/index.js.map +1 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -170,6 +170,15 @@ if (target.provider === 'postgres') {
|
|
|
170
170
|
|
|
171
171
|
The package does not make SQLite and PostgreSQL queries look identical. Applications keep driver-specific transaction and compatibility behavior while sharing target validation, pool defaults, numeric parsing, migrations, credential-safe display URLs, and shutdown.
|
|
172
172
|
|
|
173
|
+
Consumer tests can verify the real provider selection and provider-specific safety settings without sharing a schema or repository:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { assertDatabaseTargetConformance, assertSqliteConformance } from 'ras-stack/conformance'
|
|
177
|
+
|
|
178
|
+
await assertDatabaseTargetConformance(databaseTarget)
|
|
179
|
+
await assertSqliteConformance((name) => sqliteClient.pragma(name, { simple: true }))
|
|
180
|
+
```
|
|
181
|
+
|
|
173
182
|
## Realtime updates
|
|
174
183
|
|
|
175
184
|
Applications choose their channel names, authorize subscriptions, and define payloads. `ras-stack` handles Centrifugo's HTTP publication, signed tokens, and repeated browser lifecycle mechanics:
|
|
@@ -400,6 +409,30 @@ Dokploy previews can share the application/domain/image/environment/deploy/healt
|
|
|
400
409
|
|
|
401
410
|
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
411
|
|
|
412
|
+
Self-hosted images that run the app, Centrifugo, and Caddy together can share the lifecycle without sharing a Dockerfile:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
import { caddyRealtimeProxy, caddyRuntimeEnvironment, centrifugoEnvironment, superviseProcesses } from 'ras-stack/runtime'
|
|
416
|
+
|
|
417
|
+
await superviseProcesses([
|
|
418
|
+
{ name: 'app', command: 'node', args: ['.output/server/index.mjs'], env: { ...process.env, PORT: '3001' } },
|
|
419
|
+
{
|
|
420
|
+
name: 'realtime',
|
|
421
|
+
command: 'centrifugo',
|
|
422
|
+
args: ['--config=/app/realtime.json'],
|
|
423
|
+
env: { ...process.env, ...centrifugoEnvironment(realtime) },
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: 'proxy',
|
|
427
|
+
command: 'caddy',
|
|
428
|
+
args: ['run', '--config', '/app/Caddyfile'],
|
|
429
|
+
env: { ...process.env, ...caddyRuntimeEnvironment() },
|
|
430
|
+
},
|
|
431
|
+
])
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
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.
|
|
435
|
+
|
|
403
436
|
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.
|
|
404
437
|
|
|
405
438
|
Pin actions and reusable workflows to a release tag and let Dependabot propose upgrades.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare class ConformanceError extends Error {
|
|
2
|
+
readonly scenario: string;
|
|
3
|
+
constructor(scenario: string, message: string, options?: {
|
|
4
|
+
cause?: unknown;
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
export declare function assertMutationOriginConformance(guard: (request: Request) => void | Promise<void>, options?: {
|
|
8
|
+
trustForwardedHeaders?: boolean;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
export type HealthHandlerFactory = (check: () => void | Promise<void>) => () => Response | Promise<Response>;
|
|
11
|
+
export declare function assertHealthHandlerConformance(createHandler: HealthHandlerFactory): Promise<void>;
|
|
12
|
+
export type SqlitePragmaReader = (name: 'journal_mode' | 'synchronous' | 'busy_timeout' | 'foreign_keys') => unknown;
|
|
13
|
+
export declare function assertSqliteConformance(readPragma: SqlitePragmaReader): Promise<void>;
|
|
14
|
+
export type DatabaseTarget = {
|
|
15
|
+
provider: 'sqlite';
|
|
16
|
+
file: string;
|
|
17
|
+
} | {
|
|
18
|
+
provider: 'postgres';
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
export type DatabaseTargetResolver = (options: {
|
|
22
|
+
databaseUrl?: string;
|
|
23
|
+
sqliteFile: string;
|
|
24
|
+
}) => DatabaseTarget;
|
|
25
|
+
export declare function assertDatabaseTargetConformance(resolve: DatabaseTargetResolver): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export class ConformanceError extends Error {
|
|
2
|
+
scenario;
|
|
3
|
+
constructor(scenario, message, options = {}) {
|
|
4
|
+
super(`${scenario}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause });
|
|
5
|
+
this.scenario = scenario;
|
|
6
|
+
this.name = 'ConformanceError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function assertMutationOriginConformance(guard, options = {}) {
|
|
10
|
+
await accepted('same-origin request', () => guard(new Request('https://app.example/action', {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { origin: 'https://app.example', 'sec-fetch-site': 'same-origin' },
|
|
13
|
+
})));
|
|
14
|
+
await rejected('cross-origin request', () => guard(new Request('https://app.example/action', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { origin: 'https://attacker.example', 'sec-fetch-site': 'cross-site' },
|
|
17
|
+
})));
|
|
18
|
+
await rejected('missing-origin request', () => guard(new Request('https://app.example/action', { method: 'POST' })));
|
|
19
|
+
if (!options.trustForwardedHeaders) {
|
|
20
|
+
await rejected('spoofed forwarded-origin request', () => guard(new Request('https://app.example/action', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {
|
|
23
|
+
origin: 'https://attacker.example',
|
|
24
|
+
'x-forwarded-host': 'attacker.example',
|
|
25
|
+
'x-forwarded-proto': 'https',
|
|
26
|
+
},
|
|
27
|
+
})));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function assertHealthHandlerConformance(createHandler) {
|
|
31
|
+
const healthy = await createHandler(() => undefined)();
|
|
32
|
+
if (healthy.status !== 200)
|
|
33
|
+
throw new ConformanceError('healthy dependency', `expected status 200, received ${healthy.status}`);
|
|
34
|
+
const healthyBody = await responseBody(healthy, 'healthy dependency');
|
|
35
|
+
if (healthyBody.ok !== true)
|
|
36
|
+
throw new ConformanceError('healthy dependency', 'response body must contain ok: true');
|
|
37
|
+
const privateMessage = 'password=private-diagnostic';
|
|
38
|
+
const unavailable = await createHandler(() => Promise.reject(new Error(privateMessage)))();
|
|
39
|
+
if (unavailable.status !== 503) {
|
|
40
|
+
throw new ConformanceError('unavailable dependency', `expected status 503, received ${unavailable.status}`);
|
|
41
|
+
}
|
|
42
|
+
const unavailableText = await unavailable.text();
|
|
43
|
+
if (unavailableText.includes(privateMessage)) {
|
|
44
|
+
throw new ConformanceError('unavailable dependency', 'response exposed the private diagnostic message');
|
|
45
|
+
}
|
|
46
|
+
let unavailableBody;
|
|
47
|
+
try {
|
|
48
|
+
unavailableBody = JSON.parse(unavailableText);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new ConformanceError('unavailable dependency', 'response body must be JSON', { cause: error });
|
|
52
|
+
}
|
|
53
|
+
if (!unavailableBody || typeof unavailableBody !== 'object' || !('ok' in unavailableBody) || unavailableBody.ok !== false) {
|
|
54
|
+
throw new ConformanceError('unavailable dependency', 'response body must contain ok: false');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function assertSqliteConformance(readPragma) {
|
|
58
|
+
const values = {
|
|
59
|
+
journalMode: String(await readPragma('journal_mode')).toLowerCase(),
|
|
60
|
+
synchronous: Number(await readPragma('synchronous')),
|
|
61
|
+
busyTimeout: Number(await readPragma('busy_timeout')),
|
|
62
|
+
foreignKeys: Number(await readPragma('foreign_keys')),
|
|
63
|
+
};
|
|
64
|
+
if (values.journalMode !== 'wal' && values.journalMode !== 'memory') {
|
|
65
|
+
throw new ConformanceError('SQLite journal mode', `expected wal or memory, received ${values.journalMode}`);
|
|
66
|
+
}
|
|
67
|
+
if (values.synchronous !== 2) {
|
|
68
|
+
throw new ConformanceError('SQLite synchronous mode', `expected FULL (2), received ${values.synchronous}`);
|
|
69
|
+
}
|
|
70
|
+
if (values.busyTimeout !== 5000) {
|
|
71
|
+
throw new ConformanceError('SQLite busy timeout', `expected 5000, received ${values.busyTimeout}`);
|
|
72
|
+
}
|
|
73
|
+
if (values.foreignKeys !== 1) {
|
|
74
|
+
throw new ConformanceError('SQLite foreign keys', `expected enabled (1), received ${values.foreignKeys}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function assertDatabaseTargetConformance(resolve) {
|
|
78
|
+
const sqliteFile = '/data/application.sqlite';
|
|
79
|
+
const sqlite = resolve({ sqliteFile });
|
|
80
|
+
if (sqlite.provider !== 'sqlite' || sqlite.file !== sqliteFile) {
|
|
81
|
+
throw new ConformanceError('default database target', 'expected the configured SQLite file');
|
|
82
|
+
}
|
|
83
|
+
for (const url of ['postgres://user:secret@database/application', 'postgresql://user:secret@database/application']) {
|
|
84
|
+
const target = resolve({ databaseUrl: url, sqliteFile });
|
|
85
|
+
if (target.provider !== 'postgres' || target.url !== url) {
|
|
86
|
+
throw new ConformanceError('PostgreSQL database target', `expected the configured ${new URL(url).protocol} URL`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
resolve({ databaseUrl: 'https://database.example/application', sqliteFile });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw new ConformanceError('invalid database target', 'expected a non-PostgreSQL URL to be rejected');
|
|
96
|
+
}
|
|
97
|
+
async function accepted(scenario, work) {
|
|
98
|
+
try {
|
|
99
|
+
await work();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new ConformanceError(scenario, 'expected request to be accepted', { cause: error });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function rejected(scenario, work) {
|
|
106
|
+
try {
|
|
107
|
+
await work();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
throw new ConformanceError(scenario, 'expected request to be rejected');
|
|
113
|
+
}
|
|
114
|
+
async function responseBody(response, scenario) {
|
|
115
|
+
try {
|
|
116
|
+
const body = await response.json();
|
|
117
|
+
if (body && typeof body === 'object')
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new ConformanceError(scenario, 'response body must be JSON', { cause: error });
|
|
122
|
+
}
|
|
123
|
+
throw new ConformanceError(scenario, 'response body must be an object');
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/conformance/index.ts"],"names":[],"mappings":"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","sourcesContent":["export class ConformanceError extends Error {\n constructor(\n readonly scenario: string,\n message: string,\n options: { cause?: unknown } = {},\n ) {\n super(`${scenario}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })\n this.name = 'ConformanceError'\n }\n}\n\nexport async function assertMutationOriginConformance(\n guard: (request: Request) => void | Promise<void>,\n options: { trustForwardedHeaders?: boolean } = {},\n) {\n await accepted('same-origin request', () =>\n guard(\n new Request('https://app.example/action', {\n method: 'POST',\n headers: { origin: 'https://app.example', 'sec-fetch-site': 'same-origin' },\n }),\n ),\n )\n await rejected('cross-origin request', () =>\n guard(\n new Request('https://app.example/action', {\n method: 'POST',\n headers: { origin: 'https://attacker.example', 'sec-fetch-site': 'cross-site' },\n }),\n ),\n )\n await rejected('missing-origin request', () => guard(new Request('https://app.example/action', { method: 'POST' })))\n if (!options.trustForwardedHeaders) {\n await rejected('spoofed forwarded-origin request', () =>\n guard(\n new Request('https://app.example/action', {\n method: 'POST',\n headers: {\n origin: 'https://attacker.example',\n 'x-forwarded-host': 'attacker.example',\n 'x-forwarded-proto': 'https',\n },\n }),\n ),\n )\n }\n}\n\nexport type HealthHandlerFactory = (check: () => void | Promise<void>) => () => Response | Promise<Response>\n\nexport async function assertHealthHandlerConformance(createHandler: HealthHandlerFactory) {\n const healthy = await createHandler(() => undefined)()\n if (healthy.status !== 200) throw new ConformanceError('healthy dependency', `expected status 200, received ${healthy.status}`)\n const healthyBody = await responseBody(healthy, 'healthy dependency')\n if (healthyBody.ok !== true) throw new ConformanceError('healthy dependency', 'response body must contain ok: true')\n\n const privateMessage = 'password=private-diagnostic'\n const unavailable = await createHandler(() => Promise.reject(new Error(privateMessage)))()\n if (unavailable.status !== 503) {\n throw new ConformanceError('unavailable dependency', `expected status 503, received ${unavailable.status}`)\n }\n const unavailableText = await unavailable.text()\n if (unavailableText.includes(privateMessage)) {\n throw new ConformanceError('unavailable dependency', 'response exposed the private diagnostic message')\n }\n let unavailableBody: unknown\n try {\n unavailableBody = JSON.parse(unavailableText)\n } catch (error) {\n throw new ConformanceError('unavailable dependency', 'response body must be JSON', { cause: error })\n }\n if (!unavailableBody || typeof unavailableBody !== 'object' || !('ok' in unavailableBody) || unavailableBody.ok !== false) {\n throw new ConformanceError('unavailable dependency', 'response body must contain ok: false')\n }\n}\n\nexport type SqlitePragmaReader = (name: 'journal_mode' | 'synchronous' | 'busy_timeout' | 'foreign_keys') => unknown\n\nexport async function assertSqliteConformance(readPragma: SqlitePragmaReader) {\n const values = {\n journalMode: String(await readPragma('journal_mode')).toLowerCase(),\n synchronous: Number(await readPragma('synchronous')),\n busyTimeout: Number(await readPragma('busy_timeout')),\n foreignKeys: Number(await readPragma('foreign_keys')),\n }\n if (values.journalMode !== 'wal' && values.journalMode !== 'memory') {\n throw new ConformanceError('SQLite journal mode', `expected wal or memory, received ${values.journalMode}`)\n }\n if (values.synchronous !== 2) {\n throw new ConformanceError('SQLite synchronous mode', `expected FULL (2), received ${values.synchronous}`)\n }\n if (values.busyTimeout !== 5000) {\n throw new ConformanceError('SQLite busy timeout', `expected 5000, received ${values.busyTimeout}`)\n }\n if (values.foreignKeys !== 1) {\n throw new ConformanceError('SQLite foreign keys', `expected enabled (1), received ${values.foreignKeys}`)\n }\n}\n\nexport type DatabaseTarget = { provider: 'sqlite'; file: string } | { provider: 'postgres'; url: string }\n\nexport type DatabaseTargetResolver = (options: { databaseUrl?: string; sqliteFile: string }) => DatabaseTarget\n\nexport function assertDatabaseTargetConformance(resolve: DatabaseTargetResolver) {\n const sqliteFile = '/data/application.sqlite'\n const sqlite = resolve({ sqliteFile })\n if (sqlite.provider !== 'sqlite' || sqlite.file !== sqliteFile) {\n throw new ConformanceError('default database target', 'expected the configured SQLite file')\n }\n\n for (const url of ['postgres://user:secret@database/application', 'postgresql://user:secret@database/application']) {\n const target = resolve({ databaseUrl: url, sqliteFile })\n if (target.provider !== 'postgres' || target.url !== url) {\n throw new ConformanceError('PostgreSQL database target', `expected the configured ${new URL(url).protocol} URL`)\n }\n }\n\n try {\n resolve({ databaseUrl: 'https://database.example/application', sqliteFile })\n } catch {\n return\n }\n throw new ConformanceError('invalid database target', 'expected a non-PostgreSQL URL to be rejected')\n}\n\nasync function accepted(scenario: string, work: () => void | Promise<void>) {\n try {\n await work()\n } catch (error) {\n throw new ConformanceError(scenario, 'expected request to be accepted', { cause: error })\n }\n}\n\nasync function rejected(scenario: string, work: () => void | Promise<void>) {\n try {\n await work()\n } catch {\n return\n }\n throw new ConformanceError(scenario, 'expected request to be rejected')\n}\n\nasync function responseBody(response: Response, scenario: string): Promise<Record<string, unknown>> {\n try {\n const body: unknown = await response.json()\n if (body && typeof body === 'object') return body as Record<string, unknown>\n } catch (error) {\n throw new ConformanceError(scenario, 'response body must be JSON', { cause: error })\n }\n throw new ConformanceError(scenario, 'response body must be an object')\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":"AAAA,OAAO,EAAE,KAAK,EAAqB,MAAM,oBAAoB,CAAA;AAmB7D,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,SAAoC,EAAE,OAAO,GAAsB,EAAE;IAC5G,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;IACvF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAA;IAC/B,KAAK,MAAM,OAAO,IAAI,SAAS,EAAE,CAAC;QAChC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAA;QACpF,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,OAAO,CAAC,IAAI,sBAAsB,CAAC,CAAA;QACnG,IAAI,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAA;QAC/F,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IAED,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAA;IACpD,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,IAAI,MAAM,CAAA;IAC7D,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,iBAAiB,CAAC,IAAI,iBAAiB,GAAG,CAAC,EAAE,CAAC;QACtE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAA;IACrE,CAAC;IACD,MAAM,YAAY,GAChB,OAAO,CAAC,KAAK;QACb,CAAC,CAAC,aAA6B,EAAE,EAAE,CACjC,KAAK,CAAC,aAAa,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,EAAE;YAC5D,GAAG,EAAE,aAAa,CAAC,GAAG;YACtB,GAAG,EAAE,aAAa,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG;YACrC,KAAK,EAAE,SAAS;SACjB,CAAC,CAAC,CAAA;IACP,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAwB,CAAA;IAChD,IAAI,OAAO,GAAG,KAAK,CAAA;IACnB,IAAI,aAAuC,CAAA;IAC3C,MAAM,MAAM,GAAG,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,EAAE;QAC7C,aAAa,GAAG,OAAO,CAAA;IACzB,CAAC,CAAC,CAAA;IAEF,MAAM,MAAM,GAAG,KAAK,EAAE,MAAc,EAAE,EAAE;QACtC,IAAI,OAAO;YAAE,OAAM;QACnB,OAAO,GAAG,IAAI,CAAA;QACd,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;QACpC,YAAY,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;QACrC,MAAM,YAAY,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,iBAAiB,CAAC,CAAA;QAC3D,aAAa,CAAC,MAAM,CAAC,CAAA;IACvB,CAAC,CAAA;IACD,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,CAAA;IACrC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;IACrC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;IAEtC,IAAI,CAAC;QACH,KAAK,MAAM,aAAa,IAAI,SAAS,EAAE,CAAC;YACtC,MAAM,KAAK,GAAG,YAAY,CAAC,aAAa,CAAC,CAAA;YACzC,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,CAAA;YACvC,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;YACzC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,MAAM,CAAC,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QACxE,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,CAAC,CAAC,CAAC,CAAA;QACf,MAAM,KAAK,CAAA;IACb,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC;AAED,KAAK,UAAU,YAAY,CAAC,QAAwB,EAAE,SAAiB;IACrE,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC,CAAA;IAChG,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAChC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAC1H,GAAG,EAAE,CAAC,QAAiB,CACxB,CAAA;IACD,KAAK,MAAM,KAAK,IAAI,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IAClD,IAAI,KAAgD,CAAA;IACpD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAY,CAAC,OAAO,EAAE,EAAE;QACjD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAA;IACzD,CAAC,CAAC,CAAA;IACF,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACrD,IAAI,KAAK;QAAE,YAAY,CAAC,KAAK,CAAC,CAAA;IAC9B,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,KAAK,MAAM,KAAK,IAAI,OAAO;YAAE,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI;gBAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IAC9G,CAAC;AACH,CAAC;AAUD,MAAM,UAAU,qBAAqB,CAAC,OAAqC;IACzE,MAAM,MAAM,GAAG,aAAa,CAAC,OAAO,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IACtD,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,EAAE,IAAI,EAAE,CAAA;IAC3D,MAAM,uBAAuB,GAAG,OAAO,CAAC,uBAAuB,EAAE,IAAI,EAAE,CAAA;IACvE,OAAO;QACL,uBAAuB,EAAE,MAAM;QAC/B,iCAAiC,EAAE,OAAO,CAAC,cAAc,EAAE,IAAI,EAAE,IAAI,GAAG;QACxE,8BAA8B,EAAE,WAAW;QAC3C,yBAAyB,EAAE,MAAM;QACjC,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC,EAAE,uCAAuC,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5F,GAAG,CAAC,uBAAuB;YACzB,CAAC,CAAC;gBACE,4CAA4C,EAAE,MAAM;gBACpD,oDAAoD,EAAE,uBAAuB;aAC9E;YACH,CAAC,CAAC,EAAE,CAAC;QACP,GAAG,CAAC,OAAO,CAAC,QAAQ;YAClB,CAAC,CAAC,EAAE,sBAAsB,EAAE,OAAO,EAAE,+BAA+B,EAAE,aAAa,CAAC,OAAO,CAAC,QAAQ,EAAE,UAAU,CAAC,EAAE;YACnH,CAAC,CAAC,EAAE,CAAC;KACR,CAAA;AACH,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,CAAA;AACnF,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,OAAO,GAA6F,EAAE;IACvI,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,IAAI,IAAI,EAAE,YAAY,CAAC,CAAA;IACjE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,IAAI,IAAI,EAAE,SAAS,CAAC,CAAA;IACxD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,IAAI,IAAI,EAAE,cAAc,CAAC,CAAA;IACvE,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,cAAc,CAAA;IAC7D,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACnF,MAAM,IAAI,KAAK,CAAC,4DAA4D,CAAC,CAAA;IAC/E,CAAC;IACD,OAAO;;;;;;;GAON,UAAU;;mDAEsC,aAAa;;;aAGnD,aAAa;gCACM,YAAY;;;;gCAIZ,OAAO;;;;CAItC,CAAA;AACD,CAAC;AAED,SAAS,aAAa,CAAC,KAAa,EAAE,IAAY;IAChD,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC/B,IAAI,CAAC,UAAU;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,cAAc,CAAC,CAAA;IACvD,OAAO,UAAU,CAAA;AACnB,CAAC;AAED,SAAS,IAAI,CAAC,KAAa,EAAE,IAAY;IACvC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,2BAA2B,CAAC,CAAA;IAChH,OAAO,KAAK,CAAA;AACd,CAAC","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.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Composable full-stack primitives shared across Richard Solomou's applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authentication",
|
|
@@ -59,6 +59,10 @@
|
|
|
59
59
|
"types": "./dist/database/postgres.d.ts",
|
|
60
60
|
"default": "./dist/database/postgres.js"
|
|
61
61
|
},
|
|
62
|
+
"./conformance": {
|
|
63
|
+
"types": "./dist/conformance/index.d.ts",
|
|
64
|
+
"default": "./dist/conformance/index.js"
|
|
65
|
+
},
|
|
62
66
|
"./policy": {
|
|
63
67
|
"types": "./dist/policy/index.d.ts",
|
|
64
68
|
"default": "./dist/policy/index.js"
|
|
@@ -83,6 +87,10 @@
|
|
|
83
87
|
"types": "./dist/server/index.d.ts",
|
|
84
88
|
"default": "./dist/server/index.js"
|
|
85
89
|
},
|
|
90
|
+
"./runtime": {
|
|
91
|
+
"types": "./dist/runtime/index.d.ts",
|
|
92
|
+
"default": "./dist/runtime/index.js"
|
|
93
|
+
},
|
|
86
94
|
"./tanstack/query": {
|
|
87
95
|
"types": "./dist/tanstack/query.d.ts",
|
|
88
96
|
"default": "./dist/tanstack/query.js"
|