lakebed 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/package.json +5 -2
- package/src/anonymous-server.js +1066 -329
- package/src/anonymous.js +34 -13
- package/src/cli.js +57 -6
- package/src/client.d.ts +1 -0
- package/src/client.js +79 -16
- package/src/source-runtime-loader.mjs +31 -0
- package/src/source-runtime-worker.js +454 -0
- package/src/source-runtime.js +668 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ANONYMOUS_LIMITS,
|
|
10
|
+
prepareAnonymousPatch,
|
|
11
|
+
stableStringify
|
|
12
|
+
} from "./anonymous.js";
|
|
13
|
+
|
|
14
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const defaultWorkerPath = resolve(moduleDir, "source-runtime-worker.js");
|
|
16
|
+
const defaultLoaderPath = resolve(moduleDir, "source-runtime-loader.mjs");
|
|
17
|
+
const defaultTimeoutMs = 5000;
|
|
18
|
+
const defaultFetchTimeoutMs = 5000;
|
|
19
|
+
const defaultMaxOldSpaceSizeMb = 64;
|
|
20
|
+
const defaultMaxFetchResponseBytes = 1024 * 1024;
|
|
21
|
+
const defaultMaxRequestBytes = 128 * 1024;
|
|
22
|
+
const maxSourceOperations = 10000;
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function byteLength(value) {
|
|
29
|
+
return Buffer.byteLength(typeof value === "string" ? value : stableStringify(value), "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function positiveNumber(value, fallback) {
|
|
33
|
+
const number = Number(value);
|
|
34
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertFieldValue(tableName, fieldName, field, value, limits = DEFAULT_ANONYMOUS_LIMITS) {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
throw new Error(`Missing value for ${tableName}.${fieldName}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (field.kind === "string" && typeof value !== "string") {
|
|
43
|
+
throw new Error(`Expected ${tableName}.${fieldName} to be a string.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (field.kind === "boolean" && typeof value !== "boolean") {
|
|
47
|
+
throw new Error(`Expected ${tableName}.${fieldName} to be a boolean.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const maxValueBytes = limits.maxValueBytes ?? DEFAULT_ANONYMOUS_LIMITS.maxValueBytes;
|
|
51
|
+
if (byteLength(value) > maxValueBytes) {
|
|
52
|
+
throw new Error(`Value for ${tableName}.${fieldName} exceeds ${maxValueBytes} bytes.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateSourceInsertRow(schema, tableName, row, limits = DEFAULT_ANONYMOUS_LIMITS) {
|
|
57
|
+
const table = schema[tableName];
|
|
58
|
+
if (!table) {
|
|
59
|
+
throw new Error(`Unknown table: ${tableName}`);
|
|
60
|
+
}
|
|
61
|
+
if (!isPlainObject(row)) {
|
|
62
|
+
throw new Error(`Invalid insert row for ${tableName}.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fields = table.fields ?? {};
|
|
66
|
+
const metadata = new Set(["id", "createdAt", "updatedAt"]);
|
|
67
|
+
const cleanRow = {};
|
|
68
|
+
|
|
69
|
+
for (const key of Object.keys(row)) {
|
|
70
|
+
if (!fields[key] && !metadata.has(key)) {
|
|
71
|
+
throw new Error(`Unknown field for ${tableName}: ${key}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const key of metadata) {
|
|
76
|
+
if (typeof row[key] !== "string" || row[key].length === 0) {
|
|
77
|
+
throw new Error(`Invalid insert row metadata for ${tableName}.${key}.`);
|
|
78
|
+
}
|
|
79
|
+
cleanRow[key] = row[key];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
83
|
+
const value = row[fieldName] ?? field.defaultValue;
|
|
84
|
+
assertFieldValue(tableName, fieldName, field, value, limits);
|
|
85
|
+
cleanRow[fieldName] = value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return cleanRow;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateSourcePatch(schema, tableName, patch, limits = DEFAULT_ANONYMOUS_LIMITS) {
|
|
92
|
+
if (!isPlainObject(patch)) {
|
|
93
|
+
throw new Error(`Invalid update patch for ${tableName}.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const appPatch = {};
|
|
97
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
98
|
+
if (key === "updatedAt") {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
appPatch[key] = value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return prepareAnonymousPatch(schema, tableName, appPatch, limits);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function snapshotSourceState({ artifact, deployId, state }) {
|
|
108
|
+
const rows = {};
|
|
109
|
+
for (const tableName of Object.keys(artifact.server.schema ?? {})) {
|
|
110
|
+
rows[tableName] = await state.listRows(deployId, tableName);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
env: typeof state.getServerEnv === "function" ? await state.getServerEnv(deployId) : {},
|
|
115
|
+
rows
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateSourceOperations({ artifact, operations }) {
|
|
120
|
+
if (!Array.isArray(operations)) {
|
|
121
|
+
throw new Error("Source runtime returned invalid mutation operations.");
|
|
122
|
+
}
|
|
123
|
+
if (operations.length > maxSourceOperations) {
|
|
124
|
+
throw new Error(`Source runtime returned more than ${maxSourceOperations} mutation operations.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const schema = artifact.server.schema ?? {};
|
|
128
|
+
const limits = artifact.limits ?? DEFAULT_ANONYMOUS_LIMITS;
|
|
129
|
+
const cleanOperations = [];
|
|
130
|
+
for (const operation of operations) {
|
|
131
|
+
if (!isPlainObject(operation) || typeof operation.table !== "string" || !schema[operation.table]) {
|
|
132
|
+
throw new Error("Source runtime returned an invalid mutation operation.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (operation.op === "insert") {
|
|
136
|
+
cleanOperations.push({
|
|
137
|
+
op: "insert",
|
|
138
|
+
row: validateSourceInsertRow(schema, operation.table, operation.row, limits),
|
|
139
|
+
table: operation.table
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (operation.op === "update") {
|
|
145
|
+
if (typeof operation.id !== "string") {
|
|
146
|
+
throw new Error(`Source runtime returned an invalid update id for ${operation.table}.`);
|
|
147
|
+
}
|
|
148
|
+
cleanOperations.push({
|
|
149
|
+
id: operation.id,
|
|
150
|
+
op: "update",
|
|
151
|
+
patch: validateSourcePatch(schema, operation.table, operation.patch, limits),
|
|
152
|
+
table: operation.table
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (operation.op === "delete") {
|
|
158
|
+
if (typeof operation.id !== "string") {
|
|
159
|
+
throw new Error(`Source runtime returned an invalid delete id for ${operation.table}.`);
|
|
160
|
+
}
|
|
161
|
+
cleanOperations.push({ id: operation.id, op: "delete", table: operation.table });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new Error(`Source runtime returned unsupported mutation operation: ${operation.op}`);
|
|
166
|
+
}
|
|
167
|
+
return cleanOperations;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function applySourceOperations({ artifact, deployId, operations, tx }) {
|
|
171
|
+
const cleanOperations = validateSourceOperations({ artifact, operations });
|
|
172
|
+
for (const operation of cleanOperations) {
|
|
173
|
+
if (operation.op === "insert") {
|
|
174
|
+
await tx.insertRow(deployId, operation.table, operation.row);
|
|
175
|
+
} else if (operation.op === "update") {
|
|
176
|
+
await tx.updateRow(deployId, operation.table, operation.id, operation.patch);
|
|
177
|
+
} else if (operation.op === "delete") {
|
|
178
|
+
await tx.deleteRow(deployId, operation.table, operation.id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function permissionFlag() {
|
|
184
|
+
if (process.allowedNodeEnvironmentFlags?.has("--permission")) {
|
|
185
|
+
return "--permission";
|
|
186
|
+
}
|
|
187
|
+
if (process.allowedNodeEnvironmentFlags?.has("--experimental-permission")) {
|
|
188
|
+
return "--experimental-permission";
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sourceRuntimeExecArgv({ loaderPath, maxOldSpaceSizeMb, workerPath }) {
|
|
194
|
+
const execArgv = [
|
|
195
|
+
`--max-old-space-size=${maxOldSpaceSizeMb}`,
|
|
196
|
+
"--disallow-code-generation-from-strings",
|
|
197
|
+
"--loader",
|
|
198
|
+
pathToFileURL(loaderPath).href
|
|
199
|
+
];
|
|
200
|
+
const flag = permissionFlag();
|
|
201
|
+
if (flag) {
|
|
202
|
+
execArgv.unshift(flag, "--allow-worker", `--allow-fs-read=${workerPath}`, `--allow-fs-read=${loaderPath}`);
|
|
203
|
+
}
|
|
204
|
+
return execArgv;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeHeaderObject(headers = {}) {
|
|
208
|
+
if (!headers) {
|
|
209
|
+
return {};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(headers)) {
|
|
213
|
+
return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (typeof headers.entries === "function") {
|
|
217
|
+
return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isPlainObject(headers)) {
|
|
221
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, String(value)]));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sanitizeRequestHeaders(headers) {
|
|
228
|
+
const blocked = new Set([
|
|
229
|
+
"connection",
|
|
230
|
+
"content-length",
|
|
231
|
+
"host",
|
|
232
|
+
"proxy-authenticate",
|
|
233
|
+
"proxy-authorization",
|
|
234
|
+
"te",
|
|
235
|
+
"trailer",
|
|
236
|
+
"transfer-encoding",
|
|
237
|
+
"upgrade"
|
|
238
|
+
]);
|
|
239
|
+
const clean = {};
|
|
240
|
+
for (const [rawName, rawValue] of Object.entries(normalizeHeaderObject(headers))) {
|
|
241
|
+
const name = rawName.toLowerCase();
|
|
242
|
+
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(name) || blocked.has(name) || name.startsWith("sec-")) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
clean[name] = rawValue;
|
|
246
|
+
}
|
|
247
|
+
return clean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function stripIpv6Brackets(hostname) {
|
|
251
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isPrivateIpv4(address) {
|
|
255
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
256
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
const [a, b] = parts;
|
|
260
|
+
return (
|
|
261
|
+
a === 0 ||
|
|
262
|
+
a === 10 ||
|
|
263
|
+
a === 127 ||
|
|
264
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
265
|
+
(a === 169 && b === 254) ||
|
|
266
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
267
|
+
(a === 192 && b === 168) ||
|
|
268
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
269
|
+
a >= 224
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isPrivateIpv6(address) {
|
|
274
|
+
const normalized = address.toLowerCase();
|
|
275
|
+
if (
|
|
276
|
+
normalized === "::1" ||
|
|
277
|
+
normalized === "::" ||
|
|
278
|
+
normalized.startsWith("fc") ||
|
|
279
|
+
normalized.startsWith("fd") ||
|
|
280
|
+
normalized.startsWith("fe8") ||
|
|
281
|
+
normalized.startsWith("fe9") ||
|
|
282
|
+
normalized.startsWith("fea") ||
|
|
283
|
+
normalized.startsWith("feb")
|
|
284
|
+
) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
289
|
+
return mapped ? isPrivateIpv4(mapped[1]) : false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isPrivateAddress(address) {
|
|
293
|
+
const normalized = stripIpv6Brackets(address);
|
|
294
|
+
const family = isIP(normalized);
|
|
295
|
+
if (family === 4) {
|
|
296
|
+
return isPrivateIpv4(normalized);
|
|
297
|
+
}
|
|
298
|
+
if (family === 6) {
|
|
299
|
+
return isPrivateIpv6(normalized);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function resolveFetchTarget(url, { allowPrivateNetwork }) {
|
|
305
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
306
|
+
throw new Error("Source fetch only supports http and https URLs.");
|
|
307
|
+
}
|
|
308
|
+
if (url.username || url.password) {
|
|
309
|
+
throw new Error("Source fetch URLs cannot include credentials.");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const hostname = stripIpv6Brackets(url.hostname).toLowerCase();
|
|
313
|
+
if (!hostname) {
|
|
314
|
+
throw new Error("Source fetch requires a hostname.");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (isIP(hostname)) {
|
|
318
|
+
if (!allowPrivateNetwork && isPrivateAddress(hostname)) {
|
|
319
|
+
throw new Error("Source fetch cannot access private network addresses.");
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
address: hostname,
|
|
323
|
+
family: isIP(hostname),
|
|
324
|
+
hostHeader: url.host,
|
|
325
|
+
originalHostname: hostname
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!allowPrivateNetwork && (hostname === "localhost" || hostname.endsWith(".localhost"))) {
|
|
330
|
+
throw new Error("Source fetch cannot access localhost.");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const addresses = await lookup(hostname, { all: true });
|
|
334
|
+
if (addresses.length === 0) {
|
|
335
|
+
throw new Error("Source fetch hostname did not resolve.");
|
|
336
|
+
}
|
|
337
|
+
if (!allowPrivateNetwork && addresses.some((entry) => isPrivateAddress(entry.address))) {
|
|
338
|
+
throw new Error("Source fetch cannot access private network addresses.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
address: addresses[0].address,
|
|
343
|
+
family: addresses[0].family,
|
|
344
|
+
hostHeader: url.host,
|
|
345
|
+
originalHostname: hostname
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function normalizeFetchRequest(request, { maxRequestBytes }) {
|
|
350
|
+
const url = new URL(String(request.url));
|
|
351
|
+
const method = String(request.method ?? "GET").toUpperCase();
|
|
352
|
+
if (!/^[A-Z]+$/.test(method)) {
|
|
353
|
+
throw new Error("Invalid source fetch method.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const body = request.bodyBase64 ? Buffer.from(String(request.bodyBase64), "base64") : undefined;
|
|
357
|
+
if ((method === "GET" || method === "HEAD") && body?.byteLength) {
|
|
358
|
+
throw new Error("Source fetch GET and HEAD requests cannot include a body.");
|
|
359
|
+
}
|
|
360
|
+
if ((body?.byteLength ?? 0) > maxRequestBytes) {
|
|
361
|
+
throw new Error(`Source fetch request body exceeds ${maxRequestBytes} bytes.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
body,
|
|
366
|
+
headers: sanitizeRequestHeaders(request.headers),
|
|
367
|
+
method,
|
|
368
|
+
url
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function headersForBroker(headers) {
|
|
373
|
+
const clean = {};
|
|
374
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
375
|
+
clean[key] = Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
|
376
|
+
}
|
|
377
|
+
return clean;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function requestPathForUrl(url) {
|
|
381
|
+
return `${url.pathname || "/"}${url.search}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function requestWithPinnedAddress(current, options, target) {
|
|
385
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
386
|
+
let settled = false;
|
|
387
|
+
const requestFn = current.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
388
|
+
const request = requestFn({
|
|
389
|
+
family: target.family,
|
|
390
|
+
headers: {
|
|
391
|
+
...current.headers,
|
|
392
|
+
host: target.hostHeader
|
|
393
|
+
},
|
|
394
|
+
hostname: target.address,
|
|
395
|
+
method: current.method,
|
|
396
|
+
path: requestPathForUrl(current.url),
|
|
397
|
+
port: current.url.port || (current.url.protocol === "https:" ? 443 : 80),
|
|
398
|
+
...(current.url.protocol === "https:" && !isIP(target.originalHostname) ? { servername: target.originalHostname } : {})
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const finish = (callback, value) => {
|
|
402
|
+
if (settled) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
settled = true;
|
|
406
|
+
clearTimeout(timeout);
|
|
407
|
+
callback(value);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const timeout = setTimeout(() => {
|
|
411
|
+
request.destroy(new Error(`Source fetch exceeded ${options.fetchTimeoutMs}ms.`));
|
|
412
|
+
}, options.fetchTimeoutMs);
|
|
413
|
+
|
|
414
|
+
request.on("response", (response) => {
|
|
415
|
+
const location = response.headers.location;
|
|
416
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && location) {
|
|
417
|
+
response.resume();
|
|
418
|
+
finish(resolveRequest, {
|
|
419
|
+
location,
|
|
420
|
+
redirect: true,
|
|
421
|
+
status: response.statusCode
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const chunks = [];
|
|
427
|
+
let total = 0;
|
|
428
|
+
response.on("data", (chunk) => {
|
|
429
|
+
total += chunk.byteLength;
|
|
430
|
+
if (total > options.maxResponseBytes) {
|
|
431
|
+
const error = new Error(`Source fetch response exceeds ${options.maxResponseBytes} bytes.`);
|
|
432
|
+
response.destroy();
|
|
433
|
+
request.destroy();
|
|
434
|
+
finish(rejectRequest, error);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
chunks.push(Buffer.from(chunk));
|
|
438
|
+
});
|
|
439
|
+
response.on("end", () => {
|
|
440
|
+
const buffer = Buffer.concat(chunks, total);
|
|
441
|
+
finish(resolveRequest, {
|
|
442
|
+
bodyBase64: buffer.toString("base64"),
|
|
443
|
+
headers: headersForBroker(response.headers),
|
|
444
|
+
ok: response.statusCode >= 200 && response.statusCode < 300,
|
|
445
|
+
status: response.statusCode,
|
|
446
|
+
statusText: response.statusMessage,
|
|
447
|
+
url: current.url.toString()
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
response.on("error", (error) => finish(rejectRequest, error));
|
|
451
|
+
});
|
|
452
|
+
request.on("error", (error) => finish(rejectRequest, error));
|
|
453
|
+
if (current.body) {
|
|
454
|
+
request.write(current.body);
|
|
455
|
+
}
|
|
456
|
+
request.end();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function shouldRewriteRedirectToGet(status, method) {
|
|
461
|
+
if (status === 303) {
|
|
462
|
+
return method !== "GET" && method !== "HEAD";
|
|
463
|
+
}
|
|
464
|
+
return (status === 301 || status === 302) && method === "POST";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function brokeredFetch(request, options) {
|
|
468
|
+
let current = normalizeFetchRequest(request, options);
|
|
469
|
+
for (let redirectCount = 0; redirectCount <= options.maxRedirects; redirectCount += 1) {
|
|
470
|
+
const target = await resolveFetchTarget(current.url, options);
|
|
471
|
+
const response = await requestWithPinnedAddress(current, options, target);
|
|
472
|
+
if (response.redirect) {
|
|
473
|
+
if (redirectCount === options.maxRedirects) {
|
|
474
|
+
throw new Error("Source fetch exceeded redirect limit.");
|
|
475
|
+
}
|
|
476
|
+
const rewriteToGet = shouldRewriteRedirectToGet(response.status, current.method);
|
|
477
|
+
current = {
|
|
478
|
+
body: rewriteToGet ? undefined : current.body,
|
|
479
|
+
headers: current.headers,
|
|
480
|
+
method: rewriteToGet ? "GET" : current.method,
|
|
481
|
+
url: new URL(response.location, current.url)
|
|
482
|
+
};
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return response;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
throw new Error("Source fetch exceeded redirect limit.");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function workerError(payload) {
|
|
493
|
+
const error = new Error(payload?.message ?? "Source runtime failed.");
|
|
494
|
+
error.name = payload?.name ?? "SourceRuntimeError";
|
|
495
|
+
return error;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export class ChildProcessSourceRuntime {
|
|
499
|
+
constructor({
|
|
500
|
+
allowPrivateNetwork = false,
|
|
501
|
+
fetchTimeoutMs = defaultFetchTimeoutMs,
|
|
502
|
+
loaderPath = defaultLoaderPath,
|
|
503
|
+
maxFetchResponseBytes = defaultMaxFetchResponseBytes,
|
|
504
|
+
maxOldSpaceSizeMb = defaultMaxOldSpaceSizeMb,
|
|
505
|
+
maxRedirects = 5,
|
|
506
|
+
maxRequestBytes = defaultMaxRequestBytes,
|
|
507
|
+
timeoutMs = defaultTimeoutMs,
|
|
508
|
+
workerPath = defaultWorkerPath
|
|
509
|
+
} = {}) {
|
|
510
|
+
this.allowPrivateNetwork = allowPrivateNetwork;
|
|
511
|
+
this.fetchTimeoutMs = positiveNumber(fetchTimeoutMs, defaultFetchTimeoutMs);
|
|
512
|
+
this.loaderPath = loaderPath;
|
|
513
|
+
this.maxFetchResponseBytes = positiveNumber(maxFetchResponseBytes, defaultMaxFetchResponseBytes);
|
|
514
|
+
this.maxOldSpaceSizeMb = positiveNumber(maxOldSpaceSizeMb, defaultMaxOldSpaceSizeMb);
|
|
515
|
+
this.maxRedirects = Math.max(0, Math.floor(positiveNumber(maxRedirects, 5)));
|
|
516
|
+
this.maxRequestBytes = positiveNumber(maxRequestBytes, defaultMaxRequestBytes);
|
|
517
|
+
this.timeoutMs = positiveNumber(timeoutMs, defaultTimeoutMs);
|
|
518
|
+
this.workerPath = workerPath;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async executeQuery({ args = [], artifact, auth, deployId, name, state }) {
|
|
522
|
+
const snapshot = await snapshotSourceState({ artifact, deployId, state });
|
|
523
|
+
const response = await this.runWorker({
|
|
524
|
+
args,
|
|
525
|
+
artifact,
|
|
526
|
+
auth,
|
|
527
|
+
env: snapshot.env,
|
|
528
|
+
name,
|
|
529
|
+
op: "query",
|
|
530
|
+
rows: snapshot.rows
|
|
531
|
+
});
|
|
532
|
+
return response.result ?? null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async executeMutation({ args = [], artifact, auth, deployId, limits = DEFAULT_ANONYMOUS_LIMITS, name, state }) {
|
|
536
|
+
return state.transaction(deployId, async (tx) => {
|
|
537
|
+
const snapshot = await snapshotSourceState({ artifact, deployId, state: tx });
|
|
538
|
+
const response = await this.runWorker({
|
|
539
|
+
args,
|
|
540
|
+
artifact,
|
|
541
|
+
auth,
|
|
542
|
+
env: snapshot.env,
|
|
543
|
+
name,
|
|
544
|
+
op: "mutation",
|
|
545
|
+
rows: snapshot.rows
|
|
546
|
+
});
|
|
547
|
+
await applySourceOperations({
|
|
548
|
+
artifact,
|
|
549
|
+
deployId,
|
|
550
|
+
operations: response.operations ?? [],
|
|
551
|
+
tx
|
|
552
|
+
});
|
|
553
|
+
return response.result ?? null;
|
|
554
|
+
}, { stateBytesLimit: limits.stateBytes ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
runWorker(request) {
|
|
558
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
559
|
+
const child = fork(this.workerPath, [], {
|
|
560
|
+
env: {},
|
|
561
|
+
execArgv: sourceRuntimeExecArgv({
|
|
562
|
+
loaderPath: this.loaderPath,
|
|
563
|
+
maxOldSpaceSizeMb: this.maxOldSpaceSizeMb,
|
|
564
|
+
workerPath: this.workerPath
|
|
565
|
+
}),
|
|
566
|
+
serialization: "json",
|
|
567
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"]
|
|
568
|
+
});
|
|
569
|
+
let stderr = "";
|
|
570
|
+
let settled = false;
|
|
571
|
+
let timeout;
|
|
572
|
+
|
|
573
|
+
const finish = (callback, value) => {
|
|
574
|
+
if (settled) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
settled = true;
|
|
578
|
+
clearTimeout(timeout);
|
|
579
|
+
child.off("error", onError);
|
|
580
|
+
child.off("exit", onExit);
|
|
581
|
+
child.off("message", onMessage);
|
|
582
|
+
if (!child.killed) {
|
|
583
|
+
child.kill();
|
|
584
|
+
}
|
|
585
|
+
callback(value);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const onError = (error) => finish(rejectRun, error);
|
|
589
|
+
const onExit = (code, signal) => {
|
|
590
|
+
if (!settled) {
|
|
591
|
+
finish(rejectRun, new Error(`Source runtime exited before returning a result. code=${code ?? "null"} signal=${signal ?? "null"}${stderr ? ` stderr=${stderr}` : ""}`));
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const onMessage = (message) => {
|
|
595
|
+
if (!isPlainObject(message)) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (message.type === "source-runtime.result") {
|
|
600
|
+
if (message.ok) {
|
|
601
|
+
finish(resolveRun, {
|
|
602
|
+
operations: message.operations,
|
|
603
|
+
result: message.result
|
|
604
|
+
});
|
|
605
|
+
} else {
|
|
606
|
+
finish(rejectRun, workerError(message.error));
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (message.type === "source-runtime.fetch") {
|
|
612
|
+
brokeredFetch(message.request, {
|
|
613
|
+
allowPrivateNetwork: this.allowPrivateNetwork,
|
|
614
|
+
fetchTimeoutMs: this.fetchTimeoutMs,
|
|
615
|
+
maxRedirects: this.maxRedirects,
|
|
616
|
+
maxRequestBytes: this.maxRequestBytes,
|
|
617
|
+
maxResponseBytes: this.maxFetchResponseBytes
|
|
618
|
+
})
|
|
619
|
+
.then((response) => {
|
|
620
|
+
if (child.connected) {
|
|
621
|
+
child.send({ id: message.id, ok: true, response, type: "source-runtime.fetch.result" });
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
.catch((error) => {
|
|
625
|
+
if (child.connected) {
|
|
626
|
+
child.send({
|
|
627
|
+
error: { message: error instanceof Error ? error.message : String(error), name: error instanceof Error ? error.name : "Error" },
|
|
628
|
+
id: message.id,
|
|
629
|
+
ok: false,
|
|
630
|
+
type: "source-runtime.fetch.result"
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
timeout = setTimeout(() => {
|
|
638
|
+
finish(rejectRun, new Error(`Source runtime exceeded ${this.timeoutMs}ms.`));
|
|
639
|
+
}, this.timeoutMs);
|
|
640
|
+
|
|
641
|
+
child.stderr?.on("data", (chunk) => {
|
|
642
|
+
stderr = `${stderr}${String(chunk)}`.slice(-4096);
|
|
643
|
+
});
|
|
644
|
+
child.on("error", onError);
|
|
645
|
+
child.on("exit", onExit);
|
|
646
|
+
child.on("message", onMessage);
|
|
647
|
+
child.send({ request, type: "source-runtime.run" });
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function createSourceRuntimeFromEnv(env = process.env) {
|
|
653
|
+
const mode = String(env.LAKEBED_SOURCE_RUNTIME ?? "child-process");
|
|
654
|
+
if (env.LAKEBED_UNSAFE_IN_PROCESS_SOURCE === "1" || mode === "unsafe-in-process") {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if (mode !== "child-process") {
|
|
658
|
+
throw new Error(`Unsupported LAKEBED_SOURCE_RUNTIME: ${mode}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return new ChildProcessSourceRuntime({
|
|
662
|
+
allowPrivateNetwork: env.LAKEBED_SOURCE_RUNTIME_ALLOW_PRIVATE_NETWORK === "1",
|
|
663
|
+
fetchTimeoutMs: Number(env.LAKEBED_SOURCE_FETCH_TIMEOUT_MS ?? defaultFetchTimeoutMs),
|
|
664
|
+
maxFetchResponseBytes: Number(env.LAKEBED_SOURCE_FETCH_RESPONSE_BYTES ?? defaultMaxFetchResponseBytes),
|
|
665
|
+
maxOldSpaceSizeMb: Number(env.LAKEBED_SOURCE_RUNTIME_MAX_OLD_SPACE_MB ?? defaultMaxOldSpaceSizeMb),
|
|
666
|
+
timeoutMs: Number(env.LAKEBED_SOURCE_RUNTIME_TIMEOUT_MS ?? defaultTimeoutMs)
|
|
667
|
+
});
|
|
668
|
+
}
|
package/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const LAKEBED_VERSION = "0.0.
|
|
1
|
+
export const LAKEBED_VERSION = "0.0.10";
|