@tinvar/renderer 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carson Hopper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts the renderer in dev mode: Vite's dev server renders from source.
|
|
3
|
+
//
|
|
4
|
+
// node packages/renderer/bin/dev-renderer.js <socket-path> <app-root> --port=N
|
|
5
|
+
// [--deferred=FILE] [--codec=msgpack|json-lines] [--exit-with-stdin]
|
|
6
|
+
//
|
|
7
|
+
// PHP talks to it over the socket exactly as it talks to the built renderer;
|
|
8
|
+
// the browser loads modules from the dev server on loopback port N, through
|
|
9
|
+
// PHP's server (tinvar serve --dev). See src/dev-server.js for what dev mode
|
|
10
|
+
// gives up, and why that keeps it out of production.
|
|
11
|
+
|
|
12
|
+
// As bin/renderer.js: a listener of our own replaces SIGUSR1's default, which
|
|
13
|
+
// opens Node's debugger to anything that can signal this process.
|
|
14
|
+
process.on('SIGUSR1', () => {});
|
|
15
|
+
|
|
16
|
+
import { assertDevelopment, DevServerRefused } from '../src/dev-guard.js';
|
|
17
|
+
|
|
18
|
+
// Before anything else is loaded -- Vite, the app, its dependencies -- and
|
|
19
|
+
// loudly: the dev server never runs in production, whatever started it.
|
|
20
|
+
try {
|
|
21
|
+
assertDevelopment(process.env);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`[tinvar:renderer] ${error.message}`);
|
|
24
|
+
process.exit(78);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// React picks its build at import time; the development build is the one
|
|
28
|
+
// that carries component stacks and HMR.
|
|
29
|
+
process.env.NODE_ENV = 'development';
|
|
30
|
+
|
|
31
|
+
const { lstatSync, mkdirSync } = await import('node:fs');
|
|
32
|
+
const path = (await import('node:path')).default;
|
|
33
|
+
const { assertScrubbedEnvironment, createRenderer } = await import('../src/server.js');
|
|
34
|
+
const { createDevServer } = await import('../src/dev-server.js');
|
|
35
|
+
const { scrubEnvironment } = await import('../src/sandbox.js');
|
|
36
|
+
|
|
37
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
38
|
+
const options = process.argv.slice(2).filter((a) => a.startsWith('--'));
|
|
39
|
+
const option = (name) => options.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
40
|
+
|
|
41
|
+
const [socketPath, appRoot] = positional;
|
|
42
|
+
const port = Number(option('port'));
|
|
43
|
+
if (!socketPath || !appRoot || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
44
|
+
console.error(
|
|
45
|
+
'usage: dev-renderer.js <socket-path> <app-root> --port=N [--deferred=FILE] [--codec=msgpack|json-lines] [--exit-with-stdin]',
|
|
46
|
+
);
|
|
47
|
+
process.exit(64);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Unsandboxed is no reason to hold credentials: a dependency that could read
|
|
51
|
+
// them could send them, here more easily than anywhere.
|
|
52
|
+
if (process.env.TINVAR_ALLOW_UNSCRUBBED_ENV !== '1') {
|
|
53
|
+
try {
|
|
54
|
+
assertScrubbedEnvironment();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`[tinvar:renderer] ${error.message}`);
|
|
57
|
+
process.exit(78);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// And then nothing but the allowlist a sandboxed renderer's child is given,
|
|
61
|
+
// with what dev mode reads besides -- the names tinvar serve --dev passes
|
|
62
|
+
// (ServeCommand::DEV_RENDERER_ENV) -- however this process was started.
|
|
63
|
+
scrubEnvironment(['TINVAR_ENV', 'APP_ENV', 'TINVAR_PROFILE']);
|
|
64
|
+
|
|
65
|
+
// The socket's directory, private, as the built renderer's launcher makes it;
|
|
66
|
+
// and nothing that is not a socket already at the path.
|
|
67
|
+
mkdirSync(path.dirname(path.resolve(socketPath)), { recursive: true, mode: 0o700 });
|
|
68
|
+
try {
|
|
69
|
+
if (!lstatSync(socketPath).isSocket()) {
|
|
70
|
+
console.error(
|
|
71
|
+
`[tinvar:renderer] Refusing ${socketPath}: something other than a socket is already there.`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(73);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Nothing there yet.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (options.includes('--exit-with-stdin')) {
|
|
80
|
+
process.stdin.on('end', () => process.kill(process.pid, 'SIGTERM'));
|
|
81
|
+
process.stdin.resume();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let dev;
|
|
85
|
+
try {
|
|
86
|
+
dev = await createDevServer({
|
|
87
|
+
appRoot: path.resolve(appRoot),
|
|
88
|
+
port,
|
|
89
|
+
deferredManifest: option('deferred') ? path.resolve(option('deferred')) : null,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(
|
|
93
|
+
`[tinvar:renderer] ${error instanceof DevServerRefused ? error.message : (error?.stack ?? error)}`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(error instanceof DevServerRefused ? 78 : 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const renderer = await createRenderer({
|
|
99
|
+
socketPath,
|
|
100
|
+
appEntry: new URL('../src/dev-app.js', import.meta.url).pathname,
|
|
101
|
+
codec: option('codec') ?? process.env.TINVAR_RENDERER_CODEC ?? 'msgpack',
|
|
102
|
+
checkEnvironment: process.env.TINVAR_ALLOW_UNSCRUBBED_ENV !== '1',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
`[tinvar:renderer] dev server on ${dev.url}, listening on ${renderer.socketPath} (${renderer.codec}, development)`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
let stopping = false;
|
|
110
|
+
const shutDown = async () => {
|
|
111
|
+
if (stopping) return;
|
|
112
|
+
stopping = true;
|
|
113
|
+
await Promise.allSettled([renderer.close(), dev.close()]);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, shutDown);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Whether a renderer pool is up: its supervisor running, and at least one
|
|
3
|
+
// renderer answering a health frame -- which proves its event loop turns, as
|
|
4
|
+
// a connection alone does not (ADR 0028). The probe of the renderers'
|
|
5
|
+
// container in the two-container shape (ADR 0071), run as their own user.
|
|
6
|
+
//
|
|
7
|
+
// node packages/renderer/bin/renderer-probe.js [POOL]
|
|
8
|
+
//
|
|
9
|
+
// POOL defaults to TINVAR_SOCKET. Exits 0 and names the renderer that
|
|
10
|
+
// answered, or 1 and says why not.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { PID_FILE, checkHealth, readPid } from '../src/pool.js';
|
|
15
|
+
|
|
16
|
+
const pool = process.argv[2] ?? process.env.TINVAR_SOCKET;
|
|
17
|
+
|
|
18
|
+
function fail(reason) {
|
|
19
|
+
console.error(`[tinvar:renderer] not ready: ${reason}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!pool) fail('name the pool directory, or set TINVAR_SOCKET');
|
|
24
|
+
|
|
25
|
+
const supervisor = readPid(path.join(pool, PID_FILE));
|
|
26
|
+
if (supervisor === null) fail(`${pool} has no supervisor's pid file`);
|
|
27
|
+
try {
|
|
28
|
+
// This probe runs as the pool's user, so a supervisor it cannot signal is
|
|
29
|
+
// not the pool's.
|
|
30
|
+
process.kill(supervisor, 0);
|
|
31
|
+
} catch {
|
|
32
|
+
fail(`the supervisor named in ${pool} (pid ${supervisor}) is not running as this user`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let sockets;
|
|
36
|
+
try {
|
|
37
|
+
sockets = fs.readdirSync(pool).filter((name) => name.endsWith('.sock'));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
fail(`${pool} cannot be listed: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const reasons = [];
|
|
43
|
+
for (const name of sockets) {
|
|
44
|
+
try {
|
|
45
|
+
const answer = await checkHealth(path.join(pool, name), { timeoutMs: 2_000 });
|
|
46
|
+
console.log(`ready: ${name} answered (pid ${answer.pid}, ${answer.inFlight} in flight)`);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
reasons.push(`${name}: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
fail(sockets.length === 0 ? `no renderer is listening in ${pool}` : reasons.join('; '));
|
package/bin/renderer.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts the renderer on a Unix socket, or a pool of renderers in a directory.
|
|
3
|
+
//
|
|
4
|
+
// node packages/renderer/bin/renderer.js <socket-path> <app-rsc-entry>
|
|
5
|
+
// [--codec=msgpack|json-lines] [--dev] [--exit-with-stdin] [--exit-with-pid=PID] [--drain-timeout=SECONDS]
|
|
6
|
+
// [--socket-group=GROUP]
|
|
7
|
+
//
|
|
8
|
+
// node packages/renderer/bin/renderer.js <directory> <app-rsc-entry> --processes=N [...]
|
|
9
|
+
//
|
|
10
|
+
// With --processes, this process supervises N sandboxed renderers, one
|
|
11
|
+
// socket each in <directory>, which PHP reads as the pool (see
|
|
12
|
+
// src/pool.js and ADR 0028). SIGTERM drains and stops; SIGUSR2 restarts the
|
|
13
|
+
// pool one renderer at a time, for a deploy. The spec's lifecycle commands
|
|
14
|
+
// (renderer:start, :stop, :check) drive it through bin/tinvar.
|
|
15
|
+
//
|
|
16
|
+
// --socket-group is for a renderer run as a user of its own, with PHP as
|
|
17
|
+
// another: each socket is shared with GROUP (0660) and the directory it is
|
|
18
|
+
// in too (0750), so PHP's user, in the group, can connect, and no one else
|
|
19
|
+
// can (src/socket-group.js). The service manager decides who this process
|
|
20
|
+
// runs as; nothing here changes user.
|
|
21
|
+
|
|
22
|
+
// SIGUSR1 opens Node's debugger, and nothing in the permission model stops a
|
|
23
|
+
// process signalling another: the sandboxed renderer could send it to this
|
|
24
|
+
// process -- its unsandboxed parent -- and run any code there through the
|
|
25
|
+
// debugger port, reading the app key or anything else. A listener of our own
|
|
26
|
+
// replaces that default, here and in the child. (Equivalent to
|
|
27
|
+
// --disable-sigusr1, which a running process cannot add to itself.)
|
|
28
|
+
process.on('SIGUSR1', () => {});
|
|
29
|
+
|
|
30
|
+
// Set before the app is imported: React picks its build at import time, and a
|
|
31
|
+
// development build serializes component source and per-component timing rows
|
|
32
|
+
// into the RSC payload. That is useful in dev and misleading everywhere else --
|
|
33
|
+
// it tripled the payload and made render size vary request to request.
|
|
34
|
+
if (!process.env.NODE_ENV) {
|
|
35
|
+
process.env.NODE_ENV = process.argv.includes('--dev') ? 'development' : 'production';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import {
|
|
41
|
+
DEFAULT_DRAIN_MS,
|
|
42
|
+
assertScrubbedEnvironment,
|
|
43
|
+
createRenderer,
|
|
44
|
+
shareDirectory,
|
|
45
|
+
} from '../src/server.js';
|
|
46
|
+
import { resolveSocketGroup } from '../src/socket-group.js';
|
|
47
|
+
import {
|
|
48
|
+
isSandboxed,
|
|
49
|
+
lockNetwork,
|
|
50
|
+
real,
|
|
51
|
+
relaunchSandboxed,
|
|
52
|
+
sandboxFlags,
|
|
53
|
+
scrubEnvironment,
|
|
54
|
+
spawnSandboxed,
|
|
55
|
+
watchTether,
|
|
56
|
+
} from '../src/sandbox.js';
|
|
57
|
+
|
|
58
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
59
|
+
const options = process.argv.slice(2).filter((a) => a.startsWith('--'));
|
|
60
|
+
|
|
61
|
+
const socketPath = positional[0] ?? process.env.TINVAR_RENDERER_SOCKET;
|
|
62
|
+
const appEntry = positional[1] ?? process.env.TINVAR_APP_ENTRY;
|
|
63
|
+
const codec =
|
|
64
|
+
options.find((f) => f.startsWith('--codec='))?.slice(8) ?? process.env.TINVAR_RENDERER_CODEC ?? 'msgpack';
|
|
65
|
+
|
|
66
|
+
const usage =
|
|
67
|
+
'usage: renderer.js <socket-path | directory> <app-rsc-entry> [--processes=N] [--codec=msgpack|json-lines] ' +
|
|
68
|
+
'[--dev] [--exit-with-stdin] [--exit-with-pid=PID] [--drain-timeout=SECONDS] [--socket-group=GROUP]';
|
|
69
|
+
|
|
70
|
+
/** The options a sandboxed renderer is started with: the socket group as the number it was resolved to. */
|
|
71
|
+
function launched(given) {
|
|
72
|
+
return [
|
|
73
|
+
...given.filter((o) => !o.startsWith('--socket-group=')),
|
|
74
|
+
...(socketGroup === null ? [] : [`--socket-group=${socketGroup}`]),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A whole-number option within bounds, or null when it is not given. */
|
|
79
|
+
const whole = (name, min, max) => {
|
|
80
|
+
const given = options.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
81
|
+
if (given === undefined) return null;
|
|
82
|
+
if (!/^[0-9]{1,6}$/.test(given) || Number(given) < min || Number(given) > max) {
|
|
83
|
+
console.error(`--${name} must be a whole number from ${min} to ${max}; got '${given}'.\n${usage}`);
|
|
84
|
+
process.exit(64);
|
|
85
|
+
}
|
|
86
|
+
return Number(given);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const processes = whole('processes', 1, 64);
|
|
90
|
+
const drainSeconds = whole('drain-timeout', 0, 3600);
|
|
91
|
+
const drainMs = drainSeconds === null ? DEFAULT_DRAIN_MS : drainSeconds * 1000;
|
|
92
|
+
|
|
93
|
+
if (!socketPath || !appEntry) {
|
|
94
|
+
console.error(usage);
|
|
95
|
+
process.exit(64);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Resolved once, here, to a number: the sandboxed child is handed the gid,
|
|
99
|
+
// and may read no /etc/group to look a name up in.
|
|
100
|
+
const socketGroup = (() => {
|
|
101
|
+
const given = options.find((f) => f.startsWith('--socket-group='))?.slice(15);
|
|
102
|
+
if (given === undefined) return null;
|
|
103
|
+
try {
|
|
104
|
+
return resolveSocketGroup(given);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error(`[tinvar:renderer] ${error.message}`);
|
|
107
|
+
process.exit(64);
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
// For a supervisor that holds the other end of standard input and never
|
|
112
|
+
// writes to it, as tinvar serve does. Killed outright, it can forward no
|
|
113
|
+
// signal, but its end of the pipe closes: that ends the reason to serve, and
|
|
114
|
+
// this process stops as a SIGTERM would stop it. Not in the sandboxed child,
|
|
115
|
+
// whose standard input is its tether to this process (watchTether, below).
|
|
116
|
+
if (options.includes('--exit-with-stdin') && !isSandboxed()) {
|
|
117
|
+
process.stdin.on('end', () => process.kill(process.pid, 'SIGTERM'));
|
|
118
|
+
process.stdin.resume();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// For a pool no one process holds the other end of: the test suite's, which
|
|
122
|
+
// the first of its parallel workers to render starts and every worker uses
|
|
123
|
+
// (ADR 0095). It serves while the run that started it does -- the process
|
|
124
|
+
// whose pid is given, the parent of every worker -- and stops as a SIGTERM
|
|
125
|
+
// would stop it once that process has gone, however it went.
|
|
126
|
+
const exitWithPid = (() => {
|
|
127
|
+
const given = options.find((f) => f.startsWith('--exit-with-pid='))?.slice(16);
|
|
128
|
+
if (given === undefined) return null;
|
|
129
|
+
if (!/^[1-9][0-9]{0,9}$/.test(given) || Number(given) < 2) {
|
|
130
|
+
console.error(`--exit-with-pid must be the pid of a process other than init; got '${given}'.\n${usage}`);
|
|
131
|
+
process.exit(64);
|
|
132
|
+
}
|
|
133
|
+
return Number(given);
|
|
134
|
+
})();
|
|
135
|
+
if (exitWithPid !== null && !isSandboxed()) {
|
|
136
|
+
const watch = setInterval(() => {
|
|
137
|
+
try {
|
|
138
|
+
process.kill(exitWithPid, 0);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// EPERM is a process that is there, as another user.
|
|
141
|
+
if (error.code !== 'ESRCH') return;
|
|
142
|
+
clearInterval(watch);
|
|
143
|
+
process.kill(process.pid, 'SIGTERM');
|
|
144
|
+
}
|
|
145
|
+
}, 500);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Not yet sandboxed: start again under the permission model and stand by
|
|
149
|
+
// as its parent. TINVAR_RENDERER_UNSANDBOXED=1 opts out, for debugging --
|
|
150
|
+
// loudly, and never in production, where a value left over in an env file
|
|
151
|
+
// would otherwise switch the sandbox off without a word.
|
|
152
|
+
const unsandboxed = !isSandboxed() && process.env.TINVAR_RENDERER_UNSANDBOXED === '1';
|
|
153
|
+
// Any of the markers dev mode reads (dev-guard.js): an APP_ENV saying
|
|
154
|
+
// production rules the escape hatches out as surely as a NODE_ENV does.
|
|
155
|
+
const production = ['NODE_ENV', 'TINVAR_ENV', 'APP_ENV'].find(
|
|
156
|
+
(name) =>
|
|
157
|
+
String(process.env[name] ?? '')
|
|
158
|
+
.trim()
|
|
159
|
+
.toLowerCase() === 'production',
|
|
160
|
+
);
|
|
161
|
+
// TINVAR_ALLOW_UNSCRUBBED_ENV=1 skips the check that refuses a credential in
|
|
162
|
+
// the renderer's environment: for debugging, never in production, where a
|
|
163
|
+
// value left in an env file would hand secrets to every dependency unsaid.
|
|
164
|
+
const unscrubbed = process.env.TINVAR_ALLOW_UNSCRUBBED_ENV === '1';
|
|
165
|
+
if (unscrubbed && production !== undefined) {
|
|
166
|
+
console.error(
|
|
167
|
+
`[tinvar:renderer] TINVAR_ALLOW_UNSCRUBBED_ENV is refused in production (${production}=production).`,
|
|
168
|
+
);
|
|
169
|
+
process.exit(78);
|
|
170
|
+
}
|
|
171
|
+
if (unsandboxed) {
|
|
172
|
+
if (production !== undefined) {
|
|
173
|
+
console.error(
|
|
174
|
+
`[tinvar:renderer] TINVAR_RENDERER_UNSANDBOXED is refused in production (${production}=production).`,
|
|
175
|
+
);
|
|
176
|
+
process.exit(78);
|
|
177
|
+
}
|
|
178
|
+
// A pool is made of sandboxed renderers and nothing else; there is no
|
|
179
|
+
// unsandboxed pool to fall back to.
|
|
180
|
+
if (processes !== null) {
|
|
181
|
+
console.error(
|
|
182
|
+
'[tinvar:renderer] --processes runs sandboxed renderers, so it cannot be combined with TINVAR_RENDERER_UNSANDBOXED.',
|
|
183
|
+
);
|
|
184
|
+
process.exit(64);
|
|
185
|
+
}
|
|
186
|
+
console.error(
|
|
187
|
+
'[tinvar:renderer] WARNING: running WITHOUT the sandbox; every dependency can read any file.',
|
|
188
|
+
);
|
|
189
|
+
// Without the sandbox's files, but with its environment: a credential
|
|
190
|
+
// refused loudly, as a sandboxed launch refuses it, and everything else
|
|
191
|
+
// cut down to the allowlist the sandboxed child is given.
|
|
192
|
+
if (!unscrubbed) {
|
|
193
|
+
try {
|
|
194
|
+
assertScrubbedEnvironment();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(`[tinvar:renderer] ${error.message}`);
|
|
197
|
+
process.exit(78);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
scrubEnvironment();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Read before the environment was scrubbed, which takes the variable with it.
|
|
204
|
+
if (!isSandboxed() && !unsandboxed) {
|
|
205
|
+
// Checked here as well as in the child, which now sees only an allowlisted
|
|
206
|
+
// environment and so would pass whatever this process holds.
|
|
207
|
+
if (!unscrubbed) assertScrubbedEnvironment();
|
|
208
|
+
|
|
209
|
+
const entry = appEntry.startsWith('/') ? appEntry : new URL(appEntry, `file://${process.cwd()}/`).pathname;
|
|
210
|
+
const script = real(fileURLToPath(import.meta.url));
|
|
211
|
+
// Each renderer's allowlist names its own socket, so each gets its own.
|
|
212
|
+
const flagsFor = (socket) =>
|
|
213
|
+
sandboxFlags({
|
|
214
|
+
rendererRoot: fileURLToPath(new URL('..', import.meta.url)),
|
|
215
|
+
appEntry: entry,
|
|
216
|
+
socketPath: socket,
|
|
217
|
+
extraRead: (process.env.TINVAR_RENDERER_READ ?? '').split(':'),
|
|
218
|
+
appRoot: process.env.TINVAR_APP_ROOT || undefined,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (processes !== null) {
|
|
222
|
+
const { prepareDirectory, runPool } = await import('../src/pool.js');
|
|
223
|
+
// Made and checked before anything starts, and named by its real path:
|
|
224
|
+
// each renderer's allowlist is written in real paths, as a single
|
|
225
|
+
// renderer's is.
|
|
226
|
+
try {
|
|
227
|
+
prepareDirectory(path.resolve(socketPath), { socketGroup });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error(`[tinvar:renderer] ${error.message}`);
|
|
230
|
+
process.exit(73);
|
|
231
|
+
}
|
|
232
|
+
const directory = real(socketPath);
|
|
233
|
+
// What each renderer is told is what a single launch passes on, less
|
|
234
|
+
// what only this process acts on.
|
|
235
|
+
const passed = launched(
|
|
236
|
+
options.filter(
|
|
237
|
+
(o) =>
|
|
238
|
+
!o.startsWith('--processes=') && o !== '--exit-with-stdin' && !o.startsWith('--exit-with-pid='),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
process.exit(
|
|
243
|
+
await runPool({
|
|
244
|
+
directory,
|
|
245
|
+
processes,
|
|
246
|
+
codec,
|
|
247
|
+
socketGroup,
|
|
248
|
+
timings: { drainMs },
|
|
249
|
+
// Detached: a terminal's Ctrl-C reaches this process alone, which
|
|
250
|
+
// then drains every renderer, rather than each renderer deciding for
|
|
251
|
+
// itself while this one is still restarting it.
|
|
252
|
+
spawn: (socket) =>
|
|
253
|
+
spawnSandboxed(script, [socket, real(entry), ...passed], flagsFor(socket), { detached: true }),
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const flags = flagsFor(socketPath);
|
|
259
|
+
// Created here, before the sandbox: inside it the renderer may write its
|
|
260
|
+
// socket and nothing else.
|
|
261
|
+
const { lstatSync, mkdirSync } = await import('node:fs');
|
|
262
|
+
const made = mkdirSync(path.dirname(path.resolve(socketPath)), { recursive: true, mode: 0o700 });
|
|
263
|
+
// A directory made here is shared as the socket will be; one that was
|
|
264
|
+
// there is the deployment's, as it was.
|
|
265
|
+
if (made !== undefined && socketGroup !== null) {
|
|
266
|
+
await shareDirectory(path.dirname(path.resolve(socketPath)), socketGroup);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The child may read and write this one path. Something already there
|
|
270
|
+
// that is not a socket -- a symlink planted in a shared directory -- would
|
|
271
|
+
// be followed, so it is refused before the child exists.
|
|
272
|
+
const existing = (() => {
|
|
273
|
+
try {
|
|
274
|
+
return lstatSync(socketPath);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
if (existing && !existing.isSocket()) {
|
|
280
|
+
console.error(
|
|
281
|
+
`[tinvar:renderer] Refusing ${socketPath}: something other than a socket is already there.`,
|
|
282
|
+
);
|
|
283
|
+
process.exit(73);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The child is handed the real paths its allowlist was written for, as
|
|
287
|
+
// arguments -- whichever way they arrived here. Given through
|
|
288
|
+
// TINVAR_RENDERER_SOCKET or TINVAR_APP_ENTRY, they reached the child as
|
|
289
|
+
// written, and the allowlist (in real paths) refused them.
|
|
290
|
+
const args = [real(socketPath), real(entry), ...launched(options)];
|
|
291
|
+
process.exit(await relaunchSandboxed(script, args, flags));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Inside the sandbox, before the app or any of its dependencies is imported.
|
|
295
|
+
if (isSandboxed()) {
|
|
296
|
+
lockNetwork({
|
|
297
|
+
net: (await import('node:net')).default,
|
|
298
|
+
dgram: (await import('node:dgram')).default,
|
|
299
|
+
dns: (await import('node:dns')).default,
|
|
300
|
+
listenOn: socketPath,
|
|
301
|
+
});
|
|
302
|
+
// Named ESM imports of a builtin are a copy, refreshed only on request.
|
|
303
|
+
(await import('node:module')).syncBuiltinESMExports();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const renderer = await createRenderer({
|
|
307
|
+
socketPath,
|
|
308
|
+
appEntry: appEntry.startsWith('/') ? appEntry : new URL(appEntry, `file://${process.cwd()}/`).pathname,
|
|
309
|
+
codec,
|
|
310
|
+
checkEnvironment: !unscrubbed,
|
|
311
|
+
socketGroup,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
console.log(
|
|
315
|
+
`[tinvar:renderer] listening on ${renderer.socketPath} (${renderer.codec}, ${process.env.NODE_ENV})`,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Graceful: no new renders, the open ones finished, within the drain time.
|
|
319
|
+
// Asked again -- a pool stopping, then its tether closing -- it is the same
|
|
320
|
+
// drain.
|
|
321
|
+
const shutDown = () => renderer.drain(drainMs).then(() => process.exit(0));
|
|
322
|
+
|
|
323
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, shutDown);
|
|
324
|
+
|
|
325
|
+
// Launched by the sandbox's parent: when that process is gone, by any
|
|
326
|
+
// signal, so is the reason to serve. Its end of the tether closes with it.
|
|
327
|
+
if (isSandboxed()) watchTether(shutDown);
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinvar/renderer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"bin"
|
|
8
|
+
],
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./src/server.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/server.js",
|
|
16
|
+
"./vite": "./src/vite.js",
|
|
17
|
+
"./warm": {
|
|
18
|
+
"types": "./src/warm.d.ts",
|
|
19
|
+
"default": "./src/warm.js"
|
|
20
|
+
},
|
|
21
|
+
"./vitest": {
|
|
22
|
+
"types": "./src/vitest.d.ts",
|
|
23
|
+
"default": "./src/vitest.js"
|
|
24
|
+
},
|
|
25
|
+
"./eslint": "./src/eslint.js",
|
|
26
|
+
"./workbench": {
|
|
27
|
+
"types": "./src/workbench.d.ts",
|
|
28
|
+
"default": "./src/workbench.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@msgpack/msgpack": "3.1.3",
|
|
33
|
+
"react": "19.3.1",
|
|
34
|
+
"react-dom": "19.3.1",
|
|
35
|
+
"react-server-dom-webpack": "19.3.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "19.3.0",
|
|
39
|
+
"@types/react-dom": "19.3.0",
|
|
40
|
+
"@vitejs/plugin-rsc": "^0.5.38",
|
|
41
|
+
"vite": "^8.3.5"
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"tinvar-renderer": "./bin/renderer.js"
|
|
45
|
+
}
|
|
46
|
+
}
|