@fireemu/win32-x64 0.7.0 → 0.8.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/win32-x64
2
2
 
3
3
  The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `win32-x64`
4
- (Rust target `x86_64-pc-windows-msvc`), version 0.7.0.
4
+ (Rust target `x86_64-pc-windows-msvc`), version 0.8.0.
5
5
 
6
6
  Windows 10 or newer, x64. The process-census integration suites are POSIX-only; the unit tests run here.
7
7
 
package/bin/fireemu.exe CHANGED
Binary file
@@ -29,18 +29,33 @@ const safeMessage = (value) =>
29
29
  !/\p{Cc}|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(value);
30
30
 
31
31
  export function blockingFailure(error, HttpsErrors) {
32
- const code = typeof error?.code === "string" ? functionErrorCodes.get(error.code) : undefined;
33
- if (
34
- !Array.isArray(HttpsErrors) ||
35
- !HttpsErrors.some((HttpsError) =>
36
- typeof HttpsError === "function" && error instanceof HttpsError
37
- ) ||
38
- !code ||
39
- error?.httpErrorCode?.canonicalName !== code.canonicalName ||
40
- error?.httpErrorCode?.status !== code.status ||
41
- !safeMessage(error?.message)
42
- ) {
32
+ try {
33
+ if (
34
+ !Array.isArray(HttpsErrors) ||
35
+ !HttpsErrors.some((HttpsError) =>
36
+ typeof HttpsError === "function" && error instanceof HttpsError
37
+ )
38
+ ) {
39
+ return unavailable;
40
+ }
41
+ // Read each potentially user-defined accessor once. In particular, validating
42
+ // one message and returning a second getter result can leak an unsafe message.
43
+ const rawCode = error.code;
44
+ const metadata = error.httpErrorCode;
45
+ const message = error.message;
46
+ const code = typeof rawCode === "string" ? functionErrorCodes.get(rawCode) : undefined;
47
+ if (
48
+ !code ||
49
+ metadata?.canonicalName !== code.canonicalName ||
50
+ metadata?.status !== code.status ||
51
+ !safeMessage(message)
52
+ ) {
53
+ return unavailable;
54
+ }
55
+ return { ...code, message };
56
+ } catch {
57
+ // Error objects, proxies and even instanceof hooks can throw. Their details
58
+ // must not replace the fixed public fallback or break the HTTP error path.
43
59
  return unavailable;
44
60
  }
45
- return { ...code, message: error.message };
46
61
  }
@@ -19,7 +19,10 @@ const disallowedClaims = [
19
19
  const claimsMaxPayloadSize = 1000;
20
20
 
21
21
  function validateClaims(name, claims, HttpsError) {
22
- if (!claims) return;
22
+ if (claims === undefined || claims === null) return;
23
+ if (typeof claims !== "object" || Array.isArray(claims)) {
24
+ throw new HttpsError("invalid-argument", `The ${name} response must be an object or null.`);
25
+ }
23
26
  const invalid = disallowedClaims.filter((claim) =>
24
27
  Object.prototype.hasOwnProperty.call(claims, claim),
25
28
  );
@@ -49,8 +52,9 @@ function validateBlockingResult(value, eventType, HttpsError) {
49
52
  throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
50
53
  }
51
54
  validateClaims("customClaims", authRequest.customClaims, HttpsError);
52
- if (!String(eventType).includes("beforeSignIn") || !authRequest.sessionClaims) return;
55
+ if (!String(eventType).includes("beforeSignIn")) return;
53
56
  validateClaims("sessionClaims", authRequest.sessionClaims, HttpsError);
57
+ if (authRequest.sessionClaims === undefined || authRequest.sessionClaims === null) return;
54
58
  const combined = { ...authRequest.customClaims, ...authRequest.sessionClaims };
55
59
  if (JSON.stringify(combined).length > claimsMaxPayloadSize) {
56
60
  throw new HttpsError(
@@ -60,26 +64,83 @@ function validateBlockingResult(value, eventType, HttpsError) {
60
64
  }
61
65
  }
62
66
 
67
+ // Validate the representation that will actually cross the HTTP boundary. Callback
68
+ // objects can contain getters/toJSON and can remain aliased to user code. Evaluating
69
+ // those objects again after validation can change claims, sizes, or the update mask.
70
+ // JSON materialization is deliberately before claim checks; the returned value has
71
+ // no user getters or callable toJSON hooks and shares no objects with the callback.
72
+ function snapshotResponse(candidate, HttpsError) {
73
+ let snapshot;
74
+ try {
75
+ snapshot = JSON.parse(JSON.stringify(candidate));
76
+ } catch {
77
+ // Do not include a thrown value, circular-object path, or credential in the error.
78
+ throw new HttpsError("invalid-argument", "The blocking response must be JSON serializable.");
79
+ }
80
+ if (
81
+ !snapshot ||
82
+ typeof snapshot !== "object" ||
83
+ Array.isArray(snapshot) ||
84
+ !Object.prototype.hasOwnProperty.call(snapshot, "userRecord") ||
85
+ !snapshot.userRecord ||
86
+ typeof snapshot.userRecord !== "object" ||
87
+ Array.isArray(snapshot.userRecord)
88
+ ) {
89
+ throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
90
+ }
91
+ return snapshot;
92
+ }
93
+
94
+ function freezeResponse(snapshot) {
95
+ const pending = [snapshot];
96
+ while (pending.length > 0) {
97
+ const value = pending.pop();
98
+ if (value && typeof value === "object") {
99
+ for (const child of Object.values(value)) pending.push(child);
100
+ Object.freeze(value);
101
+ }
102
+ }
103
+ return snapshot;
104
+ }
105
+
63
106
  export function blockingResult(value, eventType, HttpsError) {
107
+ // Preserve the runner's established no-result behaviour.
64
108
  if (!value || typeof value !== "object") return {};
65
- validateBlockingResult(value, eventType, HttpsError);
66
- if (Object.prototype.hasOwnProperty.call(value, "userRecord")) return value;
67
- const userRecord = {};
68
- const updateMask = [];
69
- for (const [publicName, wireName] of [
70
- ["displayName", "displayName"],
71
- ["photoURL", "photoUrl"],
72
- ["disabled", "disabled"],
73
- ["emailVerified", "emailVerified"],
74
- ["customClaims", "customClaims"],
75
- ["sessionClaims", "sessionClaims"],
76
- ]) {
77
- if (Object.prototype.hasOwnProperty.call(value, publicName)) {
78
- userRecord[wireName] = value[publicName];
79
- updateMask.push(wireName);
109
+ if (Array.isArray(value)) {
110
+ throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
111
+ }
112
+ let candidate;
113
+ try {
114
+ if (Object.prototype.hasOwnProperty.call(value, "userRecord")) {
115
+ // Preserve SDK wire-envelope extensions, but never return the caller's object.
116
+ candidate = value;
117
+ } else {
118
+ const userRecord = {};
119
+ const updateMask = [];
120
+ for (const [publicName, wireName] of [
121
+ ["displayName", "displayName"],
122
+ ["photoURL", "photoUrl"],
123
+ ["disabled", "disabled"],
124
+ ["emailVerified", "emailVerified"],
125
+ ["customClaims", "customClaims"],
126
+ ["sessionClaims", "sessionClaims"],
127
+ ]) {
128
+ if (Object.prototype.hasOwnProperty.call(value, publicName)) {
129
+ const field = value[publicName];
130
+ // Like the Functions SDK's getUpdateMask, undefined means no update;
131
+ // null, false, and an empty string are still explicit updates.
132
+ if (field === undefined) continue;
133
+ userRecord[wireName] = field;
134
+ updateMask.push(wireName);
135
+ }
136
+ }
137
+ if (updateMask.length === 0) return {};
138
+ candidate = { userRecord: { ...userRecord, updateMask: updateMask.join(",") } };
80
139
  }
140
+ } catch {
141
+ throw new HttpsError("invalid-argument", "The blocking response could not be read.");
81
142
  }
82
- return updateMask.length > 0
83
- ? { userRecord: { ...userRecord, updateMask: updateMask.join(",") } }
84
- : {};
143
+ const snapshot = snapshotResponse(candidate, HttpsError);
144
+ validateBlockingResult(snapshot, eventType, HttpsError);
145
+ return freezeResponse(snapshot);
85
146
  }
@@ -0,0 +1,259 @@
1
+ // Bounded admission for direct stderr (and redirected user stdout) writes.
2
+ // The native Writable owns byte ordering and corking. Callback accounting adds
3
+ // backpressure even when a native write completes synchronously.
4
+ // This is not a process-wide sandbox: direct fd writes and replaced methods bypass it.
5
+ import { performance } from 'node:perf_hooks';
6
+ import { types } from 'node:util';
7
+
8
+ const typedPrototype = Object.getPrototypeOf(Uint8Array.prototype);
9
+ const viewGetters = Object.fromEntries(['buffer', 'byteOffset', 'byteLength'].map(key =>
10
+ [key, Object.getOwnPropertyDescriptor(typedPrototype, key).get]));
11
+ const dataViewGetters = Object.fromEntries(['buffer', 'byteOffset', 'byteLength'].map(key =>
12
+ [key, Object.getOwnPropertyDescriptor(DataView.prototype, key).get]));
13
+ function viewData(chunk) {
14
+ const getters = types.isDataView(chunk) ? dataViewGetters : viewGetters;
15
+ return { buffer: getters.buffer.call(chunk), offset: getters.byteOffset.call(chunk),
16
+ length: getters.byteLength.call(chunk) };
17
+ }
18
+
19
+ export const MAX_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
20
+ export const MAX_DIAGNOSTIC_WRITES = 1024;
21
+ export const MAX_DIAGNOSTIC_WAIT_MS = 30_000;
22
+ export const DIAGNOSTIC_FINISH_MS = 1_000;
23
+
24
+ export class DiagnosticOutputError extends Error {
25
+ constructor(reason) {
26
+ super(`runner diagnostic output failed (${reason})`);
27
+ this.name = 'DiagnosticOutputError';
28
+ this.code = 'ERR_FIREEMU_DIAGNOSTIC_OUTPUT';
29
+ }
30
+ }
31
+
32
+ function limit(value, maximum, name) {
33
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
34
+ throw new TypeError(`invalid diagnostic ${name}`);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ export class DiagnosticWriter {
40
+ #stream;
41
+ #write;
42
+ #emit;
43
+ #highWaterMark;
44
+ #needsDrain = false;
45
+ #nativeBackpressure = false;
46
+ #drainScheduled = false;
47
+ #onError;
48
+ #maxBytes;
49
+ #maxWrites;
50
+ #waitMs;
51
+ #finishMs;
52
+ #defaultEncoding = 'utf8';
53
+ #pending = new Set();
54
+ #bytes = 0;
55
+ #failed = null;
56
+ #closing = false;
57
+ #closeAt = Infinity;
58
+ #timer = null;
59
+ #finishPromise;
60
+ #finishResolve;
61
+
62
+ constructor(stream, {
63
+ onError,
64
+ maxBytes = MAX_DIAGNOSTIC_BYTES,
65
+ maxWrites = MAX_DIAGNOSTIC_WRITES,
66
+ waitMs = MAX_DIAGNOSTIC_WAIT_MS,
67
+ finishMs = DIAGNOSTIC_FINISH_MS,
68
+ } = {}) {
69
+ if (typeof stream?.write !== 'function' || typeof stream?.on !== 'function' ||
70
+ typeof stream?.emit !== 'function' ||
71
+ typeof onError !== 'function') throw new TypeError('diagnostic stream and onError required');
72
+ this.#stream = stream;
73
+ this.#write = stream.write.bind(stream);
74
+ this.#emit = stream.emit.bind(stream);
75
+ this.#onError = onError;
76
+ this.#maxBytes = limit(maxBytes, MAX_DIAGNOSTIC_BYTES, 'bytes');
77
+ this.#maxWrites = limit(maxWrites, MAX_DIAGNOSTIC_WRITES, 'writes');
78
+ this.#waitMs = limit(waitMs, MAX_DIAGNOSTIC_WAIT_MS, 'wait');
79
+ this.#finishMs = limit(finishMs, DIAGNOSTIC_FINISH_MS, 'finish wait');
80
+ const nativeHighWaterMark = stream.writableHighWaterMark;
81
+ this.#highWaterMark = Math.min(this.#maxBytes,
82
+ Number.isSafeInteger(nativeHighWaterMark) && nativeHighWaterMark > 0
83
+ ? nativeHighWaterMark : 64 * 1024);
84
+ // Native drain can fire before write callbacks. Do not tell a producer to
85
+ // resume while our callback reservations still fill the budget. Coalesce
86
+ // it with logical backpressure and emit only after BOTH have cleared.
87
+ stream.emit = (event, ...args) => {
88
+ if (event !== 'drain') return this.#emit(event, ...args);
89
+ this.#nativeBackpressure = false;
90
+ this.#requestDrain();
91
+ return (stream.listenerCount?.('drain') ?? 0) > 0;
92
+ };
93
+ // Retain the error listener after failure: an error event may follow its callback.
94
+ stream.on('error', () => this.#fail('stream error'));
95
+ stream.on('close', () => this.#fail('stream closed'));
96
+ stream.on('finish', () => this.#fail('stream ended'));
97
+ }
98
+
99
+ get state() {
100
+ return Object.freeze({ pendingBytes: this.#bytes, pendingWrites: this.#pending.size,
101
+ failed: this.#failed !== null, closing: this.#closing });
102
+ }
103
+
104
+ setDefaultEncoding(encoding) {
105
+ if (typeof encoding !== 'string' || !Buffer.isEncoding(encoding)) {
106
+ throw new TypeError('invalid diagnostic encoding');
107
+ }
108
+ this.#defaultEncoding = encoding;
109
+ return this;
110
+ }
111
+
112
+ write(chunk, encoding, callback) {
113
+ if (typeof encoding === 'function') { callback = encoding; encoding = undefined; }
114
+ if (callback !== undefined && callback !== null && typeof callback !== 'function') {
115
+ throw new TypeError('diagnostic write callback must be a function');
116
+ }
117
+ if (this.#failed || this.#closing) {
118
+ if (callback) process.nextTick(callback, this.#failed ?? new DiagnosticOutputError('closing'));
119
+ return false;
120
+ }
121
+ if (this.#expired()) { this.#fail('deadline'); return this.write(chunk, encoding, callback); }
122
+ let size, selectedEncoding, view;
123
+ if (typeof chunk === 'string') {
124
+ selectedEncoding = encoding || this.#defaultEncoding;
125
+ if (typeof selectedEncoding !== 'string' || !Buffer.isEncoding(selectedEncoding)) {
126
+ throw new TypeError('invalid diagnostic encoding');
127
+ }
128
+ size = Buffer.byteLength(chunk, selectedEncoding);
129
+ } else if (ArrayBuffer.isView(chunk)) {
130
+ view = viewData(chunk);
131
+ size = view.length;
132
+ } else {
133
+ throw new TypeError('diagnostic chunk must be a string or ArrayBuffer view');
134
+ }
135
+ // Include zero-length writes in the count: callbacks also retain memory.
136
+ if (this.#pending.size >= this.#maxWrites || this.#bytes + size > this.#maxBytes) {
137
+ this.#fail('pending limit');
138
+ return this.write(chunk, encoding, callback);
139
+ }
140
+ if (this.#stream.destroyed || this.#stream.writableEnded) {
141
+ this.#fail('stream unavailable');
142
+ return this.write(chunk, encoding, callback);
143
+ }
144
+ // Copy views before native queuing; later caller mutation cannot change admitted bytes.
145
+ // Strings are immutable. Encoding once also fixes accounting for hex/base64 inputs.
146
+ const payload = typeof chunk === 'string' ? Buffer.from(chunk, selectedEncoding) :
147
+ Buffer.from(new Uint8Array(view.buffer, view.offset, view.length));
148
+ // Buffer.byteLength(base64/hex) can be conservative for malformed input. Charge
149
+ // the bytes actually queued, after rejecting oversized allocation candidates above.
150
+ size = payload.length;
151
+ const entry = { size, at: performance.now(), callback };
152
+ this.#pending.add(entry);
153
+ this.#bytes += size;
154
+ let accepted;
155
+ try {
156
+ accepted = this.#write(payload, error => {
157
+ if (!this.#pending.has(entry)) return;
158
+ if (error) { this.#fail('write callback'); return; }
159
+ if (this.#expired()) { this.#fail('deadline'); return; }
160
+ this.#settle(entry);
161
+ this.#arm();
162
+ });
163
+ } catch {
164
+ this.#fail('write');
165
+ return false;
166
+ }
167
+ if (accepted !== true && accepted !== false) {
168
+ this.#fail('write return');
169
+ return false;
170
+ }
171
+ if (accepted === false) this.#nativeBackpressure = true;
172
+ const pressured = accepted === false || this.#bytes >= this.#highWaterMark ||
173
+ this.#pending.size >= this.#maxWrites;
174
+ if (pressured) this.#needsDrain = true;
175
+ this.#arm();
176
+ this.#requestDrain();
177
+ // Native false remains false. Native true may become false while completed
178
+ // writes still have deferred callbacks; otherwise a cooperative producer
179
+ // can exceed our limits before Node gets a turn to run those callbacks.
180
+ // In either case the write was accepted and must not be resent.
181
+ return this.#failed ? false : !pressured;
182
+ }
183
+
184
+ #settle(entry, error) {
185
+ if (!this.#pending.delete(entry)) return;
186
+ this.#bytes -= entry.size;
187
+ // User callbacks cannot reenter or corrupt admission bookkeeping. Like Writable,
188
+ // completion is asynchronous even with an inline write implementation in a test.
189
+ if (entry.callback) process.nextTick(entry.callback, error);
190
+ this.#requestDrain();
191
+ }
192
+
193
+ #requestDrain() {
194
+ if (this.#failed || this.#closing || !this.#needsDrain || this.#nativeBackpressure ||
195
+ this.#pending.size !== 0 || this.#drainScheduled) return;
196
+ this.#drainScheduled = true;
197
+ process.nextTick(() => {
198
+ this.#drainScheduled = false;
199
+ // A user write callback can enqueue new data before this notification.
200
+ if (this.#failed || this.#closing || !this.#needsDrain || this.#nativeBackpressure ||
201
+ this.#pending.size !== 0) return;
202
+ this.#needsDrain = false;
203
+ this.#emit('drain');
204
+ });
205
+ }
206
+
207
+ #expired() {
208
+ const oldest = this.#pending.values().next().value;
209
+ if (!oldest) return false;
210
+ const now = performance.now();
211
+ return now >= oldest.at + this.#waitMs || now >= this.#closeAt;
212
+ }
213
+
214
+ #fail(reason) {
215
+ if (this.#failed) return;
216
+ this.#failed = new DiagnosticOutputError(reason);
217
+ clearTimeout(this.#timer); this.#timer = null;
218
+ for (const entry of this.#pending) this.#settle(entry, this.#failed);
219
+ this.#finishResolve?.(false);
220
+ // Never write a diagnostic about this failure back into the failed stderr.
221
+ try { this.#onError(this.#failed); } catch { /* failure is already permanent */ }
222
+ }
223
+
224
+ #arm() {
225
+ clearTimeout(this.#timer); this.#timer = null;
226
+ if (this.#failed) return;
227
+ const oldest = this.#pending.values().next().value;
228
+ if (!oldest) { if (this.#closing) this.#finishResolve?.(true); return; }
229
+ const remaining = Math.min(oldest.at + this.#waitMs, this.#closeAt) - performance.now();
230
+ if (remaining <= 0) { this.#fail('deadline'); return; }
231
+ this.#timer = setTimeout(() => this.#arm(), Math.max(1, Math.ceil(remaining)));
232
+ }
233
+
234
+ // Flush already-admitted writes only; do not close fd2 or wait for user callbacks/tasks.
235
+ finish() {
236
+ if (this.#finishPromise) return this.#finishPromise;
237
+ this.#closing = true;
238
+ this.#closeAt = performance.now() + this.#finishMs;
239
+ this.#finishPromise = new Promise(resolve => { this.#finishResolve = resolve; });
240
+ if (this.#failed) this.#finishResolve(false); else this.#arm();
241
+ return this.#finishPromise;
242
+ }
243
+ }
244
+
245
+ // Install before loading a user codebase. Keep the original stream object, fd,
246
+ // event listeners, cork/uncork and false-then-drain producer contract intact.
247
+ // Own pending callbacks can add backpressure; native true is not always returned.
248
+ export function installDiagnosticOutput(stream, options) {
249
+ const writer = new DiagnosticWriter(stream, options);
250
+ const setEncoding = stream.setDefaultEncoding?.bind(stream);
251
+ stream.write = writer.write.bind(writer);
252
+ if (setEncoding) stream.setDefaultEncoding = encoding => {
253
+ // Validate with the native API too; preserve its chaining return value.
254
+ const result = setEncoding(encoding);
255
+ writer.setDefaultEncoding(encoding);
256
+ return result;
257
+ };
258
+ return writer;
259
+ }
@@ -0,0 +1,135 @@
1
+ // Discover only own enumerable exports. This is codebase inspection, not a
2
+ // sandbox: user getters can still execute arbitrary code in this process.
3
+ import { invocationFailure } from './invocation-error.mjs';
4
+
5
+ // Local resource limits, not Firebase quotas. Exceeding one aborts discovery;
6
+ // never publish a seemingly complete inventory after truncating the graph.
7
+ export const DISCOVERY_LIMITS = Object.freeze({
8
+ maxDepth: 128,
9
+ maxEntries: 10_000,
10
+ maxNameBytes: 1024,
11
+ });
12
+
13
+ export class DiscoveryError extends Error {
14
+ constructor(reason) {
15
+ super(`function export discovery failed (${reason})`);
16
+ this.name = 'DiscoveryError';
17
+ }
18
+ }
19
+
20
+ const namespaceRoots = new WeakMap();
21
+ const interopAliases = new Set(['default', 'module.exports']);
22
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
23
+
24
+ function keysOf(object) {
25
+ const keys = Object.keys(object);
26
+ if (keys.length > DISCOVERY_LIMITS.maxEntries) throw new DiscoveryError('entry limit');
27
+ return keys;
28
+ }
29
+
30
+ // Preserve CommonJS insertion order, then fill missing named ES exports. Keep
31
+ // root accessors lazy so one throwing export is diagnosed by name without
32
+ // preventing inspection of its siblings. A null prototype makes __proto__,
33
+ // constructor and toString ordinary export names. Never execute setters while
34
+ // merging or consult inherited keys when deciding which source takes priority.
35
+ export function exportNamespace(moduleNamespace) {
36
+ const namespace = Object.create(null);
37
+ let count = 0;
38
+ function add(source) {
39
+ for (const key of keysOf(source)) {
40
+ if (interopAliases.has(key) || hasOwn(namespace, key)) continue;
41
+ if (++count > DISCOVERY_LIMITS.maxEntries) throw new DiscoveryError('entry limit');
42
+ Object.defineProperty(namespace, key, {
43
+ enumerable: true,
44
+ get: () => {
45
+ // An earlier export getter may remove/hide another own property.
46
+ // Do not fall through to an inherited callback at the stale key.
47
+ if (!Object.getOwnPropertyDescriptor(source, key)?.enumerable) return undefined;
48
+ return Reflect.get(source, key);
49
+ },
50
+ });
51
+ }
52
+ }
53
+ const primary = hasOwn(moduleNamespace, 'default') ? Reflect.get(moduleNamespace, 'default') : undefined;
54
+ if (primary !== null && typeof primary === 'object') add(primary);
55
+ add(moduleNamespace);
56
+ namespaceRoots.set(namespace, [moduleNamespace, ...(primary !== null && typeof primary === 'object' ? [primary] : [])]);
57
+ return namespace;
58
+ }
59
+
60
+ function exportFailure(error) {
61
+ // Thrown values may themselves have throwing message/stack/toString hooks.
62
+ return `the export could not be read: ${invocationFailure(error).message.split('\n')[0]}`;
63
+ }
64
+
65
+ export function collectFunctions(namespace) {
66
+ const functions = new Map();
67
+ const broken = new Map();
68
+ const claimed = new Map();
69
+ const ancestors = new Set(namespaceRoots.get(namespace) ?? []);
70
+ const stack = [];
71
+ let entries = 0;
72
+
73
+ function record(name, path, fn, reason) {
74
+ const origin = JSON.stringify(path);
75
+ if (claimed.has(name)) {
76
+ functions.delete(name);
77
+ broken.set(name, `ambiguous flattened export name: ${claimed.get(name)} and ${origin}`);
78
+ return;
79
+ }
80
+ claimed.set(name, origin);
81
+ if (reason !== undefined) broken.set(name, reason);
82
+ else functions.set(name, fn);
83
+ }
84
+
85
+ function enter(group, path, name) {
86
+ if (ancestors.has(group)) {
87
+ record(name, path, undefined, 'cyclic export group');
88
+ return;
89
+ }
90
+ if (path.length > DISCOVERY_LIMITS.maxDepth) throw new DiscoveryError('depth limit');
91
+ let keys;
92
+ try {
93
+ keys = Object.keys(group);
94
+ } catch (error) {
95
+ if (path.length === 0) throw new DiscoveryError('root enumeration');
96
+ record(name, path, undefined, exportFailure(error));
97
+ return;
98
+ }
99
+ if (keys.length > DISCOVERY_LIMITS.maxEntries) throw new DiscoveryError('entry limit');
100
+ ancestors.add(group);
101
+ stack.push({ group, path, keys, next: 0 });
102
+ }
103
+
104
+ enter(namespace, [], '');
105
+ while (stack.length > 0) {
106
+ const current = stack[stack.length - 1];
107
+ if (current.next === current.keys.length) {
108
+ ancestors.delete(current.group);
109
+ stack.pop();
110
+ continue;
111
+ }
112
+ if (++entries > DISCOVERY_LIMITS.maxEntries) throw new DiscoveryError('entry limit');
113
+ const key = current.keys[current.next++];
114
+ const path = [...current.path, key];
115
+ const name = path.join('-');
116
+ if (Buffer.byteLength(name, 'utf8') > DISCOVERY_LIMITS.maxNameBytes) {
117
+ throw new DiscoveryError('name limit');
118
+ }
119
+ let value;
120
+ let isGroup;
121
+ let marked;
122
+ try {
123
+ if (!Object.getOwnPropertyDescriptor(current.group, key)?.enumerable) continue;
124
+ value = Reflect.get(current.group, key);
125
+ if (typeof value === 'function') marked = Boolean(value.__endpoint || value.__trigger);
126
+ else isGroup = value !== null && typeof value === 'object' && !Array.isArray(value);
127
+ } catch (error) {
128
+ record(name, path, undefined, exportFailure(error));
129
+ continue;
130
+ }
131
+ if (marked) record(name, path, value);
132
+ else if (isGroup) enter(value, path, name);
133
+ }
134
+ return { functions, broken };
135
+ }