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