@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.
@@ -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
- const frameWrite = process.stdout.write.bind(process.stdout);
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
- const payload = Buffer.from(JSON.stringify(msg), "utf8");
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
- if (spec.trigger?.type === "http") return "http";
152
- if (spec.trigger?.type === "schedule") return spec.v1 ? "event" : "http";
153
- return spec.v1 ? "event" : "cloudevent";
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
- const saved = new Map();
172
- for (const [name] of localSecrets) {
173
- saved.set(
174
- name,
175
- Object.prototype.hasOwnProperty.call(process.env, name) ? process.env[name] : undefined,
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
- for (const [name, value] of saved) {
186
- if (value === undefined) delete process.env[name];
187
- else process.env[name] = value;
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 result = functionEnvironmentQueue.then(run);
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
- // Export order matters: it is the order the emulator lists functions in, and the official
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
- // Flattens nested export groups: exports.api = { users: fn } -> "api-users".
227
- //
228
- // `__endpoint` and `__trigger` are getters on the v1 SDK's cloud functions and can throw
229
- // while they describe themselves (`database.ref(...)` throws when FIREBASE_CONFIG carries no
230
- // databaseURL). A throwing export is recorded as malformed and named, never allowed to take
231
- // the whole runner down: the daemon's inventory must be able to say what happened to it.
232
- function collectFunctions(ns, prefix, out, broken) {
233
- for (const [key, value] of Object.entries(ns)) {
234
- if (!value) continue;
235
- const name = prefix ? `${prefix}-${key}` : key;
236
- if (typeof value === "function") {
237
- let marked = false;
238
- try {
239
- marked = Boolean(value.__endpoint || value.__trigger);
240
- } catch (e) {
241
- broken.set(name, e?.message ? String(e.message).split("\n")[0] : String(e));
242
- continue;
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 out;
458
+ return false;
250
459
  }
251
460
 
252
- function firstRegion(ep) {
253
- const r = ep.region;
254
- if (Array.isArray(r)) return r[0];
255
- return r || undefined;
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
- function resolvedNonNegativeInteger(value, field) {
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
- const resolved =
262
- typeof value === "object" && typeof value.value === "function" ? value.value() : value;
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
- const docIndex = resource.indexOf("/documents/");
377
- const dbMatch = resource.match(/\/databases\/([^/]+)\//);
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: dbMatch ? dbMatch[1] : "(default)",
386
- document: docIndex >= 0 ? resource.slice(docIndex + "/documents/".length) : undefined,
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
- const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
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: bucketMatch ? bucketMatch[1] : undefined,
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
- function isV1(fn) {
449
- return (
450
- fn.__endpoint?.platform === "gcfv1" ||
451
- (!(fn.__endpoint && Object.keys(fn.__endpoint).length > 0) && !!fn.__trigger)
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: ep?.platform === "gcfv2" ? 2 : 1,
865
+ generation: platform === "gcfv2" ? 2 : 1,
486
866
  };
487
- if (ep?.omit === true) return { ...base, omitted: true };
488
- if (ep && ep.platform === "gcfv1") {
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
- if (ep.timeoutSeconds) base.timeoutSeconds = ep.timeoutSeconds;
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 (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
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
- String(et.eventFilters?.resource || ""),
900
+ triggerRecord(et.eventFilters, "eventFilters").resource,
512
901
  ep.scheduleTrigger,
513
- !!et.retry,
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
- if (ep.timeoutSeconds) base.timeoutSeconds = ep.timeoutSeconds;
522
- if (ep.concurrency != null)
523
- base.concurrency = resolvedNonNegativeInteger(ep.concurrency, "concurrency");
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
- const retryCount = Number(ep.scheduleTrigger.retryConfig?.retryCount || 0);
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 (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
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 = !!et.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 || "(default)",
563
- document: patterns.document || filters.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 || {}).bucket || undefined,
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 = String((et.eventFilters || {}).topic || "");
579
- return { ...base, trigger: { type: "pubsub", topic: topic.replace(/^.*\/topics\//, "") } };
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
- if (et.channel) {
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: et.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 (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
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
- String(et.resource || ""),
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, next) => {
849
- // Only the daemon's proxy may reach this server (it carries the per-runner secret and has
850
- // already applied timeouts, concurrency and idle accounting). A missing secret refuses
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
- if (!secretMatches(req.get("x-fireemu-runner-secret"), secret)) {
860
- res.status(403).send("not the fireemu proxy");
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
- if (spec.trigger?.type === "blockingAuth") {
888
- withFunctionEnvironment(spec, () => {
889
- const user = req.body?.data?.user;
890
- const context = req.body?.data?.context || {};
891
- return isV1(fn) ? fn.run(user, context) : fn.run({ ...context, data: user });
892
- })
893
- .then((value) =>
894
- res.status(200).json(blockingResult(value, spec.trigger.eventType, HttpsErrors[0])),
895
- )
896
- .catch((e) => {
897
- log("error", `${spec.name}: ${e?.stack || e}`, undefined, spec.name);
898
- const failure = blockingFailure(e, HttpsErrors);
899
- res.status(failure.status).json({
900
- error: { status: failure.canonicalName, message: failure.message },
901
- });
902
- });
903
- return;
904
- }
905
- withFunctionEnvironment(spec, async () => {
906
- await fn(req, res);
907
- if (!res.writableEnded) {
908
- await new Promise((resolve) => {
909
- res.once("finish", resolve);
910
- res.once("close", resolve);
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
- }).catch((e) => {
914
- log("error", `${spec.name}: ${e?.stack || e}`, undefined, spec.name);
915
- if (!res.headersSent) res.status(500).send("internal error");
916
- next();
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
- const server = createServer(app);
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 (isV1(fn)) {
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?.stack || 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?.message || e}`);
1475
+ log("warn", `cannot inspect firebase-functions global options: ${invocationFailure(e).message}`);
1018
1476
  }
1019
- const broken = new Map();
1020
- const functions = collectFunctions(ns, "", new Map(), broken);
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: ${e?.message || e}`,
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
- const manifest = {
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?.stack || 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
- readFrames(
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
+ });