feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -5,6 +5,7 @@ export {
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest,
|
||||
invalidateCustomWidgetResponseCache,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
@@ -24,5 +25,11 @@ export type {
|
||||
PreviewSessionServiceOptions,
|
||||
PreviewSessionStore,
|
||||
} from "./preview-sessions";
|
||||
export { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "./request-manifest";
|
||||
export {
|
||||
hashRuntimeParams,
|
||||
renderBoundValue,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} from "./request-manifest";
|
||||
export type { CustomJsxRuntimeParams } from "./request-manifest";
|
||||
@@ -1,27 +1,35 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
customJsxNetworkScopes,
|
||||
customJsxRequestSchema,
|
||||
customWidgetAuthTypes,
|
||||
customWidgetOptionsSchema,
|
||||
customWidgetRequestsSchema,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetSourcesSchema,
|
||||
hasSameCustomWidgetSourceAuthentication,
|
||||
} from "../core";
|
||||
import type { CustomJsxNetworkScope, CustomJsxRequest, CustomWidgetAuthType } from "../core";
|
||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60_000;
|
||||
const SESSION_TTL_MS = 10 * 60_000;
|
||||
const MAX_JOURNAL_ENTRIES = 50;
|
||||
|
||||
const encryptedSecretSchema = z.object({
|
||||
sourceId: z.string(),
|
||||
kind: z.enum(customWidgetSecretKinds),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
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),
|
||||
sources: customWidgetSourcesSchema,
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
requests: customWidgetRequestsSchema,
|
||||
name: z.string(),
|
||||
template: z.string(),
|
||||
optionDefinitions: customWidgetOptionsSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
definitionId: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
@@ -32,7 +40,7 @@ const journalEntrySchema = z.object({
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string(),
|
||||
path: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
@@ -42,12 +50,13 @@ 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[];
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
name: string;
|
||||
template: string;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
options: Record<string, unknown>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
@@ -77,16 +86,24 @@ export class CustomWidgetPreviewSessionService {
|
||||
}
|
||||
|
||||
public async create(input: CreatePreviewSessionInput) {
|
||||
const sourceIds = new Set(Object.keys(input.sources));
|
||||
if (input.secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
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,
|
||||
sources: input.sources,
|
||||
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
requests: input.requests,
|
||||
name: input.name,
|
||||
template: input.template,
|
||||
optionDefinitions: input.optionDefinitions,
|
||||
options: input.options,
|
||||
definitionId: input.definitionId,
|
||||
liveActions: false,
|
||||
};
|
||||
@@ -132,8 +149,69 @@ export class CustomWidgetPreviewSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
public getSecrets(session: CustomWidgetPreviewSession) {
|
||||
return session.secrets.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
|
||||
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
|
||||
return session.secrets
|
||||
.filter((secret) => secret.sourceId === sourceId)
|
||||
.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
|
||||
}
|
||||
|
||||
public async setSecrets(
|
||||
id: string,
|
||||
userId: string,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const session = await this.get(id, userId);
|
||||
const sourceIds = new Set(Object.keys(session.sources));
|
||||
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
const next = {
|
||||
...session,
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
await this.save(next);
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
public async configureSource(
|
||||
id: string,
|
||||
userId: string,
|
||||
sourceId: string,
|
||||
source: CustomWidgetSource,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const session = await this.get(id, userId);
|
||||
const currentSource = session.sources[sourceId];
|
||||
if (!currentSource) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
}
|
||||
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Preview source authentication changed; create a new configuration request",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
const next = {
|
||||
...session,
|
||||
sources: {
|
||||
...session.sources,
|
||||
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
|
||||
},
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
await this.save(next);
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
private async save(session: CustomWidgetPreviewSession): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { fetch, Headers } from "undici";
|
||||
import type { Response } from "undici";
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Headers, Response } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
|
||||
import { applyAuth } from "./auth";
|
||||
@@ -61,36 +61,39 @@ export interface CustomWidgetHttpResponse {
|
||||
}
|
||||
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
let cacheEpoch = 0;
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
assertRequest(input);
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
let currentMethod = input.method;
|
||||
let currentBody = input.body;
|
||||
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 headers = buildHeaders(input, currentUrl, currentBody);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
let responseData: Awaited<ReturnType<typeof dispatcher.request>>;
|
||||
try {
|
||||
response = await fetch(currentUrl, {
|
||||
method: input.method,
|
||||
responseData = await dispatcher.request({
|
||||
origin: currentUrl.origin,
|
||||
path: `${currentUrl.pathname}${currentUrl.search}`,
|
||||
method: currentMethod,
|
||||
headers,
|
||||
body: input.method === "GET" ? undefined : input.body,
|
||||
redirect: "manual",
|
||||
body: currentBody,
|
||||
signal: controller.signal,
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
await dispatcher.close();
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: input.method,
|
||||
method: currentMethod,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
throw new CustomWidgetDomainError({
|
||||
@@ -101,8 +104,14 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
|
||||
try {
|
||||
const body = await responseData.body.arrayBuffer();
|
||||
const response = new Response(body.byteLength > 0 ? body : null, {
|
||||
status: responseData.statusCode,
|
||||
statusText: STATUS_CODES[responseData.statusCode] ?? "",
|
||||
headers: normalizeResponseHeaders(responseData.headers),
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
@@ -113,34 +122,47 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
|
||||
await dispatcher.close();
|
||||
}
|
||||
}
|
||||
await response.body?.cancel();
|
||||
await dispatcher.close();
|
||||
try {
|
||||
await responseData.body.dump();
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
if (redirects >= maxRedirects)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
const location = response.headers.get("location");
|
||||
const location = normalizeResponseHeaders(responseData.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" });
|
||||
if (responseData.statusCode === 303 || ([301, 302].includes(responseData.statusCode) && currentMethod === "POST")) {
|
||||
currentMethod = "GET";
|
||||
currentBody = undefined;
|
||||
}
|
||||
currentUrl = redirected;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
|
||||
else if (value !== undefined) headers.set(name, value);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
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 {
|
||||
function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | undefined): 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 (body !== undefined) headers.set("Content-Type", "application/json");
|
||||
else headers.delete("Content-Type");
|
||||
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);
|
||||
@@ -149,26 +171,37 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL): Headers {
|
||||
}
|
||||
|
||||
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const key = input.method === "GET" ? input.cacheKey : undefined;
|
||||
const key = input.kind === "query" ? 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)
|
||||
const requestEpoch = cacheEpoch;
|
||||
let request: Promise<CustomWidgetHttpResponse>;
|
||||
request = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0) {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) {
|
||||
pruneCache();
|
||||
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => inFlight.delete(key));
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === request) inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void {
|
||||
if (prefixes.length === 0) return;
|
||||
cacheEpoch += 1;
|
||||
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key);
|
||||
for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key);
|
||||
}
|
||||
|
||||
function pruneCache(): void {
|
||||
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
|
||||
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
|
||||
@@ -1,79 +1,107 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
import type { CustomJsxRequest } from "../core";
|
||||
|
||||
import { collectCustomWidgetRequestReferences } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
type CustomJsxResolvedValues = Record<string, unknown>;
|
||||
|
||||
export const validateRuntimeParams = (request: CustomJsxRequest, params: CustomJsxRuntimeParams) => {
|
||||
const declaredNames = Object.keys(request.parameters).toSorted();
|
||||
const suppliedNames = Object.keys(params).toSorted();
|
||||
export function resolveCustomWidgetRequestValues(
|
||||
request: CustomJsxRequest,
|
||||
options: Record<string, unknown>,
|
||||
suppliedParams: CustomJsxRuntimeParams = {},
|
||||
): CustomJsxResolvedValues {
|
||||
const references = collectCustomWidgetRequestReferences(request);
|
||||
const suppliedNames = Object.keys(suppliedParams).toSorted();
|
||||
const expectedNames = [...references.params].toSorted();
|
||||
if (
|
||||
declaredNames.length !== suppliedNames.length ||
|
||||
declaredNames.some((name, index) => name !== suppliedNames[index])
|
||||
suppliedNames.length !== expectedNames.length ||
|
||||
suppliedNames.some((name, index) => name !== expectedNames[index])
|
||||
) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request parameters do not match the manifest" });
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Request parameters do not match the request references",
|
||||
});
|
||||
}
|
||||
for (const [name, expectedType] of Object.entries(request.parameters)) {
|
||||
if (typeof params[name] !== expectedType) {
|
||||
const values: CustomJsxResolvedValues = { ...suppliedParams };
|
||||
for (const name of references.options) {
|
||||
const value = options[name];
|
||||
if (value === undefined) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Request parameter '${name}' must be ${expectedType}`,
|
||||
message: `Widget option '${name}' is missing`,
|
||||
});
|
||||
}
|
||||
values[`option:${name}`] = value;
|
||||
}
|
||||
};
|
||||
return values;
|
||||
}
|
||||
|
||||
export const renderRequestBody = (
|
||||
template: CustomJsxRequest["bodyTemplate"],
|
||||
params: CustomJsxRuntimeParams,
|
||||
): string | undefined => {
|
||||
if (template === undefined) return undefined;
|
||||
export const renderBoundValue = (template: unknown, values: CustomJsxResolvedValues): unknown => {
|
||||
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) {
|
||||
if (
|
||||
entries.length === 1 &&
|
||||
["$param", "$option"].includes(entries[0]?.[0] ?? "") &&
|
||||
typeof entries[0]?.[1] === "string"
|
||||
) {
|
||||
const prefix = entries[0][0] === "$option" ? "option:" : "";
|
||||
const resolved = values[`${prefix}${entries[0][1]}`];
|
||||
if (resolved === undefined)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown body parameter '${entries[0][1]}'`,
|
||||
message: `Unknown request value '${entries[0][1]}'`,
|
||||
});
|
||||
}
|
||||
return parameter;
|
||||
return resolved;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(substitute(template));
|
||||
return 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 renderRequestBody = (request: CustomJsxRequest, values: CustomJsxResolvedValues): string | undefined =>
|
||||
request.body === undefined ? undefined : JSON.stringify(renderBoundValue(request.body, values));
|
||||
|
||||
export const hashRuntimeParams = (params: CustomJsxRuntimeParams) => {
|
||||
const stableParams = Object.fromEntries(
|
||||
Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, values: CustomJsxResolvedValues) => {
|
||||
const path = request.path.replaceAll(
|
||||
/\{(option|param):([A-Za-z][A-Za-z0-9_-]*)\}/gu,
|
||||
(_match, kind: string, name: string) => {
|
||||
const value = values[kind === "option" ? `option:${name}` : name];
|
||||
if (value === undefined)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path value '${name}'` });
|
||||
if (!["string", "number", "boolean"].includes(typeof value))
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Path value '${name}' must resolve to text, number, or boolean`,
|
||||
});
|
||||
return encodeURIComponent(String(value));
|
||||
},
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(stableParams)).digest("hex");
|
||||
if (/[{}]/u.test(path))
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request path contains an invalid placeholder" });
|
||||
const base = new URL(baseUrl);
|
||||
base.pathname = `${base.pathname.replace(/\/$/u, "")}${path}` || "/";
|
||||
const target = resolveSameOriginTarget(baseUrl, base);
|
||||
for (const [name, template] of Object.entries(request.query ?? {})) {
|
||||
const value = renderBoundValue(template, values);
|
||||
if (!["string", "number", "boolean"].includes(typeof value)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Query value '${name}' must resolve to text, number, or boolean`,
|
||||
});
|
||||
}
|
||||
target.searchParams.set(name, String(value));
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export function hashRuntimeParams(params: CustomJsxResolvedValues) {
|
||||
const stable = Object.fromEntries(Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)));
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
Reference in new issue
Block a user