feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)
This commit is contained in:
1 parent
0a0b3a12c9
commit
3b2fc2527e
181 files changed
+18472
-4364
No files matched your search
@@ -0,0 +1,45 @@
|
||||
type HeaderSetter = Pick<Headers, "set">;
|
||||
|
||||
export const AUTH_HANDLERS: Record<
|
||||
string,
|
||||
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
|
||||
> = {
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const secret of secrets) {
|
||||
map[secret.kind] = secret.value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function applyAuth(
|
||||
headers: HeaderSetter,
|
||||
url: URL,
|
||||
authType: string,
|
||||
secrets: Array<{ kind: string; value: string }>,
|
||||
headerName?: string | null,
|
||||
): void {
|
||||
const secretMap = buildSecretMap(secrets);
|
||||
|
||||
if (authType === "basic" && secretMap.username && secretMap.password) {
|
||||
const credentials = Buffer.from(`${secretMap.username}:${secretMap.password}`).toString("base64");
|
||||
headers.set("Authorization", `Basic ${credentials}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = AUTH_HANDLERS[authType];
|
||||
if (handler && secretMap.apiKey) {
|
||||
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type CustomWidgetDomainErrorCode =
|
||||
| "BAD_GATEWAY"
|
||||
| "BAD_REQUEST"
|
||||
| "FORBIDDEN"
|
||||
| "INTERNAL_SERVER_ERROR"
|
||||
| "NOT_FOUND"
|
||||
| "PAYLOAD_TOO_LARGE"
|
||||
| "TOO_MANY_REQUESTS";
|
||||
|
||||
export class CustomWidgetDomainError extends Error {
|
||||
public readonly code: CustomWidgetDomainErrorCode;
|
||||
public readonly retryAfterMs?: number;
|
||||
|
||||
public constructor(input: {
|
||||
code: CustomWidgetDomainErrorCode;
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
retryAfterMs?: number;
|
||||
}) {
|
||||
super(input.message, { cause: input.cause });
|
||||
this.name = "CustomWidgetDomainError";
|
||||
this.code = input.code;
|
||||
this.retryAfterMs = input.retryAfterMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export { CustomWidgetDomainError } from "./errors";
|
||||
export type { CustomWidgetDomainErrorCode } from "./errors";
|
||||
export {
|
||||
assertJsonBudget,
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./request-executor";
|
||||
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
|
||||
export { CustomWidgetRequestLimiter } from "./request-limits";
|
||||
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
|
||||
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
|
||||
export type {
|
||||
CreatePreviewSessionInput,
|
||||
CustomWidgetPreviewJournalEntry,
|
||||
CustomWidgetPreviewSession,
|
||||
PreviewSessionServiceOptions,
|
||||
PreviewSessionStore,
|
||||
} from "./preview-sessions";
|
||||
export { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "./request-manifest";
|
||||
export type { CustomJsxRuntimeParams } from "./request-manifest";
|
||||
@@ -0,0 +1,168 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import { MAX_RESPONSE_BODY_BYTES } from "./response";
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
["0.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["169.254.0.0", 16],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
] as const)
|
||||
blocked.addSubnet(address, prefix, "ipv4");
|
||||
blocked.addAddress("::", "ipv6");
|
||||
for (const [address, prefix] of [
|
||||
["64:ff9b::", 96],
|
||||
["100::", 64],
|
||||
["2001:db8::", 32],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
] as const) {
|
||||
blocked.addSubnet(address, prefix, "ipv6");
|
||||
}
|
||||
blocked.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
export type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
const normalizeHostname = (value: string) =>
|
||||
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export function classifyAddress(address: string): AddressClass {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (!family || normalized.includes("::ffff:") || blocked.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
}
|
||||
|
||||
export async function resolveAndValidateHost(
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
): Promise<ResolvedAddress[]> {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
const addresses = family
|
||||
? [{ address: normalized, family }]
|
||||
: ((await lookup(normalized, { all: true, verbatim: true })) as ResolvedAddress[]);
|
||||
if (!addresses.length)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
for (const address of addresses) {
|
||||
const classification = classifyAddress(address.address);
|
||||
const allowed =
|
||||
classification === "public" ||
|
||||
(classification === "private" && scope !== "public") ||
|
||||
(classification === "loopback" && scope === "loopback");
|
||||
if (!allowed)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function validateCustomWidgetUrl(value: string | URL): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
if (url.username || url.password)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
if (url.hash) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
return url;
|
||||
}
|
||||
|
||||
export function resolveSameOriginTarget(baseValue: string, targetValue?: string | URL): URL {
|
||||
const base = validateCustomWidgetUrl(baseValue);
|
||||
const target = validateCustomWidgetUrl(targetValue ?? base);
|
||||
if (target.origin !== base.origin)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Named requests must stay on the widget's origin",
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const value = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(value) ||
|
||||
value.startsWith("proxy-") ||
|
||||
value.startsWith("sec-") ||
|
||||
value.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
} else if (options.all) callback(null, candidates);
|
||||
else callback(null, selected.address, selected.family);
|
||||
};
|
||||
return new Agent({
|
||||
connect: { lookup: customLookup },
|
||||
connectTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
customJsxNetworkScopes,
|
||||
customJsxRequestSchema,
|
||||
customWidgetAuthTypes,
|
||||
customWidgetSecretKinds,
|
||||
} from "../core";
|
||||
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60_000;
|
||||
const MAX_JOURNAL_ENTRIES = 50;
|
||||
|
||||
const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.number(),
|
||||
baseUrl: z.string(),
|
||||
authType: z.enum(customWidgetAuthTypes),
|
||||
headerName: z.string().nullable(),
|
||||
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
|
||||
|
||||
const journalEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof journalEntrySchema>;
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
baseUrl: string;
|
||||
authType: CustomWidgetAuthType;
|
||||
headerName?: string;
|
||||
secrets: Array<{ kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
requests: CustomJsxRequest[];
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSessionStore {
|
||||
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
getSession(id: string): Promise<unknown>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
||||
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
|
||||
}
|
||||
|
||||
export interface PreviewSessionServiceOptions {
|
||||
createId(): string;
|
||||
encrypt(value: string): string;
|
||||
decrypt(value: string): string;
|
||||
now?: () => number;
|
||||
store?: PreviewSessionStore;
|
||||
}
|
||||
|
||||
export class CustomWidgetPreviewSessionService {
|
||||
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
private readonly journals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
private readonly now: () => number;
|
||||
|
||||
public constructor(private readonly options: PreviewSessionServiceOptions) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
public async create(input: CreatePreviewSessionInput) {
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: this.options.createId(),
|
||||
userId: input.userId,
|
||||
expiresAt: this.now() + SESSION_TTL_MS,
|
||||
baseUrl: input.baseUrl,
|
||||
authType: input.authType,
|
||||
headerName: input.headerName ?? null,
|
||||
secrets: input.secrets.map((secret) => ({ kind: secret.kind, value: this.options.encrypt(secret.value) })),
|
||||
networkScope: input.networkScope,
|
||||
requests: input.requests,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
await this.save(session);
|
||||
return { id: session.id, expiresAt: session.expiresAt, liveActions: false as const };
|
||||
}
|
||||
|
||||
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
|
||||
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
|
||||
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
|
||||
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= this.now()) {
|
||||
if (this.options.store) await this.options.store.deleteSession(id);
|
||||
else this.sessions.delete(id);
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async setLiveActions(id: string, userId: string, enabled: boolean) {
|
||||
const session = { ...(await this.get(id, userId)), liveActions: enabled };
|
||||
await this.save(session);
|
||||
return { id, expiresAt: session.expiresAt, liveActions: enabled };
|
||||
}
|
||||
|
||||
public async appendJournal(
|
||||
session: CustomWidgetPreviewSession,
|
||||
input: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
): Promise<void> {
|
||||
const value = journalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
|
||||
const ttlMs = Math.max(1, session.expiresAt - this.now());
|
||||
if (this.options.store) await this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs);
|
||||
else this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
|
||||
}
|
||||
|
||||
public async getJournal(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
|
||||
await this.get(id, userId);
|
||||
const entries = this.options.store
|
||||
? await this.options.store.getJournal(id, MAX_JOURNAL_ENTRIES)
|
||||
: (this.journals.get(id) ?? []);
|
||||
return entries.flatMap((entry) => {
|
||||
const parsed = journalEntrySchema.safeParse(parseStoredValue(entry));
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
}
|
||||
|
||||
public getSecrets(session: CustomWidgetPreviewSession) {
|
||||
return session.secrets.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
|
||||
}
|
||||
|
||||
private async save(session: CustomWidgetPreviewSession): Promise<void> {
|
||||
const ttlMs = Math.max(0, session.expiresAt - this.now());
|
||||
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
if (this.options.store) await this.options.store.saveSession(session.id, session, ttlMs);
|
||||
else this.sessions.set(session.id, session);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredValue(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { fetch, Headers } from "undici";
|
||||
import type { Response } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
|
||||
import { applyAuth } from "./auth";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import {
|
||||
assertSafeStaticHeaders,
|
||||
createPinnedAgent,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./network-policy";
|
||||
import { parseResponseBody } from "./response";
|
||||
|
||||
export {
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./network-policy";
|
||||
export {
|
||||
assertJsonBudget,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
} from "./response";
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod;
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
logError?: (event: { origin: string; method: CustomWidgetMethod; errorName: string }) => void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
assertRequest(input);
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
|
||||
for (let redirects = 0; ; redirects += 1) {
|
||||
const dispatcher = createPinnedAgent(
|
||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const headers = buildHeaders(input, currentUrl);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: error instanceof Error ? error.message : "External request failed",
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
||||
try {
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: await parseResponseBody(response),
|
||||
};
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
if (redirects >= maxRedirects)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
const location = response.headers.get("location");
|
||||
if (!location)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
const redirected = validateCustomWidgetUrl(new URL(location, currentUrl));
|
||||
if (redirected.origin !== baseUrl.origin)
|
||||
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
currentUrl = redirected;
|
||||
}
|
||||
}
|
||||
|
||||
function assertRequest(input: CustomWidgetHttpRequest): void {
|
||||
if (input.kind === "query" && input.method !== "GET")
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Queries must use GET" });
|
||||
if (input.kind === "action" && input.method === "GET")
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Actions cannot use GET" });
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
}
|
||||
|
||||
function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (input.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const key = input.method === "GET" ? input.cacheKey : undefined;
|
||||
if (!key) return performRequest(input);
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) cache.delete(key);
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) return pending;
|
||||
const request = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
pruneCache();
|
||||
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function pruneCache(): void {
|
||||
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
|
||||
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const key = cache.keys().next().value as string | undefined;
|
||||
if (!key) return;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const CONCURRENCY_TTL_MS = 30_000;
|
||||
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
|
||||
type RequestCategory = keyof typeof CATEGORY_LIMITS;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface RequestLimitStore {
|
||||
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
|
||||
acquireConcurrency(key: string, limit: number, ttlMs: number): Promise<boolean>;
|
||||
releaseConcurrency(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RequestLimiterOptions {
|
||||
store?: RequestLimitStore;
|
||||
onStoreError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class CustomWidgetRequestLimiter {
|
||||
private readonly rateBuckets = new Map<string, number[]>();
|
||||
private readonly concurrency = new Map<string, number>();
|
||||
|
||||
public constructor(private readonly options: RequestLimiterOptions = {}) {}
|
||||
|
||||
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
|
||||
const identity = input.userId ?? "anonymous";
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const userKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
|
||||
if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
|
||||
try {
|
||||
return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
this.options.onStoreError?.(error);
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Request limiter is unavailable",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private acquireLocal(rateKey: string, userKey: string, definitionKey: string, category: RequestCategory) {
|
||||
const now = Date.now();
|
||||
const recent = (this.rateBuckets.get(rateKey) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= CATEGORY_LIMITS[category]) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
recent.push(now);
|
||||
this.rateBuckets.set(rateKey, recent);
|
||||
this.acquireLocalConcurrency(userKey, 4);
|
||||
try {
|
||||
this.acquireLocalConcurrency(definitionKey, 8);
|
||||
} catch (error) {
|
||||
this.releaseLocalConcurrency(userKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
this.releaseLocalConcurrency(userKey);
|
||||
this.releaseLocalConcurrency(definitionKey);
|
||||
};
|
||||
}
|
||||
|
||||
private async acquireStored(
|
||||
rateKey: string,
|
||||
userKey: string,
|
||||
definitionKey: string,
|
||||
category: RequestCategory,
|
||||
store: RequestLimitStore,
|
||||
) {
|
||||
const rate = await store.incrementRate(rateKey, WINDOW_MS);
|
||||
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
|
||||
if (!(await store.acquireConcurrency(userKey, 4, CONCURRENCY_TTL_MS))) throwLimit(1_000);
|
||||
try {
|
||||
if (!(await store.acquireConcurrency(definitionKey, 8, CONCURRENCY_TTL_MS))) throwLimit(1_000);
|
||||
} catch (error) {
|
||||
await store.releaseConcurrency(userKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([store.releaseConcurrency(userKey), store.releaseConcurrency(definitionKey)]);
|
||||
};
|
||||
}
|
||||
|
||||
private acquireLocalConcurrency(key: string, limit: number): void {
|
||||
const count = this.concurrency.get(key) ?? 0;
|
||||
if (count >= limit) throwLimit(1_000);
|
||||
this.concurrency.set(key, count + 1);
|
||||
}
|
||||
|
||||
private releaseLocalConcurrency(key: string): void {
|
||||
const count = this.concurrency.get(key) ?? 0;
|
||||
if (count <= 1) this.concurrency.delete(key);
|
||||
else this.concurrency.set(key, count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
const throwLimit = (retryAfterMs: number): never => {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
retryAfterMs: Math.max(1, retryAfterMs),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
import type { CustomJsxRequest } from "../core";
|
||||
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Request parameter '${name}' must be ${expectedType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
const parameter = params[entries[0][1]];
|
||||
if (parameter === undefined) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown body parameter '${entries[0][1]}'`,
|
||||
});
|
||||
}
|
||||
return parameter;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
};
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
validateRuntimeParams(request, params);
|
||||
const path = request.pathTemplate.replaceAll(/\{([a-z][a-z0-9_-]*)\}/g, (_placeholder, name: string) => {
|
||||
const value = params[name];
|
||||
if (value === undefined) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path parameter '${name}'` });
|
||||
}
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
if (/[{}]/.test(path)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Path template contains an invalid placeholder",
|
||||
});
|
||||
}
|
||||
return resolveSameOriginTarget(baseUrl, new URL(path, baseUrl));
|
||||
};
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Response } from "undici";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
|
||||
export function assertJsonBudget(value: unknown): void {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (++nodes > MAX_RESPONSE_JSON_NODES)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
if (Array.isArray(current)) current.forEach((entry) => visit(entry, depth + 1));
|
||||
else if (current !== null && typeof current === "object")
|
||||
Object.values(current).forEach((entry) => visit(entry, depth + 1));
|
||||
};
|
||||
visit(value, 0);
|
||||
}
|
||||
|
||||
async function readLimitedBody(response: Response): Promise<string> {
|
||||
const length = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(length) && length > MAX_RESPONSE_BODY_BYTES) throw tooLarge();
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
size += chunk.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
export async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const text = await readLimitedBody(response);
|
||||
if (!text) return null;
|
||||
try {
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (response.headers.get("content-type")?.toLowerCase().includes("json")) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
const tooLarge = () =>
|
||||
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
Reference in new issue
Block a user