@volter/world-runtime 2.0.0 → 2.0.2
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/dist/known-external-services.json +0 -8
- package/dist/src/app-url.js +1 -1
- package/dist/src/branch.js +79 -2
- package/dist/src/catalog.js +1 -1
- package/dist/src/cli.js +50 -22
- package/dist/src/console-apart.d.ts +1 -0
- package/dist/src/console-apart.js +7 -0
- package/dist/src/covers.js +4 -2
- package/dist/src/fixture-env.d.ts +3 -0
- package/dist/src/fixture-env.js +24 -0
- package/dist/src/host-worker.js +2 -9
- package/dist/src/host.js +2 -9
- package/dist/src/import-module.d.ts +1 -0
- package/dist/src/import-module.js +16 -0
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.js +4 -1
- package/dist/src/infra-cli.js +61 -12
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +34 -5
- package/dist/src/local-branches.d.ts +37 -0
- package/dist/src/local-branches.js +193 -0
- package/dist/src/pglite-backing.d.ts +8 -0
- package/dist/src/pglite-backing.js +121 -35
- package/dist/src/pglite-host.mjs +520 -14
- package/dist/src/prerequisites.js +1 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/redirect-proxy.d.ts +1 -1
- package/dist/src/redirect-proxy.js +4 -4
- package/dist/src/redis-backing.d.ts +8 -0
- package/dist/src/redis-backing.js +120 -0
- package/dist/src/root.d.ts +23 -0
- package/dist/src/root.js +22 -10
- package/dist/src/run-task.js +1 -1
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +37 -20
- package/dist/src/schema.d.ts +5 -0
- package/dist/src/schema.js +10 -1
- package/dist/src/served-world.d.ts +196 -9
- package/dist/src/served-world.js +847 -103
- package/dist/src/service-recorder.js +1 -1
- package/dist/src/storage-capacity.js +2 -2
- package/dist/src/up-task.js +1 -1
- package/dist/src/world-origins.d.ts +13 -0
- package/dist/src/world-origins.js +37 -0
- package/dist/src/world-view.d.ts +25 -0
- package/dist/src/world-view.js +110 -0
- package/known-external-services.json +0 -8
- package/package.json +10 -4
- package/src/app-url.ts +1 -1
- package/src/branch.ts +66 -2
- package/src/catalog.ts +1 -1
- package/src/cli.ts +43 -21
- package/src/console-apart.ts +7 -1
- package/src/covers.ts +4 -2
- package/src/fixture-env.ts +25 -0
- package/src/host-worker.ts +2 -1
- package/src/host.ts +2 -1
- package/src/import-module.ts +9 -0
- package/src/index.ts +7 -2
- package/src/infra-cli.ts +56 -12
- package/src/init.ts +34 -5
- package/src/local-branches.ts +173 -0
- package/src/pglite-backing.ts +112 -36
- package/src/pglite-host.mjs +520 -14
- package/src/prerequisites.ts +1 -1
- package/src/process-groups.ts +1 -1
- package/src/redirect-proxy.ts +4 -4
- package/src/redis-backing.ts +107 -0
- package/src/root.ts +27 -2
- package/src/run-task.ts +1 -1
- package/src/runtime.ts +38 -20
- package/src/schema.ts +11 -1
- package/src/served-world.ts +762 -85
- package/src/service-recorder.ts +1 -1
- package/src/storage-capacity.ts +2 -2
- package/src/up-task.ts +1 -1
- package/src/world-origins.ts +38 -0
- package/src/world-view.ts +100 -0
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -18,16 +18,90 @@
|
|
|
18
18
|
// the other's writes. A connection that dies mid-transaction is rolled back
|
|
19
19
|
// before the lock is released.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
21
|
+
// Extensions: every contrib extension PGlite 0.5.8 ships (CONTRIB below) plus
|
|
22
|
+
// pgvector (`vector`, from @electric-sql/pglite-pgvector, pinned to the
|
|
23
|
+
// PGlite version) is loaded at create(), so `CREATE EXTENSION [IF NOT EXISTS]
|
|
24
|
+
// pgcrypto | citext | "uuid-ossp" | unaccent | pg_trgm | btree_gist | hstore |
|
|
25
|
+
// ltree | vector | …` behaves as on a Postgres with contrib installed. Nothing
|
|
26
|
+
// is created up front — the app's migrations create what they use. An
|
|
27
|
+
// extension outside this set (postgis, pg_cron, timescaledb, …) is absent
|
|
28
|
+
// from pg_available_extensions and CREATE EXTENSION fails with Postgres's own
|
|
29
|
+
// "extension … is not available" error. Loading the set costs boot time:
|
|
30
|
+
// PGlite.create() in-memory, node 22 on darwin arm64 (this repo's dev box,
|
|
31
|
+
// 2026-09-27, nothing else running in the process) took 1.66s bare and
|
|
32
|
+
// 2.47s with the set; RSS was within 50 MB either way.
|
|
33
|
+
//
|
|
34
|
+
// Limits, stated plainly (measured on 0.5.8 through this gateway with `pg`
|
|
35
|
+
// clients; pglite-backing.test.ts holds the extension and database checks):
|
|
36
|
+
// - ONE database. Trust auth accepts any user/password/database in the
|
|
37
|
+
// startup packet, so a DATABASE_URL naming `/rallly` connects — but every
|
|
38
|
+
// name lands in PGlite's single `postgres` database: current_database()
|
|
39
|
+
// and current_user answer `postgres`, and all names share one catalog.
|
|
40
|
+
// PGlite itself ACCEPTS `CREATE DATABASE` (a pg_database row appears) yet
|
|
41
|
+
// nothing can ever connect to it, so this host fails the statement loudly
|
|
42
|
+
// (see refusal() below): 42P04 "already exists" when the name is the
|
|
43
|
+
// connection's own startup database (true: it connects, so check-then-
|
|
44
|
+
// create for the app's own database proceeds), 0A000 "not supported" for
|
|
45
|
+
// any other name. An app that needs a second real database (Prisma's
|
|
46
|
+
// shadow database for `migrate dev`, Django's `test_<name>`; `migrate
|
|
47
|
+
// deploy` needs none) needs the container backing.
|
|
48
|
+
// - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
|
|
49
|
+
// COPY … TO STDOUT works.
|
|
50
|
+
// - ONE serialized session. Connections are accepted concurrently but run
|
|
51
|
+
// one at a time under the lock below; a client holding a transaction open
|
|
52
|
+
// blocks every other client until it ends — or until it has sat idle in
|
|
53
|
+
// that transaction (ReadyForQuery 'T' or 'E') for IDLE_IN_TRANSACTION_MS
|
|
54
|
+
// while another client waits: then it is terminated with the FATAL Postgres
|
|
55
|
+
// sends a session past idle_in_transaction_session_timeout (25P03), its
|
|
56
|
+
// transaction rolled back (writes made through it are lost, as on Postgres),
|
|
57
|
+
// and the next client runs. This DEVIATES from Postgres, whose timeout is off
|
|
58
|
+
// by default and where an idle open transaction holds only its row locks:
|
|
59
|
+
// here it would hold the whole database, so a transaction that waits on
|
|
60
|
+
// something else (an HTTP call inside it) for longer than the limit while
|
|
61
|
+
// another client queues is cut. A READ-ONLY batch another connection sends
|
|
62
|
+
// while the holder sits idle in its transaction does not wait: it runs as a
|
|
63
|
+
// guest in the holder's session, inside a savepoint (see enter()), and sees
|
|
64
|
+
// the holder's uncommitted rows — Postgres would show it only committed ones.
|
|
65
|
+
// While a guest's batch runs, the holder waits for it (its idle clock
|
|
66
|
+
// stopped); a guest batch still open after GUEST_TURN_MS while anyone waits
|
|
67
|
+
// (a cursor that has not sent its Sync) is cancelled (57014), its savepoint
|
|
68
|
+
// undone;
|
|
69
|
+
// a batch that begins with a read and goes on to write is refused (0A000).
|
|
70
|
+
// A connection that closes while it waits
|
|
71
|
+
// for the lock gives it up unrun (rallly walk 2: pg-pool evicted an idle
|
|
72
|
+
// client — its Terminate queued behind another connection's transaction,
|
|
73
|
+
// ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
|
|
74
|
+
// backend session, so session state leaks between them and outlives the
|
|
75
|
+
// connection that set it: SET (search_path, timezone…), temp tables and
|
|
76
|
+
// PREPAREd statements are visible to every other connection; session
|
|
77
|
+
// advisory locks do NOT exclude (a second connection's
|
|
78
|
+
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
79
|
+
// to the connection that sent it, not to the one that ran LISTEN.
|
|
80
|
+
// CREATE ROLE succeeds but connections never run as that role.
|
|
26
81
|
import net from 'node:net';
|
|
27
82
|
import { mkdirSync } from 'node:fs';
|
|
28
83
|
import { PGlite } from '@electric-sql/pglite';
|
|
84
|
+
import { vector } from '@electric-sql/pglite-pgvector';
|
|
29
85
|
import { fromNodeSocket } from 'pg-gateway/node';
|
|
30
86
|
|
|
87
|
+
// The contrib extensions shipped in @electric-sql/pglite/dist/contrib (the
|
|
88
|
+
// module name; `uuid_ossp` installs as "uuid-ossp"). auto_explain is omitted:
|
|
89
|
+
// it is a preload library with no CREATE EXTENSION control file.
|
|
90
|
+
const CONTRIB = [
|
|
91
|
+
'amcheck', 'bloom', 'btree_gin', 'btree_gist', 'citext', 'cube', 'dict_int',
|
|
92
|
+
'dict_xsyn', 'earthdistance', 'file_fdw', 'fuzzystrmatch', 'hstore',
|
|
93
|
+
'intarray', 'isn', 'lo', 'ltree', 'moddatetime', 'pageinspect',
|
|
94
|
+
'pg_buffercache', 'pg_freespacemap', 'pg_stat_statements', 'pg_surgery',
|
|
95
|
+
'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
|
|
96
|
+
'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
|
|
97
|
+
];
|
|
98
|
+
const extensions = { vector };
|
|
99
|
+
for (const name of CONTRIB) {
|
|
100
|
+
const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
|
|
101
|
+
if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
|
|
102
|
+
extensions[name] = extension;
|
|
103
|
+
}
|
|
104
|
+
|
|
31
105
|
function arg(name) {
|
|
32
106
|
const index = process.argv.indexOf(name);
|
|
33
107
|
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
@@ -52,28 +126,234 @@ process.stderr.on('error', () => {});
|
|
|
52
126
|
// PGlite's create() only progresses while something holds the event loop;
|
|
53
127
|
// listening starts AFTER readiness so "port answers" means "serves queries"
|
|
54
128
|
const initKeepAlive = setInterval(() => {}, 500);
|
|
55
|
-
const dbReady = PGlite.create({ dataDir });
|
|
129
|
+
const dbReady = PGlite.create({ dataDir, extensions });
|
|
56
130
|
dbReady.catch((error) => {
|
|
57
131
|
process.stderr.write(`pglite-host: backend failed to start: ${error?.message ?? error}\n`);
|
|
58
132
|
process.exit(1);
|
|
59
133
|
});
|
|
60
134
|
|
|
61
135
|
// ---- transaction-affinity lock ---------------------------------------------
|
|
136
|
+
// How long the holder may sit idle inside its transaction while another client
|
|
137
|
+
// waits before it is terminated (see the limits above). Prisma's own default
|
|
138
|
+
// interactive-transaction timeout is 5s; a session idle that long with others
|
|
139
|
+
// queued is one no client is still driving.
|
|
140
|
+
const IDLE_IN_TRANSACTION_MS = 5000;
|
|
62
141
|
let holder = null;
|
|
63
142
|
const waiters = [];
|
|
64
|
-
|
|
65
|
-
|
|
143
|
+
/** Each connection's socket, whether a message of it is running, and since
|
|
144
|
+
* when it has been idle. */
|
|
145
|
+
const sessions = new Map();
|
|
146
|
+
// A GUEST batch: while the holder sits idle inside its transaction (its last
|
|
147
|
+
// ReadyForQuery 'T', no message of its own running, none half-sent), another
|
|
148
|
+
// connection's READ-ONLY batch runs in the holder's session, inside a savepoint
|
|
149
|
+
// released after it (rolled back to on an error, so the holder's transaction is
|
|
150
|
+
// never aborted by it), and is answered ReadyForQuery 'I' as its own session
|
|
151
|
+
// would be. An application that reads on a second pooled connection while its
|
|
152
|
+
// first holds a transaction open (twenty's sign-up: a TypeORM transaction that
|
|
153
|
+
// awaits a repository read on another connection) otherwise waits for a lock
|
|
154
|
+
// the transaction never gives up. The read sees the holder's uncommitted rows —
|
|
155
|
+
// a deviation from Postgres's isolation, stated in the limits above. Writes,
|
|
156
|
+
// and a guest's own BEGIN, wait for the lock as before.
|
|
157
|
+
let guestBatch = null;
|
|
158
|
+
let guestStartedAt = 0;
|
|
159
|
+
// How long a guest batch may hold its turn while the holder or a writer waits (a cursor that never sends its Sync
|
|
160
|
+
// would otherwise hold every connection up): past it the guest's batch is refused and the waiters run.
|
|
161
|
+
const GUEST_TURN_MS = 2000;
|
|
162
|
+
function wakeAll() {
|
|
163
|
+
for (const wake of waiters.splice(0)) wake();
|
|
164
|
+
}
|
|
165
|
+
/** How `token` may run its next message: 'own' (it holds the lock, and its turn is claimed here, before any other
|
|
166
|
+
* waiter wakes to look) or 'guest'. A holder waiting for its turn is `wanting`: no new guest starts ahead of it. */
|
|
167
|
+
async function enter(token, session, data) {
|
|
168
|
+
for (;;) {
|
|
169
|
+
if (guestBatch === token) return 'guest';
|
|
170
|
+
if (guestBatch === null) {
|
|
171
|
+
if (holder === null || holder === token) {
|
|
172
|
+
holder = token;
|
|
173
|
+
session.busy = true;
|
|
174
|
+
session.wanting = false;
|
|
175
|
+
return 'own';
|
|
176
|
+
}
|
|
177
|
+
const host = sessions.get(holder);
|
|
178
|
+
if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
|
|
179
|
+
&& session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
|
|
180
|
+
guestBatch = token;
|
|
181
|
+
guestStartedAt = Date.now();
|
|
182
|
+
return 'guest';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (holder === token) session.wanting = true;
|
|
66
186
|
await new Promise((resolve) => waiters.push(resolve));
|
|
67
187
|
}
|
|
68
|
-
holder = token;
|
|
69
188
|
}
|
|
70
189
|
function release(token) {
|
|
71
190
|
if (holder !== token) return;
|
|
72
191
|
holder = null;
|
|
73
|
-
|
|
74
|
-
|
|
192
|
+
wakeAll();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A statement's text with its string literals, quoted identifiers and comments blanked, or null when it cannot be
|
|
196
|
+
* read safely (a dollar-quoted body, an unterminated literal): the caller then treats it as not read-only. */
|
|
197
|
+
function statementSkeleton(text) {
|
|
198
|
+
// dollar quoting and backslash-escape strings (E'…', where \' does not close) are not read here: not read-only
|
|
199
|
+
// (a backslash anywhere, too: with standard_conforming_strings off, which one connection can set for the shared
|
|
200
|
+
// session, a plain '…' takes backslash escapes)
|
|
201
|
+
if (/\$[A-Za-z_0-9]*\$/.test(text) || text.includes('\\')) return null;
|
|
202
|
+
let out = '';
|
|
203
|
+
for (let i = 0; i < text.length;) {
|
|
204
|
+
const c = text[i];
|
|
205
|
+
if (c === "'" || c === '"') {
|
|
206
|
+
const end = text.indexOf(c, i + 1);
|
|
207
|
+
if (end < 0) return null;
|
|
208
|
+
let j = end;
|
|
209
|
+
while (text[j + 1] === c) { const next = text.indexOf(c, j + 2); if (next < 0) return null; j = next; }
|
|
210
|
+
out += ' ';
|
|
211
|
+
i = j + 1;
|
|
212
|
+
} else if (c === '-' && text[i + 1] === '-') {
|
|
213
|
+
const end = text.indexOf('\n', i);
|
|
214
|
+
i = end < 0 ? text.length : end;
|
|
215
|
+
out += ' ';
|
|
216
|
+
} else if (c === '/' && text[i + 1] === '*') {
|
|
217
|
+
const end = text.indexOf('*/', i + 2);
|
|
218
|
+
if (end < 0) return null;
|
|
219
|
+
i = end + 2;
|
|
220
|
+
out += ' ';
|
|
221
|
+
} else { out += c; i += 1; }
|
|
222
|
+
}
|
|
223
|
+
return out.trim();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether a statement only reads: one SELECT (or WITH/SHOW/VALUES/TABLE/EXPLAIN without ANALYZE) that writes,
|
|
227
|
+
* locks or advances nothing, judged on its text with literals and comments removed. A false "no" only waits. */
|
|
228
|
+
function readOnlySql(text) {
|
|
229
|
+
const sql = statementSkeleton(text);
|
|
230
|
+
if (sql === null) return false;
|
|
231
|
+
if (sql.replace(/;\s*$/, '').includes(';')) return false; // one statement only
|
|
232
|
+
if (!/^(select|with|show|values|table|explain)\b/i.test(sql)) return false;
|
|
233
|
+
return !/\b(insert|update|delete|merge|into|copy|analyze|call|do|set_config|nextval|setval|pg_(try_)?advisory\w*|pg_notify|lo_\w+|for\s+(update|share|no\s+key\s+update|key\s+share))\b/i.test(sql);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
|
|
237
|
+
const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
|
|
238
|
+
|
|
239
|
+
/** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
|
|
240
|
+
* PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
|
|
241
|
+
function noteStatement(session, message) {
|
|
242
|
+
const data = asBuffer(message);
|
|
243
|
+
if (data[0] === 0x51 /* Q */ && /\b(prepare|deallocate|discard)\b/i.test(text(data, 5))) session.statements.clear();
|
|
244
|
+
else if (data[0] === 0x50 /* P */) {
|
|
245
|
+
const name = text(data, 5);
|
|
246
|
+
session.statements.set(name, readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)));
|
|
247
|
+
} else if (data[0] === 0x43 /* C */ && data[5] === 0x53 /* S */) {
|
|
248
|
+
session.statements.delete(text(data, 6));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether a guest may run this message: a Query or Parse of read-only SQL, a Bind of a statement this connection
|
|
253
|
+
* prepared read-only (the unnamed one included), and the batch's Describe/Execute/Sync/Flush/Close. */
|
|
254
|
+
function guestMessageAllowed(session, message) {
|
|
255
|
+
const data = asBuffer(message);
|
|
256
|
+
switch (data[0]) {
|
|
257
|
+
case 0x51: return readOnlySql(text(data, 5)); // Q
|
|
258
|
+
case 0x50: { const name = text(data, 5); return readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)); } // P
|
|
259
|
+
case 0x42: { // B: portal name, then statement name; the unnamed one only as this batch parsed it
|
|
260
|
+
const portal = text(data, 5);
|
|
261
|
+
const statement = text(data, 5 + Buffer.byteLength(portal) + 1);
|
|
262
|
+
if (statement === '') return session.guestOpen && session.guestUnnamed === true;
|
|
263
|
+
return session.statements.get(statement) === true;
|
|
264
|
+
}
|
|
265
|
+
case 0x44: case 0x45: case 0x53: case 0x48: case 0x43: return session.guestOpen; // D E S H C, inside its batch
|
|
266
|
+
default: return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** An ErrorResponse (and nothing else): the guest batch refused mid-way. */
|
|
271
|
+
function errorResponse(code, message) {
|
|
272
|
+
const fields = ['SERROR', 'VERROR', `C${code}`, `M${message}`];
|
|
273
|
+
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
274
|
+
const head = Buffer.alloc(5);
|
|
275
|
+
head[0] = 0x45;
|
|
276
|
+
head.writeInt32BE(body.length + 4, 1);
|
|
277
|
+
return Buffer.concat([head, body]);
|
|
278
|
+
}
|
|
279
|
+
const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
|
|
280
|
+
|
|
281
|
+
/** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
|
|
282
|
+
async function quiet(db, sql) {
|
|
283
|
+
try { await db.query(sql); } catch { /* the savepoint went with the transaction */ }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `response` with its last ReadyForQuery reporting `status`. */
|
|
287
|
+
function withReadyStatus(response, status) {
|
|
288
|
+
const out = Buffer.from(response);
|
|
289
|
+
let offset = 0;
|
|
290
|
+
let at = -1;
|
|
291
|
+
while (offset + 5 <= out.length) {
|
|
292
|
+
const length = out.readInt32BE(offset + 1);
|
|
293
|
+
if (length < 4) break;
|
|
294
|
+
if (out[offset] === 0x5a && offset + 5 < out.length) at = offset + 5;
|
|
295
|
+
offset += 1 + length;
|
|
296
|
+
}
|
|
297
|
+
if (at >= 0) out[at] = status.charCodeAt(0);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Postgres's FATAL for a session past idle_in_transaction_session_timeout
|
|
302
|
+
* (postgres.c: ERRCODE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, 25P03). */
|
|
303
|
+
function idleInTransactionFatal() {
|
|
304
|
+
const fields = ['SFATAL', 'VFATAL', 'C25P03', 'Mterminating connection due to idle-in-transaction timeout'];
|
|
305
|
+
const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
|
|
306
|
+
const head = Buffer.alloc(5);
|
|
307
|
+
head[0] = 0x45; // 'E'
|
|
308
|
+
head.writeInt32BE(body.length + 4, 1);
|
|
309
|
+
return Buffer.concat([head, body]);
|
|
75
310
|
}
|
|
76
311
|
|
|
312
|
+
// the holder idle in its transaction past the limit while others wait: end it
|
|
313
|
+
const idleWatch = setInterval(() => {
|
|
314
|
+
// a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
|
|
315
|
+
if (guestBatch !== null && waiters.length > 0 && Date.now() - guestStartedAt > GUEST_TURN_MS) {
|
|
316
|
+
const guest = sessions.get(guestBatch);
|
|
317
|
+
const expired = guestBatch;
|
|
318
|
+
if (guest && guest.guestRefused && !guest.guestRunning) {
|
|
319
|
+
// a refused batch that never sends its Sync: its savepoint is already undone; its turn ends
|
|
320
|
+
guest.guestRefused = false;
|
|
321
|
+
guest.guestExpired = true;
|
|
322
|
+
guest.guestErrorSent = true;
|
|
323
|
+
const host = sessions.get(holder);
|
|
324
|
+
if (host) host.idleSince += Date.now() - guestStartedAt;
|
|
325
|
+
guestBatch = null;
|
|
326
|
+
wakeAll();
|
|
327
|
+
} else if (guest && guest.guestOpen && !guest.guestRunning) {
|
|
328
|
+
guest.guestOpen = false;
|
|
329
|
+
guest.guestUnnamed = false;
|
|
330
|
+
guest.guestExpired = true;
|
|
331
|
+
void dbReady.then(async (db) => {
|
|
332
|
+
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
333
|
+
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
334
|
+
}).then(() => {
|
|
335
|
+
if (guestBatch !== expired) return;
|
|
336
|
+
const host = sessions.get(holder);
|
|
337
|
+
if (host) host.idleSince += Date.now() - guestStartedAt;
|
|
338
|
+
guestBatch = null;
|
|
339
|
+
wakeAll();
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (holder === null || waiters.length === 0) return;
|
|
344
|
+
const session = sessions.get(holder);
|
|
345
|
+
// armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
|
|
346
|
+
// a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
|
|
347
|
+
if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
|
|
348
|
+
if (Date.now() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
|
|
349
|
+
session.terminated = true;
|
|
350
|
+
process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
|
|
351
|
+
// the socket's close handler rolls the transaction back and releases the lock
|
|
352
|
+
session.socket.end(idleInTransactionFatal());
|
|
353
|
+
setTimeout(() => session.socket.destroy(), 500).unref();
|
|
354
|
+
}, 250);
|
|
355
|
+
idleWatch.unref();
|
|
356
|
+
|
|
77
357
|
/** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
|
|
78
358
|
* protocol response, or null when the response carries none (mid-pipeline:
|
|
79
359
|
* the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
|
|
@@ -94,10 +374,152 @@ function lastReadyStatus(response) {
|
|
|
94
374
|
return status;
|
|
95
375
|
}
|
|
96
376
|
|
|
377
|
+
// ---- CREATE DATABASE refusal -----------------------------------------------
|
|
378
|
+
// PGlite accepts CREATE DATABASE (a pg_database row appears) but nothing can
|
|
379
|
+
// ever connect to it: every startup database name aliases to `postgres`. A
|
|
380
|
+
// client that "creates" a database and then connects to it would silently land
|
|
381
|
+
// in the one real database (a Prisma shadow database would replay migrations
|
|
382
|
+
// into it; a test runner would treat it as disposable). So the statement fails:
|
|
383
|
+
// - naming the connection's OWN startup database (or `postgres`), with 42P04
|
|
384
|
+
// duplicate_database — true, since that name connects, and the answer
|
|
385
|
+
// check-then-create setup (`rails db:create`) tolerates;
|
|
386
|
+
// - naming any other database, with 0A000 feature_not_supported, because a
|
|
387
|
+
// tool that treats 42P04 as "reuse it" would then connect to the alias of
|
|
388
|
+
// the real database and treat it as disposable.
|
|
389
|
+
// The statement text is rewritten to a DO block that raises, so the BACKEND
|
|
390
|
+
// reports the error and owns the protocol and transaction state
|
|
391
|
+
// (ErrorResponse, aborted block, ReadyForQuery) as for any failing statement.
|
|
392
|
+
//
|
|
393
|
+
// COPY … FROM STDIN is refused the same way (0A000): PGlite 0.5.8 has no
|
|
394
|
+
// copy-in sub-protocol over execProtocolRaw — the statement aborts the WASM
|
|
395
|
+
// backend ("Program terminated with exit(1)") and every later call hangs, so
|
|
396
|
+
// one bulk load would take the World's database down. COPY … TO STDOUT works.
|
|
397
|
+
const LEADING = String.raw`^\s*(?:(?:--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*)*`;
|
|
398
|
+
const CREATE_DATABASE = new RegExp(`${LEADING}create\\s+database\\s+("(?:[^"]|"")+"|[^\\s;]+)?`, 'i');
|
|
399
|
+
// Any statement of the text (a multi-statement Query included), comments
|
|
400
|
+
// removed first, that begins with COPY and reaches FROM STDIN before its `;`.
|
|
401
|
+
// Deliberately broad: a string literal holding such text is refused too,
|
|
402
|
+
// the price of never letting one through.
|
|
403
|
+
const COMMENTS = /--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
404
|
+
const COPY_FROM_STDIN = /(?:^|;)\s*copy\b[^;]*?\bfrom\s+stdin\b/i;
|
|
405
|
+
const DOLLAR_TAG = '$pglite_host$';
|
|
406
|
+
const quoted = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
407
|
+
const raising = (errcode, message, detail, hint) => `DO ${DOLLAR_TAG} BEGIN RAISE EXCEPTION USING `
|
|
408
|
+
+ `ERRCODE = '${errcode}', MESSAGE = ${quoted(message)}, DETAIL = ${quoted(detail)}, HINT = ${quoted(hint)}; `
|
|
409
|
+
+ `END ${DOLLAR_TAG}`;
|
|
410
|
+
|
|
411
|
+
/** The DO block refusing CREATE DATABASE `name` (unquoted as Postgres folds
|
|
412
|
+
* it; undefined when unparseable) on a connection whose startup database is
|
|
413
|
+
* `own`, in plain SQL literal quoting. */
|
|
414
|
+
function refusal(name, own) {
|
|
415
|
+
const label = name && !name.includes(DOLLAR_TAG) ? ` "${name}"` : '';
|
|
416
|
+
const exists = name !== undefined && (name === own || name === 'postgres');
|
|
417
|
+
const detail = "The World's PGlite backing serves ONE database; every database name a client connects with is an alias for it, so CREATE DATABASE cannot make a separate one.";
|
|
418
|
+
const hint = 'Use the database named in DATABASE_URL, or run the World with a container runtime for multiple databases.';
|
|
419
|
+
return exists
|
|
420
|
+
? raising('duplicate_database', `database${label} already exists`, detail, hint)
|
|
421
|
+
: raising('feature_not_supported', `CREATE DATABASE${label} is not supported: this World serves one database`, detail, hint);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const COPY_REFUSAL = raising('feature_not_supported',
|
|
425
|
+
"COPY FROM STDIN is not supported by the World's PGlite backing",
|
|
426
|
+
'PGlite has no copy-in protocol over the wire; the statement would abort the one database backend.',
|
|
427
|
+
'Load rows with INSERT (multi-row VALUES or batched statements), or run the World with a container runtime.');
|
|
428
|
+
|
|
429
|
+
/** The replacement SQL for a statement this host refuses, or null. */
|
|
430
|
+
function refusalFor(sql, own) {
|
|
431
|
+
const database = CREATE_DATABASE.exec(sql);
|
|
432
|
+
if (database) {
|
|
433
|
+
const spelled = database[1];
|
|
434
|
+
return refusal(spelled?.startsWith('"') ? spelled.slice(1, -1).replaceAll('""', '"') : spelled?.toLowerCase(), own);
|
|
435
|
+
}
|
|
436
|
+
return COPY_FROM_STDIN.test(sql.replace(COMMENTS, ' ')) ? COPY_REFUSAL : null;
|
|
437
|
+
}
|
|
438
|
+
const encoder = new TextEncoder();
|
|
439
|
+
const decoder = new TextDecoder();
|
|
440
|
+
|
|
441
|
+
function cstringEnd(bytes, start) {
|
|
442
|
+
const end = bytes.indexOf(0, start);
|
|
443
|
+
return end < 0 ? bytes.length : end;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A frontend Query ('Q') or Parse ('P') message whose SQL this host refuses
|
|
447
|
+
* (CREATE DATABASE, COPY FROM STDIN), with that SQL replaced by its raising DO
|
|
448
|
+
* block; any other message is returned unchanged. `own` is the connection's
|
|
449
|
+
* startup database name. */
|
|
450
|
+
function refuseStatements(message, own) {
|
|
451
|
+
const type = message[0];
|
|
452
|
+
if (type !== 0x51 /* 'Q' */ && type !== 0x50 /* 'P' */) return message;
|
|
453
|
+
// Parse = name\0 query\0 params…; Query = query\0
|
|
454
|
+
const queryStart = type === 0x50 ? cstringEnd(message, 5) + 1 : 5;
|
|
455
|
+
if (queryStart >= message.length) return message;
|
|
456
|
+
const queryEnd = cstringEnd(message, queryStart);
|
|
457
|
+
const replacement = refusalFor(decoder.decode(message.subarray(queryStart, queryEnd)), own);
|
|
458
|
+
if (replacement === null) return message;
|
|
459
|
+
const sql = encoder.encode(replacement);
|
|
460
|
+
const tail = message.subarray(queryEnd); // the query's NUL and, for Parse, its parameter types
|
|
461
|
+
const out = new Uint8Array(queryStart + sql.length + tail.length);
|
|
462
|
+
out.set(message.subarray(0, queryStart));
|
|
463
|
+
out.set(sql, queryStart);
|
|
464
|
+
out.set(tail, queryStart + sql.length);
|
|
465
|
+
new DataView(out.buffer).setInt32(1, out.length - 1);
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---- extended-protocol error recovery --------------------------------------
|
|
470
|
+
// Postgres answers an error inside an extended-protocol batch (Parse / Bind /
|
|
471
|
+
// Describe / Execute) with ErrorResponse alone, discards messages until Sync,
|
|
472
|
+
// and sends ONE ReadyForQuery for the Sync. PGlite's execProtocolRaw, fed one
|
|
473
|
+
// message at a time, appends a ReadyForQuery to the ErrorResponse at once and
|
|
474
|
+
// then answers the Sync with a second one. The client counts two and every
|
|
475
|
+
// later result shifts by one (pg then crashes on a null handler). So the
|
|
476
|
+
// ReadyForQuery that follows an error from an extended-protocol message is
|
|
477
|
+
// dropped; the Sync's own one ends the batch (and releases the lock). Messages
|
|
478
|
+
// that end a simple cycle — Query, Sync, FunctionCall, CopyDone, CopyFail —
|
|
479
|
+
// keep theirs: Postgres always answers them with one.
|
|
480
|
+
// Measured on PGlite 0.5.8 by feeding execProtocolRaw directly: Parse of
|
|
481
|
+
// `SELECT 1/0` -> ParseComplete, Bind -> E Z(I), Execute -> nothing,
|
|
482
|
+
// Sync -> Z(I).
|
|
483
|
+
function withoutEarlyReady(message, response) {
|
|
484
|
+
const type = message[0];
|
|
485
|
+
// Query, Sync, FunctionCall, CopyDone, CopyFail
|
|
486
|
+
if (type === 0x51 || type === 0x53 || type === 0x46 || type === 0x63 || type === 0x66) return response;
|
|
487
|
+
let sawError = false;
|
|
488
|
+
let offset = 0;
|
|
489
|
+
while (offset + 5 <= response.length) {
|
|
490
|
+
const kind = response[offset];
|
|
491
|
+
const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
|
|
492
|
+
| (response[offset + 3] << 8) | response[offset + 4];
|
|
493
|
+
if (length < 4) return response; // malformed; pass through untouched
|
|
494
|
+
const end = offset + 1 + length;
|
|
495
|
+
if (kind === 0x45 /* 'E' */) sawError = true;
|
|
496
|
+
if (kind === 0x5a /* 'Z' */ && sawError) {
|
|
497
|
+
const out = new Uint8Array(response.length - (end - offset));
|
|
498
|
+
out.set(response.subarray(0, offset));
|
|
499
|
+
out.set(response.subarray(end), offset);
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
offset = end;
|
|
503
|
+
}
|
|
504
|
+
return response;
|
|
505
|
+
}
|
|
506
|
+
|
|
97
507
|
const server = net.createServer(async (socket) => {
|
|
98
508
|
const token = {};
|
|
99
509
|
let holding = false;
|
|
510
|
+
const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
|
|
511
|
+
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map() };
|
|
512
|
+
sessions.set(token, session);
|
|
100
513
|
socket.on('close', () => {
|
|
514
|
+
session.closed = true;
|
|
515
|
+
sessions.delete(token);
|
|
516
|
+
if (guestBatch === token) {
|
|
517
|
+
// a guest gone mid-batch: undo what it did in the holder's transaction
|
|
518
|
+
void dbReady
|
|
519
|
+
.then(async (db) => { if (session.guestOpen) { await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest'); await quiet(db, 'RELEASE SAVEPOINT volter_guest'); } })
|
|
520
|
+
.then(() => { session.guestOpen = false; guestBatch = null; wakeAll(); });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
101
523
|
if (!holding) return;
|
|
102
524
|
// the client vanished inside its transaction: roll it back, then let the
|
|
103
525
|
// next connection in — never leak a half-open transaction into a stranger
|
|
@@ -113,16 +535,100 @@ const server = net.createServer(async (socket) => {
|
|
|
113
535
|
await fromNodeSocket(socket, {
|
|
114
536
|
serverVersion: '18.3 (PGlite)',
|
|
115
537
|
auth: { method: 'trust' },
|
|
116
|
-
async onMessage(data, { isAuthenticated }) {
|
|
538
|
+
async onMessage(data, { isAuthenticated, clientParams }) {
|
|
117
539
|
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
118
540
|
const db = await dbReady;
|
|
119
|
-
|
|
541
|
+
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
542
|
+
if (session.guestExpired) {
|
|
543
|
+
if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
|
|
544
|
+
if (session.guestErrorSent) return new Uint8Array(0);
|
|
545
|
+
session.guestErrorSent = true;
|
|
546
|
+
return errorResponse('57014', 'the containerless Postgres (one session) cancelled this read: it ran beside another connection\'s open transaction longer than its turn allows');
|
|
547
|
+
}
|
|
548
|
+
const role = await enter(token, session, data);
|
|
549
|
+
if (role === 'guest') {
|
|
550
|
+
if (session.closed) return undefined;
|
|
551
|
+
const own = clientParams?.database || clientParams?.user;
|
|
552
|
+
const sync = data[0] === 0x53;
|
|
553
|
+
const endGuest = () => {
|
|
554
|
+
session.guestOpen = false;
|
|
555
|
+
session.guestRefused = false;
|
|
556
|
+
session.status = 'I';
|
|
557
|
+
guestBatch = null;
|
|
558
|
+
session.guestUnnamed = false;
|
|
559
|
+
// the holder's idle clock stops while a guest runs, and goes on from where it was
|
|
560
|
+
const host = sessions.get(holder);
|
|
561
|
+
if (host) host.idleSince += Date.now() - guestStartedAt;
|
|
562
|
+
wakeAll();
|
|
563
|
+
};
|
|
564
|
+
// a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
|
|
565
|
+
if (session.guestRefused) {
|
|
566
|
+
if (sync) { endGuest(); return READY_IDLE; }
|
|
567
|
+
return new Uint8Array(0);
|
|
568
|
+
}
|
|
569
|
+
if (session.guestOpen && !guestMessageAllowed(session, data)) {
|
|
570
|
+
session.guestRunning = true; // the expiry waits until this refusal is done
|
|
571
|
+
await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
572
|
+
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
573
|
+
session.guestOpen = false;
|
|
574
|
+
session.guestRefused = true;
|
|
575
|
+
session.guestRunning = false;
|
|
576
|
+
return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
|
|
577
|
+
}
|
|
578
|
+
if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
|
|
579
|
+
noteStatement(session, data);
|
|
580
|
+
if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
|
|
581
|
+
let raw;
|
|
582
|
+
session.guestRunning = true;
|
|
583
|
+
try {
|
|
584
|
+
raw = await db.execProtocolRaw(refuseStatements(data, own));
|
|
585
|
+
} catch (error) {
|
|
586
|
+
process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
const response = withoutEarlyReady(data, raw);
|
|
590
|
+
const status = lastReadyStatus(response);
|
|
591
|
+
if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
|
|
592
|
+
// the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
|
|
593
|
+
if (status === 'E') await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
|
|
594
|
+
await quiet(db, 'RELEASE SAVEPOINT volter_guest');
|
|
595
|
+
session.guestRunning = false; // the batch has ended: nothing left for the expiry to cancel
|
|
596
|
+
endGuest();
|
|
597
|
+
return withReadyStatus(response, 'I');
|
|
598
|
+
}
|
|
599
|
+
// closed (or cut) while it waited: give the lock up without running the
|
|
600
|
+
// message — a Terminate run now would leave no ReadyForQuery to release it
|
|
601
|
+
if (session.closed || session.terminated) {
|
|
602
|
+
session.busy = false;
|
|
603
|
+
if (!holding) release(token);
|
|
604
|
+
return undefined;
|
|
605
|
+
}
|
|
120
606
|
holding = true;
|
|
121
|
-
|
|
607
|
+
session.busy = true;
|
|
608
|
+
noteStatement(session, data);
|
|
609
|
+
// libpq's default: no database parameter means the user's name
|
|
610
|
+
const own = clientParams?.database || clientParams?.user;
|
|
611
|
+
let raw;
|
|
612
|
+
try {
|
|
613
|
+
raw = await db.execProtocolRaw(refuseStatements(data, own));
|
|
614
|
+
} catch (error) {
|
|
615
|
+
// SQL errors arrive as ErrorResponse bytes; a throw means the WASM
|
|
616
|
+
// backend itself aborted and every later call would hang. Exit so the
|
|
617
|
+
// World sees the service down (status fails) instead of a wedged port.
|
|
618
|
+
process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
|
|
619
|
+
process.exit(1);
|
|
620
|
+
}
|
|
621
|
+
session.busy = false;
|
|
622
|
+
session.idleSince = Date.now();
|
|
623
|
+
const response = withoutEarlyReady(data, raw);
|
|
122
624
|
const status = lastReadyStatus(response);
|
|
625
|
+
session.midBatch = status === null;
|
|
626
|
+
if (status !== null) session.status = status;
|
|
123
627
|
if (status === 'I') {
|
|
124
628
|
holding = false;
|
|
125
629
|
release(token);
|
|
630
|
+
} else if (status === 'T') {
|
|
631
|
+
wakeAll(); // idle in its transaction: a waiting reader may now run as a guest
|
|
126
632
|
}
|
|
127
633
|
return response;
|
|
128
634
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
const realRunner = (cmd, args) => {
|
|
3
|
-
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
3
|
+
const result = spawnSync(cmd, args, { windowsHide: true, encoding: 'utf8' });
|
|
4
4
|
return {
|
|
5
5
|
status: result.status,
|
|
6
6
|
stdout: result.stdout || '',
|
|
@@ -22,7 +22,7 @@ export function survivingOwnedGroups(pids) {
|
|
|
22
22
|
});
|
|
23
23
|
if (!candidates.length || process.platform === 'win32')
|
|
24
24
|
return candidates;
|
|
25
|
-
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], {
|
|
25
|
+
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
|
|
26
26
|
encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
27
27
|
});
|
|
28
28
|
if (result.status !== 0 || result.error)
|
|
@@ -11,7 +11,7 @@ import { type CaTrustInputs } from './ca-trust.js';
|
|
|
11
11
|
* the host was MITM'd, i.e. the host IS twinned in this world — so the bare "no twin for host"
|
|
12
12
|
* sent readers hunting for a `*_TWIN_URL` that was already set. On a shared host the path may
|
|
13
13
|
* belong to a vendor whose twin merely is not configured (youtube vs googleauth), OR to no pack
|
|
14
|
-
* at all (Google
|
|
14
|
+
* at all (Google Drive on www.googleapis.com has no twin yet) — the message states both,
|
|
15
15
|
* because this refusal is exactly what replaced the silent mis-route that answered Calendar
|
|
16
16
|
* calls with plausible Google-shaped 404s from the gemini pack.
|
|
17
17
|
*/
|
|
@@ -46,7 +46,7 @@ import { sessionTrustEnv } from "./ca-trust.js";
|
|
|
46
46
|
* the host was MITM'd, i.e. the host IS twinned in this world — so the bare "no twin for host"
|
|
47
47
|
* sent readers hunting for a `*_TWIN_URL` that was already set. On a shared host the path may
|
|
48
48
|
* belong to a vendor whose twin merely is not configured (youtube vs googleauth), OR to no pack
|
|
49
|
-
* at all (Google
|
|
49
|
+
* at all (Google Drive on www.googleapis.com has no twin yet) — the message states both,
|
|
50
50
|
* because this refusal is exactly what replaced the silent mis-route that answered Calendar
|
|
51
51
|
* calls with plausible Google-shaped 404s from the gemini pack.
|
|
52
52
|
*/
|
|
@@ -55,7 +55,7 @@ export function noTwinMessage(host, path) {
|
|
|
55
55
|
+ 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
|
|
56
56
|
+ 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
|
|
57
57
|
+ 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
|
|
58
|
-
+ 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google
|
|
58
|
+
+ 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Drive /drive/v3/*),\n'
|
|
59
59
|
+ 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
|
|
60
60
|
+ 'wrong twin nor leaked to the real vendor.\n';
|
|
61
61
|
}
|
|
@@ -199,11 +199,11 @@ export function opensslAvailable() {
|
|
|
199
199
|
// this env var in real usage.
|
|
200
200
|
if (process.env.VOLTER_TEST_NO_OPENSSL === '1')
|
|
201
201
|
return false;
|
|
202
|
-
const r = spawnSync('openssl', ['version'], { stdio: 'ignore' });
|
|
202
|
+
const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
|
|
203
203
|
return r.status === 0;
|
|
204
204
|
}
|
|
205
205
|
function runOpenssl(args) {
|
|
206
|
-
const r = spawnSync('openssl', args, { encoding: 'utf8' });
|
|
206
|
+
const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
|
|
207
207
|
if (r.status !== 0)
|
|
208
208
|
throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
209
209
|
return r.stdout;
|