@fireemu/win32-x64 0.7.1 → 0.8.0

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