@tinvar/renderer 0.0.0-stage → 0.4.1

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 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('; '));
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.1",
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
+ }