@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 +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/src/server.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// The renderer: a private Unix socket server next to PHP.
|
|
2
|
+
//
|
|
3
|
+
// Hardening the spec requires of this process:
|
|
4
|
+
// - no secrets: it starts with a scrubbed environment and holds no credentials
|
|
5
|
+
// - private socket, mode 0600, owned by the app user -- or, run as a user
|
|
6
|
+
// of its own, 0660 in a group of that user and PHP's (ADR 0028)
|
|
7
|
+
// - frames capped at a configured size
|
|
8
|
+
// - it never decodes client payloads, which is the class of bug behind
|
|
9
|
+
// React's CVE-2025-55182
|
|
10
|
+
// - per-render isolation: each render has its own context and nothing is
|
|
11
|
+
// cached across requests
|
|
12
|
+
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { FrameReader, DEFAULT_MAX_FRAME_BYTES, createCodec, encodeFrame, msgpackCodec } from './codec.js';
|
|
17
|
+
import { logLine, requestIdOf } from './log.js';
|
|
18
|
+
import { FRAME, assertFromPhp } from './protocol.js';
|
|
19
|
+
import { RenderSession } from './render-session.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Credentials must never reach this process. Refuse to start if they have.
|
|
23
|
+
*
|
|
24
|
+
* By shape as well as by name. A fixed list missed the names applications
|
|
25
|
+
* actually use -- TINVAR_APP_KEY, TINVAR_DB_PASSWORD, TINVAR_DSN -- and a
|
|
26
|
+
* renderer started from a shell that exported them started without a word.
|
|
27
|
+
* Anything whose last segment names a key, password, secret or connection
|
|
28
|
+
* string counts, under any prefix.
|
|
29
|
+
*
|
|
30
|
+
* The live hub's keys too (ADR 0048). PHP signs the hub's access and
|
|
31
|
+
* publisher tokens with them -- TINVAR_LIVE_SUBSCRIBER_KEY and
|
|
32
|
+
* TINVAR_LIVE_PUBLISHER_KEY, or Mercure's own MERCURE_*_JWT_KEY -- and a
|
|
33
|
+
* renderer holding the publisher key could tell every open page anything.
|
|
34
|
+
* Their names end in neither APP_KEY nor PRIVATE_KEY, and passed.
|
|
35
|
+
*
|
|
36
|
+
* And the probes' and the trace exporter's (ADR 0041): TINVAR_PROBE_TOKEN
|
|
37
|
+
* opens /_metrics and /_ready to whoever holds it, and
|
|
38
|
+
* OTEL_EXPORTER_OTLP_HEADERS -- or its per-signal form -- is where a
|
|
39
|
+
* collector's API key or bearer token is written. Neither name ends in a
|
|
40
|
+
* key, a password or a secret, and both passed.
|
|
41
|
+
*
|
|
42
|
+
* And where a private key's file is (ADR 0116): TINVAR_MAIL_DKIM_PRIVATE_KEY
|
|
43
|
+
* ends in PRIVATE_KEY, and its _FILE form names a file this process might
|
|
44
|
+
* read.
|
|
45
|
+
*
|
|
46
|
+
* And then every name the list did not reach: STRIPE_SECRET_KEY,
|
|
47
|
+
* OPENAI_API_KEY, GITHUB_TOKEN, AWS_SESSION_TOKEN, TINVAR_SESSION_KEY,
|
|
48
|
+
* TINVAR_SMTP_PASS. Any name with a segment that says key, token, pass,
|
|
49
|
+
* secret, credential or private, anywhere in it -- so a _FILE form, which
|
|
50
|
+
* names where one is, too. None of the names the renderer is told anything
|
|
51
|
+
* by (TINVAR_RENDERER_*, TINVAR_APP_*, NODE_*) has one.
|
|
52
|
+
*/
|
|
53
|
+
const FORBIDDEN_ENV =
|
|
54
|
+
/(^|_)(KEYS?|APIKEY|TOKENS?|AUTHTOKEN|PASS|PASSWORD|PASSWD|PASSPHRASE|SECRETS?|CREDENTIALS?|PRIVATE|DSN|DATABASE_URL|DB_USERNAME|DB_USER|OTLP_HEADERS|OTLP_(TRACES|METRICS|LOGS)_HEADERS)(_|$)/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A URL with credentials in it -- `redis://:password@cache` -- anywhere in
|
|
58
|
+
* a value: a connection string carries its password whatever its variable
|
|
59
|
+
* is called. An `@` in the URL's authority, before any path, query or
|
|
60
|
+
* fragment.
|
|
61
|
+
*/
|
|
62
|
+
const URL_WITH_USERINFO = /[a-z][a-z0-9+.-]*:\/\/[^/?#\s]*@/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Record<string, string | undefined>} [env]
|
|
66
|
+
* @throws {Error} naming each variable that holds a credential, never its value
|
|
67
|
+
*/
|
|
68
|
+
export function assertScrubbedEnvironment(env = process.env) {
|
|
69
|
+
const present = Object.keys(env).filter((key) => {
|
|
70
|
+
const value = env[key];
|
|
71
|
+
if (typeof value !== 'string' || value === '') return false;
|
|
72
|
+
return FORBIDDEN_ENV.test(key) || URL_WITH_USERINFO.test(value);
|
|
73
|
+
});
|
|
74
|
+
if (present.length > 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`The renderer must start with a scrubbed environment; found ${present.join(', ')}. ` +
|
|
77
|
+
'It never needs credentials, and holding them widens what a compromised dependency reaches.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How long a renderer told to stop goes on serving the renders it has:
|
|
84
|
+
* PHP's overall cap on one render, the 15 s deadline four times over, which
|
|
85
|
+
* counts a slow client too. Past it PHP has stopped waiting for the render,
|
|
86
|
+
* so there is nothing left to finish it for.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_DRAIN_MS = 60_000;
|
|
89
|
+
|
|
90
|
+
/** How long a draining renderer goes on accepting once its socket's path is gone; see drain(). */
|
|
91
|
+
const BACKLOG_GRACE_MS = 100;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Has the app load what its renders will, before the socket says it is
|
|
95
|
+
* ready: an entry's `warm()`, where it exports one -- every client
|
|
96
|
+
* component's module, so the first page's head preloads what every later
|
|
97
|
+
* one's does (@tinvar/renderer/warm). What does not load is reported, and
|
|
98
|
+
* left for the render that needs it to fail as it would have.
|
|
99
|
+
*
|
|
100
|
+
* @param {Record<string, unknown>} app the app's rsc entry
|
|
101
|
+
*/
|
|
102
|
+
export async function warmApp(app) {
|
|
103
|
+
if (typeof app.warm !== 'function') return;
|
|
104
|
+
try {
|
|
105
|
+
const warmed = await app.warm();
|
|
106
|
+
const failed = Array.isArray(warmed?.failed) ? warmed.failed : [];
|
|
107
|
+
if (failed.length > 0) {
|
|
108
|
+
console.error(`[tinvar:renderer] could not load ahead of the first render: ${failed.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(
|
|
112
|
+
'[tinvar:renderer] could not warm the app:',
|
|
113
|
+
error instanceof Error ? error.message : error,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {{socketPath: string, appEntry: string, maxFrameBytes?: number, checkEnvironment?: boolean, codec?: 'msgpack' | 'json-lines', socketGroup?: number | null}} options
|
|
120
|
+
* `socketGroup` shares the socket with that group (0660), for PHP
|
|
121
|
+
* running as another user in it; 0600 without one. `codec` is the
|
|
122
|
+
* frames' encoding, msgpack unless set to json-lines to match PHP
|
|
123
|
+
*/
|
|
124
|
+
export async function createRenderer(options) {
|
|
125
|
+
const {
|
|
126
|
+
socketPath,
|
|
127
|
+
appEntry,
|
|
128
|
+
maxFrameBytes = DEFAULT_MAX_FRAME_BYTES,
|
|
129
|
+
checkEnvironment = true,
|
|
130
|
+
codec: codecName = 'msgpack',
|
|
131
|
+
socketGroup = null,
|
|
132
|
+
} = options;
|
|
133
|
+
|
|
134
|
+
const codec = typeof codecName === 'string' ? createCodec(codecName) : (codecName ?? msgpackCodec);
|
|
135
|
+
|
|
136
|
+
if (checkEnvironment) assertScrubbedEnvironment();
|
|
137
|
+
|
|
138
|
+
const app = await import(appEntry);
|
|
139
|
+
if (typeof app.renderHtml !== 'function') {
|
|
140
|
+
throw new Error(`${appEntry} does not export renderHtml(frame, client).`);
|
|
141
|
+
}
|
|
142
|
+
await warmApp(app);
|
|
143
|
+
|
|
144
|
+
await fs.rm(socketPath, { force: true });
|
|
145
|
+
|
|
146
|
+
// The directory is locked down first, so the socket is never reachable even
|
|
147
|
+
// for the instant before its own mode is set.
|
|
148
|
+
// Under the sandbox the launcher made it, and the renderer may neither look
|
|
149
|
+
// into it nor write there beyond its own socket.
|
|
150
|
+
if (typeof process.permission?.has !== 'function') {
|
|
151
|
+
const made = await fs.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
152
|
+
if (made !== undefined && socketGroup !== null) {
|
|
153
|
+
await shareDirectory(path.dirname(socketPath), socketGroup);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// What a health check reports, and what a drain waits for.
|
|
158
|
+
const state = { inFlight: 0, served: 0 };
|
|
159
|
+
const connections = new Set();
|
|
160
|
+
|
|
161
|
+
const server = net.createServer((socket) => {
|
|
162
|
+
connections.add(socket);
|
|
163
|
+
socket.on('close', () => connections.delete(socket));
|
|
164
|
+
socket.on('error', (error) => logLine('warning', 'socket error', {}, error));
|
|
165
|
+
handleConnection(socket, app, maxFrameBytes, codec, state);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// listen() creates the socket with 0666 & ~umask. chmod afterwards leaves a
|
|
169
|
+
// window in which any local user can connect, so the mode is set by umask
|
|
170
|
+
// before the socket exists rather than corrected after.
|
|
171
|
+
const previousUmask = process.umask(0o177);
|
|
172
|
+
try {
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
server.once('error', reject);
|
|
175
|
+
server.listen(socketPath, resolve);
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
process.umask(previousUmask);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Belt and braces on platforms that ignore umask for sockets, then verify:
|
|
182
|
+
// the spec requires mode 0600, so starting wider is a failure, not a warning.
|
|
183
|
+
await fs.chmod(socketPath, 0o600);
|
|
184
|
+
|
|
185
|
+
// Shared with PHP's user through a group of the two, as a renderer run as
|
|
186
|
+
// a user of its own must be: given to the group while still 0600, and only
|
|
187
|
+
// then opened to it, so it is never open to a group it was not meant for
|
|
188
|
+
// -- Linux makes it in this process's group, macOS in its directory's.
|
|
189
|
+
if (socketGroup !== null) {
|
|
190
|
+
await fs.chown(socketPath, -1, socketGroup);
|
|
191
|
+
await fs.chmod(socketPath, 0o660);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const listening = await fs.stat(socketPath);
|
|
195
|
+
const mode = listening.mode & 0o777;
|
|
196
|
+
const wanted = socketGroup === null ? 0o600 : 0o660;
|
|
197
|
+
if (mode !== wanted || (socketGroup !== null && listening.gid !== socketGroup)) {
|
|
198
|
+
await new Promise((resolve) => server.close(resolve));
|
|
199
|
+
throw new Error(
|
|
200
|
+
socketGroup === null
|
|
201
|
+
? `Renderer socket is mode ${mode.toString(8)}; it must be 600.`
|
|
202
|
+
: `Renderer socket is mode ${mode.toString(8)} in group ${listening.gid}; it must be 660 in group ${socketGroup}.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let draining = null;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Stops taking renders and finishes the ones it has: the graceful stop.
|
|
210
|
+
* Renders still open after `timeoutMs` are cut off, and PHP reports each
|
|
211
|
+
* as an incomplete render, the error it gives any renderer that goes away
|
|
212
|
+
* mid-render.
|
|
213
|
+
*/
|
|
214
|
+
const drain = (timeoutMs = DEFAULT_DRAIN_MS) => {
|
|
215
|
+
draining ??= (async () => {
|
|
216
|
+
// The path goes first, so that PHP finds no renderer here at once and
|
|
217
|
+
// picks another, rather than connecting to a socket nobody accepts on.
|
|
218
|
+
// A pool gives each renderer a path of its own and never reuses one,
|
|
219
|
+
// so this can never remove a successor's.
|
|
220
|
+
await fs.rm(socketPath, { force: true });
|
|
221
|
+
|
|
222
|
+
// Then the listener stays open a moment longer. A connect that found
|
|
223
|
+
// the path just before it went is waiting in the kernel's backlog --
|
|
224
|
+
// connected, as far as PHP knows, with its render frame sent -- and
|
|
225
|
+
// closing the listener under it reset it: a render dropped by a
|
|
226
|
+
// rolling restart. With the path gone no more can arrive; the wait,
|
|
227
|
+
// then one more pass through the loop's poll phase (setImmediate runs
|
|
228
|
+
// after it, where a timer runs before), accepts every one still there.
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, BACKLOG_GRACE_MS));
|
|
230
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
231
|
+
|
|
232
|
+
// The server reports itself closed once it has counted its last
|
|
233
|
+
// connection out, which is before that connection's own 'close' --
|
|
234
|
+
// its handle closes a loop turn later -- so the connections are
|
|
235
|
+
// waited for too, or a drain could end with a render's last bytes
|
|
236
|
+
// still unwritten.
|
|
237
|
+
const finished = new Promise((resolve) => server.close(() => resolve())).then(() =>
|
|
238
|
+
Promise.all(
|
|
239
|
+
[...connections].map((socket) => new Promise((resolve) => socket.once('close', resolve))),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
let timer;
|
|
244
|
+
const late = new Promise((resolve) => {
|
|
245
|
+
timer = setTimeout(() => resolve('late'), timeoutMs);
|
|
246
|
+
});
|
|
247
|
+
if ((await Promise.race([finished, late])) === 'late') {
|
|
248
|
+
for (const socket of connections) socket.destroy();
|
|
249
|
+
await finished;
|
|
250
|
+
}
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
})();
|
|
253
|
+
|
|
254
|
+
return draining;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
server,
|
|
259
|
+
socketPath,
|
|
260
|
+
codec: codec.name,
|
|
261
|
+
mode: process.env.NODE_ENV ?? 'development',
|
|
262
|
+
drain,
|
|
263
|
+
close: () => drain(),
|
|
264
|
+
/** Renders open now, and renders finished since it started. */
|
|
265
|
+
stats: () => ({ ...state }),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Opens a socket's directory, made 0700, to the group its socket is shared
|
|
271
|
+
* with: 0750, so PHP's user can find the socket and no one else can look.
|
|
272
|
+
* Given to the group first, then opened to it.
|
|
273
|
+
*/
|
|
274
|
+
export async function shareDirectory(directory, socketGroup) {
|
|
275
|
+
await fs.chown(directory, -1, socketGroup);
|
|
276
|
+
await fs.chmod(directory, 0o750);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function handleConnection(socket, app, maxFrameBytes, codec, state) {
|
|
280
|
+
const reader = new FrameReader(maxFrameBytes, codec);
|
|
281
|
+
// write() says when the socket's buffer is full, and drained() when it has
|
|
282
|
+
// room again, so the session reads the render no faster than PHP takes it.
|
|
283
|
+
const write = (bytes) => (socket.destroyed ? true : socket.write(bytes));
|
|
284
|
+
const drained = () =>
|
|
285
|
+
new Promise((resolve) => {
|
|
286
|
+
if (socket.destroyed || !socket.writableNeedDrain) {
|
|
287
|
+
resolve();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const done = () => {
|
|
291
|
+
socket.off('drain', done);
|
|
292
|
+
socket.off('close', done);
|
|
293
|
+
resolve();
|
|
294
|
+
};
|
|
295
|
+
socket.on('drain', done);
|
|
296
|
+
socket.on('close', done);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// One session per connection: PHP opens a socket per request, so there is no
|
|
300
|
+
// state to carry between renders.
|
|
301
|
+
const session = new RenderSession(write, app, { codec, maxFrameBytes, drained });
|
|
302
|
+
let rendering = null;
|
|
303
|
+
let checked = false;
|
|
304
|
+
let greeted = false;
|
|
305
|
+
|
|
306
|
+
// Counted from the render frame until the connection closes, which is
|
|
307
|
+
// when PHP has everything: a drain that waited only for the render to
|
|
308
|
+
// settle could exit with its last chunks still in the socket's buffer.
|
|
309
|
+
let counted = false;
|
|
310
|
+
socket.on('close', () => {
|
|
311
|
+
if (counted) state.inFlight--;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
socket.on('data', (chunk) => {
|
|
315
|
+
let frames;
|
|
316
|
+
try {
|
|
317
|
+
frames = reader.push(new Uint8Array(chunk));
|
|
318
|
+
} catch (error) {
|
|
319
|
+
logLine('warning', 'framing error', {}, error);
|
|
320
|
+
socket.destroy();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const frame of frames) {
|
|
325
|
+
let type;
|
|
326
|
+
try {
|
|
327
|
+
type = assertFromPhp(frame);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logLine('warning', 'protocol violation', {}, error);
|
|
330
|
+
socket.destroy();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (type === FRAME.HELLO) {
|
|
335
|
+
// First on its connection, and once: PHP asks before it sends
|
|
336
|
+
// anything, and reads who answered from the kernel's credentials
|
|
337
|
+
// on the reply. Later, it would be a reply to nothing PHP waits on.
|
|
338
|
+
if (greeted || rendering || checked) {
|
|
339
|
+
logLine('warning', 'a hello frame arrived on a connection already in use');
|
|
340
|
+
socket.destroy();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
greeted = true;
|
|
344
|
+
write(encodeFrame({ t: FRAME.READY }, maxFrameBytes, codec));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (type === FRAME.HEALTH) {
|
|
349
|
+
// Alone on its connection: a health check that rode along with a
|
|
350
|
+
// render could report on a render it was itself part of.
|
|
351
|
+
if (rendering || checked) {
|
|
352
|
+
logLine('warning', 'a health frame arrived on a connection already in use');
|
|
353
|
+
socket.destroy();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
checked = true;
|
|
357
|
+
write(
|
|
358
|
+
encodeFrame(
|
|
359
|
+
{ t: FRAME.HEALTHY, pid: process.pid, inFlight: state.inFlight, served: state.served },
|
|
360
|
+
maxFrameBytes,
|
|
361
|
+
codec,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
socket.end();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (type === FRAME.RENDER || type === FRAME.FRAGMENT || type === FRAME.MAIL) {
|
|
369
|
+
if (rendering || checked) {
|
|
370
|
+
logLine('warning', 'a second render frame arrived on one connection');
|
|
371
|
+
socket.destroy();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
counted = true;
|
|
375
|
+
state.inFlight++;
|
|
376
|
+
rendering = (type === FRAME.MAIL ? session.mail(frame) : session.render(frame)).then(
|
|
377
|
+
() => {
|
|
378
|
+
// Served only where PHP was there to take it: a render
|
|
379
|
+
// abandoned when its socket closed settles too.
|
|
380
|
+
if (!socket.destroyed) state.served++;
|
|
381
|
+
socket.end();
|
|
382
|
+
},
|
|
383
|
+
(error) => {
|
|
384
|
+
logLine('error', 'unhandled render failure', { request_id: requestIdOf(frame) }, error);
|
|
385
|
+
socket.destroy();
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (type === FRAME.RESULT) {
|
|
392
|
+
try {
|
|
393
|
+
session.settle(normalizeResults(frame.r));
|
|
394
|
+
} catch (error) {
|
|
395
|
+
// As for minted below: thrown here, it is outside any try and
|
|
396
|
+
// takes the process -- every render on it -- down with this one.
|
|
397
|
+
logLine('warning', 'malformed result frame', {}, error);
|
|
398
|
+
socket.destroy();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (type === FRAME.MINTED) {
|
|
405
|
+
// Not a reply at all, so it settles no batch: each minted frame is
|
|
406
|
+
// taken as the answer to the oldest mint frame still waiting, and
|
|
407
|
+
// boundaries it leaves out are refused.
|
|
408
|
+
if (!Array.isArray(frame.m)) continue;
|
|
409
|
+
try {
|
|
410
|
+
session.settleMints(normalizeMints(frame.m));
|
|
411
|
+
} catch (error) {
|
|
412
|
+
// Thrown inside socket.on('data'), which is outside any try the
|
|
413
|
+
// caller has, so an unchecked destructure here takes the process
|
|
414
|
+
// down. PHP validates its side; this is the symmetric half.
|
|
415
|
+
logLine('warning', 'malformed minted frame', {}, error);
|
|
416
|
+
socket.destroy();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// BOTH clients. The mint client was left out, and RenderSession's own
|
|
424
|
+
// finally cannot cover it: that finally runs when the render settles, and
|
|
425
|
+
// the render is awaiting the mint promise -- so a refused mint left the
|
|
426
|
+
// session, the React tree and the promise alive for the life of the
|
|
427
|
+
// process, once per request to a deferred page.
|
|
428
|
+
//
|
|
429
|
+
// And the render itself: the pump read on into a destroyed socket and
|
|
430
|
+
// React rendered the page to its end for no one. abort() cancels the
|
|
431
|
+
// app's stream and fires the signal its entries hand React.
|
|
432
|
+
socket.on('close', () => {
|
|
433
|
+
session.abort('socket_closed');
|
|
434
|
+
session.client?.close('socket_closed');
|
|
435
|
+
session.mints?.close('socket_closed');
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* MessagePack gives back BigInt for int64 and Uint8Array for bin. Query results
|
|
441
|
+
* are handed to React, so they are normalized to ordinary JS values first.
|
|
442
|
+
*/
|
|
443
|
+
/** @param {unknown} tokens */
|
|
444
|
+
function normalizeMints(tokens) {
|
|
445
|
+
if (!Array.isArray(tokens)) return [];
|
|
446
|
+
|
|
447
|
+
return tokens.map((entry) => {
|
|
448
|
+
if (entry === null || typeof entry !== 'object') {
|
|
449
|
+
throw new TypeError('A minted entry must be an object.');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { id: Number(entry.id), token: entry.token };
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeResults(results) {
|
|
457
|
+
if (!Array.isArray(results)) return [];
|
|
458
|
+
|
|
459
|
+
return results.map((result) => {
|
|
460
|
+
if (result === null || typeof result !== 'object') {
|
|
461
|
+
throw new TypeError('A result entry must be an object.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { ...result, id: Number(result.id) };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The group a renderer run as a user of its own shares its socket with
|
|
2
|
+
// (ADR 0028).
|
|
3
|
+
//
|
|
4
|
+
// A renderer's socket is 0600: only its own user may connect, which is PHP
|
|
5
|
+
// when both run as one user. Run as a user of its own -- the stronger
|
|
6
|
+
// boundary -- the renderer must let PHP connect, and no one else: its
|
|
7
|
+
// socket is made 0660 in a group of exactly the two, and its directory
|
|
8
|
+
// 0750 in the same group. Which group is the deployment's to say, and to
|
|
9
|
+
// keep to those two members; the renderer checks only that it is in it,
|
|
10
|
+
// since it cannot give a socket to a group it is not in.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The gid `given` names: a number, or a group's name looked up in
|
|
16
|
+
* /etc/group -- where Linux keeps the groups a deployment makes, and where
|
|
17
|
+
* a group only a directory service knows of is not, so its number is
|
|
18
|
+
* asked for instead.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} given
|
|
21
|
+
* @param {{ groupFile?: string, gids?: number[] }} [options] `gids` are the
|
|
22
|
+
* groups this process is in: its own by default
|
|
23
|
+
* @returns {number}
|
|
24
|
+
*/
|
|
25
|
+
export function resolveSocketGroup(given, { groupFile = '/etc/group', gids = processGroups() } = {}) {
|
|
26
|
+
let gid;
|
|
27
|
+
if (/^[0-9]{1,10}$/.test(given)) {
|
|
28
|
+
gid = Number(given);
|
|
29
|
+
} else if (/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(given)) {
|
|
30
|
+
gid = groupNamed(given, groupFile);
|
|
31
|
+
if (gid === null) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`--socket-group names '${given}', and ${groupFile} has no such group; give the group's number instead.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error(`--socket-group must be a group's name or number; got '${given}'.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (gid === 0) {
|
|
41
|
+
throw new Error("--socket-group names root's group; the socket is shared with a group of its own.");
|
|
42
|
+
}
|
|
43
|
+
if (!gids.includes(gid)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`--socket-group names group ${gid}, and this process is not in it, so it cannot give its socket to it. ` +
|
|
46
|
+
'Start the renderer in that group (systemd Group=, sudo -g).',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return gid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every group this process is in: its effective one and the rest. */
|
|
54
|
+
function processGroups() {
|
|
55
|
+
if (typeof process.getegid !== 'function') return [];
|
|
56
|
+
return [process.getegid(), ...(process.getgroups?.() ?? [])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The gid of the group named `name` in `groupFile`, or null. */
|
|
60
|
+
function groupNamed(name, groupFile) {
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = fs.readFileSync(groupFile, 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
for (const line of text.split('\n')) {
|
|
68
|
+
const [entry, , gid] = line.split(':');
|
|
69
|
+
if (entry === name && /^[0-9]{1,10}$/.test(gid ?? '')) return Number(gid);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|