@fireemu/darwin-arm64 0.10.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 +132 -6
- 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
|
}
|
|
@@ -1056,6 +1064,40 @@ function describe(name, fn, instrumentation) {
|
|
|
1056
1064
|
|
|
1057
1065
|
// v1 functions are called as (data, context) with the legacy event shapes.
|
|
1058
1066
|
|
|
1067
|
+
// An RFC 3339 time with exactly three fractional digits (the extra digits are cut), or the input
|
|
1068
|
+
// unchanged when it is not a time.
|
|
1069
|
+
function millisecondTimestamp(time) {
|
|
1070
|
+
const parsed = new Date(time);
|
|
1071
|
+
return Number.isNaN(parsed.getTime()) ? time : parsed.toISOString();
|
|
1072
|
+
}
|
|
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
|
+
|
|
1059
1101
|
function v1Context(msg) {
|
|
1060
1102
|
const event = msg.event;
|
|
1061
1103
|
switch (msg.trigger) {
|
|
@@ -1078,15 +1120,26 @@ function v1Context(msg) {
|
|
|
1078
1120
|
eventType: legacy,
|
|
1079
1121
|
// A string: firebase-functions rewrites a legacy event type's resource into
|
|
1080
1122
|
// { service, name } itself (makeCloudFunction); an object here would be nested.
|
|
1081
|
-
resource: event.
|
|
1123
|
+
resource: [event.project, event.database, event.document].every(
|
|
1124
|
+
value => typeof value === "string" && value.length > 0,
|
|
1125
|
+
)
|
|
1126
|
+
? `projects/${event.project}/databases/${event.database}/documents/${event.document}`
|
|
1127
|
+
: event.source,
|
|
1082
1128
|
params: event.params || {},
|
|
1129
|
+
// Production's Gen1 Firestore context has an empty `notSupported` member (all 60 recorded frames).
|
|
1130
|
+
notSupported: {},
|
|
1083
1131
|
};
|
|
1084
1132
|
}
|
|
1085
1133
|
case "storage": {
|
|
1086
1134
|
const o = event.data;
|
|
1087
1135
|
return {
|
|
1088
1136
|
eventId: event.id,
|
|
1089
|
-
timestamp
|
|
1137
|
+
// Production hands a 1st gen Storage handler a timestamp with exactly three fractional
|
|
1138
|
+
// digits (observed 2026-10-01: `2026-10-01T08:49:26.577Z`, after the object's
|
|
1139
|
+
// `timeCreated` of `.486Z`). The runtime admits the event later than it creates the
|
|
1140
|
+
// object, so the timestamp is cut from the admission instant the frame carries; the
|
|
1141
|
+
// CloudEvent time of a finalize event is the creation instant.
|
|
1142
|
+
timestamp: millisecondTimestamp(msg.admittedAt ?? event.time),
|
|
1090
1143
|
eventType: {
|
|
1091
1144
|
"google.cloud.storage.object.v1.finalized": "google.storage.object.finalize",
|
|
1092
1145
|
"google.cloud.storage.object.v1.deleted": "google.storage.object.delete",
|
|
@@ -1095,15 +1148,20 @@ function v1Context(msg) {
|
|
|
1095
1148
|
}[event.type],
|
|
1096
1149
|
// The official emulator's legacy storage event resource: no generation suffix, and
|
|
1097
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).
|
|
1098
1153
|
resource: {
|
|
1099
|
-
service: "storage.googleapis.com",
|
|
1100
1154
|
name: `projects/_/buckets/${o.bucket}/objects/${o.name}`,
|
|
1155
|
+
service: "storage.googleapis.com",
|
|
1101
1156
|
type: "storage#object",
|
|
1102
1157
|
},
|
|
1103
1158
|
params: {},
|
|
1104
1159
|
};
|
|
1105
1160
|
}
|
|
1106
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);
|
|
1107
1165
|
return {
|
|
1108
1166
|
eventId: event.id,
|
|
1109
1167
|
timestamp: event.time,
|
|
@@ -1113,11 +1171,18 @@ function v1Context(msg) {
|
|
|
1113
1171
|
};
|
|
1114
1172
|
case "pubsub": {
|
|
1115
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.
|
|
1116
1176
|
return {
|
|
1117
1177
|
eventId: event.id,
|
|
1118
|
-
timestamp: event.time,
|
|
1178
|
+
timestamp: millisecondTimestamp(event.time),
|
|
1119
1179
|
eventType: "google.pubsub.topic.publish",
|
|
1120
|
-
|
|
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
|
+
},
|
|
1121
1186
|
params: {},
|
|
1122
1187
|
};
|
|
1123
1188
|
}
|
|
@@ -1137,6 +1202,8 @@ function v1Context(msg) {
|
|
|
1137
1202
|
: "providers/firebase.auth/eventTypes/user.create",
|
|
1138
1203
|
resource: project,
|
|
1139
1204
|
params: {},
|
|
1205
|
+
// As Firestore's: all 28 recorded Gen1 Auth frames carry an empty `notSupported` member.
|
|
1206
|
+
notSupported: {},
|
|
1140
1207
|
};
|
|
1141
1208
|
}
|
|
1142
1209
|
default:
|
|
@@ -1346,6 +1413,54 @@ async function makeHttpServer(functions, manifest) {
|
|
|
1346
1413
|
});
|
|
1347
1414
|
}
|
|
1348
1415
|
|
|
1416
|
+
let writtenFirestoreCodec;
|
|
1417
|
+
|
|
1418
|
+
function firestoreProtoTimestamp(value) {
|
|
1419
|
+
if (typeof value !== "string") return value;
|
|
1420
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
1421
|
+
if (!match) throw new Error("invalid Firestore protobuf timestamp");
|
|
1422
|
+
const milliseconds = Date.parse(`${match[1]}${match[3]}`);
|
|
1423
|
+
if (!Number.isFinite(milliseconds)) throw new Error("invalid Firestore protobuf timestamp");
|
|
1424
|
+
return { seconds: Math.floor(milliseconds / 1000), nanos: Number((match[2] ?? "").padEnd(9, "0")) };
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function firestoreProtoFields(fields) {
|
|
1428
|
+
return Object.fromEntries(Object.entries(fields ?? {}).map(([key, value]) => {
|
|
1429
|
+
const converted = { ...value };
|
|
1430
|
+
if (value.timestampValue !== undefined) converted.timestampValue = firestoreProtoTimestamp(value.timestampValue);
|
|
1431
|
+
if (value.mapValue !== undefined) converted.mapValue = { fields: firestoreProtoFields(value.mapValue.fields) };
|
|
1432
|
+
if (value.arrayValue !== undefined) {
|
|
1433
|
+
converted.arrayValue = { values: (value.arrayValue.values ?? []).map(item => firestoreProtoFields({ item }).item) };
|
|
1434
|
+
}
|
|
1435
|
+
return [key, converted];
|
|
1436
|
+
}));
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function writtenFirestoreEvent(event) {
|
|
1440
|
+
if (!["google.cloud.firestore.document.v1.written", "google.cloud.firestore.document.v1.written.withAuthContext"].includes(event.type) ||
|
|
1441
|
+
!event.datacontenttype?.includes("application/json")) return event;
|
|
1442
|
+
// The SDK's protobuf decoder derives missing snapshot paths from typed metadata,
|
|
1443
|
+
// independently of the canonical database source. Reuse the codebase's shipped codec.
|
|
1444
|
+
if (!writtenFirestoreCodec) {
|
|
1445
|
+
const require = createRequire(join(sourceDir, "package.json"));
|
|
1446
|
+
const { root } = firebaseFunctionsPackage(require);
|
|
1447
|
+
writtenFirestoreCodec = require(join(root, "protos/compiledFirestore.js")).google.events.cloud.firestore.v1.DocumentEventData;
|
|
1448
|
+
}
|
|
1449
|
+
const data = { ...event.data };
|
|
1450
|
+
for (const side of ["value", "oldValue"]) {
|
|
1451
|
+
if (data[side] === undefined || data[side] === null) continue;
|
|
1452
|
+
const document = { ...data[side], fields: firestoreProtoFields(data[side].fields) };
|
|
1453
|
+
for (const time of ["createTime", "updateTime"]) {
|
|
1454
|
+
if (document[time] !== undefined) document[time] = firestoreProtoTimestamp(document[time]);
|
|
1455
|
+
}
|
|
1456
|
+
data[side] = document;
|
|
1457
|
+
}
|
|
1458
|
+
const message = writtenFirestoreCodec.fromObject(data);
|
|
1459
|
+
const invalid = writtenFirestoreCodec.verify(message);
|
|
1460
|
+
if (invalid) throw new Error(`invalid Firestore protobuf event: ${invalid}`);
|
|
1461
|
+
return { ...event, datacontenttype: "application/protobuf", data: Buffer.from(writtenFirestoreCodec.encode(message).finish()) };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1349
1464
|
async function invoke(functions, manifest, msg) {
|
|
1350
1465
|
const spec = manifest.functions.find((f) => f.name === msg.function);
|
|
1351
1466
|
if (!spec) throw new Error("unknown function");
|
|
@@ -1372,12 +1487,23 @@ async function invoke(functions, manifest, msg) {
|
|
|
1372
1487
|
}
|
|
1373
1488
|
switch (msg.trigger) {
|
|
1374
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
|
+
}
|
|
1375
1497
|
const run = fn.run || fn;
|
|
1376
1498
|
await run(msg.event.data);
|
|
1377
1499
|
return;
|
|
1378
1500
|
}
|
|
1379
1501
|
case "firestore":
|
|
1502
|
+
await fn(writtenFirestoreEvent(msg.event));
|
|
1503
|
+
return;
|
|
1380
1504
|
case "storage":
|
|
1505
|
+
await fn({ ...msg.event, data: storageObjectInRecordedOrder(msg.event.data) });
|
|
1506
|
+
return;
|
|
1381
1507
|
case "pubsub":
|
|
1382
1508
|
// A custom event reaches the handler as the CloudEvent itself, exactly as the official
|
|
1383
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
|
+
}
|