@fireemu/darwin-arm64 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/fireemu +0 -0
- package/bin/runner-node/index.mjs +64 -4
- package/bin/runner-node/schedule-delivery.mjs +208 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fireemu/darwin-arm64
|
|
2
2
|
|
|
3
3
|
The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `darwin-arm64`
|
|
4
|
-
(Rust target `aarch64-apple-darwin`), version 0.
|
|
4
|
+
(Rust target `aarch64-apple-darwin`), version 0.12.0.
|
|
5
5
|
|
|
6
6
|
macOS 13 or newer on Apple silicon.
|
|
7
7
|
|
package/bin/fireemu
CHANGED
|
Binary file
|
|
@@ -22,6 +22,7 @@ import { blockingResult } from "./blocking-response.mjs";
|
|
|
22
22
|
import { boundLogMessage, createInvocationLogger } from "./log-context.mjs";
|
|
23
23
|
import { invocationFailure } from "./invocation-error.mjs";
|
|
24
24
|
import { InvocationBudget, readFrames } from "./protocol.mjs";
|
|
25
|
+
import { deliverSchedule, v1ScheduleContext } from "./schedule-delivery.mjs";
|
|
25
26
|
import { collectFunctions, exportNamespace } from "./discovery.mjs";
|
|
26
27
|
import { FrameWriter } from "./output.mjs";
|
|
27
28
|
import { installDiagnosticOutput } from "./diagnostic-output.mjs";
|
|
@@ -619,7 +620,14 @@ function describeSchedule(base, value) {
|
|
|
619
620
|
SCHEDULE_RETRY_FIELDS, base.generation === 1 ? V1_SCHEDULE_DURATIONS : undefined);
|
|
620
621
|
return {
|
|
621
622
|
...base,
|
|
622
|
-
|
|
623
|
+
// Production retries a Gen2 job (HTTP target) with a retry window and no count until the window ends (a job with
|
|
624
|
+
// `maxRetryDuration: 30s` and no `retryCount` was attempted four times in run 156715222b86ea44), so a window
|
|
625
|
+
// alone is a retry declaration there too. A Gen1 schedule's job targets Pub/Sub, so Cloud Scheduler's retry covers
|
|
626
|
+
// the publish and never the handler: `schedFailV1`'s handler threw at each occurrence, ran once per occurrence,
|
|
627
|
+
// and every attempt of its job finished without an error. A Gen1 handler is not retried at all.
|
|
628
|
+
retry:
|
|
629
|
+
base.generation !== 1 &&
|
|
630
|
+
((retryConfig.retryCount ?? 0) > 0 || (retryConfig.maxRetrySeconds ?? 0) > 0),
|
|
623
631
|
trigger: { type: "schedule", schedule, timeZone, retryConfig },
|
|
624
632
|
};
|
|
625
633
|
}
|
|
@@ -1063,6 +1071,33 @@ function millisecondTimestamp(time) {
|
|
|
1063
1071
|
return Number.isNaN(parsed.getTime()) ? time : parsed.toISOString();
|
|
1064
1072
|
}
|
|
1065
1073
|
|
|
1074
|
+
// The members of a 2nd gen Storage event's `data` in the order production hands them over (recorded,
|
|
1075
|
+
// FE v5 production run functions-events-formal-20261004T182904Z-a9621bfae74fe9bc: kind, id, selfLink,
|
|
1076
|
+
// name, bucket, generation, metageneration, contentType, timeCreated, updated, [timeDeleted],
|
|
1077
|
+
// storageClass, timeStorageClassUpdated, size, md5Hash, mediaLink, [metadata], crc32c, etag; the order
|
|
1078
|
+
// of all 44 recorded v2 frames is in tests/fixtures/production-storage-v5-v2-member-orders.json).
|
|
1079
|
+
// The runtime's JSON lists members by name; a member the recordings never showed follows the recorded
|
|
1080
|
+
// ones in name order.
|
|
1081
|
+
const STORAGE_OBJECT_MEMBERS = [
|
|
1082
|
+
"kind", "id", "selfLink", "name", "bucket", "generation", "metageneration", "contentType",
|
|
1083
|
+
"timeCreated", "updated", "timeDeleted", "storageClass", "timeStorageClassUpdated", "size", "md5Hash",
|
|
1084
|
+
"mediaLink", "metadata", "crc32c", "etag",
|
|
1085
|
+
];
|
|
1086
|
+
function storageObjectInRecordedOrder(data) {
|
|
1087
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return data;
|
|
1088
|
+
const ordered = {};
|
|
1089
|
+
for (const key of STORAGE_OBJECT_MEMBERS) {
|
|
1090
|
+
if (Object.hasOwn(data, key)) ordered[key] = data[key];
|
|
1091
|
+
}
|
|
1092
|
+
for (const key of Object.keys(data).sort()) {
|
|
1093
|
+
if (!Object.hasOwn(ordered, key)) ordered[key] = data[key];
|
|
1094
|
+
}
|
|
1095
|
+
return ordered;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Whether the daemon runs the strict profile (production's behaviour where the official emulator differs). */
|
|
1099
|
+
const strictProfile = () => process.env.FIREEMU_HTTP_PROFILE === "strict";
|
|
1100
|
+
|
|
1066
1101
|
function v1Context(msg) {
|
|
1067
1102
|
const event = msg.event;
|
|
1068
1103
|
switch (msg.trigger) {
|
|
@@ -1091,6 +1126,8 @@ function v1Context(msg) {
|
|
|
1091
1126
|
? `projects/${event.project}/databases/${event.database}/documents/${event.document}`
|
|
1092
1127
|
: event.source,
|
|
1093
1128
|
params: event.params || {},
|
|
1129
|
+
// Production's Gen1 Firestore context has an empty `notSupported` member (all 60 recorded frames).
|
|
1130
|
+
notSupported: {},
|
|
1094
1131
|
};
|
|
1095
1132
|
}
|
|
1096
1133
|
case "storage": {
|
|
@@ -1111,15 +1148,20 @@ function v1Context(msg) {
|
|
|
1111
1148
|
}[event.type],
|
|
1112
1149
|
// The official emulator's legacy storage event resource: no generation suffix, and
|
|
1113
1150
|
// a `type` member (its createLegacyEventRequestBody).
|
|
1151
|
+
// Members in the order production hands them over (recorded, FE v5, 2026-10-04: name,
|
|
1152
|
+
// service, type; the order is observable through Object.keys and JSON.stringify).
|
|
1114
1153
|
resource: {
|
|
1115
|
-
service: "storage.googleapis.com",
|
|
1116
1154
|
name: `projects/_/buckets/${o.bucket}/objects/${o.name}`,
|
|
1155
|
+
service: "storage.googleapis.com",
|
|
1117
1156
|
type: "storage#object",
|
|
1118
1157
|
},
|
|
1119
1158
|
params: {},
|
|
1120
1159
|
};
|
|
1121
1160
|
}
|
|
1122
1161
|
case "schedule":
|
|
1162
|
+
// The strict profile hands a Gen1 handler the context of the Pub/Sub message production's Scheduler
|
|
1163
|
+
// published (run 156715222b86ea44): see schedule-delivery.mjs.
|
|
1164
|
+
if (strictProfile()) return v1ScheduleContext(event);
|
|
1123
1165
|
return {
|
|
1124
1166
|
eventId: event.id,
|
|
1125
1167
|
timestamp: event.time,
|
|
@@ -1129,11 +1171,18 @@ function v1Context(msg) {
|
|
|
1129
1171
|
};
|
|
1130
1172
|
case "pubsub": {
|
|
1131
1173
|
const topic = String(event.source || "").replace(/^\/\/pubsub\.googleapis\.com\//, "");
|
|
1174
|
+
// Production (functions-events-formal run a9621bfae74fe9bc, handler pubsubPublishedV1): the
|
|
1175
|
+
// timestamp has exactly three fraction digits and the resource carries the message type.
|
|
1132
1176
|
return {
|
|
1133
1177
|
eventId: event.id,
|
|
1134
|
-
timestamp: event.time,
|
|
1178
|
+
timestamp: millisecondTimestamp(event.time),
|
|
1135
1179
|
eventType: "google.pubsub.topic.publish",
|
|
1136
|
-
|
|
1180
|
+
// Members in the order of the recorded frames (FE v5 and v7: name, service, type).
|
|
1181
|
+
resource: {
|
|
1182
|
+
name: topic,
|
|
1183
|
+
service: "pubsub.googleapis.com",
|
|
1184
|
+
type: "type.googleapis.com/google.pubsub.v1.PubsubMessage",
|
|
1185
|
+
},
|
|
1137
1186
|
params: {},
|
|
1138
1187
|
};
|
|
1139
1188
|
}
|
|
@@ -1153,6 +1202,8 @@ function v1Context(msg) {
|
|
|
1153
1202
|
: "providers/firebase.auth/eventTypes/user.create",
|
|
1154
1203
|
resource: project,
|
|
1155
1204
|
params: {},
|
|
1205
|
+
// As Firestore's: all 28 recorded Gen1 Auth frames carry an empty `notSupported` member.
|
|
1206
|
+
notSupported: {},
|
|
1156
1207
|
};
|
|
1157
1208
|
}
|
|
1158
1209
|
default:
|
|
@@ -1436,6 +1487,13 @@ async function invoke(functions, manifest, msg) {
|
|
|
1436
1487
|
}
|
|
1437
1488
|
switch (msg.trigger) {
|
|
1438
1489
|
case "schedule": {
|
|
1490
|
+
// The strict profile calls the function the way production's Scheduler does, through its HTTP wrapper,
|
|
1491
|
+
// so the SDK builds the event from the scheduler headers (job id, Los Angeles time, `context` getter)
|
|
1492
|
+
// and its own error handling runs. A bare handler (no separate wrapper) is called directly.
|
|
1493
|
+
if (strictProfile() && typeof fn.run === "function" && fn.run !== fn) {
|
|
1494
|
+
await deliverSchedule(fn, msg.event.data);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1439
1497
|
const run = fn.run || fn;
|
|
1440
1498
|
await run(msg.event.data);
|
|
1441
1499
|
return;
|
|
@@ -1444,6 +1502,8 @@ async function invoke(functions, manifest, msg) {
|
|
|
1444
1502
|
await fn(writtenFirestoreEvent(msg.event));
|
|
1445
1503
|
return;
|
|
1446
1504
|
case "storage":
|
|
1505
|
+
await fn({ ...msg.event, data: storageObjectInRecordedOrder(msg.event.data) });
|
|
1506
|
+
return;
|
|
1447
1507
|
case "pubsub":
|
|
1448
1508
|
// A custom event reaches the handler as the CloudEvent itself, exactly as the official
|
|
1449
1509
|
// Eventarc emulator POSTs it to the functions emulator.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// How the strict profile delivers a scheduled run the way production's Cloud Scheduler does, recorded in the second
|
|
2
|
+
// SCHEDULED-FUNCTIONS delivery recording (run 156715222b86ea44, 2026-10-05, 97 handler frames, project
|
|
3
|
+
// fireemu-oracle-sbx, us-central1):
|
|
4
|
+
//
|
|
5
|
+
// - a Gen2 `onSchedule` function is called as an HTTP function: `POST /` with `x-cloudscheduler: true`, the job's id in
|
|
6
|
+
// `x-cloudscheduler-jobname` (not its resource name) and the schedule time in `x-cloudscheduler-scheduletime`
|
|
7
|
+
// written in America/Los_Angeles with its offset (`2026-10-05T01:45:00-07:00`) whatever the job's own time zone
|
|
8
|
+
// (UTC and Asia/Tokyo jobs alike), `user-agent: Google-Cloud-Scheduler`, an empty body. The SDK builds the event
|
|
9
|
+
// from those headers, including the non-enumerable `context` getter, and answers 200, or 500 when the handler
|
|
10
|
+
// throws (after logging the error); the Scheduler judges the attempt by that status.
|
|
11
|
+
// - a Gen1 `pubsub.schedule` handler gets the context of a Pub/Sub message published to the job's topic: a
|
|
12
|
+
// 17-digit message id as `eventId`, the topic as `resource.name`, `resource.type` the PubsubMessage type, and the
|
|
13
|
+
// message's publish time (milliseconds at most, trailing zeros dropped) as `timestamp`.
|
|
14
|
+
//
|
|
15
|
+
// The Authorization (OIDC) header, trace headers, `forwarded` and `x-forwarded-for` of the recording are not
|
|
16
|
+
// reproduced: nothing here can sign a token for Google, and the others carry a trace id and a client address.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
const INSTANT = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?Z$/;
|
|
20
|
+
|
|
21
|
+
function parseInstant(value, what) {
|
|
22
|
+
const match = typeof value === "string" ? INSTANT.exec(value) : null;
|
|
23
|
+
if (!match) throw new Error(`the ${what} is not an RFC 3339 UTC instant: ${String(value)}`);
|
|
24
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
|
|
25
|
+
const millis = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
26
|
+
const back = new Date(millis);
|
|
27
|
+
if (
|
|
28
|
+
back.getUTCFullYear() !== year ||
|
|
29
|
+
back.getUTCMonth() !== month - 1 ||
|
|
30
|
+
back.getUTCDate() !== day ||
|
|
31
|
+
back.getUTCHours() !== hour ||
|
|
32
|
+
back.getUTCMinutes() !== minute ||
|
|
33
|
+
back.getUTCSeconds() !== second
|
|
34
|
+
)
|
|
35
|
+
throw new Error(`the ${what} is not an RFC 3339 UTC instant: ${value}`);
|
|
36
|
+
return { millis, fraction: (match[7] ?? "").replace(/0+$/, ""), digits: match[7] ?? "" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LOS_ANGELES = new Intl.DateTimeFormat("en-US", {
|
|
40
|
+
timeZone: "America/Los_Angeles",
|
|
41
|
+
hourCycle: "h23",
|
|
42
|
+
year: "numeric",
|
|
43
|
+
month: "2-digit",
|
|
44
|
+
day: "2-digit",
|
|
45
|
+
hour: "2-digit",
|
|
46
|
+
minute: "2-digit",
|
|
47
|
+
second: "2-digit",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** An RFC 3339 UTC instant written in America/Los_Angeles with its offset, the fraction kept without trailing zeros. */
|
|
51
|
+
export function schedulerTimestamp(instant) {
|
|
52
|
+
const { millis, fraction } = parseInstant(instant, "scheduled time");
|
|
53
|
+
const parts = Object.fromEntries(
|
|
54
|
+
LOS_ANGELES.formatToParts(new Date(millis)).map((part) => [part.type, part.value]),
|
|
55
|
+
);
|
|
56
|
+
const local = Date.UTC(
|
|
57
|
+
Number(parts.year),
|
|
58
|
+
Number(parts.month) - 1,
|
|
59
|
+
Number(parts.day),
|
|
60
|
+
Number(parts.hour),
|
|
61
|
+
Number(parts.minute),
|
|
62
|
+
Number(parts.second),
|
|
63
|
+
);
|
|
64
|
+
const offset = (local - millis) / 60_000;
|
|
65
|
+
const sign = offset < 0 ? "-" : "+";
|
|
66
|
+
const abs = Math.abs(offset);
|
|
67
|
+
const clock = `${parts.hour}:${parts.minute}:${parts.second}`;
|
|
68
|
+
const zone = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
69
|
+
return `${parts.year}-${parts.month}-${parts.day}T${clock}${fraction ? "." + fraction : ""}${zone}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The job id: the last segment of a job's resource name (an id passes through). */
|
|
73
|
+
export function schedulerJobId(jobName) {
|
|
74
|
+
if (typeof jobName !== "string" || jobName.length === 0)
|
|
75
|
+
throw new Error("the scheduled run names no job name");
|
|
76
|
+
return jobName.split("/").at(-1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const NAME = /^[A-Za-z0-9-]+$/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The public host of a function: `<region>-<project>.cloudfunctions.net`, from the project and location of a job's
|
|
83
|
+
* resource name (recorded: `us-central1-<project>.cloudfunctions.net` in all 76 distinct Gen2 frames of run 2 and the 3
|
|
84
|
+
* of run 1; the host is the host of the job's `httpTarget.uri`, which firebase-tools writes). Only us-central1 was
|
|
85
|
+
* recorded: for another region the host follows the same rule, unrecorded, and no handler can read it. A job id alone
|
|
86
|
+
* names neither, and then no host is invented.
|
|
87
|
+
*/
|
|
88
|
+
function functionHost(jobName) {
|
|
89
|
+
const match = /^projects\/([^/]*)\/locations\/([^/]*)\/jobs\//.exec(String(jobName));
|
|
90
|
+
return match && NAME.test(match[1]) && NAME.test(match[2])
|
|
91
|
+
? `${match[2]}-${match[1]}.cloudfunctions.net`
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The request production's Cloud Scheduler sends a Gen2 function, as an Express-like object. */
|
|
96
|
+
export function schedulerRequest({ jobName, scheduleTime }) {
|
|
97
|
+
const host = functionHost(jobName);
|
|
98
|
+
const headers = {
|
|
99
|
+
"x-cloudscheduler": "true",
|
|
100
|
+
"x-cloudscheduler-jobname": schedulerJobId(jobName),
|
|
101
|
+
"x-cloudscheduler-scheduletime": schedulerTimestamp(scheduleTime),
|
|
102
|
+
"user-agent": "Google-Cloud-Scheduler",
|
|
103
|
+
"content-length": "0",
|
|
104
|
+
// constant in every recorded frame: the front end's encodings and protocol
|
|
105
|
+
"accept-encoding": "gzip, deflate, br",
|
|
106
|
+
"x-forwarded-proto": "https",
|
|
107
|
+
...(host ? { host } : {}),
|
|
108
|
+
};
|
|
109
|
+
const header = (name) => headers[String(name).toLowerCase()];
|
|
110
|
+
return {
|
|
111
|
+
method: "POST",
|
|
112
|
+
url: "/",
|
|
113
|
+
originalUrl: "/",
|
|
114
|
+
headers,
|
|
115
|
+
header,
|
|
116
|
+
get: header,
|
|
117
|
+
body: undefined,
|
|
118
|
+
rawBody: undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function responseDouble() {
|
|
123
|
+
const res = {
|
|
124
|
+
statusCode: 200,
|
|
125
|
+
answered: false,
|
|
126
|
+
status(code) {
|
|
127
|
+
res.statusCode = code;
|
|
128
|
+
return res;
|
|
129
|
+
},
|
|
130
|
+
sendStatus(code) {
|
|
131
|
+
res.statusCode = code;
|
|
132
|
+
res.answered = true;
|
|
133
|
+
return res;
|
|
134
|
+
},
|
|
135
|
+
send() {
|
|
136
|
+
res.answered = true;
|
|
137
|
+
return res;
|
|
138
|
+
},
|
|
139
|
+
json() {
|
|
140
|
+
res.answered = true;
|
|
141
|
+
return res;
|
|
142
|
+
},
|
|
143
|
+
end() {
|
|
144
|
+
res.answered = true;
|
|
145
|
+
return res;
|
|
146
|
+
},
|
|
147
|
+
setHeader() {
|
|
148
|
+
return res;
|
|
149
|
+
},
|
|
150
|
+
set() {
|
|
151
|
+
return res;
|
|
152
|
+
},
|
|
153
|
+
header() {
|
|
154
|
+
return res;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
return res;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Calls a Gen2 scheduled function's HTTP wrapper with the scheduler's request. Resolves when the function answered
|
|
162
|
+
* with a status below 400; rejects when it answered 400 or more (the SDK's 500 for a handler that threw), threw, or
|
|
163
|
+
* never answered.
|
|
164
|
+
*/
|
|
165
|
+
export async function deliverSchedule(fn, data) {
|
|
166
|
+
const res = responseDouble();
|
|
167
|
+
await fn(schedulerRequest(data), res);
|
|
168
|
+
if (!res.answered) throw new Error("the scheduled function did not answer");
|
|
169
|
+
if (res.statusCode >= 400) throw new Error(`the scheduled function answered ${res.statusCode}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A Pub/Sub message id: 17 digits, the first a 2, derived from the event id (the same event, the same id). */
|
|
173
|
+
export function pubsubMessageId(eventId) {
|
|
174
|
+
if (typeof eventId !== "string" || eventId.length === 0)
|
|
175
|
+
throw new Error("a Pub/Sub message id needs an event id");
|
|
176
|
+
const digest = createHash("sha256").update(eventId).digest();
|
|
177
|
+
const number = digest.readBigUInt64BE(0) % 10_000_000_000_000_000n;
|
|
178
|
+
return "2" + number.toString().padStart(16, "0");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The id of the message the runtime published for this occurrence, when it published one and the id has the recorded form. */
|
|
182
|
+
function publishedMessageId(value) {
|
|
183
|
+
return typeof value === "string" && /^2\d{16}$/.test(value) ? value : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A publish time as Pub/Sub prints it to a Gen1 handler: milliseconds at most, trailing zeros dropped. */
|
|
187
|
+
function publishTime(instant) {
|
|
188
|
+
const { millis, digits } = parseInstant(instant, "publish time");
|
|
189
|
+
const fraction = digits.padEnd(3, "0").slice(0, 3).replace(/0+$/, "");
|
|
190
|
+
return new Date(millis).toISOString().slice(0, 19) + (fraction ? "." + fraction : "") + "Z";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The context of a Gen1 `pubsub.schedule` handler: the message published to the job's topic. */
|
|
194
|
+
export function v1ScheduleContext(event) {
|
|
195
|
+
const id = schedulerJobId(event?.data?.jobName);
|
|
196
|
+
const project = String(event.data.jobName).match(/^projects\/([^/]+)\//)?.[1];
|
|
197
|
+
return {
|
|
198
|
+
eventId: publishedMessageId(event.data.messageId) ?? pubsubMessageId(event.id),
|
|
199
|
+
eventType: "google.pubsub.topic.publish",
|
|
200
|
+
resource: {
|
|
201
|
+
name: project ? `projects/${project}/topics/${id}` : id,
|
|
202
|
+
service: "pubsub.googleapis.com",
|
|
203
|
+
type: "type.googleapis.com/google.pubsub.v1.PubsubMessage",
|
|
204
|
+
},
|
|
205
|
+
timestamp: publishTime(event.time),
|
|
206
|
+
params: {},
|
|
207
|
+
};
|
|
208
|
+
}
|