@fireemu/linux-x64 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fireemu/linux-x64
2
2
 
3
3
  The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `linux-x64`
4
- (Rust target `x86_64-unknown-linux-musl`), version 0.10.0.
4
+ (Rust target `x86_64-unknown-linux-musl`), version 0.12.0.
5
5
 
6
6
  Statically linked against musl: any glibc or musl distribution, including distroless and Alpine.
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
- retry: (retryConfig.retryCount ?? 0) > 0,
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.source,
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: event.time,
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
- resource: { service: "pubsub.googleapis.com", name: topic },
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireemu/linux-x64",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "The fireemu daemon for linux-x64",
5
5
  "homepage": "https://github.com/t-k/fireemu#readme",
6
6
  "repository": {