@fireemu/win32-x64 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/fireemu.exe +0 -0
- package/bin/runner-node/blocking-error.mjs +27 -12
- package/bin/runner-node/blocking-response.mjs +81 -20
- package/bin/runner-node/diagnostic-output.mjs +259 -0
- package/bin/runner-node/discovery.mjs +135 -0
- package/bin/runner-node/http-admission.mjs +145 -0
- package/bin/runner-node/http-lifecycle.mjs +44 -0
- package/bin/runner-node/index.mjs +667 -239
- package/bin/runner-node/invocation-error.mjs +29 -0
- package/bin/runner-node/output.mjs +204 -0
- package/bin/runner-node/protocol.mjs +280 -0
- package/package.json +1 -1
|
@@ -10,17 +10,191 @@
|
|
|
10
10
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, readlinkSync } from "node:fs";
|
|
14
14
|
import { resolve, join, dirname } from "node:path";
|
|
15
|
-
import { timingSafeEqual } from "node:crypto";
|
|
16
15
|
import { createServer } from "node:http";
|
|
16
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
17
17
|
import { url as inspectorUrl } from "node:inspector";
|
|
18
18
|
import { instrumentCallables } from "./callable-app-check.mjs";
|
|
19
19
|
import { blockingFailure } from "./blocking-error.mjs";
|
|
20
20
|
import { blockingResult } from "./blocking-response.mjs";
|
|
21
21
|
import { boundLogMessage, createInvocationLogger } from "./log-context.mjs";
|
|
22
|
+
import { invocationFailure } from "./invocation-error.mjs";
|
|
23
|
+
import { InvocationBudget, readFrames } from "./protocol.mjs";
|
|
24
|
+
import { collectFunctions, exportNamespace } from "./discovery.mjs";
|
|
25
|
+
import { FrameWriter } from "./output.mjs";
|
|
26
|
+
import { installDiagnosticOutput } from "./diagnostic-output.mjs";
|
|
27
|
+
import { trackHttpResponse } from "./http-lifecycle.mjs";
|
|
28
|
+
import { createHttpAdmission } from "./http-admission.mjs";
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
// Keep one best-effort terminal diagnostic available after ordinary output
|
|
31
|
+
// admission closes. It is only used when stderr has no pending data, never to
|
|
32
|
+
// bypass a failed/stalled diagnostic channel or grow its queue.
|
|
33
|
+
const terminalDiagnosticWrite = process.stderr.write.bind(process.stderr);
|
|
34
|
+
let outputFailed = false;
|
|
35
|
+
const diagnosticOutput = installDiagnosticOutput(process.stderr, {
|
|
36
|
+
onError() {
|
|
37
|
+
// fd2 may be stalled or broken. Do not recursively log to it or pretend that
|
|
38
|
+
// already-executed callback side effects were rolled back.
|
|
39
|
+
outputFailed = true;
|
|
40
|
+
process.exit(2);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const frameOutput = new FrameWriter(process.stdout, {
|
|
44
|
+
onError(error) {
|
|
45
|
+
outputFailed = true;
|
|
46
|
+
// Retire rather than drop frames or let an unbounded stream queue grow.
|
|
47
|
+
// Callback side effects are not rolled back; native waiters see RunnerGone.
|
|
48
|
+
try {
|
|
49
|
+
const state = diagnosticOutput.state;
|
|
50
|
+
const message = `${error.message}\n`;
|
|
51
|
+
if (!state.failed && state.pendingWrites === 0 && Buffer.byteLength(message) <= 256) {
|
|
52
|
+
terminalDiagnosticWrite(message);
|
|
53
|
+
}
|
|
54
|
+
} finally { process.exit(2); }
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
let finishingOutput = false;
|
|
58
|
+
let groupCleanupPromise = Promise.resolve(true);
|
|
59
|
+
function finishOutput() {
|
|
60
|
+
if (finishingOutput || outputFailed) return;
|
|
61
|
+
finishingOutput = true;
|
|
62
|
+
void Promise.all([frameOutput.finish(), diagnosticOutput.finish(), groupCleanupPromise])
|
|
63
|
+
.then(results => process.exit(results.every(Boolean) && !outputFailed ? 0 : 2));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function processField(pid, field) {
|
|
67
|
+
try {
|
|
68
|
+
return execFileSync('/bin/ps', ['-o', `${field}=`, '-p', String(pid)], {
|
|
69
|
+
encoding: 'utf8', timeout: 1000, maxBuffer: 2048,
|
|
70
|
+
}).trim();
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function processExecutable(pid) {
|
|
77
|
+
try {
|
|
78
|
+
if (process.platform === 'linux') return readlinkSync(`/proc/${pid}/exe`);
|
|
79
|
+
if (process.platform === 'darwin') {
|
|
80
|
+
const files = execFileSync('/usr/sbin/lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn'], {
|
|
81
|
+
encoding: 'utf8', timeout: 1000, maxBuffer: 16384,
|
|
82
|
+
});
|
|
83
|
+
return files.split('\n').find(line => line.startsWith('n'))?.slice(1) ?? null;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Unknown executable identity cannot authorize signaling a parent-led group.
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runnerProcessGroup() {
|
|
92
|
+
if (process.platform === 'win32') return null;
|
|
93
|
+
const group = Number(processField(process.pid, 'pgid'));
|
|
94
|
+
if (!Number.isSafeInteger(group) || group <= 1) return null;
|
|
95
|
+
if (group === process.pid) return group;
|
|
96
|
+
if (group !== process.ppid || Number(processField(group, 'pgid')) !== group) return null;
|
|
97
|
+
return processExecutable(group)?.endsWith('/volta-shim') ? group : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Capture the group before user code can spawn children or replace process information.
|
|
101
|
+
const daemonManagedRunner = process.env.FIREEMU_RUNNER === '1';
|
|
102
|
+
const ownedProcessGroup = daemonManagedRunner ? runnerProcessGroup() : null;
|
|
103
|
+
let inputCleanupStarted = false;
|
|
104
|
+
let explicitShutdown = false;
|
|
105
|
+
let deferredExitCleanup = false;
|
|
106
|
+
const groupCleanupHelper = `
|
|
107
|
+
const {execFileSync} = require('node:child_process');
|
|
108
|
+
const group = Number(process.argv[1]);
|
|
109
|
+
function killOwnedGroup() {
|
|
110
|
+
try {
|
|
111
|
+
const ownGroup = Number(execFileSync('/bin/ps', ['-o', 'pgid=', '-p', String(process.pid)], {
|
|
112
|
+
encoding: 'utf8', timeout: 1000
|
|
113
|
+
}).trim());
|
|
114
|
+
if (group > 1 && ownGroup === group) process.kill(-group, 'SIGKILL');
|
|
115
|
+
} catch { /* The group is already gone or signaling is unavailable. */ }
|
|
116
|
+
}
|
|
117
|
+
const watchdog = setTimeout(killOwnedGroup, 4000);
|
|
118
|
+
process.stdin.on('end', () => {
|
|
119
|
+
clearTimeout(watchdog);
|
|
120
|
+
setTimeout(killOwnedGroup, 1500);
|
|
121
|
+
});
|
|
122
|
+
process.stdin.on('error', () => {});
|
|
123
|
+
process.stdin.resume();
|
|
124
|
+
`;
|
|
125
|
+
function deferCleanShutdownGroupCleanup() {
|
|
126
|
+
if (!ownedProcessGroup ||
|
|
127
|
+
Number(processField(process.pid, 'pgid')) !== ownedProcessGroup) return;
|
|
128
|
+
// Keep a known member in the group after Node exits. Rust normally kills it
|
|
129
|
+
// first; if the daemon dies in that gap, the helper performs bounded cleanup.
|
|
130
|
+
try {
|
|
131
|
+
const helper = spawn(process.execPath, ['-e', groupCleanupHelper, String(ownedProcessGroup)], {
|
|
132
|
+
stdio: ['pipe', 'ignore', 'ignore'], env: {},
|
|
133
|
+
});
|
|
134
|
+
if (!helper.pid) return;
|
|
135
|
+
helper.on('error', () => { deferredExitCleanup = false; });
|
|
136
|
+
helper.stdin.on('error', () => {});
|
|
137
|
+
helper.unref();
|
|
138
|
+
helper.stdin.unref?.();
|
|
139
|
+
deferredExitCleanup = true;
|
|
140
|
+
} catch {
|
|
141
|
+
// The exit handler still cleans the group when a helper cannot start.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function deferOwnedGroupCleanupUntilUserExitHandlers() {
|
|
145
|
+
if (!ownedProcessGroup) return;
|
|
146
|
+
process.removeListener('exit', killOwnedGroupOnExit);
|
|
147
|
+
process.on('exit', killOwnedGroupOnExit);
|
|
148
|
+
}
|
|
149
|
+
function cleanupProcessGroupOnInputClose() {
|
|
150
|
+
if (inputCleanupStarted || process.platform === 'win32') return;
|
|
151
|
+
inputCleanupStarted = true;
|
|
152
|
+
if (!ownedProcessGroup || Number(processField(process.pid, 'pgid')) !== ownedProcessGroup) {
|
|
153
|
+
if (daemonManagedRunner) {
|
|
154
|
+
process.stderr.write('[functions] stdin closed; runner process group ownership is unverified\n');
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// The group signal reaches this Node process too. Keep it alive until escalation;
|
|
159
|
+
// a shim may forward another TERM after receiving the group signal itself.
|
|
160
|
+
process.on('SIGTERM', () => {});
|
|
161
|
+
// User exit handlers registered during discovery must run before final group cleanup.
|
|
162
|
+
deferOwnedGroupCleanupUntilUserExitHandlers();
|
|
163
|
+
groupCleanupPromise = new Promise(resolve => {
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
// An undrained output pipe must not postpone descendant cleanup.
|
|
166
|
+
if (Number(processField(process.pid, 'pgid')) === ownedProcessGroup) {
|
|
167
|
+
try { process.kill(-ownedProcessGroup, 'SIGKILL'); }
|
|
168
|
+
catch { /* The exit handler makes one final ownership-checked attempt. */ }
|
|
169
|
+
}
|
|
170
|
+
resolve(true);
|
|
171
|
+
}, 500);
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
process.kill(-ownedProcessGroup, 'SIGTERM');
|
|
175
|
+
} catch (error) {
|
|
176
|
+
process.stderr.write(`[functions] runner process group cleanup failed: ${error.code ?? 'unknown'}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function killOwnedGroupOnExit() {
|
|
180
|
+
// A fatal frame or output error can exit before stdin close or the grace timer.
|
|
181
|
+
// The runner is still a member, so its original group ID cannot be reused yet.
|
|
182
|
+
if (deferredExitCleanup || !ownedProcessGroup ||
|
|
183
|
+
Number(processField(process.pid, 'pgid')) !== ownedProcessGroup) return;
|
|
184
|
+
// Fatal exits can precede user exit listeners. Keep a verified group member
|
|
185
|
+
// alive until those listeners finish, then let it reap the group.
|
|
186
|
+
if (!inputCleanupStarted) {
|
|
187
|
+
deferCleanShutdownGroupCleanup();
|
|
188
|
+
if (deferredExitCleanup) return;
|
|
189
|
+
}
|
|
190
|
+
try { process.kill(-ownedProcessGroup, 'SIGKILL'); }
|
|
191
|
+
catch { /* The group has already ended or signaling was denied. */ }
|
|
192
|
+
}
|
|
193
|
+
process.on('exit', killOwnedGroupOnExit);
|
|
194
|
+
process.stdin.on('close', () => {
|
|
195
|
+
if (!explicitShutdown) cleanupProcessGroupOnInputClose();
|
|
196
|
+
finishOutput();
|
|
197
|
+
});
|
|
24
198
|
process.stdout.write = (chunk, encoding, cb) => process.stderr.write(chunk, encoding, cb);
|
|
25
199
|
|
|
26
200
|
const localSecrets = (() => {
|
|
@@ -31,6 +205,9 @@ const localSecrets = (() => {
|
|
|
31
205
|
return new Map(Object.entries(parsed).filter(([, value]) => typeof value === "string"));
|
|
32
206
|
})();
|
|
33
207
|
let functionEnvironmentQueue = Promise.resolve();
|
|
208
|
+
const sharedFunctionInvocations = new Set();
|
|
209
|
+
let activeSharedEnvironments = 0;
|
|
210
|
+
let sharedSavedSecrets;
|
|
34
211
|
let discoveredGlobalOptions = {};
|
|
35
212
|
|
|
36
213
|
function inspectorPort() {
|
|
@@ -106,9 +283,7 @@ async function firebaseHttpsErrorConstructors(require) {
|
|
|
106
283
|
}
|
|
107
284
|
|
|
108
285
|
function send(msg) {
|
|
109
|
-
|
|
110
|
-
frameWrite(`${payload.length}\n`);
|
|
111
|
-
frameWrite(payload);
|
|
286
|
+
return frameOutput.send(msg);
|
|
112
287
|
}
|
|
113
288
|
|
|
114
289
|
function log(level, message, invocationId, functionName, user = false, fields) {
|
|
@@ -148,9 +323,12 @@ process.env.K_SERVICE = process.env.K_SERVICE || "";
|
|
|
148
323
|
// `getSignatureType` (functionsEmulatorShared.js:292): what the runtime is being asked to
|
|
149
324
|
// speak for one function.
|
|
150
325
|
function signatureType(spec) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
326
|
+
// Tasks and Blocking Auth also enter through the HTTP wrapper, regardless
|
|
327
|
+
// of generation. Generation is the value announced to the daemon, not a
|
|
328
|
+
// second read of user-owned function metadata at invocation time.
|
|
329
|
+
if (["http", "tasks", "blockingAuth"].includes(spec.trigger?.type)) return "http";
|
|
330
|
+
if (spec.trigger?.type === "schedule") return spec.generation === 1 ? "event" : "http";
|
|
331
|
+
return spec.generation === 1 ? "event" : "cloudevent";
|
|
154
332
|
}
|
|
155
333
|
|
|
156
334
|
// The official emulator starts one runtime process per trigger, so it can set these three at
|
|
@@ -166,15 +344,17 @@ function setFunctionIdentity(spec) {
|
|
|
166
344
|
}
|
|
167
345
|
|
|
168
346
|
function withFunctionEnvironment(spec, task, invocationId) {
|
|
347
|
+
const shared = localSecrets.size > 0 && activeInspectorPort === undefined &&
|
|
348
|
+
!(spec?.platformOptions?.secrets || []).some(name => localSecrets.has(name));
|
|
169
349
|
const run = async () => {
|
|
350
|
+
if (finishingOutput || outputFailed) throw new Error("runner is shutting down");
|
|
170
351
|
setFunctionIdentity(spec);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
177
|
-
delete process.env[name];
|
|
352
|
+
let saved;
|
|
353
|
+
if (shared) {
|
|
354
|
+
if (activeSharedEnvironments === 0) sharedSavedSecrets = hideLocalSecrets();
|
|
355
|
+
activeSharedEnvironments++;
|
|
356
|
+
} else {
|
|
357
|
+
saved = hideLocalSecrets();
|
|
178
358
|
}
|
|
179
359
|
for (const name of spec?.platformOptions?.secrets || []) {
|
|
180
360
|
if (localSecrets.has(name)) process.env[name] = localSecrets.get(name);
|
|
@@ -182,18 +362,56 @@ function withFunctionEnvironment(spec, task, invocationId) {
|
|
|
182
362
|
try {
|
|
183
363
|
return await invocationLogger.run({ functionName: spec?.name, invocationId }, task);
|
|
184
364
|
} finally {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
365
|
+
if (shared) {
|
|
366
|
+
activeSharedEnvironments--;
|
|
367
|
+
if (activeSharedEnvironments === 0) {
|
|
368
|
+
restoreLocalSecrets(sharedSavedSecrets);
|
|
369
|
+
sharedSavedSecrets = undefined;
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
restoreLocalSecrets(saved);
|
|
188
373
|
}
|
|
189
374
|
}
|
|
190
375
|
};
|
|
191
376
|
if (localSecrets.size === 0 && activeInspectorPort === undefined) return run();
|
|
192
|
-
const
|
|
377
|
+
const previous = functionEnvironmentQueue;
|
|
378
|
+
if (shared) {
|
|
379
|
+
// Undeclared calls share one cleared environment; a queued declaring call
|
|
380
|
+
// closes this group before later calls can enter.
|
|
381
|
+
const result = previous.then(run);
|
|
382
|
+
sharedFunctionInvocations.add(result);
|
|
383
|
+
void result.then(
|
|
384
|
+
() => sharedFunctionInvocations.delete(result),
|
|
385
|
+
() => sharedFunctionInvocations.delete(result),
|
|
386
|
+
);
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
const pendingShared = [...sharedFunctionInvocations];
|
|
390
|
+
sharedFunctionInvocations.clear();
|
|
391
|
+
const result = Promise.allSettled([previous, ...pendingShared]).then(run);
|
|
193
392
|
functionEnvironmentQueue = result.catch(() => {});
|
|
194
393
|
return result;
|
|
195
394
|
}
|
|
196
395
|
|
|
396
|
+
function hideLocalSecrets() {
|
|
397
|
+
const saved = new Map();
|
|
398
|
+
for (const [name] of localSecrets) {
|
|
399
|
+
saved.set(
|
|
400
|
+
name,
|
|
401
|
+
Object.prototype.hasOwnProperty.call(process.env, name) ? process.env[name] : undefined,
|
|
402
|
+
);
|
|
403
|
+
delete process.env[name];
|
|
404
|
+
}
|
|
405
|
+
return saved;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function restoreLocalSecrets(saved) {
|
|
409
|
+
for (const [name, value] of saved) {
|
|
410
|
+
if (value === undefined) delete process.env[name];
|
|
411
|
+
else process.env[name] = value;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
197
415
|
async function loadCodebase() {
|
|
198
416
|
const pkgPath = join(sourceDir, "package.json");
|
|
199
417
|
let main = "index.js";
|
|
@@ -208,64 +426,203 @@ async function loadCodebase() {
|
|
|
208
426
|
const entry = resolve(sourceDir, main);
|
|
209
427
|
if (!existsSync(entry)) throw new Error(`functions entry point ${entry} does not exist`);
|
|
210
428
|
const mod = await import(pathToFileURL(entry).href);
|
|
211
|
-
|
|
212
|
-
// emulator reads a CommonJS codebase's `module.exports` object, which keeps it. An ES
|
|
213
|
-
// module namespace object sorts its keys, so `module.exports` -- which Node hands over as
|
|
214
|
-
// `default` -- goes in first and the namespace only fills in what it did not carry.
|
|
215
|
-
const ns = {};
|
|
216
|
-
if (mod.default && typeof mod.default === "object") Object.assign(ns, mod.default);
|
|
217
|
-
for (const [key, value] of Object.entries(mod)) {
|
|
218
|
-
if (!(key in ns)) ns[key] = value;
|
|
219
|
-
}
|
|
220
|
-
// Node exposes CommonJS exports as `default` and (22+) as "module.exports".
|
|
221
|
-
delete ns.default;
|
|
222
|
-
delete ns["module.exports"];
|
|
223
|
-
return ns;
|
|
429
|
+
return exportNamespace(mod);
|
|
224
430
|
}
|
|
225
431
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
432
|
+
// Unsupported async options may already be rejected. Observe that rejection
|
|
433
|
+
// before isolating the export so it cannot terminate discovery of siblings.
|
|
434
|
+
function observeAsyncValue(value) {
|
|
435
|
+
try {
|
|
436
|
+
if (value instanceof Promise) {
|
|
437
|
+
void Promise.prototype.then.call(value, undefined, () => {});
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
441
|
+
// Obtain a thenable's accessor once; Promise.resolve(value) would read it
|
|
442
|
+
// again. The fixed wrapper preserves asynchronous assimilation and the
|
|
443
|
+
// original receiver without consulting the user object's getter twice.
|
|
444
|
+
const then = value.then;
|
|
445
|
+
if (typeof then === "function") {
|
|
446
|
+
void Promise.resolve({
|
|
447
|
+
then(resolve, reject) { Reflect.apply(then, value, [resolve, reject]); },
|
|
448
|
+
}).catch(() => {});
|
|
449
|
+
return true;
|
|
243
450
|
}
|
|
244
|
-
if (marked) out.set(name, value);
|
|
245
|
-
} else if (typeof value === "object" && !Array.isArray(value)) {
|
|
246
|
-
collectFunctions(value, name, out, broken);
|
|
247
451
|
}
|
|
452
|
+
} catch {
|
|
453
|
+
// Even instanceof/getPrototypeOf or a then getter may throw (e.g. a
|
|
454
|
+
// revoked Proxy). Error reporting must not throw a second time and kill
|
|
455
|
+
// healthy sibling discovery. This is not an arbitrary-code sandbox.
|
|
248
456
|
}
|
|
249
|
-
return
|
|
457
|
+
return false;
|
|
250
458
|
}
|
|
251
459
|
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return
|
|
460
|
+
function rejectAsyncOption(value, field) {
|
|
461
|
+
if (observeAsyncValue(value))
|
|
462
|
+
throw new Error(`${field} expression returned an asynchronous value`);
|
|
463
|
+
return value;
|
|
256
464
|
}
|
|
257
465
|
|
|
258
|
-
|
|
466
|
+
// SDK endpoint options keep Expression objects until the local runtime resolves
|
|
467
|
+
// them. JSON/toString encode a deployment expression, not its runtime value.
|
|
468
|
+
// Match the existing numeric-option `.value()` protocol, but never read its
|
|
469
|
+
// accessor twice or coerce an unresolved value into false/true/default.
|
|
470
|
+
function resolvedOption(value, field) {
|
|
259
471
|
if (value == null) return undefined;
|
|
260
472
|
if (value?.[Symbol.for("firebase-functions:ResetValue:Tag")] === true) return undefined;
|
|
261
|
-
|
|
262
|
-
|
|
473
|
+
if (typeof value === "object") {
|
|
474
|
+
const evaluate = value.value;
|
|
475
|
+
if (typeof evaluate === "function") {
|
|
476
|
+
const resolved = evaluate.call(value);
|
|
477
|
+
if (resolved == null) throw new Error(`${field} expression did not resolve to a value`);
|
|
478
|
+
return rejectAsyncOption(resolved, field);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return rejectAsyncOption(value, field);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function resolvedBoolean(value, field) {
|
|
485
|
+
const resolved = resolvedOption(value, field);
|
|
486
|
+
if (resolved === undefined) return undefined;
|
|
487
|
+
if (typeof resolved !== "boolean") throw new Error(`${field} did not resolve to a boolean`);
|
|
488
|
+
return resolved;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function firstRegion(ep) {
|
|
492
|
+
const resolved = resolvedOption(ep.region, "region");
|
|
493
|
+
if (resolved === undefined) return undefined;
|
|
494
|
+
// Preserve this runner's existing first-region policy. This is not a new
|
|
495
|
+
// multi-region deployment implementation. Validate the list rather than
|
|
496
|
+
// allowing malformed later entries to be silently hidden by selection.
|
|
497
|
+
const values = Array.isArray(resolved)
|
|
498
|
+
? resolved.map(region => resolvedOption(region, "region"))
|
|
499
|
+
: [resolved];
|
|
500
|
+
for (const value of values) {
|
|
501
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
502
|
+
throw new Error("region did not resolve to a non-empty string");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return values[0];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function resolvedNonNegativeInteger(value, field) {
|
|
509
|
+
const resolved = resolvedOption(value, field);
|
|
510
|
+
if (resolved === undefined) return undefined;
|
|
263
511
|
if (!Number.isSafeInteger(resolved) || resolved < 0) {
|
|
264
512
|
throw new Error(`${field} did not resolve to a non-negative integer`);
|
|
265
513
|
}
|
|
266
514
|
return resolved;
|
|
267
515
|
}
|
|
268
516
|
|
|
517
|
+
// Routing and dispatch options must be concrete before publishing the hello.
|
|
518
|
+
// Do not stringify an Expression (that emits its deployment representation),
|
|
519
|
+
// coerce malformed values, or retain a caller-owned map with a later toJSON.
|
|
520
|
+
function triggerRecord(value, field) {
|
|
521
|
+
if (value == null || value?.[Symbol.for("firebase-functions:ResetValue:Tag")] === true) {
|
|
522
|
+
return Object.create(null);
|
|
523
|
+
}
|
|
524
|
+
rejectAsyncOption(value, field);
|
|
525
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
526
|
+
throw new Error(`${field} must be an object`);
|
|
527
|
+
}
|
|
528
|
+
return value;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function triggerString(value, field, { optional = false, allowEmpty = false } = {}) {
|
|
532
|
+
const resolved = resolvedOption(value, field);
|
|
533
|
+
if (resolved === undefined && optional) return undefined;
|
|
534
|
+
if (typeof resolved !== "string" || (!allowEmpty && resolved.length === 0)) {
|
|
535
|
+
throw new Error(`${field} did not resolve to ${allowEmpty ? "a string" : "a non-empty string"}`);
|
|
536
|
+
}
|
|
537
|
+
return resolved;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function triggerFilters(value) {
|
|
541
|
+
const input = triggerRecord(value, "eventFilters");
|
|
542
|
+
const output = Object.create(null);
|
|
543
|
+
for (const key of Object.keys(input)) {
|
|
544
|
+
// Preserve empty exact-match values and literal __proto__/toJSON keys as
|
|
545
|
+
// strings, not object hooks. Null is not an instruction to drop a filter.
|
|
546
|
+
output[key] = triggerString(input[key], `eventFilters.${key}`, { allowEmpty: true });
|
|
547
|
+
}
|
|
548
|
+
return Object.freeze(output);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const SCHEDULE_RETRY_FIELDS = new Set([
|
|
552
|
+
"retryCount", "maxRetrySeconds", "minBackoffSeconds", "maxBackoffSeconds", "maxDoublings",
|
|
553
|
+
]);
|
|
554
|
+
const TASK_RETRY_FIELDS = new Set([
|
|
555
|
+
"maxAttempts", "maxRetrySeconds", "minBackoffSeconds", "maxBackoffSeconds", "maxDoublings",
|
|
556
|
+
]);
|
|
557
|
+
const TASK_RATE_FIELDS = new Set(["maxConcurrentDispatches", "maxDispatchesPerSecond"]);
|
|
558
|
+
|
|
559
|
+
const V1_SCHEDULE_DURATIONS = Object.freeze({
|
|
560
|
+
maxRetryDuration: "maxRetrySeconds",
|
|
561
|
+
minBackoffDuration: "minBackoffSeconds",
|
|
562
|
+
maxBackoffDuration: "maxBackoffSeconds",
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
function triggerNumbers(value, field, allowed, durationAliases = undefined) {
|
|
566
|
+
const input = triggerRecord(value, field);
|
|
567
|
+
const output = Object.create(null);
|
|
568
|
+
const populated = new Set();
|
|
569
|
+
for (const key of Object.keys(input)) {
|
|
570
|
+
const durationKey = durationAliases && Object.hasOwn(durationAliases, key)
|
|
571
|
+
? durationAliases[key] : undefined;
|
|
572
|
+
if (!allowed.has(key) && !durationKey) {
|
|
573
|
+
observeAsyncValue(input[key]);
|
|
574
|
+
throw new Error(`${field}.${key} is not supported`);
|
|
575
|
+
}
|
|
576
|
+
const outputKey = durationKey ?? key;
|
|
577
|
+
const original = input[key];
|
|
578
|
+
if (original !== undefined) {
|
|
579
|
+
if (populated.has(outputKey)) throw new Error(`${field}.${outputKey} is specified twice`);
|
|
580
|
+
populated.add(outputKey);
|
|
581
|
+
}
|
|
582
|
+
let resolved = resolvedOption(original, `${field}.${key}`);
|
|
583
|
+
if (resolved === undefined) {
|
|
584
|
+
// Pinned SDKs put null/ResetValue in numeric records for defaults. Keep
|
|
585
|
+
// that wire meaning; an expression returning null has already failed.
|
|
586
|
+
if (original !== undefined) output[outputKey] = null;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (durationKey) {
|
|
590
|
+
// Gen1's public ScheduleRetryConfig uses protobuf Duration strings,
|
|
591
|
+
// while this runner's existing native protocol uses numeric seconds.
|
|
592
|
+
// Accept the non-negative seconds form; never parseFloat a suffix or CEL.
|
|
593
|
+
if (typeof resolved !== "string" || !/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,9})?s$/.test(resolved)) {
|
|
594
|
+
throw new Error(`${field}.${key} did not resolve to a seconds duration`);
|
|
595
|
+
}
|
|
596
|
+
resolved = Number(resolved.slice(0, -1));
|
|
597
|
+
}
|
|
598
|
+
if (typeof resolved !== "number" || !Number.isFinite(resolved)) {
|
|
599
|
+
throw new Error(`${field}.${key} did not resolve to a finite number`);
|
|
600
|
+
}
|
|
601
|
+
// Preserve fractional seconds and rates. Native range/count conversion is
|
|
602
|
+
// unchanged: this is not a second implementation of cloud quota policy.
|
|
603
|
+
output[outputKey] = resolved;
|
|
604
|
+
}
|
|
605
|
+
return Object.freeze(output);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function describeSchedule(base, value) {
|
|
609
|
+
const input = triggerRecord(value, "scheduleTrigger");
|
|
610
|
+
const schedule = triggerString(input.schedule, "scheduleTrigger.schedule");
|
|
611
|
+
const timeZone = triggerString(input.timeZone, "scheduleTrigger.timeZone", { optional: true });
|
|
612
|
+
// Some Gen1 SDK entrypoints already turn an Expression into braced CEL.
|
|
613
|
+
// This local resolver does not implement CEL: do not announce it as a cron.
|
|
614
|
+
if (schedule.includes("{{") || timeZone?.includes("{{")) {
|
|
615
|
+
throw new Error("scheduleTrigger contains an unresolved deployment expression");
|
|
616
|
+
}
|
|
617
|
+
const retryConfig = triggerNumbers(input.retryConfig, "scheduleTrigger.retryConfig",
|
|
618
|
+
SCHEDULE_RETRY_FIELDS, base.generation === 1 ? V1_SCHEDULE_DURATIONS : undefined);
|
|
619
|
+
return {
|
|
620
|
+
...base,
|
|
621
|
+
retry: (retryConfig.retryCount ?? 0) > 0,
|
|
622
|
+
trigger: { type: "schedule", schedule, timeZone, retryConfig },
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
269
626
|
// Preserve shared Gen1/Gen2 endpoint options. Memory and instance limits shape local
|
|
270
627
|
// admission; the other deployment and IAM values remain visible for faithful diagnostics.
|
|
271
628
|
function platformOptions(ep) {
|
|
@@ -373,8 +730,16 @@ function describeV1Event(base, type, resource, schedule, retry) {
|
|
|
373
730
|
/^providers\/cloud\.firestore\/eventTypes\/document\.(create|update|delete|write)$/,
|
|
374
731
|
);
|
|
375
732
|
if (fsMatch) {
|
|
376
|
-
|
|
377
|
-
|
|
733
|
+
// Resource labels have fixed positions. A project/database can itself be
|
|
734
|
+
// named "documents" or "databases"; substring searches pick the wrong slash.
|
|
735
|
+
// Preserve the document pattern verbatim for the native pattern validator.
|
|
736
|
+
resource = triggerString(resource, "eventTrigger.resource", { optional: true, allowEmpty: true }) ?? "";
|
|
737
|
+
const match = resource.match(/^projects\/[^/]+\/databases\/([^/]+)\/documents\/(.+)$/s);
|
|
738
|
+
if (!match) {
|
|
739
|
+
return ignored(
|
|
740
|
+
base, "firestore", "unsupported", "v1 Firestore resource has an unsupported shape",
|
|
741
|
+
);
|
|
742
|
+
}
|
|
378
743
|
return {
|
|
379
744
|
...base,
|
|
380
745
|
...v1,
|
|
@@ -382,14 +747,22 @@ function describeV1Event(base, type, resource, schedule, retry) {
|
|
|
382
747
|
trigger: {
|
|
383
748
|
type: "firestore",
|
|
384
749
|
eventType: `google.cloud.firestore.document.v1.${{ create: "created", update: "updated", delete: "deleted", write: "written" }[fsMatch[1]]}`,
|
|
385
|
-
database:
|
|
386
|
-
document:
|
|
750
|
+
database: match[1],
|
|
751
|
+
document: match[2],
|
|
387
752
|
},
|
|
388
753
|
};
|
|
389
754
|
}
|
|
390
755
|
const stMatch = type.match(/^google\.storage\.object\.(finalize|delete|metadataUpdate|archive)$/);
|
|
391
756
|
if (stMatch) {
|
|
392
|
-
|
|
757
|
+
resource = triggerString(resource, "eventTrigger.resource", { optional: true, allowEmpty: true }) ?? "";
|
|
758
|
+
const match = resource.match(/^projects\/[^/]+\/buckets\/([^/]+)$/);
|
|
759
|
+
if (!match) {
|
|
760
|
+
// Missing/unparseable bucket metadata must not become an unfiltered
|
|
761
|
+
// Storage trigger. Valid SDK v1 resources use projects/_/buckets/<bucket>.
|
|
762
|
+
return ignored(
|
|
763
|
+
base, "storage", "unsupported", "v1 Storage resource has an unsupported shape",
|
|
764
|
+
);
|
|
765
|
+
}
|
|
393
766
|
return {
|
|
394
767
|
...base,
|
|
395
768
|
...v1,
|
|
@@ -397,22 +770,12 @@ function describeV1Event(base, type, resource, schedule, retry) {
|
|
|
397
770
|
trigger: {
|
|
398
771
|
type: "storage",
|
|
399
772
|
eventType: `google.cloud.storage.object.v1.${{ finalize: "finalized", delete: "deleted", metadataUpdate: "metadataUpdated", archive: "archived" }[stMatch[1]]}`,
|
|
400
|
-
bucket:
|
|
773
|
+
bucket: match[1],
|
|
401
774
|
},
|
|
402
775
|
};
|
|
403
776
|
}
|
|
404
777
|
if (schedule) {
|
|
405
|
-
return {
|
|
406
|
-
...base,
|
|
407
|
-
...v1,
|
|
408
|
-
retry: Number(schedule.retryConfig?.retryCount || 0) > 0,
|
|
409
|
-
trigger: {
|
|
410
|
-
type: "schedule",
|
|
411
|
-
schedule: schedule.schedule,
|
|
412
|
-
timeZone: schedule.timeZone || undefined,
|
|
413
|
-
retryConfig: schedule.retryConfig || {},
|
|
414
|
-
},
|
|
415
|
-
};
|
|
778
|
+
return describeSchedule({ ...base, ...v1 }, schedule);
|
|
416
779
|
}
|
|
417
780
|
const authMatch = type.match(/^providers\/firebase\.auth\/eventTypes\/user\.(create|delete)$/);
|
|
418
781
|
if (authMatch) {
|
|
@@ -430,6 +793,7 @@ function describeV1Event(base, type, resource, schedule, retry) {
|
|
|
430
793
|
type === "providers/cloud.pubsub/eventTypes/topic.publish" ||
|
|
431
794
|
type === "google.pubsub.topic.publish"
|
|
432
795
|
) {
|
|
796
|
+
resource = triggerString(resource, "eventTrigger.resource");
|
|
433
797
|
const topicMatch = resource.match(/\/topics\/([^/]+)$/);
|
|
434
798
|
return {
|
|
435
799
|
...base,
|
|
@@ -445,11 +809,16 @@ function describeV1Event(base, type, resource, schedule, retry) {
|
|
|
445
809
|
return ignored(base, "unknown", "unsupported", `v1 event type ${type} is not recognised`);
|
|
446
810
|
}
|
|
447
811
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
)
|
|
812
|
+
// Both SDK generations expose a taskQueueTrigger (legacy v1 also exposes it
|
|
813
|
+
// on __trigger). Always call the exported HTTP wrapper, not its testing-only
|
|
814
|
+
// .run callback, so decoding and task context stay in the SDK wrapper.
|
|
815
|
+
function describeTaskQueue(base, queue) {
|
|
816
|
+
if (!queue || typeof queue !== "object" || Array.isArray(queue)) {
|
|
817
|
+
throw new Error("taskQueueTrigger must be an object");
|
|
818
|
+
}
|
|
819
|
+
const retryConfig = triggerNumbers(queue.retryConfig, "taskQueueTrigger.retryConfig", TASK_RETRY_FIELDS);
|
|
820
|
+
const rateLimits = triggerNumbers(queue.rateLimits, "taskQueueTrigger.rateLimits", TASK_RATE_FIELDS);
|
|
821
|
+
return { ...base, trigger: { type: "tasks", retryConfig, rateLimits } };
|
|
453
822
|
}
|
|
454
823
|
|
|
455
824
|
// The callable App Check options of one function, as the loader instrumentation observed
|
|
@@ -479,18 +848,28 @@ function describe(name, fn, instrumentation) {
|
|
|
479
848
|
...callableAppCheck(instrumentation, fn),
|
|
480
849
|
});
|
|
481
850
|
const ep = fn.__endpoint;
|
|
851
|
+
const platform = ep?.platform;
|
|
482
852
|
const base = {
|
|
483
853
|
name,
|
|
484
854
|
entryPoint: name,
|
|
485
|
-
generation:
|
|
855
|
+
generation: platform === "gcfv2" ? 2 : 1,
|
|
486
856
|
};
|
|
487
|
-
if (ep?.omit === true) return { ...base, omitted: true };
|
|
488
|
-
if (ep && ep.platform
|
|
857
|
+
if (resolvedBoolean(ep?.omit, "omit") === true) return { ...base, omitted: true };
|
|
858
|
+
if (ep && Object.keys(ep).length > 0 && platform !== "gcfv1" && platform !== "gcfv2") {
|
|
859
|
+
// A nonempty endpoint without a known platform is not a legacy trigger.
|
|
860
|
+
// Do not advertise generation 1 while silently invoking the v2 convention.
|
|
861
|
+
return ignored(base, "unknown", "unsupported", "endpoint platform is not recognised");
|
|
862
|
+
}
|
|
863
|
+
if (ep && platform === "gcfv1") {
|
|
489
864
|
const deployment = platformOptions(ep);
|
|
490
865
|
if (deployment) base.platformOptions = deployment;
|
|
491
|
-
|
|
866
|
+
const timeout = resolvedNonNegativeInteger(ep.timeoutSeconds, "timeoutSeconds");
|
|
867
|
+
// The pinned SDK uses literal zero for unset/default timeout. Do not turn
|
|
868
|
+
// an Expression resolving to zero into a zero-length native deadline.
|
|
869
|
+
if (timeout !== undefined && timeout > 0) base.timeoutSeconds = timeout;
|
|
492
870
|
const region = firstRegion(ep);
|
|
493
871
|
if (region) base.region = region;
|
|
872
|
+
if (ep.taskQueueTrigger) return describeTaskQueue(base, ep.taskQueueTrigger);
|
|
494
873
|
if (ep.httpsTrigger) return { ...base, trigger: { type: "http", callable: false } };
|
|
495
874
|
if (ep.callableTrigger) return { ...base, trigger: callable() };
|
|
496
875
|
if (ep.blockingTrigger) {
|
|
@@ -508,9 +887,9 @@ function describe(name, fn, instrumentation) {
|
|
|
508
887
|
return describeV1Event(
|
|
509
888
|
base,
|
|
510
889
|
String(et.eventType || ""),
|
|
511
|
-
|
|
890
|
+
triggerRecord(et.eventFilters, "eventFilters").resource,
|
|
512
891
|
ep.scheduleTrigger,
|
|
513
|
-
|
|
892
|
+
resolvedBoolean(et.retry, "eventTrigger.retry") ?? false,
|
|
514
893
|
);
|
|
515
894
|
}
|
|
516
895
|
if (ep && Object.keys(ep).length > 0) {
|
|
@@ -518,23 +897,17 @@ function describe(name, fn, instrumentation) {
|
|
|
518
897
|
if (deployment) base.platformOptions = deployment;
|
|
519
898
|
const region = firstRegion(ep);
|
|
520
899
|
if (region) base.region = region;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
900
|
+
const timeout = resolvedNonNegativeInteger(ep.timeoutSeconds, "timeoutSeconds");
|
|
901
|
+
// The pinned SDK uses literal zero for unset/default timeout. Do not turn
|
|
902
|
+
// an Expression resolving to zero into a zero-length native deadline.
|
|
903
|
+
if (timeout !== undefined && timeout > 0) base.timeoutSeconds = timeout;
|
|
904
|
+
const concurrency = resolvedNonNegativeInteger(ep.concurrency, "concurrency");
|
|
905
|
+
if (concurrency !== undefined) base.concurrency = concurrency;
|
|
906
|
+
if (ep.taskQueueTrigger) return describeTaskQueue(base, ep.taskQueueTrigger);
|
|
524
907
|
if (ep.httpsTrigger) return { ...base, trigger: { type: "http", callable: false } };
|
|
525
908
|
if (ep.callableTrigger) return { ...base, trigger: callable() };
|
|
526
909
|
if (ep.scheduleTrigger) {
|
|
527
|
-
|
|
528
|
-
return {
|
|
529
|
-
...base,
|
|
530
|
-
retry: retryCount > 0,
|
|
531
|
-
trigger: {
|
|
532
|
-
type: "schedule",
|
|
533
|
-
schedule: ep.scheduleTrigger.schedule,
|
|
534
|
-
timeZone: ep.scheduleTrigger.timeZone || undefined,
|
|
535
|
-
retryConfig: ep.scheduleTrigger.retryConfig || {},
|
|
536
|
-
},
|
|
537
|
-
};
|
|
910
|
+
return describeSchedule(base, ep.scheduleTrigger);
|
|
538
911
|
}
|
|
539
912
|
if (ep.blockingTrigger) {
|
|
540
913
|
const eventType = String(ep.blockingTrigger.eventType || "");
|
|
@@ -550,17 +923,18 @@ function describe(name, fn, instrumentation) {
|
|
|
550
923
|
if (ep.eventTrigger) {
|
|
551
924
|
const et = ep.eventTrigger;
|
|
552
925
|
const type = et.eventType || "";
|
|
553
|
-
base.retry =
|
|
926
|
+
base.retry = resolvedBoolean(et.retry, "eventTrigger.retry") ?? false;
|
|
554
927
|
if (type.startsWith("google.cloud.firestore.")) {
|
|
555
|
-
const filters = et.eventFilters
|
|
556
|
-
const patterns = et.eventFilterPathPatterns
|
|
928
|
+
const filters = triggerRecord(et.eventFilters, "eventFilters");
|
|
929
|
+
const patterns = triggerRecord(et.eventFilterPathPatterns, "eventFilterPathPatterns");
|
|
930
|
+
const documentPattern = triggerString(patterns.document, "eventFilterPathPatterns.document", { optional: true });
|
|
557
931
|
return {
|
|
558
932
|
...base,
|
|
559
933
|
trigger: {
|
|
560
934
|
type: "firestore",
|
|
561
935
|
eventType: type,
|
|
562
|
-
database: filters.database
|
|
563
|
-
document:
|
|
936
|
+
database: triggerString(filters.database, "eventFilters.database", { optional: true }) ?? "(default)",
|
|
937
|
+
document: documentPattern ?? triggerString(filters.document, "eventFilters.document"),
|
|
564
938
|
},
|
|
565
939
|
};
|
|
566
940
|
}
|
|
@@ -570,15 +944,18 @@ function describe(name, fn, instrumentation) {
|
|
|
570
944
|
trigger: {
|
|
571
945
|
type: "storage",
|
|
572
946
|
eventType: type,
|
|
573
|
-
bucket: (et.eventFilters
|
|
947
|
+
bucket: triggerString(triggerRecord(et.eventFilters, "eventFilters").bucket, "eventFilters.bucket", { optional: true }),
|
|
574
948
|
},
|
|
575
949
|
};
|
|
576
950
|
}
|
|
577
951
|
if (type === "google.cloud.pubsub.topic.v1.messagePublished") {
|
|
578
|
-
const topic =
|
|
579
|
-
|
|
952
|
+
const topic = triggerString(triggerRecord(et.eventFilters, "eventFilters").topic, "eventFilters.topic");
|
|
953
|
+
const projectedTopic = topic.replace(/^.*\/topics\//, "");
|
|
954
|
+
if (!projectedTopic) throw new Error("eventFilters.topic resolved to an empty topic");
|
|
955
|
+
return { ...base, trigger: { type: "pubsub", topic: projectedTopic } };
|
|
580
956
|
}
|
|
581
|
-
|
|
957
|
+
const channel = triggerString(et.channel, "eventTrigger.channel", { optional: true });
|
|
958
|
+
if (channel !== undefined) {
|
|
582
959
|
// `onCustomEventPublished`: the channel is `locations/<l>/channels/<c>` and every
|
|
583
960
|
// eventFilter beyond the type is matched against the published event's attributes.
|
|
584
961
|
return {
|
|
@@ -586,8 +963,8 @@ function describe(name, fn, instrumentation) {
|
|
|
586
963
|
trigger: {
|
|
587
964
|
type: "eventarc",
|
|
588
965
|
eventType: type,
|
|
589
|
-
channel
|
|
590
|
-
filters: et.eventFilters
|
|
966
|
+
channel,
|
|
967
|
+
filters: triggerFilters(et.eventFilters),
|
|
591
968
|
},
|
|
592
969
|
};
|
|
593
970
|
}
|
|
@@ -604,7 +981,7 @@ function describe(name, fn, instrumentation) {
|
|
|
604
981
|
type: "eventarc",
|
|
605
982
|
eventType: type,
|
|
606
983
|
channel: "google",
|
|
607
|
-
filters: et.eventFilters
|
|
984
|
+
filters: triggerFilters(et.eventFilters),
|
|
608
985
|
},
|
|
609
986
|
};
|
|
610
987
|
}
|
|
@@ -612,21 +989,6 @@ function describe(name, fn, instrumentation) {
|
|
|
612
989
|
if (product) return ignored(base, product.triggerType, product.scope, product.reason);
|
|
613
990
|
return ignored(base, "unknown", "unsupported", `event type ${type} is not recognised`);
|
|
614
991
|
}
|
|
615
|
-
if (ep.taskQueueTrigger) {
|
|
616
|
-
// An `onTaskDispatched` function is an HTTP function that only its queue calls: the
|
|
617
|
-
// official emulator sets both `httpsTrigger` and `taskQueueTrigger` on the definition
|
|
618
|
-
// and gives it the ordinary /{project}/{region}/{name} URL, which becomes the queue's
|
|
619
|
-
// defaultUri. The nulls the manifest carries mean "the default", as the emulator's `??`
|
|
620
|
-
// reads them.
|
|
621
|
-
return {
|
|
622
|
-
...base,
|
|
623
|
-
trigger: {
|
|
624
|
-
type: "tasks",
|
|
625
|
-
retryConfig: ep.taskQueueTrigger.retryConfig || {},
|
|
626
|
-
rateLimits: ep.taskQueueTrigger.rateLimits || {},
|
|
627
|
-
},
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
992
|
return ignored(
|
|
631
993
|
base,
|
|
632
994
|
"unknown",
|
|
@@ -638,6 +1000,7 @@ function describe(name, fn, instrumentation) {
|
|
|
638
1000
|
if (t) {
|
|
639
1001
|
if (t.timeout) base.timeoutSeconds = Number(String(t.timeout).replace(/s$/, "")) || undefined;
|
|
640
1002
|
if (t.regions?.length) base.region = t.regions[0];
|
|
1003
|
+
if (t.taskQueueTrigger) return describeTaskQueue(base, t.taskQueueTrigger);
|
|
641
1004
|
if (t.httpsTrigger) {
|
|
642
1005
|
return {
|
|
643
1006
|
...base,
|
|
@@ -660,7 +1023,7 @@ function describe(name, fn, instrumentation) {
|
|
|
660
1023
|
return describeV1Event(
|
|
661
1024
|
base,
|
|
662
1025
|
String(et.eventType || ""),
|
|
663
|
-
|
|
1026
|
+
et.resource,
|
|
664
1027
|
t.schedule,
|
|
665
1028
|
!!et.failurePolicy || !!t.failurePolicy,
|
|
666
1029
|
);
|
|
@@ -775,16 +1138,11 @@ function v1Context(msg) {
|
|
|
775
1138
|
}
|
|
776
1139
|
}
|
|
777
1140
|
|
|
778
|
-
// Constant-time comparison of the per-runner secret.
|
|
779
|
-
function secretMatches(presented, expected) {
|
|
780
|
-
if (typeof presented !== "string") return false;
|
|
781
|
-
const a = Buffer.from(presented, "utf8");
|
|
782
|
-
const b = Buffer.from(expected, "utf8");
|
|
783
|
-
// `timingSafeEqual` throws on a length mismatch, which would itself be a length oracle.
|
|
784
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
1141
|
async function makeHttpServer(functions, manifest) {
|
|
1142
|
+
const httpProfile = process.env.FIREEMU_HTTP_PROFILE ?? "emulator";
|
|
1143
|
+
if (httpProfile !== "strict" && httpProfile !== "emulator") {
|
|
1144
|
+
throw new Error("invalid Functions HTTP profile");
|
|
1145
|
+
}
|
|
788
1146
|
const require = createRequire(join(sourceDir, "package.json"));
|
|
789
1147
|
let express;
|
|
790
1148
|
let expressRequire = require;
|
|
@@ -803,10 +1161,15 @@ async function makeHttpServer(functions, manifest) {
|
|
|
803
1161
|
}
|
|
804
1162
|
const HttpsErrors = await firebaseHttpsErrorConstructors(require);
|
|
805
1163
|
const app = express();
|
|
1164
|
+
const admission = createHttpAdmission({
|
|
1165
|
+
secret: process.env.FIREEMU_RUNNER_SECRET || "",
|
|
1166
|
+
isStopping: () => finishingOutput || outputFailed,
|
|
1167
|
+
});
|
|
806
1168
|
app.use(
|
|
807
1169
|
express.json({
|
|
808
1170
|
limit: "32mb",
|
|
809
1171
|
verify: (req, _res, buf) => {
|
|
1172
|
+
admission.verify(req, _res, buf);
|
|
810
1173
|
req.rawBody = buf;
|
|
811
1174
|
},
|
|
812
1175
|
}),
|
|
@@ -815,6 +1178,7 @@ async function makeHttpServer(functions, manifest) {
|
|
|
815
1178
|
express.text({
|
|
816
1179
|
limit: "32mb",
|
|
817
1180
|
verify: (req, _res, buf) => {
|
|
1181
|
+
admission.verify(req, _res, buf);
|
|
818
1182
|
req.rawBody = buf;
|
|
819
1183
|
},
|
|
820
1184
|
}),
|
|
@@ -824,6 +1188,7 @@ async function makeHttpServer(functions, manifest) {
|
|
|
824
1188
|
extended: true,
|
|
825
1189
|
limit: "32mb",
|
|
826
1190
|
verify: (req, _res, buf) => {
|
|
1191
|
+
admission.verify(req, _res, buf);
|
|
827
1192
|
req.rawBody = buf;
|
|
828
1193
|
},
|
|
829
1194
|
}),
|
|
@@ -833,35 +1198,29 @@ async function makeHttpServer(functions, manifest) {
|
|
|
833
1198
|
type: () => true,
|
|
834
1199
|
limit: "32mb",
|
|
835
1200
|
verify: (req, _res, buf) => {
|
|
1201
|
+
admission.verify(req, _res, buf);
|
|
836
1202
|
req.rawBody = buf;
|
|
837
1203
|
},
|
|
838
1204
|
}),
|
|
839
1205
|
);
|
|
1206
|
+
app.use((req, _res, next) => {
|
|
1207
|
+
if (Buffer.isBuffer(req.body) && req.body.length === 0) req.body = {};
|
|
1208
|
+
next();
|
|
1209
|
+
});
|
|
840
1210
|
const major = Number.parseInt(
|
|
841
1211
|
String(expressRequire("express/package.json").version).split(".")[0],
|
|
842
1212
|
10,
|
|
843
1213
|
);
|
|
844
1214
|
// Express 5 (path-to-regexp 8) and Express 4 spell the optional rest differently.
|
|
845
1215
|
const route = major >= 5 ? "/:project/:region/:name{/*rest}" : "/:project/:region/:name*";
|
|
846
|
-
const secret = process.env.FIREEMU_RUNNER_SECRET || "";
|
|
847
1216
|
const project = process.env.GCLOUD_PROJECT || "";
|
|
848
|
-
app.all(route, (req, res
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
// every request instead of waving them through: this server is the one place that decodes
|
|
852
|
-
// the credentials the daemon prevalidated, and under the trusted callable protocol it also
|
|
853
|
-
// honours the auth-override headers, so an unguarded runner would be an open
|
|
854
|
-
// impersonation endpoint for anything else on the loopback interface.
|
|
855
|
-
if (!secret) {
|
|
856
|
-
res.status(500).send("FIREEMU_RUNNER_SECRET is required");
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
if (!secretMatches(req.get("x-fireemu-runner-secret"), secret)) {
|
|
860
|
-
res.status(403).send("not the fireemu proxy");
|
|
1217
|
+
app.all(route, (req, res) => {
|
|
1218
|
+
if (finishingOutput || outputFailed) {
|
|
1219
|
+
res.status(503).send("runner is shutting down");
|
|
861
1220
|
return;
|
|
862
1221
|
}
|
|
863
|
-
//
|
|
864
|
-
|
|
1222
|
+
// Node-level admission authenticated the proxy before body parsing and
|
|
1223
|
+
// removed its capability header. Selection below retains namespace checks.
|
|
865
1224
|
const spec = manifest.functions.find(
|
|
866
1225
|
(f) =>
|
|
867
1226
|
f.name === req.params.name &&
|
|
@@ -884,39 +1243,79 @@ async function makeHttpServer(functions, manifest) {
|
|
|
884
1243
|
const query = queryAt >= 0 ? original.slice(queryAt) : "";
|
|
885
1244
|
const rest = pathPart.split("/").slice(4).join("/");
|
|
886
1245
|
req.url = `/${rest}${query}`;
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1246
|
+
// Capture close/finish while waiting for the per-function environment too.
|
|
1247
|
+
// A completed IncomingMessage is not a disconnected response: do not use
|
|
1248
|
+
// req.close or req.destroyed to cancel a valid, fully received request.
|
|
1249
|
+
const completeAdmission = admission.begin(req);
|
|
1250
|
+
if (!completeAdmission) { if (!res.destroyed && !res.writableEnded) res.destroy(); return; }
|
|
1251
|
+
const lifetime = trackHttpResponse(res);
|
|
1252
|
+
const blocking = spec.trigger?.type === "blockingAuth";
|
|
1253
|
+
const replyFailure = (error) => {
|
|
1254
|
+
const failure = blocking
|
|
1255
|
+
? blockingFailure(error, HttpsErrors)
|
|
1256
|
+
: invocationFailure(error);
|
|
1257
|
+
try {
|
|
1258
|
+
const diagnostic = blocking ? invocationFailure(error).diagnostic : failure.diagnostic;
|
|
1259
|
+
log("error", `${spec.name}: ${diagnostic}`, undefined, spec.name);
|
|
1260
|
+
} catch {
|
|
1261
|
+
// Diagnostics cannot suppress the already classified response.
|
|
1262
|
+
}
|
|
1263
|
+
// Never try to append a second error body to a partial/finished response,
|
|
1264
|
+
// or send a reply to a peer that has already disconnected.
|
|
1265
|
+
if (res.destroyed || res.writableEnded) return;
|
|
1266
|
+
try {
|
|
1267
|
+
if (res.headersSent) res.destroy();
|
|
1268
|
+
else if (blocking) res.status(failure.status).json({
|
|
1269
|
+
error: { status: failure.canonicalName, message: failure.message },
|
|
911
1270
|
});
|
|
1271
|
+
else res.status(500).send("internal error");
|
|
1272
|
+
} catch {
|
|
1273
|
+
res.destroy();
|
|
912
1274
|
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1275
|
+
};
|
|
1276
|
+
void withFunctionEnvironment(spec, async () => {
|
|
1277
|
+
// Do not run queued callbacks whose requesting peer has gone away. This
|
|
1278
|
+
// check never races a running callback against close: its Promise still
|
|
1279
|
+
// owns the environment until it settles, even after the response closes.
|
|
1280
|
+
if (!lifetime.canStart()) return;
|
|
1281
|
+
try {
|
|
1282
|
+
if (blocking) {
|
|
1283
|
+
const user = req.body?.data?.user;
|
|
1284
|
+
const context = req.body?.data?.context || {};
|
|
1285
|
+
const value = await (spec.generation === 1 ? fn.run(user, context) : fn.run({ ...context, data: user }));
|
|
1286
|
+
if (lifetime.canStart()) {
|
|
1287
|
+
// Materialization can call user getters/toJSON. Keep it inside this
|
|
1288
|
+
// function's secret/logger environment, not the next queue entry's.
|
|
1289
|
+
const payload = blockingResult(value, spec.trigger.eventType, HttpsErrors[0]);
|
|
1290
|
+
if (lifetime.canStart()) res.status(200).json(payload);
|
|
1291
|
+
}
|
|
1292
|
+
} else {
|
|
1293
|
+
await fn(req, res);
|
|
1294
|
+
}
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
replyFailure(error);
|
|
1297
|
+
}
|
|
1298
|
+
// Already-observed close/finish resolves immediately. A callback that
|
|
1299
|
+
// returned before ending its response still retains the environment.
|
|
1300
|
+
await lifetime.wait();
|
|
1301
|
+
})
|
|
1302
|
+
.catch(replyFailure)
|
|
1303
|
+
.finally(() => { lifetime.dispose(); completeAdmission(); });
|
|
918
1304
|
});
|
|
919
|
-
|
|
1305
|
+
if (httpProfile === "strict") {
|
|
1306
|
+
app.use((error, _req, res, next) => {
|
|
1307
|
+
if (error?.status !== 400 || error?.type !== "entity.parse.failed") {
|
|
1308
|
+
next(error);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
res.status(400);
|
|
1312
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1313
|
+
res.end('<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Error</title>\n</head>\n<body>\n<pre>Bad Request</pre>\n</body>\n</html>\n');
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
const server = createServer((req, res) => admission.handle(req, res, app));
|
|
1317
|
+
// Do not let Node send 100 Continue before authentication/capacity checks.
|
|
1318
|
+
server.on("checkContinue", (req, res) => admission.handle(req, res, app, true));
|
|
920
1319
|
return new Promise((resolveServer, reject) => {
|
|
921
1320
|
server.on("error", reject);
|
|
922
1321
|
server.listen(0, "127.0.0.1", () => resolveServer(server));
|
|
@@ -924,13 +1323,21 @@ async function makeHttpServer(functions, manifest) {
|
|
|
924
1323
|
}
|
|
925
1324
|
|
|
926
1325
|
async function invoke(functions, manifest, msg) {
|
|
927
|
-
const fn = functions.get(msg.entryPoint) || functions.get(msg.function);
|
|
928
|
-
if (!fn) throw new Error(`unknown function ${msg.function}`);
|
|
929
1326
|
const spec = manifest.functions.find((f) => f.name === msg.function);
|
|
1327
|
+
if (!spec) throw new Error("unknown function");
|
|
1328
|
+
if (msg.entryPoint !== undefined && msg.entryPoint !== spec.entryPoint) {
|
|
1329
|
+
throw new Error("entry point does not match the function manifest");
|
|
1330
|
+
}
|
|
1331
|
+
if (msg.trigger !== spec.trigger?.type ||
|
|
1332
|
+
!["schedule", "firestore", "storage", "pubsub", "eventarc", "auth"].includes(msg.trigger)) {
|
|
1333
|
+
throw new Error("trigger does not match the function manifest");
|
|
1334
|
+
}
|
|
1335
|
+
const fn = functions.get(spec.entryPoint);
|
|
1336
|
+
if (typeof fn !== "function") throw new Error("missing function entry point");
|
|
930
1337
|
await withFunctionEnvironment(
|
|
931
1338
|
spec,
|
|
932
1339
|
async () => {
|
|
933
|
-
if (
|
|
1340
|
+
if (spec.generation === 1) {
|
|
934
1341
|
const context = v1Context(msg);
|
|
935
1342
|
let data = msg.event.data;
|
|
936
1343
|
if (msg.trigger === "schedule") data = {};
|
|
@@ -963,32 +1370,61 @@ async function invoke(functions, manifest, msg) {
|
|
|
963
1370
|
);
|
|
964
1371
|
}
|
|
965
1372
|
|
|
966
|
-
function readFrames(onFrame, onEnd) {
|
|
967
|
-
let buffer = Buffer.alloc(0);
|
|
968
|
-
process.stdin.on("data", (chunk) => {
|
|
969
|
-
buffer = Buffer.concat([buffer, chunk]);
|
|
970
|
-
for (;;) {
|
|
971
|
-
const nl = buffer.indexOf(10);
|
|
972
|
-
if (nl < 0) return;
|
|
973
|
-
const len = Number.parseInt(buffer.subarray(0, nl).toString("utf8"), 10);
|
|
974
|
-
if (!Number.isFinite(len)) {
|
|
975
|
-
log("error", "malformed frame length");
|
|
976
|
-
process.exit(2);
|
|
977
|
-
}
|
|
978
|
-
if (buffer.length < nl + 1 + len) return;
|
|
979
|
-
const payload = buffer.subarray(nl + 1, nl + 1 + len).toString("utf8");
|
|
980
|
-
buffer = buffer.subarray(nl + 1 + len);
|
|
981
|
-
try {
|
|
982
|
-
onFrame(JSON.parse(payload));
|
|
983
|
-
} catch (e) {
|
|
984
|
-
log("error", `malformed frame: ${e.message}`);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
process.stdin.on("end", onEnd);
|
|
989
|
-
}
|
|
990
1373
|
|
|
991
1374
|
async function main() {
|
|
1375
|
+
let functions;
|
|
1376
|
+
let manifest;
|
|
1377
|
+
let runnerReady = false;
|
|
1378
|
+
const activeInvocations = new InvocationBudget();
|
|
1379
|
+
// Read before loading user code, which may await indefinitely after spawning children.
|
|
1380
|
+
readFrames(
|
|
1381
|
+
process.stdin,
|
|
1382
|
+
(msg, payloadBytes) => {
|
|
1383
|
+
if (msg.type === "shutdown") {
|
|
1384
|
+
explicitShutdown = true;
|
|
1385
|
+
// Volta must observe Node exit before its shim is killed, or the daemon
|
|
1386
|
+
// can finish shutdown while Node still owns an inspector listener.
|
|
1387
|
+
deferCleanShutdownGroupCleanup();
|
|
1388
|
+
finishOutput();
|
|
1389
|
+
return false;
|
|
1390
|
+
}
|
|
1391
|
+
if (!runnerReady) {
|
|
1392
|
+
process.stderr.write('[functions] invocation received before runner hello\n');
|
|
1393
|
+
process.exit(2);
|
|
1394
|
+
}
|
|
1395
|
+
// Includes callbacks waiting for secret/debugger environment selection.
|
|
1396
|
+
// Retire on overflow; never report an unexecuted request as successful or
|
|
1397
|
+
// retry uncertain in-flight side effects inside this runner.
|
|
1398
|
+
const release = activeInvocations.reserve(msg.invocationId, payloadBytes);
|
|
1399
|
+
invoke(functions, manifest, msg)
|
|
1400
|
+
.then(() => send({ type: "result", invocationId: msg.invocationId, ok: true }))
|
|
1401
|
+
.catch((error) => {
|
|
1402
|
+
const failure = invocationFailure(error);
|
|
1403
|
+
try {
|
|
1404
|
+
log("error", `${msg.function}: ${failure.diagnostic}`, msg.invocationId, msg.function);
|
|
1405
|
+
} catch {
|
|
1406
|
+
// Diagnostic output cannot suppress the invocation's failure result.
|
|
1407
|
+
}
|
|
1408
|
+
send({
|
|
1409
|
+
type: "result",
|
|
1410
|
+
invocationId: msg.invocationId,
|
|
1411
|
+
ok: false,
|
|
1412
|
+
error: failure.message,
|
|
1413
|
+
});
|
|
1414
|
+
})
|
|
1415
|
+
.finally(release);
|
|
1416
|
+
},
|
|
1417
|
+
() => {
|
|
1418
|
+
cleanupProcessGroupOnInputClose();
|
|
1419
|
+
finishOutput();
|
|
1420
|
+
},
|
|
1421
|
+
(error) => {
|
|
1422
|
+
// The pipe cannot be resynchronized safely. Retire the runner so the daemon
|
|
1423
|
+
// resolves outstanding invocations as RunnerGone, rather than timing out.
|
|
1424
|
+
// Do not print JSON.parse diagnostics containing user payload fragments.
|
|
1425
|
+
try { process.stderr.write(`${error.message}\n`); } finally { process.exit(2); }
|
|
1426
|
+
},
|
|
1427
|
+
);
|
|
992
1428
|
// Before any user code loads: the callable options are only observable as a callable is
|
|
993
1429
|
// declared (spec 13.4).
|
|
994
1430
|
const instrumentation = instrumentCallables(sourceDir);
|
|
@@ -996,7 +1432,7 @@ async function main() {
|
|
|
996
1432
|
try {
|
|
997
1433
|
ns = await loadCodebase();
|
|
998
1434
|
} catch (e) {
|
|
999
|
-
log("error", `cannot load functions from ${sourceDir}: ${e
|
|
1435
|
+
log("error", `cannot load functions from ${sourceDir}: ${invocationFailure(e).diagnostic}`);
|
|
1000
1436
|
process.exit(1);
|
|
1001
1437
|
}
|
|
1002
1438
|
try {
|
|
@@ -1014,19 +1450,23 @@ async function main() {
|
|
|
1014
1450
|
...esmOptions.getGlobalOptions(),
|
|
1015
1451
|
};
|
|
1016
1452
|
} catch (e) {
|
|
1017
|
-
log("warn", `cannot inspect firebase-functions global options: ${e
|
|
1453
|
+
log("warn", `cannot inspect firebase-functions global options: ${invocationFailure(e).message}`);
|
|
1018
1454
|
}
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1455
|
+
const discovered = collectFunctions(ns);
|
|
1456
|
+
functions = discovered.functions;
|
|
1457
|
+
const { broken } = discovered;
|
|
1021
1458
|
const described = [...functions.entries()].map(([name, fn]) => {
|
|
1022
1459
|
try {
|
|
1023
1460
|
return describe(name, fn, instrumentation);
|
|
1024
1461
|
} catch (e) {
|
|
1462
|
+
const failure = observeAsyncValue(e)
|
|
1463
|
+
? new Error("asynchronous endpoint metadata failure")
|
|
1464
|
+
: e;
|
|
1025
1465
|
return ignored(
|
|
1026
1466
|
{ name, entryPoint: name },
|
|
1027
1467
|
"unknown",
|
|
1028
1468
|
"unsupported",
|
|
1029
|
-
`the export could not be described: ${
|
|
1469
|
+
`the export could not be described: ${invocationFailure(failure).message}`,
|
|
1030
1470
|
);
|
|
1031
1471
|
}
|
|
1032
1472
|
});
|
|
@@ -1043,7 +1483,7 @@ async function main() {
|
|
|
1043
1483
|
// Nothing is dropped: an export this runner cannot serve travels in the manifest's
|
|
1044
1484
|
// `ignored` array with its region, its trigger type and its product scope, and the daemon
|
|
1045
1485
|
// decides what to do with it.
|
|
1046
|
-
|
|
1486
|
+
manifest = {
|
|
1047
1487
|
functions: described.filter((d) => !d.ignored && !d.omitted),
|
|
1048
1488
|
ignored: described
|
|
1049
1489
|
.filter((d) => d.ignored)
|
|
@@ -1073,7 +1513,7 @@ async function main() {
|
|
|
1073
1513
|
"express is not installed in the functions codebase; HTTP functions are unavailable",
|
|
1074
1514
|
);
|
|
1075
1515
|
} catch (e) {
|
|
1076
|
-
log("error", `cannot start the HTTP server: ${e
|
|
1516
|
+
log("error", `cannot start the HTTP server: ${invocationFailure(e).diagnostic}`);
|
|
1077
1517
|
}
|
|
1078
1518
|
}
|
|
1079
1519
|
activeInspectorPort = inspectorPort();
|
|
@@ -1094,24 +1534,12 @@ async function main() {
|
|
|
1094
1534
|
graphs: instrumentation.graphs,
|
|
1095
1535
|
},
|
|
1096
1536
|
});
|
|
1097
|
-
|
|
1098
|
-
(msg) => {
|
|
1099
|
-
if (msg.type === "shutdown") process.exit(0);
|
|
1100
|
-
if (msg.type !== "invoke") return;
|
|
1101
|
-
invoke(functions, manifest, msg)
|
|
1102
|
-
.then(() => send({ type: "result", invocationId: msg.invocationId, ok: true }))
|
|
1103
|
-
.catch((e) => {
|
|
1104
|
-
log("error", `${msg.function}: ${e?.stack || e}`, msg.invocationId, msg.function);
|
|
1105
|
-
send({
|
|
1106
|
-
type: "result",
|
|
1107
|
-
invocationId: msg.invocationId,
|
|
1108
|
-
ok: false,
|
|
1109
|
-
error: String(e?.message || e),
|
|
1110
|
-
});
|
|
1111
|
-
});
|
|
1112
|
-
},
|
|
1113
|
-
() => process.exit(0),
|
|
1114
|
-
);
|
|
1537
|
+
runnerReady = true;
|
|
1115
1538
|
}
|
|
1116
1539
|
|
|
1117
|
-
main()
|
|
1540
|
+
main().catch((error) => {
|
|
1541
|
+
// Failed discovery must not publish a partial hello or rely on Node's
|
|
1542
|
+
// unhandled-rejection formatting of arbitrary thrown user objects.
|
|
1543
|
+
const failure = invocationFailure(error);
|
|
1544
|
+
try { process.stderr.write(`${failure.message}\n`); } finally { process.exit(1); }
|
|
1545
|
+
});
|