@fireemu/darwin-arm64 0.7.1 → 0.9.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.7.1.
4
+ (Rust target `aarch64-apple-darwin`), version 0.9.0.
5
5
 
6
6
  macOS 13 or newer on Apple silicon.
7
7
 
package/bin/fireemu 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
  }
@@ -0,0 +1,224 @@
1
+ // The event a blocking function receives, built as Identity Platform's delivery builds it: the
2
+ // request carries an unsigned blocking token (`{"data":{"jwt":…}}`, the official Auth
3
+ // emulator's form), and the codebase's own firebase-functions parsers turn it into the user
4
+ // record and the event context, exactly as the SDK's wrapped handler does before it calls the
5
+ // handler (firebase-functions `common/providers/identity` `wrapHandler`). The runner keeps its
6
+ // own response and error handling (blocking-response.mjs, blocking-error.mjs).
7
+ import { dirname, join } from "node:path";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+
10
+ /** A blocking token's payload, decoded without verification as the SDK does under the emulator. */
11
+ export function decodeBlockingToken(jwt) {
12
+ if (typeof jwt !== "string") throw new TypeError("the blocking token is not a string");
13
+ const parts = jwt.split(".");
14
+ if (parts.length !== 3) throw new TypeError("the blocking token is not a JWT");
15
+ let payload;
16
+ try {
17
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
18
+ } catch {
19
+ throw new TypeError("the blocking token's payload is not JSON");
20
+ }
21
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
22
+ throw new TypeError("the blocking token's payload is not an object");
23
+ }
24
+ // The SDK's unsafeDecodeAuthBlockingToken names the subject as the uid.
25
+ payload.uid = payload.sub;
26
+ return payload;
27
+ }
28
+
29
+ /**
30
+ * The handler's `{user, context}` for a request that carries a blocking token, or undefined
31
+ * for one that does not. `parsers` are the SDK's `parseAuthUserRecord` and
32
+ * `parseAuthEventContext`; the user record is parsed for the user events only.
33
+ */
34
+ export function blockingEvent(body, parsers, projectId) {
35
+ const jwt = body?.data?.jwt;
36
+ if (jwt === undefined) return undefined;
37
+ const decoded = decodeBlockingToken(jwt);
38
+ const user =
39
+ decoded.event_type === "beforeCreate" || decoded.event_type === "beforeSignIn"
40
+ ? parsers.parseAuthUserRecord(decoded.user_record)
41
+ : undefined;
42
+ return { user, context: parsers.parseAuthEventContext(decoded, projectId) };
43
+ }
44
+
45
+ function packageRoot(require) {
46
+ let root = dirname(require.resolve("firebase-functions"));
47
+ for (;;) {
48
+ const candidate = join(root, "package.json");
49
+ if (existsSync(candidate) && JSON.parse(readFileSync(candidate, "utf8")).name === "firebase-functions")
50
+ return root;
51
+ const parent = dirname(root);
52
+ if (parent === root) throw new Error("cannot locate the firebase-functions package root");
53
+ root = parent;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The SDK's parsers from the codebase's firebase-functions (`lib/common/providers/identity.js`,
59
+ * not in its public exports), or null when that SDK does not expose both there; the runner then
60
+ * uses [`portedIdentityParsers`].
61
+ */
62
+ export function loadIdentityParsers(require) {
63
+ try {
64
+ const identity = require(join(packageRoot(require), "lib", "common", "providers", "identity.js"));
65
+ const { parseAuthUserRecord, parseAuthEventContext } = identity;
66
+ if (typeof parseAuthUserRecord !== "function" || typeof parseAuthEventContext !== "function")
67
+ return null;
68
+ return { parseAuthUserRecord, parseAuthEventContext };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ // A port of firebase-functions 7.3.2 `lib/common/providers/identity.js` (MIT License, Copyright
75
+ // Google LLC): the event a handler receives when the codebase's firebase-functions does not
76
+ // expose its own parsers. Identity Platform delivers the same token to every SDK version, so
77
+ // the event is built as that SDK builds it rather than refused (an SDK's own parsers are used
78
+ // whenever they can be loaded).
79
+ const EVENT_MAPPING = {
80
+ beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
81
+ beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
82
+ beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
83
+ beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms",
84
+ };
85
+
86
+ const utcDate = (value) => (value ? new Date(value).toUTCString() : null);
87
+
88
+ function parseMetadata(metadata) {
89
+ return {
90
+ creationTime: utcDate(metadata?.creation_time),
91
+ lastSignInTime: utcDate(metadata?.last_sign_in_time),
92
+ };
93
+ }
94
+
95
+ function parseProviderData(providerData) {
96
+ return providerData.map((provider) => ({
97
+ uid: provider.uid,
98
+ displayName: provider.display_name,
99
+ email: provider.email,
100
+ photoURL: provider.photo_url,
101
+ providerId: provider.provider_id,
102
+ phoneNumber: provider.phone_number,
103
+ }));
104
+ }
105
+
106
+ function parseDate(seconds) {
107
+ if (!seconds) return null;
108
+ const date = new Date(seconds * 1000);
109
+ return isNaN(date.getTime()) ? null : date.toUTCString();
110
+ }
111
+
112
+ function parseMultiFactor(multiFactor) {
113
+ if (!multiFactor) return null;
114
+ const enrolledFactors = [];
115
+ for (const factor of multiFactor.enrolled_factors || []) {
116
+ if (!factor.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid multi-factor info response");
117
+ enrolledFactors.push({
118
+ uid: factor.uid,
119
+ factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
120
+ displayName: factor.display_name,
121
+ enrollmentTime: factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null,
122
+ phoneNumber: factor.phone_number,
123
+ });
124
+ }
125
+ return enrolledFactors.length > 0 ? { enrolledFactors } : null;
126
+ }
127
+
128
+ function parseAuthUserRecord(record) {
129
+ if (!record?.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid user response");
130
+ return {
131
+ uid: record.uid,
132
+ email: record.email,
133
+ emailVerified: record.email_verified,
134
+ displayName: record.display_name,
135
+ photoURL: record.photo_url,
136
+ phoneNumber: record.phone_number,
137
+ disabled: record.disabled || false,
138
+ metadata: parseMetadata(record.metadata),
139
+ providerData: parseProviderData(record.provider_data),
140
+ passwordHash: record.password_hash,
141
+ passwordSalt: record.password_salt,
142
+ customClaims: record.custom_claims,
143
+ tenantId: record.tenant_id,
144
+ tokensValidAfterTime: parseDate(record.tokens_valid_after_time),
145
+ multiFactor: parseMultiFactor(record.multi_factor),
146
+ };
147
+ }
148
+
149
+ function parseAdditionalUserInfo(decoded) {
150
+ let profile;
151
+ let username;
152
+ if (decoded.raw_user_info) {
153
+ try {
154
+ profile = JSON.parse(decoded.raw_user_info);
155
+ } catch {
156
+ // The SDK only logs an unparsable profile.
157
+ }
158
+ }
159
+ if (profile) {
160
+ if (decoded.sign_in_method === "github.com") username = profile.login;
161
+ if (decoded.sign_in_method === "twitter.com") username = profile.screen_name;
162
+ }
163
+ return {
164
+ providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
165
+ profile,
166
+ username,
167
+ isNewUser: decoded.event_type === "beforeCreate",
168
+ recaptchaScore: decoded.recaptcha_score,
169
+ email: decoded.email,
170
+ phoneNumber: decoded.phone_number,
171
+ };
172
+ }
173
+
174
+ function parseAuthCredential(decoded, time) {
175
+ if (
176
+ !decoded.sign_in_attributes &&
177
+ !decoded.oauth_id_token &&
178
+ !decoded.oauth_access_token &&
179
+ !decoded.oauth_refresh_token
180
+ ) {
181
+ return null;
182
+ }
183
+ return {
184
+ claims: decoded.sign_in_attributes,
185
+ idToken: decoded.oauth_id_token,
186
+ accessToken: decoded.oauth_access_token,
187
+ refreshToken: decoded.oauth_refresh_token,
188
+ expirationTime: decoded.oauth_expires_in
189
+ ? new Date(time + decoded.oauth_expires_in * 1000).toUTCString()
190
+ : undefined,
191
+ secret: decoded.oauth_token_secret,
192
+ providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
193
+ signInMethod: decoded.sign_in_method,
194
+ };
195
+ }
196
+
197
+ function parseAuthEventContext(decoded, projectId, time = new Date().getTime()) {
198
+ const eventType =
199
+ (EVENT_MAPPING[decoded.event_type] || decoded.event_type) +
200
+ (decoded.sign_in_method ? `:${decoded.sign_in_method}` : "");
201
+ return {
202
+ locale: decoded.locale,
203
+ ipAddress: decoded.ip_address,
204
+ userAgent: decoded.user_agent,
205
+ eventId: decoded.event_id,
206
+ eventType,
207
+ authType: decoded.user_record ? "USER" : "UNAUTHENTICATED",
208
+ resource: {
209
+ service: "identitytoolkit.googleapis.com",
210
+ name: decoded.tenant_id
211
+ ? `projects/${projectId}/tenants/${decoded.tenant_id}`
212
+ : `projects/${projectId}`,
213
+ },
214
+ timestamp: new Date(decoded.iat * 1000).toUTCString(),
215
+ additionalUserInfo: parseAdditionalUserInfo(decoded),
216
+ credential: parseAuthCredential(decoded, time),
217
+ emailType: decoded.email_type,
218
+ smsType: decoded.sms_type,
219
+ params: {},
220
+ };
221
+ }
222
+
223
+ /** The ported parsers ([`loadIdentityParsers`] prefers the codebase's own). */
224
+ export const portedIdentityParsers = Object.freeze({ parseAuthUserRecord, parseAuthEventContext });
@@ -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,101 @@ 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
+
106
+ // The customClaims text as the function's JSON.stringify gives it: what the Functions SDK sends
107
+ // Identity Platform, which production reads back as `customAttributes` with its key order
108
+ // (AUTH-TENANT-BLOCKING recording 2026-09-28). The runner owns this member; the daemon uses the
109
+ // text only when it parses to the claims it applies.
110
+ const CUSTOM_CLAIMS_TEXT = "fireemuCustomClaimsText";
111
+
112
+ function attachCustomClaimsText(snapshot) {
113
+ delete snapshot[CUSTOM_CLAIMS_TEXT];
114
+ // validateBlockingResult has admitted only an object, null or no claims.
115
+ const claims = snapshot.userRecord.customClaims;
116
+ if (claims != null) snapshot[CUSTOM_CLAIMS_TEXT] = JSON.stringify(claims);
117
+ return snapshot;
118
+ }
119
+
63
120
  export function blockingResult(value, eventType, HttpsError) {
121
+ // Preserve the runner's established no-result behaviour.
64
122
  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);
123
+ if (Array.isArray(value)) {
124
+ throw new HttpsError("invalid-argument", "The userRecord response must be an object.");
125
+ }
126
+ let candidate;
127
+ try {
128
+ if (Object.prototype.hasOwnProperty.call(value, "userRecord")) {
129
+ // Preserve SDK wire-envelope extensions, but never return the caller's object.
130
+ candidate = value;
131
+ } else {
132
+ const userRecord = {};
133
+ const updateMask = [];
134
+ // Each field goes under its public name, as the Functions SDK's generateResponsePayload
135
+ // sends it: Identity Platform does not apply `photoURL` (it reads `photoUrl`), so a
136
+ // function's photoURL is ignored (AUTH-TENANT-BLOCKING recording 2026-09-28,
137
+ // ordering#lookup-profile; the official Auth emulator reads `photoUrl` only too).
138
+ for (const name of [
139
+ "displayName",
140
+ "photoURL",
141
+ "disabled",
142
+ "emailVerified",
143
+ "customClaims",
144
+ "sessionClaims",
145
+ ]) {
146
+ if (Object.prototype.hasOwnProperty.call(value, name)) {
147
+ const field = value[name];
148
+ // Like the Functions SDK's getUpdateMask, undefined means no update;
149
+ // null, false, and an empty string are still explicit updates.
150
+ if (field === undefined) continue;
151
+ userRecord[name] = field;
152
+ updateMask.push(name);
153
+ }
154
+ }
155
+ if (updateMask.length === 0) return {};
156
+ candidate = { userRecord: { ...userRecord, updateMask: updateMask.join(",") } };
80
157
  }
158
+ } catch {
159
+ throw new HttpsError("invalid-argument", "The blocking response could not be read.");
81
160
  }
82
- return updateMask.length > 0
83
- ? { userRecord: { ...userRecord, updateMask: updateMask.join(",") } }
84
- : {};
161
+ const snapshot = snapshotResponse(candidate, HttpsError);
162
+ validateBlockingResult(snapshot, eventType, HttpsError);
163
+ return freezeResponse(attachCustomClaimsText(snapshot));
85
164
  }
@@ -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
+ }