返回 DeepSeek-Reasonix
firebase_rtdb.ts
根目录 / workers / crash-report / src / firebase_rtdb.ts
1 import type { Env } from "./env";
2
3 const TOKEN_URL = "https://oauth2.googleapis.com/token";
4 const TOKEN_SCOPE = [
5 "https://www.googleapis.com/auth/userinfo.email",
6 "https://www.googleapis.com/auth/firebase.database",
7 ].join(" ");
8 const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
9 const REQUEST_TIMEOUT_MS = 5_000;
10
11 type Fetcher = typeof fetch;
12
13 type CachedToken = {
14 clientEmail: string;
15 value: string;
16 expiresAt: number;
17 };
18
19 let cachedToken: CachedToken | undefined;
20 let tokenRequest: Promise<CachedToken> | undefined;
21
22 export type FirebaseCrashSample = Record<string, unknown> & {
23 eventId: string;
24 receivedAt: string;
25 groupCount: number;
26 writerGeneration: number;
27 sampleEpoch: number;
28 };
29
30 export type FirebaseCrashSampleInput = Record<string, unknown> & {
31 eventId: string;
32 receivedAt: string;
33 };
34
35 export type FirebaseSampleMarker = {
36 marker: "compacted" | "archiving";
37 groupCount: number;
38 writerGeneration: number;
39 sampleEpoch: number;
40 };
41
42 export type FirebaseCrashGroupMeta = {
43 fingerprint: string;
44 kind: string;
45 count: number;
46 firstSeen: string;
47 lastSeen: string;
48 firstVersion: string;
49 lastVersion: string;
50 status: string;
51 title: string;
52 source: string;
53 label: string;
54 errorType: string;
55 topFrame: string;
56 severity: string;
57 lastOS: string;
58 lastArch: string;
59 lastBuildCommit: string;
60 lastChannel: string;
61 regressedAt: string;
62 regressionReview?: string;
63 resolutionPlatform?: string;
64 resolutionRuntime?: string;
65 resolutionBasis?: string;
66 lastCategory?: string;
67 writerGeneration?: number;
68 sampleEpoch?: number;
69 sampleState?: "active" | "compacted" | "archiving" | "archived";
70 };
71
72 type FirebaseFencedMeta = FirebaseCrashGroupMeta & {
73 writerGeneration: number;
74 sampleEpoch: number;
75 sampleState: "active" | "compacted" | "archiving" | "archived";
76 };
77
78 export type FirebaseCrashGroup = {
79 meta?: FirebaseCrashGroupMeta;
80 samples?: {
81 first?: FirebaseCrashSample;
82 latest?: Record<string, FirebaseCrashSample | FirebaseSampleMarker> |
83 Array<FirebaseCrashSample | FirebaseSampleMarker>;
84 };
85 };
86
87 function base64url(data: Uint8Array | string): string {
88 const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
89 let binary = "";
90 for (let offset = 0; offset < bytes.length; offset += 0x8000) {
91 binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
92 }
93 return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
94 }
95
96 function privateKeyBytes(pem: string): Uint8Array {
97 const normalized = pem.replace(/\\n/g, "\n").trim();
98 const encoded = normalized
99 .replace("-----BEGIN PRIVATE KEY-----", "")
100 .replace("-----END PRIVATE KEY-----", "")
101 .replace(/\s/g, "");
102 if (!encoded) throw new Error("firebase private key is empty");
103 const binary = atob(encoded);
104 return Uint8Array.from(binary, (character) => character.charCodeAt(0));
105 }
106
107 async function serviceAccountAssertion(env: Env, now: number): Promise<string> {
108 const clientEmail = env.FIREBASE_CLIENT_EMAIL?.trim();
109 const privateKey = env.FIREBASE_PRIVATE_KEY;
110 if (!clientEmail || !privateKey) throw new Error("firebase service account is not configured");
111 const issuedAt = Math.floor(now / 1000);
112 const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
113 const claims = base64url(JSON.stringify({
114 iss: clientEmail,
115 scope: TOKEN_SCOPE,
116 aud: TOKEN_URL,
117 iat: issuedAt,
118 exp: issuedAt + 3600,
119 }));
120 const unsigned = `${header}.${claims}`;
121 const key = await crypto.subtle.importKey(
122 "pkcs8",
123 privateKeyBytes(privateKey),
124 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
125 false,
126 ["sign"],
127 );
128 const signature = await crypto.subtle.sign(
129 "RSASSA-PKCS1-v1_5",
130 key,
131 new TextEncoder().encode(unsigned),
132 );
133 return `${unsigned}.${base64url(new Uint8Array(signature))}`;
134 }
135
136 async function requestAccessToken(env: Env, fetcher: Fetcher, now: number): Promise<CachedToken> {
137 const assertion = await serviceAccountAssertion(env, now);
138 const body = new URLSearchParams({
139 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
140 assertion,
141 });
142 const controller = new AbortController();
143 const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
144 try {
145 const response = await fetcher(TOKEN_URL, {
146 method: "POST",
147 headers: { "content-type": "application/x-www-form-urlencoded" },
148 body,
149 signal: controller.signal,
150 });
151 if (!response.ok) throw new Error(`firebase oauth failed with ${response.status}`);
152 const payload = await response.json() as { access_token?: unknown; expires_in?: unknown };
153 if (typeof payload.access_token !== "string" || !payload.access_token) {
154 throw new Error("firebase oauth response omitted access_token");
155 }
156 const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : 3600;
157 return {
158 clientEmail: env.FIREBASE_CLIENT_EMAIL!.trim(),
159 value: payload.access_token,
160 expiresAt: now + Math.max(60, expiresIn) * 1000,
161 };
162 } finally {
163 clearTimeout(timeout);
164 }
165 }
166
167 async function accessToken(env: Env, fetcher: Fetcher, now = Date.now()): Promise<string> {
168 const clientEmail = env.FIREBASE_CLIENT_EMAIL?.trim() ?? "";
169 if (
170 cachedToken?.clientEmail === clientEmail &&
171 cachedToken.expiresAt - TOKEN_REFRESH_SKEW_MS > now
172 ) {
173 return cachedToken.value;
174 }
175 if (!tokenRequest) {
176 tokenRequest = requestAccessToken(env, fetcher, now).finally(() => {
177 tokenRequest = undefined;
178 });
179 }
180 cachedToken = await tokenRequest;
181 return cachedToken.value;
182 }
183
184 function databaseURL(env: Env): string {
185 const raw = env.FIREBASE_DATABASE_URL?.trim();
186 if (!raw) throw new Error("firebase database URL is not configured");
187 const url = new URL(raw);
188 if (url.protocol !== "https:" || !(
189 url.hostname.endsWith(".firebaseio.com") ||
190 url.hostname.endsWith(".firebasedatabase.app")
191 )) {
192 throw new Error("firebase database URL is not an approved Realtime Database host");
193 }
194 url.pathname = url.pathname.replace(/\/$/, "");
195 url.search = "";
196 url.hash = "";
197 return url.toString().replace(/\/$/, "");
198 }
199
200 async function firebaseFetch(
201 env: Env,
202 path: string,
203 init: RequestInit,
204 fetcher: Fetcher,
205 retryAuth = true,
206 ): Promise<Response> {
207 const token = await accessToken(env, fetcher);
208 const controller = new AbortController();
209 const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
210 try {
211 const queryOffset = path.indexOf("?");
212 const resource = queryOffset === -1 ? path : path.slice(0, queryOffset);
213 const query = queryOffset === -1 ? "" : path.slice(queryOffset);
214 const response = await fetcher(`${databaseURL(env)}/${resource}.json${query}`, {
215 ...init,
216 signal: controller.signal,
217 headers: {
218 "content-type": "application/json",
219 ...init.headers,
220 authorization: `Bearer ${token}`,
221 },
222 });
223 if (response.status === 401 && retryAuth) {
224 cachedToken = undefined;
225 return firebaseFetch(env, path, init, fetcher, false);
226 }
227 return response;
228 } finally {
229 clearTimeout(timeout);
230 }
231 }
232
233 export function firebaseConfigured(env: Env): boolean {
234 return Boolean(
235 env.FIREBASE_DATABASE_URL?.trim() &&
236 env.FIREBASE_CLIENT_EMAIL?.trim() &&
237 env.FIREBASE_PRIVATE_KEY,
238 );
239 }
240
241 export class FirebaseFenceError extends Error {
242 constructor() {
243 super("firebase conditional write was fenced by a newer writer");
244 this.name = "FirebaseFenceError";
245 }
246 }
247
248 type FencedValue = {
249 writerGeneration?: unknown;
250 sampleEpoch?: unknown;
251 groupCount?: unknown;
252 count?: unknown;
253 marker?: unknown;
254 };
255
256 function numberField(value: FencedValue | null, field: keyof FencedValue): number {
257 const raw = value?.[field];
258 return typeof raw === "number" && Number.isFinite(raw) ? raw : -1;
259 }
260
261 async function conditionalPut(
262 env: Env,
263 path: string,
264 candidate: FencedValue,
265 mayWrite: (current: FencedValue | null) => "write" | "complete" | "fenced",
266 fetcher: Fetcher,
267 beforeWrite: () => Promise<boolean>,
268 ): Promise<void> {
269 for (let attempt = 0; attempt < 3; attempt++) {
270 const currentResponse = await firebaseFetch(env, path, {
271 method: "GET",
272 headers: { "X-Firebase-ETag": "true" },
273 }, fetcher);
274 if (!currentResponse.ok) {
275 throw new Error(`firebase database conditional read failed with ${currentResponse.status}`);
276 }
277 const etag = currentResponse.headers.get("etag");
278 if (!etag) throw new Error("firebase database conditional read omitted ETag");
279 const raw = await currentResponse.json() as unknown;
280 const current = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as FencedValue : null;
281 const decision = mayWrite(current);
282 if (decision === "complete") return;
283 if (decision === "fenced") throw new FirebaseFenceError();
284 if (!await beforeWrite()) throw new FirebaseFenceError();
285 const write = await firebaseFetch(env, `${path}?print=silent`, {
286 method: "PUT",
287 headers: { "If-Match": etag },
288 body: JSON.stringify(candidate),
289 }, fetcher);
290 if (write.status === 412) continue;
291 if (!write.ok) throw new Error(`firebase database conditional write failed with ${write.status}`);
292 return;
293 }
294 throw new Error("firebase database conditional write retry limit reached");
295 }
296
297 function metaDecision(candidate: FirebaseFencedMeta) {
298 return (current: FencedValue | null): "write" | "complete" | "fenced" => {
299 if (!current) return "write";
300 const generation = numberField(current, "writerGeneration");
301 const epoch = numberField(current, "sampleEpoch");
302 const count = numberField(current, "count");
303 if (generation > candidate.writerGeneration || epoch > candidate.sampleEpoch) return "fenced";
304 if (
305 generation === candidate.writerGeneration && epoch === candidate.sampleEpoch &&
306 count >= candidate.count
307 ) return "complete";
308 return "write";
309 };
310 }
311
312 function sampleDecision(candidate: FirebaseCrashSample | FirebaseSampleMarker, first: boolean) {
313 return (current: FencedValue | null): "write" | "complete" | "fenced" => {
314 if (!current) return "write";
315 const generation = numberField(current, "writerGeneration");
316 const epoch = numberField(current, "sampleEpoch");
317 const count = numberField(current, "groupCount");
318 if (epoch > candidate.sampleEpoch || generation > candidate.writerGeneration) return "fenced";
319 if (epoch < candidate.sampleEpoch) return "write";
320 if ("marker" in candidate) {
321 return generation === candidate.writerGeneration && current.marker === candidate.marker
322 ? "complete" : "write";
323 }
324 if (first && current.marker === undefined) return "complete";
325 if (generation === candidate.writerGeneration && count >= candidate.groupCount) return "complete";
326 if (!first && current.marker === undefined && count >= candidate.groupCount) return "complete";
327 return "write";
328 };
329 }
330
331 export async function writeFirebaseCrashGroup(
332 env: Env,
333 meta: FirebaseCrashGroupMeta,
334 sample: FirebaseCrashSampleInput,
335 latestSlot: number | null,
336 firstSample: boolean,
337 writerGeneration: number,
338 sampleEpoch: number,
339 beforeWrite: () => Promise<boolean> = async () => true,
340 fetcher: Fetcher = fetch,
341 ): Promise<void> {
342 if (latestSlot !== null && (!Number.isInteger(latestSlot) || latestSlot < 0 || latestSlot > 4)) {
343 throw new Error("firebase latest sample slot is invalid");
344 }
345 const fencedMeta: FirebaseFencedMeta = {
346 ...meta,
347 writerGeneration,
348 sampleEpoch,
349 sampleState: "active",
350 };
351 const fencedSample: FirebaseCrashSample = {
352 ...sample,
353 groupCount: meta.count,
354 writerGeneration,
355 sampleEpoch,
356 };
357 const root = `groups/${encodeURIComponent(meta.fingerprint)}`;
358 await conditionalPut(env, `${root}/meta`, fencedMeta, metaDecision(fencedMeta), fetcher, beforeWrite);
359 if (firstSample) {
360 await conditionalPut(
361 env, `${root}/samples/first`, fencedSample, sampleDecision(fencedSample, true), fetcher, beforeWrite,
362 );
363 }
364 if (latestSlot !== null) {
365 await conditionalPut(
366 env, `${root}/samples/latest/${latestSlot}`, fencedSample, sampleDecision(fencedSample, false), fetcher,
367 beforeWrite,
368 );
369 }
370 }
371
372 export async function readFirebaseCrashGroup(
373 env: Env,
374 fingerprint: string,
375 fetcher: Fetcher = fetch,
376 ): Promise<FirebaseCrashGroup | null> {
377 const response = await firebaseFetch(
378 env,
379 `groups/${encodeURIComponent(fingerprint)}`,
380 { method: "GET" },
381 fetcher,
382 );
383 if (!response.ok) throw new Error(`firebase database read failed with ${response.status}`);
384 const value = await response.json() as unknown;
385 if (value === null) return null;
386 if (typeof value !== "object" || Array.isArray(value)) {
387 throw new Error("firebase database group response is invalid");
388 }
389 return value as FirebaseCrashGroup;
390 }
391
392 export async function writeFirebaseGroupMeta(
393 env: Env,
394 fingerprint: string,
395 meta: FirebaseCrashGroupMeta,
396 writerGeneration: number,
397 sampleEpoch: number,
398 sampleState: FirebaseFencedMeta["sampleState"],
399 beforeWrite: () => Promise<boolean> = async () => true,
400 fetcher: Fetcher = fetch,
401 ): Promise<void> {
402 const candidate: FirebaseFencedMeta = { ...meta, writerGeneration, sampleEpoch, sampleState };
403 await conditionalPut(
404 env,
405 `groups/${encodeURIComponent(fingerprint)}/meta`,
406 candidate,
407 metaDecision(candidate),
408 fetcher,
409 beforeWrite,
410 );
411 }
412
413 export async function writeFirebaseSampleMarkers(
414 env: Env,
415 fingerprint: string,
416 groupCount: number,
417 writerGeneration: number,
418 sampleEpoch: number,
419 marker: FirebaseSampleMarker["marker"],
420 includeFirst: boolean,
421 beforeWrite: () => Promise<boolean> = async () => true,
422 fetcher: Fetcher = fetch,
423 ): Promise<void> {
424 const candidate: FirebaseSampleMarker = {
425 marker, groupCount, writerGeneration, sampleEpoch,
426 };
427 const root = `groups/${encodeURIComponent(fingerprint)}/samples`;
428 const paths = Array.from({ length: 5 }, (_, slot) => `${root}/latest/${slot}`);
429 if (includeFirst) paths.unshift(`${root}/first`);
430 for (const path of paths) {
431 await conditionalPut(env, path, candidate, sampleDecision(candidate, false), fetcher, beforeWrite);
432 }
433 }
434
435 export async function deleteFirebaseCrashGroup(
436 env: Env,
437 fingerprint: string,
438 fetcher: Fetcher = fetch,
439 ): Promise<void> {
440 const response = await firebaseFetch(
441 env,
442 `groups/${encodeURIComponent(fingerprint)}`,
443 { method: "DELETE" },
444 fetcher,
445 );
446 if (!response.ok) throw new Error(`firebase database delete failed with ${response.status}`);
447 }
448
449 export async function deleteFirebaseCrashGroupConditional(
450 env: Env,
451 fingerprint: string,
452 beforeDelete: () => Promise<boolean>,
453 fetcher: Fetcher = fetch,
454 ): Promise<void> {
455 const path = `groups/${encodeURIComponent(fingerprint)}`;
456 for (let attempt = 0; attempt < 3; attempt++) {
457 const current = await firebaseFetch(env, path, {
458 method: "GET",
459 headers: { "X-Firebase-ETag": "true" },
460 }, fetcher);
461 if (!current.ok) throw new Error(`firebase database delete precondition read failed with ${current.status}`);
462 const etag = current.headers.get("etag");
463 if (!etag) throw new Error("firebase database delete precondition read omitted ETag");
464 if (!await beforeDelete()) throw new FirebaseFenceError();
465 const response = await firebaseFetch(env, `${path}?print=silent`, {
466 method: "DELETE",
467 headers: { "If-Match": etag },
468 }, fetcher);
469 if (response.status === 412) continue;
470 if (!response.ok) throw new Error(`firebase database conditional delete failed with ${response.status}`);
471 return;
472 }
473 throw new Error("firebase database conditional delete retry limit reached");
474 }
475
476 export function resetFirebaseAuthForTests(): void {
477 cachedToken = undefined;
478 tokenRequest = undefined;
479 }
480
480 lines TYPESCRIPT