@fireemu/win32-x64 0.1.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.
@@ -0,0 +1,1117 @@
1
+ #!/usr/bin/env node
2
+ // fireemu Node runner: loads a Firebase Functions codebase (firebase-functions v2,
3
+ // plus v1 HTTP functions), discovers its functions, and serves invocations sent by the
4
+ // daemon over stdin/stdout (length-prefixed JSON frames). HTTP and callable functions are
5
+ // hosted on a local express server whose port is announced in the hello frame.
6
+ //
7
+ // node tools/runner-node/index.mjs --source <functions dir>
8
+ //
9
+ // stdout is the protocol channel: everything the functions print goes to stderr.
10
+
11
+ import { createRequire } from "node:module";
12
+ import { pathToFileURL } from "node:url";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { resolve, join, dirname } from "node:path";
15
+ import { timingSafeEqual } from "node:crypto";
16
+ import { createServer } from "node:http";
17
+ import { url as inspectorUrl } from "node:inspector";
18
+ import { instrumentCallables } from "./callable-app-check.mjs";
19
+ import { blockingFailure } from "./blocking-error.mjs";
20
+ import { blockingResult } from "./blocking-response.mjs";
21
+ import { boundLogMessage, createInvocationLogger } from "./log-context.mjs";
22
+
23
+ const frameWrite = process.stdout.write.bind(process.stdout);
24
+ process.stdout.write = (chunk, encoding, cb) => process.stderr.write(chunk, encoding, cb);
25
+
26
+ const localSecrets = (() => {
27
+ const encoded = process.env.FIREEMU_LOCAL_SECRETS_JSON;
28
+ delete process.env.FIREEMU_LOCAL_SECRETS_JSON;
29
+ if (!encoded) return new Map();
30
+ const parsed = JSON.parse(encoded);
31
+ return new Map(Object.entries(parsed).filter(([, value]) => typeof value === "string"));
32
+ })();
33
+ let functionEnvironmentQueue = Promise.resolve();
34
+ let discoveredGlobalOptions = {};
35
+
36
+ function inspectorPort() {
37
+ const activeUrl = inspectorUrl();
38
+ if (!activeUrl) return undefined;
39
+ try {
40
+ const endpoint = new URL(activeUrl);
41
+ if (endpoint.hostname !== "127.0.0.1") return undefined;
42
+ const port = Number(endpoint.port);
43
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : undefined;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ let activeInspectorPort;
50
+
51
+ function esmExportTarget(value) {
52
+ if (typeof value === "string") return value;
53
+ if (Array.isArray(value)) {
54
+ for (const candidate of value) {
55
+ const selected = esmExportTarget(candidate);
56
+ if (selected) return selected;
57
+ }
58
+ return undefined;
59
+ }
60
+ if (!value || typeof value !== "object") return undefined;
61
+ for (const [condition, candidate] of Object.entries(value)) {
62
+ if (condition === "node" || condition === "import" || condition === "default") {
63
+ const selected = esmExportTarget(candidate);
64
+ if (selected) return selected;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function firebaseFunctionsPackage(require) {
71
+ let root = dirname(require.resolve("firebase-functions"));
72
+ for (;;) {
73
+ const candidate = join(root, "package.json");
74
+ if (existsSync(candidate)) {
75
+ const manifest = JSON.parse(readFileSync(candidate, "utf8"));
76
+ if (manifest.name === "firebase-functions") return { root, manifest };
77
+ }
78
+ const parent = dirname(root);
79
+ if (parent === root) throw new Error("cannot locate the firebase-functions package root");
80
+ root = parent;
81
+ }
82
+ }
83
+
84
+ async function firebaseHttpsErrorConstructors(require) {
85
+ const constructors = [];
86
+ try {
87
+ constructors.push(require("firebase-functions/https").HttpsError);
88
+ } catch {
89
+ // The ESM export below may still be available. An empty set fails closed.
90
+ }
91
+ try {
92
+ const { root, manifest } = firebaseFunctionsPackage(require);
93
+ const esmTarget = esmExportTarget(manifest.exports?.["./https"]);
94
+ if (typeof esmTarget !== "string" || !esmTarget.startsWith("./")) {
95
+ throw new Error("firebase-functions does not export ESM https");
96
+ }
97
+ const esmHttps = await import(pathToFileURL(resolve(root, esmTarget)).href);
98
+ constructors.push(esmHttps.HttpsError);
99
+ } catch {
100
+ // A missing constructor never widens trust; genuine errors from that module fail closed.
101
+ }
102
+ return constructors.filter(
103
+ (constructor, index) =>
104
+ typeof constructor === "function" && constructors.indexOf(constructor) === index,
105
+ );
106
+ }
107
+
108
+ function send(msg) {
109
+ const payload = Buffer.from(JSON.stringify(msg), "utf8");
110
+ frameWrite(`${payload.length}\n`);
111
+ frameWrite(payload);
112
+ }
113
+
114
+ function log(level, message, invocationId, functionName, user = false, fields) {
115
+ const entry = {
116
+ type: "log",
117
+ level,
118
+ message: boundLogMessage(message),
119
+ invocationId,
120
+ functionName,
121
+ user,
122
+ };
123
+ if (fields && Object.keys(fields).length > 0) entry.fields = fields;
124
+ send(entry);
125
+ }
126
+
127
+ const invocationLogger = createInvocationLogger((entry) =>
128
+ log(entry.level, entry.message, entry.invocationId, entry.functionName, entry.user, entry.fields),
129
+ );
130
+ invocationLogger.install();
131
+
132
+ function parseArgs(argv) {
133
+ const out = { source: process.cwd(), codebase: "default" };
134
+ for (let i = 0; i < argv.length; i++) {
135
+ if (argv[i] === "--source" && argv[i + 1]) out.source = argv[++i];
136
+ else if (argv[i] === "--codebase" && argv[i + 1]) out.codebase = argv[++i];
137
+ }
138
+ return out;
139
+ }
140
+
141
+ const args = parseArgs(process.argv.slice(2));
142
+ const sourceDir = resolve(args.source);
143
+ process.env.FUNCTIONS_EMULATOR = process.env.FUNCTIONS_EMULATOR || "true";
144
+ process.env.FUNCTION_TARGET = process.env.FUNCTION_TARGET || "";
145
+ process.env.FUNCTION_SIGNATURE_TYPE = process.env.FUNCTION_SIGNATURE_TYPE || "";
146
+ process.env.K_SERVICE = process.env.K_SERVICE || "";
147
+
148
+ // `getSignatureType` (functionsEmulatorShared.js:292): what the runtime is being asked to
149
+ // speak for one function.
150
+ 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";
154
+ }
155
+
156
+ // The official emulator starts one runtime process per trigger, so it can set these three at
157
+ // spawn and they stay true for the life of the process (functionsEmulator.js:983-988). One
158
+ // fireemu runner serves a whole codebase, so they are set synchronously just before a handler
159
+ // is entered and name the invocation that started most recently. Under concurrency that is
160
+ // not the same guarantee, and it is the honest one a shared process can make; it is written
161
+ // down in the Functions section of README.md.
162
+ function setFunctionIdentity(spec) {
163
+ process.env.FUNCTION_TARGET = spec?.entryPoint ?? "";
164
+ process.env.FUNCTION_SIGNATURE_TYPE = spec ? signatureType(spec) : "";
165
+ process.env.K_SERVICE = spec?.name ?? "";
166
+ }
167
+
168
+ function withFunctionEnvironment(spec, task, invocationId) {
169
+ const run = async () => {
170
+ 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];
178
+ }
179
+ for (const name of spec?.platformOptions?.secrets || []) {
180
+ if (localSecrets.has(name)) process.env[name] = localSecrets.get(name);
181
+ }
182
+ try {
183
+ return await invocationLogger.run({ functionName: spec?.name, invocationId }, task);
184
+ } finally {
185
+ for (const [name, value] of saved) {
186
+ if (value === undefined) delete process.env[name];
187
+ else process.env[name] = value;
188
+ }
189
+ }
190
+ };
191
+ if (localSecrets.size === 0 && activeInspectorPort === undefined) return run();
192
+ const result = functionEnvironmentQueue.then(run);
193
+ functionEnvironmentQueue = result.catch(() => {});
194
+ return result;
195
+ }
196
+
197
+ async function loadCodebase() {
198
+ const pkgPath = join(sourceDir, "package.json");
199
+ let main = "index.js";
200
+ if (existsSync(pkgPath)) {
201
+ try {
202
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
203
+ if (pkg.main) main = pkg.main;
204
+ } catch (e) {
205
+ throw new Error(`cannot read ${pkgPath}: ${e.message}`);
206
+ }
207
+ }
208
+ const entry = resolve(sourceDir, main);
209
+ if (!existsSync(entry)) throw new Error(`functions entry point ${entry} does not exist`);
210
+ 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;
224
+ }
225
+
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;
243
+ }
244
+ if (marked) out.set(name, value);
245
+ } else if (typeof value === "object" && !Array.isArray(value)) {
246
+ collectFunctions(value, name, out, broken);
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ function firstRegion(ep) {
253
+ const r = ep.region;
254
+ if (Array.isArray(r)) return r[0];
255
+ return r || undefined;
256
+ }
257
+
258
+ function resolvedNonNegativeInteger(value, field) {
259
+ if (value == null) return undefined;
260
+ 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;
263
+ if (!Number.isSafeInteger(resolved) || resolved < 0) {
264
+ throw new Error(`${field} did not resolve to a non-negative integer`);
265
+ }
266
+ return resolved;
267
+ }
268
+
269
+ // Preserve shared Gen1/Gen2 endpoint options. Memory and instance limits shape local
270
+ // admission; the other deployment and IAM values remain visible for faithful diagnostics.
271
+ function platformOptions(ep) {
272
+ if (!ep) return undefined;
273
+ const options = {};
274
+ const preserveExternalChanges =
275
+ ep.preserveExternalChanges ??
276
+ (ep.platform === "gcfv2" ? discoveredGlobalOptions.preserveExternalChanges : undefined);
277
+ if (preserveExternalChanges != null)
278
+ options.preserveExternalChanges = Boolean(preserveExternalChanges);
279
+ const availableMemoryMb = resolvedNonNegativeInteger(ep.availableMemoryMb, "availableMemoryMb");
280
+ if (availableMemoryMb !== undefined) options.availableMemoryMb = availableMemoryMb;
281
+ const minInstances = resolvedNonNegativeInteger(ep.minInstances, "minInstances");
282
+ if (minInstances !== undefined) options.minInstances = minInstances;
283
+ const maxInstances = resolvedNonNegativeInteger(ep.maxInstances, "maxInstances");
284
+ if (maxInstances !== undefined) options.maxInstances = maxInstances;
285
+ if (ep.ingressSettings != null) options.ingressSettings = ep.ingressSettings;
286
+ if (ep.httpsTrigger?.invoker?.length) options.invoker = ep.httpsTrigger.invoker;
287
+ if (ep.serviceAccountEmail != null) options.serviceAccountEmail = ep.serviceAccountEmail;
288
+ if (ep.vpc?.connector != null) options.vpcConnector = ep.vpc.connector;
289
+ if (ep.vpc?.egressSettings != null) options.vpcEgressSettings = ep.vpc.egressSettings;
290
+ if (ep.labels && Object.keys(ep.labels).length > 0) options.labels = ep.labels;
291
+ if (ep.platform === "gcfv2") {
292
+ if (ep.cpu != null) options.cpu = String(ep.cpu);
293
+ if (Array.isArray(ep.vpc?.networkInterfaces))
294
+ options.networkInterfaces = ep.vpc.networkInterfaces;
295
+ }
296
+ if (Array.isArray(ep.secretEnvironmentVariables)) {
297
+ options.secrets = ep.secretEnvironmentVariables.map((secret) => secret.key).filter(Boolean);
298
+ }
299
+ return Object.keys(options).length > 0 ? options : undefined;
300
+ }
301
+
302
+ // Every export the runner cannot serve is reported, never dropped. `scope` says why, in the
303
+ // daemon's product-scope vocabulary: a product decision (`deferred`, `planned`, `notPlanned`)
304
+ // is fatal to discovery, while `unsupported` -- a shape neither the official emulator nor
305
+ // this runner recognises -- is reported and skipped, which is what the official emulator does
306
+ // (`functionsEmulator.js:488` logs `Unsupported trigger`, `:497` logs `Unsupported function
307
+ // type on <name>`, and both leave the definition in the inventory with `ignored: true`).
308
+ function ignored(base, triggerType, scope, reason) {
309
+ return { ...base, ignored: { triggerType, scope, reason } };
310
+ }
311
+
312
+ // A blocking trigger the runner does not serve: a product decision when the event belongs to
313
+ // a deferred or not-planned product (Firebase AI Logic), otherwise the official emulator's
314
+ // "not served" report for an identity event it has no hook for (email and SMS).
315
+ function ignoredBlocking(base, eventType) {
316
+ const product = deferredProduct(eventType);
317
+ if (product) return ignored(base, product.triggerType, product.scope, product.reason);
318
+ return ignored(base, "blocking", "unsupported", `blocking identity event ${eventType} is not served`);
319
+ }
320
+
321
+ // Products the official emulator has a trigger service for and fireemu does not serve.
322
+ // The event-type substring is matched the way `getServiceFromEventType` matches it.
323
+ const DEFERRED_TRIGGER_PRODUCTS = [
324
+ {
325
+ match: (type) =>
326
+ type.includes("firebase.database") || type.includes("google.firebase.database"),
327
+ triggerType: "database",
328
+ scope: "deferred",
329
+ reason: "deferred: the Realtime Database emulator is not in the active supported surface",
330
+ },
331
+ {
332
+ match: (type) => type.includes("remoteconfig"),
333
+ triggerType: "remoteConfig",
334
+ scope: "deferred",
335
+ reason: "deferred: Remote Config has no emulator in the active supported surface",
336
+ },
337
+ {
338
+ match: (type) => type.includes("dataconnect"),
339
+ triggerType: "dataconnect",
340
+ scope: "deferred",
341
+ reason: "deferred: the Data Connect emulator is not in the active supported surface",
342
+ },
343
+ {
344
+ match: (type) => type.includes("ailogic"),
345
+ triggerType: "ai",
346
+ scope: "notPlanned",
347
+ reason: "not planned: Firebase AI Logic blocking triggers have no local emulator",
348
+ },
349
+ {
350
+ match: (type) => type.includes("analytics"),
351
+ triggerType: "analytics",
352
+ scope: "notPlanned",
353
+ reason: "not planned: Google Analytics triggers have no local emulator",
354
+ },
355
+ {
356
+ match: (type) => type.includes("testing"),
357
+ triggerType: "testLab",
358
+ scope: "notPlanned",
359
+ reason: "not planned: Test Lab triggers have no local emulator",
360
+ },
361
+ ];
362
+
363
+ // The product scope of an event type fireemu does not serve, or null when the type is simply
364
+ // unrecognised.
365
+ function deferredProduct(type) {
366
+ return DEFERRED_TRIGGER_PRODUCTS.find((p) => p.match(type)) || null;
367
+ }
368
+
369
+ // firebase-functions v1 triggers: legacy event types and a resource pattern.
370
+ function describeV1Event(base, type, resource, schedule, retry) {
371
+ const v1 = { v1: true };
372
+ const fsMatch = type.match(
373
+ /^providers\/cloud\.firestore\/eventTypes\/document\.(create|update|delete|write)$/,
374
+ );
375
+ if (fsMatch) {
376
+ const docIndex = resource.indexOf("/documents/");
377
+ const dbMatch = resource.match(/\/databases\/([^/]+)\//);
378
+ return {
379
+ ...base,
380
+ ...v1,
381
+ retry,
382
+ trigger: {
383
+ type: "firestore",
384
+ 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,
387
+ },
388
+ };
389
+ }
390
+ const stMatch = type.match(/^google\.storage\.object\.(finalize|delete|metadataUpdate|archive)$/);
391
+ if (stMatch) {
392
+ const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
393
+ return {
394
+ ...base,
395
+ ...v1,
396
+ retry,
397
+ trigger: {
398
+ type: "storage",
399
+ eventType: `google.cloud.storage.object.v1.${{ finalize: "finalized", delete: "deleted", metadataUpdate: "metadataUpdated", archive: "archived" }[stMatch[1]]}`,
400
+ bucket: bucketMatch ? bucketMatch[1] : undefined,
401
+ },
402
+ };
403
+ }
404
+ 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
+ };
416
+ }
417
+ const authMatch = type.match(/^providers\/firebase\.auth\/eventTypes\/user\.(create|delete)$/);
418
+ if (authMatch) {
419
+ return {
420
+ ...base,
421
+ ...v1,
422
+ retry,
423
+ trigger: {
424
+ type: "auth",
425
+ eventType: `google.firebase.auth.user.v1.${{ create: "created", delete: "deleted" }[authMatch[1]]}`,
426
+ },
427
+ };
428
+ }
429
+ if (
430
+ type === "providers/cloud.pubsub/eventTypes/topic.publish" ||
431
+ type === "google.pubsub.topic.publish"
432
+ ) {
433
+ const topicMatch = resource.match(/\/topics\/([^/]+)$/);
434
+ return {
435
+ ...base,
436
+ ...v1,
437
+ retry,
438
+ trigger: { type: "pubsub", topic: topicMatch ? topicMatch[1] : resource },
439
+ };
440
+ }
441
+ const product = deferredProduct(type);
442
+ if (product) {
443
+ return ignored(base, product.triggerType, product.scope, product.reason);
444
+ }
445
+ return ignored(base, "unknown", "unsupported", `v1 event type ${type} is not recognised`);
446
+ }
447
+
448
+ function isV1(fn) {
449
+ return (
450
+ fn.__endpoint?.platform === "gcfv1" ||
451
+ (!(fn.__endpoint && Object.keys(fn.__endpoint).length > 0) && !!fn.__trigger)
452
+ );
453
+ }
454
+
455
+ // The callable App Check options of one function, as the loader instrumentation observed
456
+ // them. An unobserved callable is `undetermined`, never a guessed `false` (spec 13.4).
457
+ function callableAppCheck(instrumentation, fn) {
458
+ const observed = instrumentation.optionsOf(fn);
459
+ return {
460
+ enforceAppCheck: observed?.enforceAppCheck === true,
461
+ consumeAppCheckToken: observed ? observed.consumeAppCheckToken : "undetermined",
462
+ };
463
+ }
464
+
465
+ function blockingAuthTrigger(eventType, options) {
466
+ return {
467
+ type: "blockingAuth",
468
+ eventType,
469
+ accessToken: options?.accessToken === true,
470
+ idToken: options?.idToken === true,
471
+ refreshToken: options?.refreshToken === true,
472
+ };
473
+ }
474
+
475
+ function describe(name, fn, instrumentation) {
476
+ const callable = () => ({
477
+ type: "http",
478
+ callable: true,
479
+ ...callableAppCheck(instrumentation, fn),
480
+ });
481
+ const ep = fn.__endpoint;
482
+ const base = {
483
+ name,
484
+ entryPoint: name,
485
+ generation: ep?.platform === "gcfv2" ? 2 : 1,
486
+ };
487
+ if (ep?.omit === true) return { ...base, omitted: true };
488
+ if (ep && ep.platform === "gcfv1") {
489
+ const deployment = platformOptions(ep);
490
+ if (deployment) base.platformOptions = deployment;
491
+ if (ep.timeoutSeconds) base.timeoutSeconds = ep.timeoutSeconds;
492
+ const region = firstRegion(ep);
493
+ if (region) base.region = region;
494
+ if (ep.httpsTrigger) return { ...base, trigger: { type: "http", callable: false } };
495
+ if (ep.callableTrigger) return { ...base, trigger: callable() };
496
+ if (ep.blockingTrigger) {
497
+ const eventType = String(ep.blockingTrigger.eventType || "");
498
+ if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
499
+ return {
500
+ ...base,
501
+ trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
502
+ };
503
+ }
504
+ return ignoredBlocking(base, eventType,
505
+ );
506
+ }
507
+ const et = ep.eventTrigger || {};
508
+ return describeV1Event(
509
+ base,
510
+ String(et.eventType || ""),
511
+ String(et.eventFilters?.resource || ""),
512
+ ep.scheduleTrigger,
513
+ !!et.retry,
514
+ );
515
+ }
516
+ if (ep && Object.keys(ep).length > 0) {
517
+ const deployment = platformOptions(ep);
518
+ if (deployment) base.platformOptions = deployment;
519
+ const region = firstRegion(ep);
520
+ 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");
524
+ if (ep.httpsTrigger) return { ...base, trigger: { type: "http", callable: false } };
525
+ if (ep.callableTrigger) return { ...base, trigger: callable() };
526
+ 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
+ };
538
+ }
539
+ if (ep.blockingTrigger) {
540
+ const eventType = String(ep.blockingTrigger.eventType || "");
541
+ if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
542
+ return {
543
+ ...base,
544
+ trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
545
+ };
546
+ }
547
+ return ignoredBlocking(base, eventType,
548
+ );
549
+ }
550
+ if (ep.eventTrigger) {
551
+ const et = ep.eventTrigger;
552
+ const type = et.eventType || "";
553
+ base.retry = !!et.retry;
554
+ if (type.startsWith("google.cloud.firestore.")) {
555
+ const filters = et.eventFilters || {};
556
+ const patterns = et.eventFilterPathPatterns || {};
557
+ return {
558
+ ...base,
559
+ trigger: {
560
+ type: "firestore",
561
+ eventType: type,
562
+ database: filters.database || "(default)",
563
+ document: patterns.document || filters.document,
564
+ },
565
+ };
566
+ }
567
+ if (type.startsWith("google.cloud.storage.")) {
568
+ return {
569
+ ...base,
570
+ trigger: {
571
+ type: "storage",
572
+ eventType: type,
573
+ bucket: (et.eventFilters || {}).bucket || undefined,
574
+ },
575
+ };
576
+ }
577
+ 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\//, "") } };
580
+ }
581
+ if (et.channel) {
582
+ // `onCustomEventPublished`: the channel is `locations/<l>/channels/<c>` and every
583
+ // eventFilter beyond the type is matched against the published event's attributes.
584
+ return {
585
+ ...base,
586
+ trigger: {
587
+ type: "eventarc",
588
+ eventType: type,
589
+ channel: et.channel,
590
+ filters: et.eventFilters || {},
591
+ },
592
+ };
593
+ }
594
+ if (type.includes("firebasealerts")) {
595
+ // Every `onAlertPublished` family member registers an ordinary event trigger with no
596
+ // channel and `eventFilters: {alerttype, appid?}`. The official emulator hands it to
597
+ // its Eventarc emulator, which indexes it under `<eventType>-google` and delivers to
598
+ // it verbatim from `POST /google/publishEvents`. It is an Eventarc trigger on the
599
+ // sentinel channel, and modelling it as anything else would need a second mechanism
600
+ // for the same wire path.
601
+ return {
602
+ ...base,
603
+ trigger: {
604
+ type: "eventarc",
605
+ eventType: type,
606
+ channel: "google",
607
+ filters: et.eventFilters || {},
608
+ },
609
+ };
610
+ }
611
+ const product = deferredProduct(type);
612
+ if (product) return ignored(base, product.triggerType, product.scope, product.reason);
613
+ return ignored(base, "unknown", "unsupported", `event type ${type} is not recognised`);
614
+ }
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
+ return ignored(
631
+ base,
632
+ "unknown",
633
+ "unsupported",
634
+ "the endpoint declares no trigger this runner recognises",
635
+ );
636
+ }
637
+ const t = fn.__trigger;
638
+ if (t) {
639
+ if (t.timeout) base.timeoutSeconds = Number(String(t.timeout).replace(/s$/, "")) || undefined;
640
+ if (t.regions?.length) base.region = t.regions[0];
641
+ if (t.httpsTrigger) {
642
+ return {
643
+ ...base,
644
+ trigger: t.labels?.["deployment-callable"] ? callable() : { type: "http", callable: false },
645
+ };
646
+ }
647
+ if (t.blockingTrigger) {
648
+ const eventType = String(t.blockingTrigger.eventType || "");
649
+ if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
650
+ return {
651
+ ...base,
652
+ trigger: blockingAuthTrigger(eventType, t.blockingTrigger.options),
653
+ };
654
+ }
655
+ return ignoredBlocking(base, eventType,
656
+ );
657
+ }
658
+ const et = t.eventTrigger;
659
+ if (et)
660
+ return describeV1Event(
661
+ base,
662
+ String(et.eventType || ""),
663
+ String(et.resource || ""),
664
+ t.schedule,
665
+ !!et.failurePolicy || !!t.failurePolicy,
666
+ );
667
+ return ignored(
668
+ base,
669
+ "unknown",
670
+ "unsupported",
671
+ "the v1 trigger declares no shape this runner recognises",
672
+ );
673
+ }
674
+ // The official emulator's `Unsupported function type on <name>. Expected either an
675
+ // httpsTrigger, eventTrigger, or blockingTrigger.` (functionsEmulator.js:497).
676
+ return ignored(
677
+ base,
678
+ "unknown",
679
+ "unsupported",
680
+ "unsupported function type: expected either an httpsTrigger, eventTrigger, or blockingTrigger",
681
+ );
682
+ }
683
+
684
+ // v1 functions are called as (data, context) with the legacy event shapes.
685
+
686
+ function v1Context(msg) {
687
+ const event = msg.event;
688
+ switch (msg.trigger) {
689
+ case "firestore": {
690
+ const legacy = {
691
+ "google.cloud.firestore.document.v1.created":
692
+ "providers/cloud.firestore/eventTypes/document.create",
693
+ "google.cloud.firestore.document.v1.updated":
694
+ "providers/cloud.firestore/eventTypes/document.update",
695
+ "google.cloud.firestore.document.v1.deleted":
696
+ "providers/cloud.firestore/eventTypes/document.delete",
697
+ "google.cloud.firestore.document.v1.written":
698
+ "providers/cloud.firestore/eventTypes/document.write",
699
+ }[event.type];
700
+ return {
701
+ eventId: event.id,
702
+ timestamp: event.time,
703
+ eventType: legacy,
704
+ // A string: firebase-functions rewrites a legacy event type's resource into
705
+ // { service, name } itself (makeCloudFunction); an object here would be nested.
706
+ resource: event.source,
707
+ params: event.params || {},
708
+ };
709
+ }
710
+ case "storage": {
711
+ const o = event.data;
712
+ return {
713
+ eventId: event.id,
714
+ timestamp: event.time,
715
+ eventType: {
716
+ "google.cloud.storage.object.v1.finalized": "google.storage.object.finalize",
717
+ "google.cloud.storage.object.v1.deleted": "google.storage.object.delete",
718
+ "google.cloud.storage.object.v1.metadataUpdated": "google.storage.object.metadataUpdate",
719
+ "google.cloud.storage.object.v1.archived": "google.storage.object.archive",
720
+ }[event.type],
721
+ // The official emulator's legacy storage event resource: no generation suffix, and
722
+ // a `type` member (its createLegacyEventRequestBody).
723
+ resource: {
724
+ service: "storage.googleapis.com",
725
+ name: `projects/_/buckets/${o.bucket}/objects/${o.name}`,
726
+ type: "storage#object",
727
+ },
728
+ params: {},
729
+ };
730
+ }
731
+ case "schedule":
732
+ return {
733
+ eventId: event.id,
734
+ timestamp: event.time,
735
+ eventType: "google.pubsub.topic.publish",
736
+ resource: { service: "pubsub.googleapis.com", name: event.data.jobName },
737
+ params: {},
738
+ };
739
+ case "pubsub": {
740
+ const topic = String(event.source || "").replace(/^\/\/pubsub\.googleapis\.com\//, "");
741
+ return {
742
+ eventId: event.id,
743
+ timestamp: event.time,
744
+ eventType: "google.pubsub.topic.publish",
745
+ resource: { service: "pubsub.googleapis.com", name: topic },
746
+ params: {},
747
+ };
748
+ }
749
+ case "auth": {
750
+ // A string resource: the SDK rewrites a legacy event type's resource into
751
+ // { service, name } itself.
752
+ const project = String(event.source || "").replace(
753
+ /^\/\/firebaseauth\.googleapis\.com\//,
754
+ "",
755
+ );
756
+ return {
757
+ eventId: event.id,
758
+ timestamp: event.time,
759
+ eventType:
760
+ event.type === "google.firebase.auth.user.v1.deleted"
761
+ ? "providers/firebase.auth/eventTypes/user.delete"
762
+ : "providers/firebase.auth/eventTypes/user.create",
763
+ resource: project,
764
+ params: {},
765
+ };
766
+ }
767
+ default:
768
+ return {
769
+ eventId: event.id,
770
+ timestamp: event.time,
771
+ eventType: event.type,
772
+ resource: event.source,
773
+ params: {},
774
+ };
775
+ }
776
+ }
777
+
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
+ async function makeHttpServer(functions, manifest) {
788
+ const require = createRequire(join(sourceDir, "package.json"));
789
+ let express;
790
+ let expressRequire = require;
791
+ try {
792
+ express = require("express");
793
+ } catch {
794
+ // A Functions codebase is required to install firebase-functions, not Express directly.
795
+ // npm commonly hoists the SDK's dependency, while pnpm keeps it beside the SDK. Resolve
796
+ // from firebase-functions as a fallback so both valid layouts behave the same way.
797
+ try {
798
+ expressRequire = createRequire(require.resolve("firebase-functions"));
799
+ express = expressRequire("express");
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+ const HttpsErrors = await firebaseHttpsErrorConstructors(require);
805
+ const app = express();
806
+ app.use(
807
+ express.json({
808
+ limit: "32mb",
809
+ verify: (req, _res, buf) => {
810
+ req.rawBody = buf;
811
+ },
812
+ }),
813
+ );
814
+ app.use(
815
+ express.text({
816
+ limit: "32mb",
817
+ verify: (req, _res, buf) => {
818
+ req.rawBody = buf;
819
+ },
820
+ }),
821
+ );
822
+ app.use(
823
+ express.urlencoded({
824
+ extended: true,
825
+ limit: "32mb",
826
+ verify: (req, _res, buf) => {
827
+ req.rawBody = buf;
828
+ },
829
+ }),
830
+ );
831
+ app.use(
832
+ express.raw({
833
+ type: () => true,
834
+ limit: "32mb",
835
+ verify: (req, _res, buf) => {
836
+ req.rawBody = buf;
837
+ },
838
+ }),
839
+ );
840
+ const major = Number.parseInt(
841
+ String(expressRequire("express/package.json").version).split(".")[0],
842
+ 10,
843
+ );
844
+ // Express 5 (path-to-regexp 8) and Express 4 spell the optional rest differently.
845
+ const route = major >= 5 ? "/:project/:region/:name{/*rest}" : "/:project/:region/:name*";
846
+ const secret = process.env.FIREEMU_RUNNER_SECRET || "";
847
+ 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");
861
+ return;
862
+ }
863
+ // The secret is not part of the request the function sees.
864
+ delete req.headers["x-fireemu-runner-secret"];
865
+ const spec = manifest.functions.find(
866
+ (f) =>
867
+ f.name === req.params.name &&
868
+ (f.trigger?.type === "http" ||
869
+ f.trigger?.type === "tasks" ||
870
+ f.trigger?.type === "blockingAuth"),
871
+ );
872
+ const fn = spec && functions.get(spec.entryPoint);
873
+ const region = spec?.region || "us-central1";
874
+ if (!fn || (project && req.params.project !== project) || req.params.region !== region) {
875
+ res
876
+ .status(404)
877
+ .send(`no HTTP function ${req.params.project}/${req.params.region}/${req.params.name}`);
878
+ return;
879
+ }
880
+ // The function sees the path relative to its mount point: drop the three route segments.
881
+ const original = req.url;
882
+ const queryAt = original.indexOf("?");
883
+ const pathPart = queryAt >= 0 ? original.slice(0, queryAt) : original;
884
+ const query = queryAt >= 0 ? original.slice(queryAt) : "";
885
+ const rest = pathPart.split("/").slice(4).join("/");
886
+ 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);
911
+ });
912
+ }
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
+ });
918
+ });
919
+ const server = createServer(app);
920
+ return new Promise((resolveServer, reject) => {
921
+ server.on("error", reject);
922
+ server.listen(0, "127.0.0.1", () => resolveServer(server));
923
+ });
924
+ }
925
+
926
+ 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
+ const spec = manifest.functions.find((f) => f.name === msg.function);
930
+ await withFunctionEnvironment(
931
+ spec,
932
+ async () => {
933
+ if (isV1(fn)) {
934
+ const context = v1Context(msg);
935
+ let data = msg.event.data;
936
+ if (msg.trigger === "schedule") data = {};
937
+ // v1 `topic().onPublish(message, context)`: the message itself is the data.
938
+ if (msg.trigger === "pubsub") data = msg.event.data.message;
939
+ await fn(data, context);
940
+ return;
941
+ }
942
+ switch (msg.trigger) {
943
+ case "schedule": {
944
+ const run = fn.run || fn;
945
+ await run(msg.event.data);
946
+ return;
947
+ }
948
+ case "firestore":
949
+ case "storage":
950
+ case "pubsub":
951
+ // A custom event reaches the handler as the CloudEvent itself, exactly as the official
952
+ // Eventarc emulator POSTs it to the functions emulator.
953
+ case "eventarc":
954
+ await fn(msg.event);
955
+ return;
956
+ case "auth":
957
+ throw new Error("Auth user events are delivered to v1 auth.user() handlers only");
958
+ default:
959
+ throw new Error(`unsupported trigger ${msg.trigger}`);
960
+ }
961
+ },
962
+ msg.invocationId,
963
+ );
964
+ }
965
+
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
+
991
+ async function main() {
992
+ // Before any user code loads: the callable options are only observable as a callable is
993
+ // declared (spec 13.4).
994
+ const instrumentation = instrumentCallables(sourceDir);
995
+ let ns;
996
+ try {
997
+ ns = await loadCodebase();
998
+ } catch (e) {
999
+ log("error", `cannot load functions from ${sourceDir}: ${e?.stack || e}`);
1000
+ process.exit(1);
1001
+ }
1002
+ try {
1003
+ const require = createRequire(join(sourceDir, "package.json"));
1004
+ const cjsOptions = require("firebase-functions/v2/options").getGlobalOptions();
1005
+ const { root: sdkRoot, manifest: sdkPackage } = firebaseFunctionsPackage(require);
1006
+ const optionsExport = sdkPackage.exports?.["./v2/options"];
1007
+ const esmTarget = esmExportTarget(optionsExport);
1008
+ if (typeof esmTarget !== "string" || !esmTarget.startsWith("./")) {
1009
+ throw new Error("firebase-functions does not export ESM v2/options");
1010
+ }
1011
+ const esmOptions = await import(pathToFileURL(resolve(sdkRoot, esmTarget)).href);
1012
+ discoveredGlobalOptions = {
1013
+ ...cjsOptions,
1014
+ ...esmOptions.getGlobalOptions(),
1015
+ };
1016
+ } catch (e) {
1017
+ log("warn", `cannot inspect firebase-functions global options: ${e?.message || e}`);
1018
+ }
1019
+ const broken = new Map();
1020
+ const functions = collectFunctions(ns, "", new Map(), broken);
1021
+ const described = [...functions.entries()].map(([name, fn]) => {
1022
+ try {
1023
+ return describe(name, fn, instrumentation);
1024
+ } catch (e) {
1025
+ return ignored(
1026
+ { name, entryPoint: name },
1027
+ "unknown",
1028
+ "unsupported",
1029
+ `the export could not be described: ${e?.message || e}`,
1030
+ );
1031
+ }
1032
+ });
1033
+ for (const [name, reason] of broken) {
1034
+ described.push(
1035
+ ignored(
1036
+ { name, entryPoint: name },
1037
+ "unknown",
1038
+ "unsupported",
1039
+ `the export could not be described: ${reason}`,
1040
+ ),
1041
+ );
1042
+ }
1043
+ // Nothing is dropped: an export this runner cannot serve travels in the manifest's
1044
+ // `ignored` array with its region, its trigger type and its product scope, and the daemon
1045
+ // decides what to do with it.
1046
+ const manifest = {
1047
+ functions: described.filter((d) => !d.ignored && !d.omitted),
1048
+ ignored: described
1049
+ .filter((d) => d.ignored)
1050
+ .map((d) => ({
1051
+ name: d.name,
1052
+ region: d.region || "us-central1",
1053
+ triggerType: d.ignored.triggerType,
1054
+ scope: d.ignored.scope,
1055
+ reason: d.ignored.reason,
1056
+ })),
1057
+ };
1058
+ let httpPort;
1059
+ if (
1060
+ manifest.functions.some(
1061
+ (f) =>
1062
+ f.trigger.type === "http" ||
1063
+ f.trigger.type === "tasks" ||
1064
+ f.trigger.type === "blockingAuth",
1065
+ )
1066
+ ) {
1067
+ try {
1068
+ const server = await makeHttpServer(functions, manifest);
1069
+ if (server) httpPort = server.address().port;
1070
+ else
1071
+ log(
1072
+ "warn",
1073
+ "express is not installed in the functions codebase; HTTP functions are unavailable",
1074
+ );
1075
+ } catch (e) {
1076
+ log("error", `cannot start the HTTP server: ${e?.stack || e}`);
1077
+ }
1078
+ }
1079
+ activeInspectorPort = inspectorPort();
1080
+ send({
1081
+ type: "hello",
1082
+ runner: "node",
1083
+ codebase: args.codebase,
1084
+ version: process.version,
1085
+ inspectorPort: activeInspectorPort,
1086
+ httpPort,
1087
+ manifest,
1088
+ appCheck: {
1089
+ firebaseFunctionsVersion: instrumentation.version,
1090
+ instrumentation: instrumentation.supported ? "ok" : instrumentation.reason,
1091
+ debugFeatures: instrumentation.debugFeatures,
1092
+ debugMode: process.env.FIREBASE_DEBUG_MODE === "true",
1093
+ authHeaders: instrumentation.authHeaders,
1094
+ graphs: instrumentation.graphs,
1095
+ },
1096
+ });
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
+ );
1115
+ }
1116
+
1117
+ main();