@fireemu/darwin-arm64 0.43.0 → 0.45.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/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.43.0.
4
+ (Rust target `aarch64-apple-darwin`), version 0.45.0.
5
5
 
6
6
  macOS 13 or newer on Apple silicon.
7
7
 
package/bin/fireemu CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/fireemu
3
- size: 36876128 bytes
4
- sha256: a0e1688bf4e915c0be5d973019e3e643cb15a3f869e596cdf63937aeae506e7b
3
+ size: 37455200 bytes
4
+ sha256: 366bfa1748b43f36a77959b1571e4ba5fa5c86c908b1cd78b72becd39bfd7c75
@@ -724,14 +724,14 @@ function ignoredBlocking(base, eventType) {
724
724
  return ignored(base, "blocking", "unsupported", `blocking identity event ${eventType} is not served`);
725
725
  }
726
726
 
727
- // Products the official emulator has a trigger service for and fireemu does not serve.
727
+ // Trigger families outside the served surface; exact supported types are handled first.
728
728
  // The event-type substring is matched the way `getServiceFromEventType` matches it.
729
729
  const DEFERRED_TRIGGER_PRODUCTS = [
730
730
  {
731
731
  match: (type) => type.includes("remoteconfig"),
732
732
  triggerType: "remoteConfig",
733
733
  scope: "deferred",
734
- reason: "deferred: Remote Config has no emulator in the active supported surface",
734
+ reason: "deferred: only Remote Config update triggers are served by the local injection action",
735
735
  },
736
736
  {
737
737
  match: (type) => type.includes("dataconnect"),
@@ -768,6 +768,12 @@ function deferredProduct(type) {
768
768
  // firebase-functions v1 triggers: legacy event types and a resource pattern.
769
769
  function describeV1Event(base, type, resource, schedule, retry) {
770
770
  const v1 = { v1: true };
771
+ if (type === "google.firebase.remoteconfig.update") {
772
+ if (resource !== `projects/${process.env.GCLOUD_PROJECT}`) {
773
+ return ignored(base, "remoteConfig", "unsupported", "Remote Config resource must name the runtime project");
774
+ }
775
+ return { ...base, ...v1, retry, trigger: { type: "remoteConfig" } };
776
+ }
771
777
  const dbMatch = type.match(/^providers\/google\.firebase\.database\/eventTypes\/ref\.(create|update|delete|write)$/);
772
778
  if (dbMatch) {
773
779
  resource = triggerString(resource, "eventTrigger.resource", { optional: true, allowEmpty: true }) ?? "";
@@ -1020,6 +1026,9 @@ function describe(name, fn, instrumentation) {
1020
1026
  },
1021
1027
  };
1022
1028
  }
1029
+ if (type === "google.firebase.remoteconfig.remoteConfig.v1.updated" && et.channel === undefined) {
1030
+ return { ...base, trigger: { type: "remoteConfig" } };
1031
+ }
1023
1032
  if (type === "google.cloud.pubsub.topic.v1.messagePublished") {
1024
1033
  const topic = triggerString(triggerRecord(et.eventFilters, "eventFilters").topic, "eventFilters.topic");
1025
1034
  const projectedTopic = topic.replace(/^.*\/topics\//, "");
@@ -1155,6 +1164,8 @@ const strictProfile = () => process.env.FIREEMU_HTTP_PROFILE === "strict";
1155
1164
  function v1Context(msg) {
1156
1165
  const event = msg.event;
1157
1166
  switch (msg.trigger) {
1167
+ case "remoteConfig":
1168
+ return event.context;
1158
1169
  case "database": {
1159
1170
  const operation = {created:"create",updated:"update",deleted:"delete",written:"write"}[event.type.split(".").at(-1)];
1160
1171
  const context = {
@@ -1497,6 +1508,8 @@ function firestoreProtoTimestamp(value) {
1497
1508
  function firestoreProtoFields(fields) {
1498
1509
  return Object.fromEntries(Object.entries(fields ?? {}).map(([key, value]) => {
1499
1510
  const converted = { ...value };
1511
+ // protobufjs skips null members; the NullValue enum must be its numeric zero.
1512
+ if (value.nullValue === null) converted.nullValue = 0;
1500
1513
  if (value.timestampValue !== undefined) converted.timestampValue = firestoreProtoTimestamp(value.timestampValue);
1501
1514
  if (value.mapValue !== undefined) converted.mapValue = { fields: firestoreProtoFields(value.mapValue.fields) };
1502
1515
  if (value.arrayValue !== undefined) {
@@ -1507,6 +1520,14 @@ function firestoreProtoFields(fields) {
1507
1520
  }
1508
1521
 
1509
1522
  function writtenFirestoreEvent(event) {
1523
+ if (event.datacontenttype === "application/protobuf") {
1524
+ // A single character repetition avoids stack growth; require the absolute end, including no trailing newline.
1525
+ if (typeof event.data !== "string" || event.data.length % 4 !== 0 ||
1526
+ !/^[A-Za-z0-9+/]*={0,2}(?![\s\S])/.test(event.data)) {
1527
+ throw new Error("Firestore protobuf IPC data must be base64 text");
1528
+ }
1529
+ return { ...event, data: Buffer.from(event.data, "base64") };
1530
+ }
1510
1531
  if (!["google.cloud.firestore.document.v1.written", "google.cloud.firestore.document.v1.written.withAuthContext"].includes(event.type) ||
1511
1532
  !event.datacontenttype?.includes("application/json")) return event;
1512
1533
  // The SDK's protobuf decoder derives missing snapshot paths from typed metadata,
@@ -1538,7 +1559,7 @@ async function invoke(functions, manifest, msg) {
1538
1559
  throw new Error("entry point does not match the function manifest");
1539
1560
  }
1540
1561
  if (msg.trigger !== spec.trigger?.type ||
1541
- !["schedule", "database", "firestore", "storage", "pubsub", "eventarc", "auth"].includes(msg.trigger)) {
1562
+ !["schedule", "database", "firestore", "storage", "pubsub", "eventarc", "auth", "remoteConfig"].includes(msg.trigger)) {
1542
1563
  throw new Error("trigger does not match the function manifest");
1543
1564
  }
1544
1565
  const fn = functions.get(spec.entryPoint);
@@ -1581,6 +1602,7 @@ async function invoke(functions, manifest, msg) {
1581
1602
  case "storage":
1582
1603
  await fn({ ...msg.event, data: storageObjectInRecordedOrder(msg.event.data) });
1583
1604
  return;
1605
+ case "remoteConfig":
1584
1606
  case "pubsub":
1585
1607
  await fn(msg.event);
1586
1608
  return;
@@ -0,0 +1,255 @@
1
+ """Callable App Check: the Admin SDK verifies the token against the App Check emulator's keys.
2
+
3
+ SECURITY-SENSITIVE. A Python callable checks the `X-Firebase-AppCheck` header itself, in the SDK's wrapper, with
4
+ `firebase_admin.app_check.verify_token` (`firebase_functions/private/util.py`), which fetches Google's JWKS. The
5
+ Python SDK has no switch to skip that check (Node's `skipTokenVerification`), and the daemon's callable trust
6
+ boundary does not apply to it, so the runner makes the SDK's own check meaningful locally:
7
+
8
+ - when the daemon names the App Check emulator's JWKS (`FIREEMU_APP_CHECK_JWKS_URL`, a loopback address), the
9
+ Admin SDK's App Check service is replaced, before any user code runs, by one that fetches keys from it alone, so
10
+ only a token the emulator's key signed verifies, and the runner tells the daemon the callables do not consume tokens (`consumeAppCheckToken:
11
+ disabled`, true: the SDK verifies with `consume=False`), which lets the daemon forward the header;
12
+ - otherwise nothing is changed and the runner says nothing, so the daemon keeps removing the header before the
13
+ function and the SDK never fetches a key from Google.
14
+
15
+ Anything but a loopback `http` URL is refused, so a stray value cannot make the runner fetch keys from elsewhere.
16
+
17
+ The runner fetches the keys itself (`LoopbackKeyClient`) instead of leaving it to PyJWT's `PyJWKClient`, whose
18
+ redirect and proxy behaviour depends on the PyJWT version (2.13.0, which the SDK's constraints allow, follows
19
+ redirects). The client connects to a loopback address it resolved and checked, ignores proxy variables, treats every
20
+ redirect as a failure, and bounds time and size. What the Python SDK checks of a token is the stock SDK's, plus what
21
+ the emulator always does and the stock SDK leaves optional (`exp`, the exact issuer).
22
+
23
+ Differences from Node, which the Functions page states: the Python SDK verifies against the keys alone, so it has no
24
+ check of the daemon's current project epoch or app state (the Node callable trust boundary has one), and it is not
25
+ instrumented, so it has no start-up self-test. The PyJWT floor below is the lowest version the tests ran against.
26
+ """
27
+
28
+ import http.client
29
+ import ipaddress
30
+ import json
31
+ import re
32
+ import socket
33
+ import threading
34
+ import time
35
+ from urllib.parse import urlsplit
36
+
37
+ URL_VARIABLE = "FIREEMU_APP_CHECK_JWKS_URL"
38
+ _LOOPBACK_NAMES = frozenset(("localhost",))
39
+
40
+
41
+ def jwks_url(environ) -> str | None:
42
+ """The App Check emulator's JWKS URL from the daemon's environment, or None when it is absent or not loopback."""
43
+ value = environ.get(URL_VARIABLE)
44
+ if not value or not isinstance(value, str):
45
+ return None
46
+ try:
47
+ parts = urlsplit(value)
48
+ port = parts.port # raises for a port out of range
49
+ host = parts.hostname
50
+ except ValueError:
51
+ return None
52
+ if parts.scheme != "http" or host is None or parts.username is not None or parts.password is not None:
53
+ return None
54
+ if parts.fragment or not parts.path.startswith("/") or len(parts.path) < 2 or port is None:
55
+ return None
56
+ if host not in _LOOPBACK_NAMES:
57
+ try:
58
+ if not ipaddress.ip_address(host).is_loopback:
59
+ return None
60
+ except ValueError:
61
+ return None
62
+ return value
63
+
64
+
65
+ MIN_PYJWT = (2, 10, 1)
66
+ _REQUIRED_ATTRIBUTES = ("_JWKS_URL", "_APP_CHECK_HEADERS", "_decode_and_verify", "_has_valid_token_headers", "verify_token")
67
+ _ISSUER = re.compile(r"https://firebaseappcheck\.googleapis\.com/([0-9]+)")
68
+
69
+
70
+ def _pyjwt_supported() -> bool:
71
+ import jwt
72
+
73
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)", str(getattr(jwt, "__version__", "")))
74
+ return match is not None and tuple(int(part) for part in match.groups()) >= MIN_PYJWT
75
+
76
+
77
+ def _loopback_addresses(host: str, port: int) -> list[str]:
78
+ """The loopback addresses `host` names. Every address a name resolves to must be loopback, or none is used."""
79
+ literal = host.strip("[]")
80
+ try:
81
+ addresses = [ipaddress.ip_address(literal)]
82
+ except ValueError:
83
+ try:
84
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
85
+ except OSError as error:
86
+ raise OSError(f"cannot resolve {host}") from error
87
+ addresses = [ipaddress.ip_address(info[4][0].split("%")[0]) for info in infos]
88
+ if not addresses or not all(address.is_loopback for address in addresses):
89
+ raise OSError(f"{host} is not a loopback address")
90
+ return [str(address) for address in addresses]
91
+
92
+
93
+ class LoopbackKeyClient:
94
+ """Fetches and caches the App Check emulator's signing keys from one loopback URL, and nowhere else.
95
+
96
+ Same contract as `jwt.PyJWKClient.get_signing_key_from_jwt` (the object it returns has a `.key`), without any of
97
+ its transport: no redirect is followed (a 3xx is a failure), no proxy variable is read, the connection goes to a
98
+ loopback address the client resolved and checked itself, and time and size are bounded. A failure means no key,
99
+ so the token does not verify.
100
+ """
101
+
102
+ def __init__(self, url, *, timeout=5.0, max_bytes=256 * 1024, lifespan=21600, refresh_interval=1.0, headers=None):
103
+ self._url = url
104
+ self._timeout = timeout
105
+ self._max_bytes = max_bytes
106
+ self._lifespan = lifespan
107
+ self._refresh_interval = refresh_interval
108
+ self._headers = dict(headers or {})
109
+ self._lock = threading.Lock()
110
+ self._keys = {}
111
+ self._fetched_at = None
112
+ self._attempted_at = None
113
+
114
+ def _connect(self, addresses, port):
115
+ """A connection to the first of the checked loopback addresses that accepts one (`localhost` may list `::1`)."""
116
+ last = OSError("no address")
117
+ for address in addresses:
118
+ try:
119
+ return socket.create_connection((address, port), timeout=self._timeout)
120
+ except OSError as error:
121
+ last = error
122
+ raise last
123
+
124
+ def _fetch(self):
125
+ parts = urlsplit(self._url)
126
+ host, port = parts.hostname, parts.port
127
+ if parts.scheme != "http" or host is None or port is None:
128
+ raise OSError("the key URL is not a loopback http URL")
129
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
130
+ addresses = _loopback_addresses(host, port)
131
+ connection = http.client.HTTPConnection(host, port, timeout=self._timeout)
132
+ try:
133
+ connection.sock = self._connect(addresses, port)
134
+ connection.request("GET", path, headers={"Accept": "application/json", "Connection": "close", **self._headers})
135
+ response = connection.getresponse()
136
+ if response.status != 200:
137
+ raise OSError(f"the key server answered {response.status}")
138
+ body = response.read(self._max_bytes + 1)
139
+ if len(body) > self._max_bytes:
140
+ raise OSError("the key set is larger than the bound")
141
+ finally:
142
+ connection.close()
143
+ document = json.loads(body)
144
+ listed = document.get("keys") if isinstance(document, dict) else None
145
+ if not isinstance(listed, list):
146
+ raise ValueError("the key set has no keys list")
147
+ import jwt
148
+
149
+ keys = {}
150
+ for entry in listed:
151
+ if isinstance(entry, dict) and entry.get("kty") == "RSA" and isinstance(entry.get("kid"), str):
152
+ try:
153
+ keys[entry["kid"]] = jwt.PyJWK(entry)
154
+ except Exception: # a key that cannot be built is no key
155
+ continue
156
+ return keys
157
+
158
+ def get_signing_key_from_jwt(self, token):
159
+ import jwt
160
+ from jwt.exceptions import PyJWKClientError
161
+
162
+ kid = jwt.get_unverified_header(token).get("kid")
163
+ if not isinstance(kid, str) or not kid:
164
+ raise PyJWKClientError("the token has no key id")
165
+ with self._lock:
166
+ now = time.monotonic()
167
+ stale = self._fetched_at is None or now - self._fetched_at > self._lifespan
168
+ if kid in self._keys and not stale:
169
+ return self._keys[kid]
170
+ if self._attempted_at is None or now - self._attempted_at >= self._refresh_interval:
171
+ self._attempted_at = now
172
+ try:
173
+ self._keys = self._fetch()
174
+ self._fetched_at = now
175
+ except Exception as error:
176
+ self._keys, self._fetched_at = {}, None
177
+ raise PyJWKClientError("the App Check keys could not be fetched") from error
178
+ if kid not in self._keys:
179
+ raise PyJWKClientError(f"unable to find a signing key that matches {kid!r}")
180
+ return self._keys[kid]
181
+
182
+
183
+ def _emulator_service(stock, url: str):
184
+ """The Admin SDK's App Check service, keyed to `url` and without credentials or a way to reach Google.
185
+
186
+ The stock service builds an authorised HTTP client in its constructor (`app.credential.get_credential()`), which
187
+ only limited-use consumption uses; a runner has no Google credential, so the stock service cannot even verify.
188
+ This one is the same verifier (`verify_token`, the header and signature checks are the SDK's own methods) with
189
+ the keys fetched by `LoopbackKeyClient`, consumption refused, and two claim checks the emulator always satisfies.
190
+ The audience rule stays the stock SDK's (`projects/<projectId>`).
191
+ """
192
+ from firebase_admin import exceptions
193
+
194
+ class EmulatorAppCheckService(stock):
195
+ _JWKS_URL = url
196
+
197
+ def __init__(self, app):
198
+ project_id = app.project_id
199
+ if not project_id:
200
+ raise ValueError(
201
+ "A project ID must be specified to access the App Check service. Either set the projectId "
202
+ "option, use service account credentials, or set the GOOGLE_CLOUD_PROJECT environment variable."
203
+ )
204
+ self._project_id = project_id
205
+ self._scoped_project_id = "projects/" + project_id
206
+ self._jwks_client = LoopbackKeyClient(self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS)
207
+ self._http_client = None
208
+
209
+ def _decode_and_verify(self, token, signing_key):
210
+ payload = super()._decode_and_verify(token, signing_key)
211
+ exp = payload.get("exp")
212
+ if isinstance(exp, bool) or not isinstance(exp, (int, float)):
213
+ raise ValueError('The provided App Check token has no "exp" (expiration) claim.')
214
+ issuer = payload.get("iss")
215
+ match = _ISSUER.fullmatch(issuer) if isinstance(issuer, str) else None
216
+ if match is None:
217
+ raise ValueError('The provided App Check token has an incorrect "iss" (issuer) claim.')
218
+ audience = payload.get("aud")
219
+ audiences = [audience] if isinstance(audience, str) else audience if isinstance(audience, list) else []
220
+ numbers = [item[len("projects/"):] for item in audiences if isinstance(item, str) and item.startswith("projects/") and item[len("projects/"):].isdigit()]
221
+ if any(number != match.group(1) for number in numbers):
222
+ raise ValueError('The provided App Check token has a project number in "aud" that is not its issuer\'s.')
223
+ subject = payload.get("sub")
224
+ if not isinstance(subject, str) or not subject:
225
+ # The stock SDK refuses a missing one and lets an empty one through.
226
+ raise ValueError('The provided App Check token has no "sub" (app ID) claim.')
227
+ return payload
228
+
229
+ def _verify_replay_protection(self, token):
230
+ raise exceptions.FailedPreconditionError(
231
+ "limited-use App Check token consumption is not available in fireemu (APP_CHECK_REPLAY_UNSUPPORTED)"
232
+ )
233
+
234
+ return EmulatorAppCheckService
235
+
236
+
237
+ def install_jwks_hook(url: str) -> bool:
238
+ """Replaces the Admin SDK's App Check service with one keyed to `url`. False when this SDK is not the one that
239
+ replacement was written for, or PyJWT is older than the tested floor (the runner then announces nothing, and the
240
+ daemon keeps removing the header)."""
241
+ from firebase_admin import app_check
242
+
243
+ stock = getattr(app_check, "_AppCheckService", None)
244
+ if stock is None or not isinstance(getattr(stock, "_JWKS_URL", None), str):
245
+ return False
246
+ if not all(hasattr(stock, name) for name in _REQUIRED_ATTRIBUTES) or not _pyjwt_supported():
247
+ return False
248
+ app_check._AppCheckService = _emulator_service(stock, url)
249
+ return True
250
+
251
+
252
+ def install_from_environment(environ) -> bool:
253
+ """Installs the hook when the daemon named a loopback JWKS URL; True when the SDK now verifies against it."""
254
+ url = jwks_url(environ)
255
+ return url is not None and install_jwks_hook(url)
@@ -0,0 +1,96 @@
1
+ """The text of an event's JSON body in the layouts the recorded deliveries had.
2
+
3
+ Recorded 2026-11-04 (sandbox run PYCHECK-EVENTS-20261104-01, the raw twins; `tests/recorded_bodies.json`):
4
+
5
+ - an Eventarc custom event: the publisher's own compact text, member order as published (51 and 48 bytes);
6
+ - a Pub/Sub message: sorted compact JSON followed by a newline (348 and 382 bytes);
7
+ - a Cloud Storage object: the object resource indented by two spaces, in the Cloud Storage JSON API member order, followed by a
8
+ newline (963 bytes).
9
+
10
+ A Realtime Database event was not recorded. Numbers keep the spelling they have in the text the daemon framed (`1e-7`, `1e20`,
11
+ `-0.0`): `Number` is a number as written. Characters are written as UTF-8, not as `\\u` escapes (the recorded bodies hold none).
12
+ """
13
+
14
+ import json
15
+
16
+
17
+ class Number(str):
18
+ """A JSON number as it was written."""
19
+
20
+
21
+ def parse_keeping_numbers(text: str):
22
+ """The value of a JSON text with every number kept as `Number`."""
23
+ return json.loads(text, parse_int=Number, parse_float=Number)
24
+
25
+
26
+ def write(value, *, sort_keys: bool = False, indent: int | None = None, level: int = 0) -> str:
27
+ """JSON text of `value`: compact, or with each member on its own line indented by `indent` spaces."""
28
+ if isinstance(value, Number):
29
+ return str(value)
30
+ if isinstance(value, dict):
31
+ members = sorted(value.items()) if sort_keys else list(value.items())
32
+ if not members:
33
+ return "{}"
34
+ parts = [f"{json.dumps(str(k), ensure_ascii=False)}{': ' if indent else ':'}{write(v, sort_keys=sort_keys, indent=indent, level=level + 1)}" for k, v in members]
35
+ return _join("{", parts, "}", indent, level)
36
+ if isinstance(value, list):
37
+ if not value:
38
+ return "[]"
39
+ return _join("[", [write(v, sort_keys=sort_keys, indent=indent, level=level + 1) for v in value], "]", indent, level)
40
+ return json.dumps(value, ensure_ascii=False)
41
+
42
+
43
+ def _join(open_: str, parts: list, close: str, indent: int | None, level: int) -> str:
44
+ if not indent:
45
+ return open_ + ",".join(parts) + close
46
+ inner = "\n" + " " * (indent * (level + 1))
47
+ return open_ + inner + ("," + inner).join(parts) + "\n" + " " * (indent * level) + close
48
+
49
+
50
+ def compact(value) -> str:
51
+ return write(value)
52
+
53
+
54
+ def pubsub_layout(value) -> str:
55
+ """Sorted compact JSON and a newline: what Pub/Sub's push delivery sent (recorded)."""
56
+ return write(value, sort_keys=True) + "\n"
57
+
58
+
59
+ # The member order of a Cloud Storage object resource as the recorded body had it. Members the recording does not show are
60
+ # UNRECORDED: they follow the known ones in alphabetical order.
61
+ _GCS_OBJECT_ORDER = (
62
+ "kind",
63
+ "id",
64
+ "selfLink",
65
+ "name",
66
+ "bucket",
67
+ "generation",
68
+ "metageneration",
69
+ "contentType",
70
+ "timeCreated",
71
+ "updated",
72
+ "storageClass",
73
+ "timeStorageClassUpdated",
74
+ "size",
75
+ "md5Hash",
76
+ "mediaLink",
77
+ "crc32c",
78
+ "etag",
79
+ )
80
+
81
+
82
+ def _sorted_deep(value):
83
+ if isinstance(value, dict):
84
+ return {k: _sorted_deep(v) for k, v in sorted(value.items())}
85
+ if isinstance(value, list):
86
+ return [_sorted_deep(v) for v in value]
87
+ return value
88
+
89
+
90
+ def storage_layout(value) -> str:
91
+ """The object resource in the API's member order, indented by two spaces, and a newline (recorded for the known members)."""
92
+ if isinstance(value, dict):
93
+ known = [(k, _sorted_deep(value[k])) for k in _GCS_OBJECT_ORDER if k in value]
94
+ rest = [(k, _sorted_deep(v)) for k, v in sorted(value.items()) if k not in _GCS_OBJECT_ORDER]
95
+ value = dict(known + rest)
96
+ return write(value, indent=2) + "\n"
@@ -0,0 +1,36 @@
1
+ """Turning a failure into text without trusting the failure.
2
+
3
+ An exception raised by user code can run user code when it is turned into text (`__str__`, `__repr__`, a
4
+ chained cause). With one process environment shared by every invocation, that text must be made while the
5
+ failing invocation still holds its place in the secret gate, and a failure to make it must not lose the result.
6
+ """
7
+
8
+ import inspect
9
+ import traceback
10
+
11
+
12
+ def describe(error: BaseException) -> tuple[str, str]:
13
+ """`(traceback text, short text)` of `error`; never raises, whatever the error's methods do."""
14
+ try:
15
+ diagnostic = "".join(traceback.format_exception(error))
16
+ except BaseException:
17
+ diagnostic = f"{type(error).__name__}: (the error could not be formatted)\n"
18
+ try:
19
+ text = str(error) or type(error).__name__
20
+ except BaseException:
21
+ text = type(error).__name__
22
+ return diagnostic, text
23
+
24
+
25
+ ASYNC_HANDLER = (
26
+ "the function is an async handler: the Python SDK never awaits a handler, so its body would never run "
27
+ "(production skips it silently); declare it with def"
28
+ )
29
+
30
+
31
+ def is_async_handler(function) -> bool:
32
+ """Whether the user's function behind an SDK wrapper is a coroutine function."""
33
+ try:
34
+ return inspect.iscoroutinefunction(inspect.unwrap(function))
35
+ except ValueError: # a cycle of __wrapped__ links: not something this can decide
36
+ return False
@@ -2,29 +2,46 @@
2
2
 
3
3
  import os
4
4
  import threading
5
- import traceback
6
-
7
- from .events import (
8
- database_cloud_event,
9
- eventarc_cloud_event,
10
- firestore_cloud_event,
11
- pubsub_cloud_event,
12
- storage_cloud_event,
13
- )
5
+
6
+ from cloudevents.http import from_http
7
+
8
+ from .events import database_request, eventarc_request, firestore_request, pubsub_request, storage_request
9
+ from .request_view import delivery_request
10
+ from .diagnostics import ASYNC_HANDLER, describe, is_async_handler
11
+ from .environment import EnvironmentGate
14
12
  from .framing import FrameWriter
13
+ from .scheduler import SchedulerFailure, deliver_schedule
15
14
 
16
15
  MAX_ACTIVE_INVOCATIONS = 4096
17
16
  _MAX_ERROR_CHARS = 4096
18
17
  _MAX_DIAGNOSTIC_CHARS = 256 * 1024
19
18
 
20
- # Trigger types that arrive as an `invoke` frame, and how each becomes the handler's argument.
21
- _EVENT_ADAPTERS = {
22
- "firestore": firestore_cloud_event,
23
- "pubsub": pubsub_cloud_event,
24
- "storage": storage_cloud_event,
25
- "eventarc": eventarc_cloud_event,
26
- "database": database_cloud_event,
19
+ # Trigger types that arrive as an `invoke` frame whose function takes a CloudEvent, and the adapter that builds the request
20
+ # (headers and body) that delivers it.
21
+ _CLOUD_EVENTS = {
22
+ "firestore": firestore_request,
23
+ "pubsub": pubsub_request,
24
+ "storage": storage_request,
25
+ "eventarc": eventarc_request,
26
+ "database": database_request,
27
27
  }
28
+ # A Gen2 scheduled function is an HTTP function: the SDK wrapper takes the scheduler's request.
29
+ _TRIGGERS = frozenset((*_CLOUD_EVENTS, "schedule"))
30
+
31
+
32
+ def _deliver(trigger: str, function, event, raw_data: str | None = None) -> None:
33
+ if trigger == "schedule":
34
+ deliver_schedule(function, event.get("data") if isinstance(event, dict) else None)
35
+ else:
36
+ delivery = _CLOUD_EVENTS[trigger](event, raw_data)
37
+ # As functions-framework does: the function runs inside the request that delivers the event. The event is built
38
+ # from the encoding the adapters have always used; the request shows the layout the recorded delivery had.
39
+ with delivery_request(delivery.headers, delivery.request_body, event):
40
+ function(from_http(delivery.headers, delivery.event_body))
41
+
42
+
43
+ # What `FUNCTION_SIGNATURE_TYPE` says for each (the Node runner's `signatureType`).
44
+ _SIGNATURE_TYPES = {"schedule": "http"}
28
45
 
29
46
 
30
47
  def _bound(text: str, limit: int) -> str:
@@ -34,7 +51,8 @@ def _bound(text: str, limit: int) -> str:
34
51
  class Dispatcher:
35
52
  """Runs `invoke` frames against the functions the manifest serves."""
36
53
 
37
- def __init__(self, functions: dict, manifest: dict, writer: FrameWriter):
54
+ def __init__(self, functions: dict, manifest: dict, writer: FrameWriter, gate: EnvironmentGate | None = None):
55
+ self._gate = gate if gate is not None else EnvironmentGate({})
38
56
  self._functions = functions
39
57
  self._specs = {spec["name"]: spec for spec in manifest["functions"]}
40
58
  self._writer = writer
@@ -48,7 +66,7 @@ class Dispatcher:
48
66
  if "entryPoint" in message and message["entryPoint"] != spec["entryPoint"]:
49
67
  raise LookupError("entry point does not match the function manifest")
50
68
  trigger = spec["trigger"]["type"]
51
- if message["trigger"] != trigger or trigger not in _EVENT_ADAPTERS:
69
+ if message["trigger"] != trigger or trigger not in _TRIGGERS:
52
70
  raise LookupError("trigger does not match the function manifest")
53
71
  function = self._functions.get(spec["entryPoint"])
54
72
  if not callable(function):
@@ -66,22 +84,41 @@ class Dispatcher:
66
84
  """Runs one invoke frame on a new thread; the caller has reserved a slot with `reserve`."""
67
85
  threading.Thread(target=self._run, args=(message,), daemon=True, name=f"invoke-{message['invocationId']}").start()
68
86
 
69
- def _run(self, message: dict) -> None:
70
- invocation = message["invocationId"]
87
+ def _deliver_held(self, message: dict) -> tuple[str, str] | None:
88
+ """Runs the invocation and returns `(diagnostic, text)` of its failure, or None.
89
+
90
+ The failure is described here, inside the gate: turning an exception into text can run user code
91
+ (`__str__`), and after the invocation leaves the gate another invocation's secrets may be present.
92
+ """
71
93
  try:
94
+ spec, function = self._check(message)
95
+ except BaseException as error: # not user code, but a malformed frame: still answered, never raised
96
+ return describe(error)
97
+ if is_async_handler(function):
98
+ return ASYNC_HANDLER, ASYNC_HANDLER
99
+ with self._gate.hold(spec.get("platformOptions", {}).get("secrets")):
72
100
  try:
73
- spec, function = self._check(message)
74
101
  # One process serves the whole codebase, so these name the invocation that started most
75
102
  # recently, not a fixed target (the design memo, section 5).
76
103
  os.environ["FUNCTION_TARGET"] = spec["entryPoint"]
77
- os.environ["FUNCTION_SIGNATURE_TYPE"] = "cloudevent"
104
+ os.environ["FUNCTION_SIGNATURE_TYPE"] = _SIGNATURE_TYPES.get(message["trigger"], "cloudevent")
78
105
  os.environ["K_SERVICE"] = spec["name"]
79
- function(_EVENT_ADAPTERS[message["trigger"]](message["event"]))
80
- result = {"type": "result", "invocationId": invocation, "ok": True}
106
+ _deliver(message["trigger"], function, message["event"], message.get("rawEventData"))
81
107
  except BaseException as error: # a SystemExit in user code must still answer the daemon
82
- diagnostic = "".join(traceback.format_exception(error))
108
+ diagnostic, text = describe(error)
109
+ # A scheduled attempt the SDK wrapper answered 500 has been logged by the SDK; its status is the news.
110
+ return (str(error) if isinstance(error, SchedulerFailure) else diagnostic), text
111
+ return None
112
+
113
+ def _run(self, message: dict) -> None:
114
+ invocation = message["invocationId"]
115
+ try:
116
+ failure = self._deliver_held(message)
117
+ if failure is None:
118
+ result = {"type": "result", "invocationId": invocation, "ok": True}
119
+ else:
120
+ diagnostic, text = failure
83
121
  self._send_log("error", f"{message['function']}: {diagnostic}", invocation, message["function"])
84
- text = str(error) or type(error).__name__
85
122
  result = {"type": "result", "invocationId": invocation, "ok": False, "error": _bound(text, _MAX_ERROR_CHARS)}
86
123
  self._writer.write(result)
87
124
  finally: