///
// Extend CacheStorage with Cloudflare's default cache property
declare global {
interface CacheStorage {
readonly default: Cache;
}
}
/**
* Cloudflare Edge Cache Store
*
* Production cache store using Cloudflare's Cache API (L1) with optional
* KV persistence (L2).
*
* L1 (Cache API): Per-colo, fast, ephemeral. Handles SWR atomically.
* L2 (KV): Global, persistent, ~50ms reads. Auto-warms cold colos.
*
* Read flow: L1 hit → serve | L1 miss → L2 hit → serve + promote to L1 | both miss → render
* Write flow: L1 write + L2 write (both via waitUntil)
*
* Features:
* - Extended TTL for SWR window (max-age = ttl + swr)
* - Staleness via x-edge-cache-stale-at header
* - Atomic REVALIDATING status for thundering herd prevention (L1 only)
* - Non-blocking writes via waitUntil
* - KV L2 for cross-colo cache persistence
*/
import type {
SegmentCacheStore,
CachedEntryData,
CacheDefaults,
CacheGetResult,
CacheItemResult,
CacheItemOptions,
} from "../types.js";
import {
_getRequestContext,
type RequestContext,
} from "../../server/request-context.js";
import { VERSION } from "@rangojs/router:version";
import {
resolveTtl,
resolveSwrWindow,
DEFAULT_FUNCTION_TTL,
} from "../cache-policy.js";
// ============================================================================
// Constants
// ============================================================================
/** Header storing timestamp when entry becomes stale */
export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
/** Header storing cache status: HIT | REVALIDATING */
export const CACHE_STATUS_HEADER = "x-edge-cache-status";
/**
* Maximum age in seconds for REVALIDATING status before allowing new revalidation.
* After this period, a stale entry in REVALIDATING status will trigger revalidation again.
* @internal
*/
export const MAX_REVALIDATION_INTERVAL = 30;
// ============================================================================
// Types
// ============================================================================
// Re-exported from the canonical home so cf-cache-store consumers keep
// importing `ExecutionContext` from this module without a second interface
// drifting over time.
export type { ExecutionContext } from "../../types/request-scope.js";
import type { ExecutionContext } from "../../types/request-scope.js";
/**
* Minimal Cloudflare KV Namespace interface.
* Avoids hard dependency on @cloudflare/workers-types.
*/
export interface KVNamespace {
get(key: string, options?: { type?: string }): Promise;
put(
key: string,
value: string,
options?: { expirationTtl?: number },
): Promise;
delete(key: string): Promise;
}
/**
* KV envelope for segment cache entries.
* @internal
*/
interface KVSegmentEnvelope {
/** Cached segment data */
d: CachedEntryData;
/** When entry becomes stale (ms epoch) */
s: number;
/** When entry hard-expires (ms epoch) */
e: number;
}
/**
* KV envelope for function cache entries ("use cache").
* @internal
*/
interface KVItemEnvelope {
/** RSC-serialized return value */
v: string;
/** Handle data */
h?: Record>;
/** When entry becomes stale (ms epoch) */
s: number;
/** When entry hard-expires (ms epoch) */
e: number;
}
/**
* KV envelope for document cache entries.
* @internal
*/
interface KVResponseEnvelope {
/** Response body as base64-encoded string (safe for binary payloads) */
b: string;
/** HTTP status code */
st: number;
/** HTTP status text */
stx: string;
/** Serialized headers as key-value pairs */
hd: [string, string][];
/** When entry becomes stale (ms epoch) */
s: number;
/** When entry hard-expires (ms epoch) */
e: number;
}
export interface CFCacheStoreOptions {
/**
* Cache namespace. If not provided, uses caches.default (recommended).
* Only set this if you need isolated cache storage.
*/
namespace?: string;
/**
* Base URL for cache keys.
*
* If not provided, derives from request hostname via requestContext:
* - Production domains → uses `https://{hostname}/`
* - Dev/preview (localhost, workers.dev, pages.dev) → uses internal fallback URL
*/
baseUrl?: string;
/** Default cache options */
defaults?: CacheDefaults;
/**
* Cloudflare ExecutionContext for non-blocking cache writes.
* Pass the `ctx` from your worker's fetch handler.
*
* @example
* ```typescript
* new CFCacheStore({ ctx: env.ctx })
* ```
*/
ctx: ExecutionContext;
/**
* Optional KV namespace for L2 cache persistence.
*
* When provided, KV acts as a global fallback behind the per-colo Cache API.
* On L1 miss, KV is checked and hits are promoted back to L1.
* On writes, data is persisted to both L1 and KV.
*
* @example
* ```typescript
* new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
* ```
*/
kv?: KVNamespace;
/**
* Cache version string override. When this changes, all cached entries are
* effectively invalidated (new keys won't match old entries).
*
* Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
* Only set this if you need a custom versioning strategy.
*/
version?: string;
/**
* Custom key generator applied to all cache operations.
* Receives the full RequestContext (including env) and the default-generated key.
* Return value becomes the final cache key (unless route overrides with `key` option).
*
* @example Using headers for user segmentation
* ```typescript
* keyGenerator: (ctx, defaultKey) => {
* const segment = ctx.request.headers.get('x-user-segment') || 'default';
* return `${segment}:${defaultKey}`;
* }
* ```
*
* @example Using env bindings for multi-region
* ```typescript
* keyGenerator: (ctx, defaultKey) => {
* const region = ctx.env.REGION || 'us';
* return `${region}:${defaultKey}`;
* }
* ```
*
* @example Using cookies for locale-aware caching
* ```typescript
* keyGenerator: (ctx, defaultKey) => {
* const locale = cookies().get('locale')?.value || 'en';
* return `${locale}:${defaultKey}`;
* }
* ```
*/
keyGenerator?: (
ctx: RequestContext,
defaultKey: string,
) => string | Promise;
}
/**
* Cache status values for the x-edge-cache-status header.
* @internal
*/
export type CacheStatus = "HIT" | "REVALIDATING";
// ============================================================================
// CFCacheStore Implementation
// ============================================================================
export class CFCacheStore implements SegmentCacheStore {
readonly defaults?: CacheDefaults;
readonly keyGenerator?: (
ctx: RequestContext,
defaultKey: string,
) => string | Promise;
private readonly namespace?: string;
private readonly baseUrl: string;
private readonly waitUntil?: (fn: () => Promise) => void;
private readonly version?: string;
private readonly kv?: KVNamespace;
constructor(options: CFCacheStoreOptions) {
if (!options.ctx) {
throw new Error(
"[CFCacheStore] ExecutionContext (ctx) is required. " +
"Pass the Cloudflare ExecutionContext from your worker's fetch handler: " +
"new CFCacheStore({ ctx: env.ctx })",
);
}
this.namespace = options.namespace;
this.baseUrl = options.baseUrl ?? this.deriveBaseUrl();
this.defaults = options.defaults;
this.version = options.version ?? VERSION;
this.keyGenerator = options.keyGenerator;
this.waitUntil = (fn) => options.ctx.waitUntil(fn());
this.kv = options.kv;
}
/**
* Derive base URL from request hostname via requestContext.
* Uses internal fallback for dev/preview environments and untrusted hostnames.
* @internal
*/
private deriveBaseUrl(): string {
const fallback = "https://rsc-cache.internal.com/";
const ctx = _getRequestContext();
if (!ctx?.request) {
return fallback;
}
try {
const url = new URL(ctx.request.url);
const hostname = url.hostname;
// Use fallback for dev/preview environments
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.endsWith(".workers.dev") ||
hostname.endsWith(".pages.dev")
) {
return fallback;
}
// Validate hostname: must be a valid domain (alphanumeric, hyphens, dots)
// to prevent host header injection into cache keys
if (!/^[a-zA-Z0-9.-]+$/.test(hostname) || hostname.length > 253) {
return fallback;
}
// Use actual hostname for production
return `https://${hostname}/`;
} catch {
return fallback;
}
}
/**
* Get the cache instance - uses caches.default unless namespace is specified.
* @internal
*/
private getCache(): Cache | Promise {
if (this.namespace) {
return caches.open(this.namespace);
}
return caches.default;
}
// ============================================================================
// Segment Cache Methods
// ============================================================================
/**
* Get cached entry data by key.
*
* Handles SWR atomically:
* - If stale and not already revalidating, marks as REVALIDATING and returns shouldRevalidate: true
* - If already REVALIDATING (and recent), returns shouldRevalidate: false
* - If fresh, returns shouldRevalidate: false
*
* On L1 miss, falls back to KV (L2) if configured.
* KV hits are promoted to L1 in the background.
*/
async get(key: string): Promise {
try {
const cache = await this.getCache();
const request = this.keyToRequest(key);
const response = await cache.match(request);
if (!response) {
return this.kvGetSegment(key);
}
// Read status headers
const status = response.headers.get(CACHE_STATUS_HEADER);
const age = Number(response.headers.get("age") ?? "0");
const staleAt = Number(
response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
);
const isStale = staleAt > 0 && Date.now() > staleAt;
const isRevalidating =
status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
// Case 1: Fresh or already being revalidated - just return data
if (!isStale || isRevalidating) {
const data = (await response.json()) as CachedEntryData;
return { data, shouldRevalidate: false };
}
// Case 2: Stale and needs revalidation - atomically mark REVALIDATING
const [b1, b2] = response.body!.tee();
const headers = new Headers(response.headers);
headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
// Blocking write - must complete before returning to prevent race
await cache.put(
request,
new Response(b1, { status: response.status, headers }),
);
const data = (await new Response(b2).json()) as CachedEntryData;
return { data, shouldRevalidate: true };
} catch (error) {
console.error("[CFCacheStore] get failed:", error);
return null;
}
}
/**
* Store entry data with TTL and optional SWR window.
* Uses waitUntil for non-blocking write when available.
* When KV is configured, also persists to L2.
*/
async set(
key: string,
data: CachedEntryData,
ttl: number,
swr?: number,
): Promise {
try {
const cache = await this.getCache();
const request = this.keyToRequest(key);
// Extended TTL covers SWR window
const swrWindow = resolveSwrWindow(swr, this.defaults);
const totalTtl = ttl + swrWindow;
const staleAt = Date.now() + ttl * 1000;
const body = JSON.stringify(data);
const response = new Response(body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${totalTtl}`,
[CACHE_STALE_AT_HEADER]: String(staleAt),
[CACHE_STATUS_HEADER]: "HIT",
},
});
const putPromise = cache.put(request, response);
if (this.waitUntil) {
// Non-blocking write
this.waitUntil(async () => {
await putPromise;
});
} else {
// Blocking fallback
await putPromise;
}
// L2: persist to KV
this.kvSetSegment(key, data, staleAt, totalTtl);
} catch (error) {
console.error("[CFCacheStore] set failed:", error);
}
}
/**
* Delete a cached entry from L1 and L2.
*/
async delete(key: string): Promise {
try {
const cache = await this.getCache();
const result = await cache.delete(this.keyToRequest(key));
// L2: delete from KV
if (this.kv && this.waitUntil) {
const kvKey = this.toKVKey(key);
this.waitUntil(async () => {
try {
await this.kv!.delete(kvKey);
} catch {
// KV delete failures are non-critical
}
});
}
return result;
} catch (error) {
console.error("[CFCacheStore] delete failed:", error);
return false;
}
}
// ============================================================================
// Document Cache Methods
// ============================================================================
/**
* Get a cached Response by key (for document-level caching).
* Returns the response and whether it should be revalidated (SWR).
* Falls back to KV (L2) on L1 miss.
*/
async getResponse(
key: string,
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
try {
const cache = await this.getCache();
const request = this.keyToRequest(`doc:${key}`);
const response = await cache.match(request);
if (!response || response.status !== 200) {
return this.kvGetResponse(key);
}
// Check staleness
const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
const isStale = staleAt > 0 && Date.now() > staleAt;
return {
response,
shouldRevalidate: isStale,
};
} catch (error) {
console.error("[CFCacheStore] getResponse failed:", error);
return null;
}
}
/**
* Store a Response with TTL and optional SWR window (for document-level caching).
* When KV is configured, also persists to L2.
*/
async putResponse(
key: string,
response: Response,
ttl: number,
swr?: number,
): Promise {
try {
const cache = await this.getCache();
const request = this.keyToRequest(`doc:${key}`);
// Extended TTL covers SWR window
const swrWindow = resolveSwrWindow(swr, this.defaults);
const totalTtl = ttl + swrWindow;
const staleAt = Date.now() + ttl * 1000;
// Clone body for potential KV write before consuming it for L1
const [l1Body, kvBody] = this.kv
? response.body
? response.body.tee()
: [null, null]
: [response.body, null];
// Clone and add cache headers
const headers = new Headers(response.headers);
headers.set("Cache-Control", `public, max-age=${totalTtl}`);
headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
const toCache = new Response(l1Body, {
status: response.status,
statusText: response.statusText,
headers,
});
const putPromise = cache.put(request, toCache);
if (this.waitUntil) {
// Non-blocking write
this.waitUntil(async () => {
await putPromise;
});
} else {
// Blocking fallback
await putPromise;
}
// L2: persist to KV (KV requires expirationTtl >= 60s)
if (this.kv && this.waitUntil && totalTtl >= 60) {
const kvKey = this.toKVKey(`doc:${key}`);
const headersArray: [string, string][] = [];
response.headers.forEach((v, k) => headersArray.push([k, v]));
// Read body as ArrayBuffer and encode to base64 to preserve binary payloads
const bodyBuf = kvBody
? await new Response(kvBody).arrayBuffer()
: new ArrayBuffer(0);
const bodyBase64 = bufferToBase64(bodyBuf);
this.waitUntil(async () => {
try {
const envelope: KVResponseEnvelope = {
b: bodyBase64,
st: response.status,
stx: response.statusText,
hd: headersArray,
s: staleAt,
e: staleAt + swrWindow * 1000,
};
await this.kv!.put(kvKey, JSON.stringify(envelope), {
expirationTtl: totalTtl,
});
} catch (error) {
console.error("[CFCacheStore] KV putResponse failed:", error);
}
});
}
} catch (error) {
console.error("[CFCacheStore] putResponse failed:", error);
}
}
// ============================================================================
// Function Cache Methods (for "use cache" directive)
// ============================================================================
/**
* Get a cached function result by key.
* Follows the same SWR pattern as get() for segment caching.
* Falls back to KV (L2) on L1 miss.
*/
async getItem(key: string): Promise {
try {
const cache = await this.getCache();
const request = this.keyToRequest(`fn:${key}`);
const response = await cache.match(request);
if (!response) return this.kvGetItem(key);
const staleAt = Number(
response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
);
const status = response.headers.get(CACHE_STATUS_HEADER);
const age = Number(response.headers.get("age") ?? "0");
const isStale = staleAt > 0 && Date.now() > staleAt;
const isRevalidating =
status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
const data = (await response.json()) as {
value: string;
handles?: Record>;
};
if (!isStale || isRevalidating) {
return {
value: data.value,
handles: data.handles,
shouldRevalidate: false,
};
}
// Stale and needs revalidation — mark REVALIDATING atomically
const headers = new Headers(response.headers);
headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
await cache.put(
request,
new Response(JSON.stringify(data), { status: 200, headers }),
);
return {
value: data.value,
handles: data.handles,
shouldRevalidate: true,
};
} catch (error) {
console.error("[CFCacheStore] getItem failed:", error);
return null;
}
}
/**
* Store a function result with TTL and optional SWR window.
* When KV is configured, also persists to L2.
*/
async setItem(
key: string,
value: string,
options?: CacheItemOptions,
): Promise {
try {
const cache = await this.getCache();
const request = this.keyToRequest(`fn:${key}`);
const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
const totalTtl = ttl + swrWindow;
const staleAt = Date.now() + ttl * 1000;
const body = JSON.stringify({ value, handles: options?.handles });
const response = new Response(body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${totalTtl}`,
[CACHE_STALE_AT_HEADER]: String(staleAt),
[CACHE_STATUS_HEADER]: "HIT",
},
});
const putPromise = cache.put(request, response);
if (this.waitUntil) {
this.waitUntil(async () => {
await putPromise;
});
} else {
await putPromise;
}
// L2: persist to KV (KV requires expirationTtl >= 60s)
if (this.kv && this.waitUntil && totalTtl >= 60) {
const kvKey = this.toKVKey(`fn:${key}`);
this.waitUntil(async () => {
try {
const envelope: KVItemEnvelope = {
v: value,
h: options?.handles,
s: staleAt,
e: staleAt + swrWindow * 1000,
};
await this.kv!.put(kvKey, JSON.stringify(envelope), {
expirationTtl: totalTtl,
});
} catch (error) {
console.error("[CFCacheStore] KV setItem failed:", error);
}
});
}
} catch (error) {
console.error("[CFCacheStore] setItem failed:", error);
}
}
// ============================================================================
// Key Helpers
// ============================================================================
/**
* Convert string key to Request object for CF Cache API.
* Includes version in URL if specified (for cache invalidation on code changes).
* @internal
*/
private keyToRequest(key: string): Request {
const encodedKey = encodeURIComponent(key);
// Include version in URL path to invalidate cache when version changes
const versionPath = this.version ? `v/${this.version}/` : "";
return new Request(`${this.baseUrl}${versionPath}${encodedKey}`, {
method: "GET",
});
}
/**
* Convert string key to KV key string.
* Uses same version prefix as Cache API for consistent invalidation.
* @internal
*/
private toKVKey(key: string): string {
const versionPath = this.version ? `v/${this.version}/` : "";
return `${versionPath}${key}`;
}
// ============================================================================
// KV L2 Helpers
// ============================================================================
/**
* KV fallback for segment cache reads.
* Returns null if KV is not configured, entry is missing, or expired.
* Promotes hits to L1 via waitUntil.
* @internal
*/
private async kvGetSegment(key: string): Promise {
if (!this.kv) return null;
try {
const kvKey = this.toKVKey(key);
const raw = await this.kv.get(kvKey, { type: "json" });
if (!raw) return null;
const envelope = raw as KVSegmentEnvelope;
const now = Date.now();
// Hard-expired — treat as miss
if (now > envelope.e) return null;
const shouldRevalidate = now > envelope.s;
// Promote to L1 in background
this.promoteSegmentToL1(key, envelope);
return { data: envelope.d, shouldRevalidate };
} catch (error) {
console.error("[CFCacheStore] KV get failed:", error);
return null;
}
}
/**
* Write segment data to KV.
* @internal
*/
private kvSetSegment(
key: string,
data: CachedEntryData,
staleAt: number,
totalTtl: number,
): void {
// KV requires expirationTtl >= 60s. Skip write for short-lived entries.
if (!this.kv || !this.waitUntil || totalTtl < 60) return;
const kvKey = this.toKVKey(key);
const swrWindow = totalTtl * 1000 - (staleAt - Date.now());
const expiresAt = staleAt + swrWindow;
this.waitUntil(async () => {
try {
const envelope: KVSegmentEnvelope = {
d: data,
s: staleAt,
e: expiresAt,
};
await this.kv!.put(kvKey, JSON.stringify(envelope), {
expirationTtl: totalTtl,
});
} catch (error) {
console.error("[CFCacheStore] KV set failed:", error);
}
});
}
/**
* Promote segment data from KV to L1 Cache API.
* @internal
*/
private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
if (!this.waitUntil) return;
this.waitUntil(async () => {
try {
const now = Date.now();
const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
const cache = await this.getCache();
const request = this.keyToRequest(key);
const response = new Response(JSON.stringify(envelope.d), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${remainingTtl}`,
[CACHE_STALE_AT_HEADER]: String(envelope.s),
[CACHE_STATUS_HEADER]: "HIT",
},
});
await cache.put(request, response);
} catch (error) {
console.error("[CFCacheStore] L1 promote failed:", error);
}
});
}
/**
* KV fallback for function cache reads.
* @internal
*/
private async kvGetItem(key: string): Promise {
if (!this.kv) return null;
try {
const kvKey = this.toKVKey(`fn:${key}`);
const raw = await this.kv.get(kvKey, { type: "json" });
if (!raw) return null;
const envelope = raw as KVItemEnvelope;
const now = Date.now();
if (now > envelope.e) return null;
const shouldRevalidate = now > envelope.s;
// Promote to L1
this.promoteItemToL1(key, envelope);
return {
value: envelope.v,
handles: envelope.h,
shouldRevalidate,
};
} catch (error) {
console.error("[CFCacheStore] KV getItem failed:", error);
return null;
}
}
/**
* Promote function cache data from KV to L1.
* @internal
*/
private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
if (!this.waitUntil) return;
this.waitUntil(async () => {
try {
const now = Date.now();
const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
const cache = await this.getCache();
const request = this.keyToRequest(`fn:${key}`);
const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
const response = new Response(body, {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${remainingTtl}`,
[CACHE_STALE_AT_HEADER]: String(envelope.s),
[CACHE_STATUS_HEADER]: "HIT",
},
});
await cache.put(request, response);
} catch (error) {
console.error("[CFCacheStore] L1 item promote failed:", error);
}
});
}
/**
* KV fallback for document cache reads.
* @internal
*/
private async kvGetResponse(
key: string,
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
if (!this.kv) return null;
try {
const kvKey = this.toKVKey(`doc:${key}`);
const raw = await this.kv.get(kvKey, { type: "json" });
if (!raw) return null;
const envelope = raw as KVResponseEnvelope;
const now = Date.now();
if (now > envelope.e) return null;
const shouldRevalidate = now > envelope.s;
// Reconstruct Response (decode base64 → binary)
const headers = new Headers(envelope.hd);
const bodyBuffer = base64ToBuffer(envelope.b);
const response = new Response(bodyBuffer, {
status: envelope.st,
statusText: envelope.stx,
headers,
});
// Promote to L1
this.promoteResponseToL1(key, envelope);
return { response, shouldRevalidate };
} catch (error) {
console.error("[CFCacheStore] KV getResponse failed:", error);
return null;
}
}
/**
* Promote document cache data from KV to L1.
* @internal
*/
private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
if (!this.waitUntil) return;
this.waitUntil(async () => {
try {
const now = Date.now();
const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
const cache = await this.getCache();
const request = this.keyToRequest(`doc:${key}`);
const headers = new Headers(envelope.hd);
headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
const bodyBuffer = base64ToBuffer(envelope.b);
const response = new Response(bodyBuffer, {
status: envelope.st,
statusText: envelope.stx,
headers,
});
await cache.put(request, response);
} catch (error) {
console.error("[CFCacheStore] L1 response promote failed:", error);
}
});
}
}
// ============================================================================
// Base64 Helpers (binary-safe response body encoding for KV)
// ============================================================================
/** Encode ArrayBuffer to base64 string. */
function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}
/** Decode base64 string to ArrayBuffer. */
function base64ToBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}