edgespec
Version:
Write Winter-CG compatible routes with filesystem routing and tons of features
546 lines (539 loc) • 19 kB
JavaScript
import { ResponseValidationError, MethodNotAllowedError, InvalidContentTypeError, InputParsingError, BadRequestError, InputValidationError, InvalidQueryParamsError } from './chunk-J77ZRDU2.js';
export { loadBundle } from './chunk-PRZQE4JB.js';
export { defineConfig } from './chunk-CE43QKKO.js';
import './chunk-5WRI5ZAA.js';
import { z, ZodError, ZodFirstPartyTypeKind } from 'zod';
// src/types/web-handler.ts
var EdgeSpecResponse = class {
constructor(options = {}) {
this.options = options;
}
statusCode() {
return this.options.status ?? 200;
}
status(status) {
this.options.status = status;
return this;
}
header(key, value) {
this.options.headers = mergeHeaders(this.options.headers, {
[key]: value
});
return this;
}
headers(headers) {
this.options.headers = mergeHeaders(this.options.headers, headers);
return this;
}
statusText(statusText) {
this.options.statusText = statusText;
return this;
}
static json(...args) {
return new EdgeSpecJsonResponse(...args);
}
static multipartFormData(...args) {
return new EdgeSpecMultiPartFormDataResponse(...args);
}
static custom(...args) {
return new EdgeSpecCustomResponse(...args);
}
};
var EdgeSpecJsonResponse = class extends EdgeSpecResponse {
constructor(data, options = {}) {
super(options);
this.data = data;
this.options.headers = mergeHeaders(this.options.headers, {
"Content-Type": "application/json"
});
}
serializeToResponse(schema) {
return new Response(JSON.stringify(schema.parse(this.data)), this.options);
}
};
var EdgeSpecCustomResponse = class extends EdgeSpecResponse {
constructor(data, contentType, options = {}) {
super(options);
this.data = data;
this.contentType = contentType;
this.options.headers = mergeHeaders(this.options.headers, {
"Content-Type": contentType
});
}
serializeToResponse(schema) {
return new Response(schema.parse(this.data), this.options);
}
};
var EdgeSpecMultiPartFormDataResponse = class extends EdgeSpecResponse {
constructor(data, options = {}) {
super(options);
this.data = data;
this.options.headers = mergeHeaders(this.options.headers, {
"Content-Type": "multipart/form-data"
});
}
serializeToResponse(schema) {
const formData = new FormData();
for (const [key, value] of Object.entries(schema.parse(this.data))) {
formData.append(key, value instanceof Blob ? value : String(value));
}
return new Response(formData, this.options);
}
};
function createEdgeSpecRequest(request, options) {
return Object.assign(request, options);
}
function mergeHeaders(h1, h2) {
return new Headers(
Object.fromEntries([
...h1 instanceof Headers ? h1 : new Headers(h1 ?? void 0).entries() ?? [],
...h2 instanceof Headers ? h2 : new Headers(h2 ?? void 0).entries() ?? []
])
);
}
// src/middleware/with-methods.ts
var withMethods = (methods) => (req, ctx, next) => {
if (!methods.includes(req.method)) {
throw new MethodNotAllowedError(methods);
}
return next(req, ctx);
};
var getZodObjectSchemaFromZodEffectSchema = (isZodEffect, schema) => {
if (!isZodEffect) {
return schema;
}
let currentSchema = schema;
while (currentSchema instanceof z.ZodEffects) {
currentSchema = currentSchema._def.schema;
}
return currentSchema;
};
var getZodDefFromZodSchemaHelpers = (schema) => {
const special_zod_types = [
ZodFirstPartyTypeKind.ZodOptional,
ZodFirstPartyTypeKind.ZodDefault,
ZodFirstPartyTypeKind.ZodEffects
];
while (special_zod_types.includes(schema._def.typeName)) {
if (schema._def.typeName === ZodFirstPartyTypeKind.ZodOptional || schema._def.typeName === ZodFirstPartyTypeKind.ZodDefault) {
schema = schema._def.innerType;
continue;
}
if (schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects) {
schema = schema._def.schema;
continue;
}
}
return schema._def;
};
var tryGetZodSchemaAsObject = (schema) => {
const isZodEffect = schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects;
const safe_schema = getZodObjectSchemaFromZodEffectSchema(isZodEffect, schema);
const isZodObject = safe_schema._def.typeName === ZodFirstPartyTypeKind.ZodObject;
if (!isZodObject) {
return void 0;
}
return safe_schema;
};
var isZodSchemaArray = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === ZodFirstPartyTypeKind.ZodArray;
};
var isZodSchemaBoolean = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === ZodFirstPartyTypeKind.ZodBoolean;
};
var parseQueryParams = (schema, input, supportedArrayFormats) => {
const parsed_input = Object.assign({}, input);
const obj_schema = tryGetZodSchemaAsObject(schema);
if (obj_schema) {
for (const [key, value] of Object.entries(obj_schema.shape)) {
if (isZodSchemaArray(value)) {
const array_input = input[key];
if (typeof array_input === "string" && supportedArrayFormats.includes("comma")) {
parsed_input[key] = array_input.split(",");
}
const bracket_syntax_array_input = input[`${key}[]`];
if (typeof bracket_syntax_array_input === "string" && supportedArrayFormats.includes("brackets")) {
const pre_split_array = bracket_syntax_array_input;
parsed_input[key] = pre_split_array.split(",");
}
if (Array.isArray(bracket_syntax_array_input) && supportedArrayFormats.includes("brackets")) {
parsed_input[key] = bracket_syntax_array_input;
}
continue;
}
if (isZodSchemaBoolean(value)) {
const boolean_input = input[key];
if (typeof boolean_input === "string") {
parsed_input[key] = boolean_input === "true";
}
}
}
}
return schema.parse(parsed_input);
};
var validateQueryParams = (inputUrl, schema, supportedArrayFormats) => {
const url = new URL(inputUrl, "http://dummy.com");
const seenKeys = /* @__PURE__ */ new Set();
const obj_schema = tryGetZodSchemaAsObject(schema);
if (!obj_schema) {
return;
}
for (const key of url.searchParams.keys()) {
for (const [schemaKey, value] of Object.entries(obj_schema.shape)) {
if (isZodSchemaArray(value)) {
if (key === `${schemaKey}[]` && !supportedArrayFormats.includes("brackets")) {
throw new InvalidQueryParamsError(
`Bracket syntax not supported for query param "${schemaKey}"`
);
}
}
}
const key_schema = obj_schema.shape[key];
if (key_schema) {
if (isZodSchemaArray(key_schema)) {
if (seenKeys.has(key) && !supportedArrayFormats.includes("repeat")) {
throw new InvalidQueryParamsError(
`Repeated parameters not supported for duplicate query param "${key}"`
);
}
}
}
seenKeys.add(key);
}
};
var withInputValidation = (input) => async (req, ctx, next) => {
const { supportedArrayFormats } = input;
if (input.formData && input.jsonBody || input.formData && input.commonParams) {
throw new Error("Cannot use formData with jsonBody or commonParams");
}
if ((req.method === "POST" || req.method === "PATCH") && (input.jsonBody || input.commonParams) && !req.headers.get("content-type")?.includes("application/json")) {
throw new InvalidContentTypeError(
`${req.method} requests must have Content-Type header with "application/json"`
);
}
if (input.urlEncodedFormData && req.method !== "GET" && !req.headers.get("content-type")?.includes("application/x-www-form-urlencoded")) {
throw new InvalidContentTypeError(
`Must have Content-Type header with "application/x-www-form-urlencoded"`
);
}
if (input.formData && (req.method === "POST" || req.method === "PATCH") && !req.headers.get("content-type")?.includes("multipart/form-data")) {
throw new InvalidContentTypeError(
`${req.method} requests must have Content-Type header with "multipart/form-data"`
);
}
const originalParams = Object.fromEntries(
new URL(req.url).searchParams.entries()
);
let jsonBody;
if (input.jsonBody || input.commonParams) {
try {
jsonBody = await req.clone().json();
} catch (e) {
if (!input.jsonBody?.isOptional()) {
throw new InputParsingError("Error while parsing JSON body");
}
}
}
let multiPartFormData = void 0;
if (input.formData) {
try {
multiPartFormData = await req.clone().formData();
multiPartFormData = Object.fromEntries(multiPartFormData.entries());
} catch (e) {
if (!input.formData?.isOptional()) {
throw new InputParsingError("Error while parsing form data");
}
}
}
let urlEncodedFormData = void 0;
if (input.urlEncodedFormData) {
try {
const params = new URLSearchParams(await req.clone().text());
urlEncodedFormData = Object.fromEntries(params.entries());
} catch (e) {
if (!input.urlEncodedFormData?.isOptional()) {
throw new InputParsingError(
"Error while parsing url encoded form data"
);
}
}
}
try {
const originalCombinedParams = {
...originalParams,
...typeof jsonBody === "object" ? jsonBody : {}
};
const willValidateRequestBody = !["GET", "DELETE", "HEAD"].includes(
req.method
);
if (Boolean(input.formData) && willValidateRequestBody) {
req.multiPartFormData = input.formData?.parse(multiPartFormData);
}
if (Boolean(input.jsonBody) && willValidateRequestBody) {
req.jsonBody = input.jsonBody?.parse(jsonBody);
}
if (Boolean(input.urlEncodedFormData) && willValidateRequestBody) {
req.urlEncodedFormData = input.urlEncodedFormData?.parse(urlEncodedFormData);
}
if (Boolean(input.routeParams) && "routeParams" in req) {
req.routeParams = input.routeParams?.parse(req.routeParams);
}
if (input.queryParams) {
if (!req.url) {
throw new Error("req.url is undefined");
}
validateQueryParams(req.url, input.queryParams, supportedArrayFormats);
req.query = parseQueryParams(
input.queryParams,
originalParams,
supportedArrayFormats
);
}
if (input.commonParams) {
req.commonParams = parseQueryParams(
input.commonParams,
originalCombinedParams,
supportedArrayFormats
);
}
} catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
if (error instanceof ZodError) {
throw new InputValidationError(error);
}
throw new InputParsingError("Error while parsing input");
}
return next(req, ctx);
};
// src/middleware/with-unhandled-exception-handling.ts
var withUnhandledExceptionHandling = async (req, ctx, next) => {
try {
return await next(req, ctx);
} catch (e) {
const logger = ctx.logger ?? console;
if ("_isHttpException" in e) {
logger.warn(
"Caught unhandled HTTP exception thrown by EdgeSpec provided middleware. Consider adding createWithDefaultExceptionHandling middleware to your global or route spec."
);
} else {
logger.warn(
"Caught unknown unhandled exception; consider adding a exception handling middleware to your global or route spec."
);
}
logger.error(e);
return new Response(null, {
status: 500
});
}
};
// src/create-with-edge-spec.ts
var attachMetadataToRouteFn = ({ globalSpec, routeSpec }, routeFn) => {
routeFn._globalSpec = globalSpec;
routeFn._routeSpec = routeSpec;
return routeFn;
};
var createWithEdgeSpec = (globalSpec) => {
return (routeSpec) => (routeFn) => attachMetadataToRouteFn(
{
globalSpec,
routeSpec
},
async (request, ctx) => {
const onMultipleAuthMiddlewareFailures = globalSpec.onMultipleAuthMiddlewareFailures ?? routeSpec.onMultipleAuthMiddlewareFailures;
const supportedAuthMiddlewares = new Set(
routeSpec.auth == null || routeSpec.auth === "none" ? [] : Array.isArray(routeSpec.auth) ? routeSpec.auth : [routeSpec.auth]
);
const authMiddlewares = Object.entries(globalSpec.authMiddleware).filter(([k, _]) => supportedAuthMiddlewares.has(k)).map(([_, v]) => v);
return await wrapMiddlewares(
[
// Injected into the VM when running in WinterCG emulation mode
// @ts-expect-error
...typeof _injectedEdgeSpecMiddleware !== "undefined" ? (
// @ts-expect-error
_injectedEdgeSpecMiddleware
) : [],
withUnhandledExceptionHandling,
// this serializes responses that are returned by middleware WITHOUT
// validating them against the routeSpec
//
// this allows returning EdgeSpecResponse.json or ctx.json in a
// middleware, instead of having to return a raw Response
//
// this is needed, for instance, when an error middleware returns an
// error response that does not match the routeSpec's response shape
serializeResponse(globalSpec, routeSpec, false),
...globalSpec.beforeAuthMiddleware ?? [],
firstAuthMiddlewareThatSucceeds(
authMiddlewares,
onMultipleAuthMiddlewareFailures
),
...globalSpec.afterAuthMiddleware ?? [],
...routeSpec.middleware ?? [],
withMethods(routeSpec.methods),
withInputValidation({
supportedArrayFormats: globalSpec.supportedArrayFormats ?? [
"brackets",
"comma",
"repeat"
],
commonParams: routeSpec.commonParams,
formData: routeSpec.multiPartFormData,
jsonBody: routeSpec.jsonBody,
queryParams: routeSpec.queryParams,
routeParams: routeSpec.routeParams,
urlEncodedFormData: routeSpec.urlEncodedFormData
}),
// this serializes responses that are returned by the route function,
// validating them against the routeSpec
serializeResponse(globalSpec, routeSpec)
],
routeFn,
request,
ctx
);
}
);
};
function serializeResponse(globalSpec, routeSpec, skipValidation = false) {
return async (req, ctx, next) => {
const rawResponse = await next(req, ctx);
const statusCode = rawResponse instanceof EdgeSpecResponse ? rawResponse.statusCode() : rawResponse.status;
const isSuccess = statusCode >= 200 && statusCode < 300;
try {
const response = serializeToResponse(
isSuccess && !skipValidation && (globalSpec.shouldValidateResponses ?? true),
routeSpec,
rawResponse
);
return response;
} catch (err) {
throw new ResponseValidationError(err);
}
};
}
async function wrapMiddlewares(middlewares, routeFn, request, ctx) {
return await middlewares.reduceRight(
(next, middleware) => {
return async (req, ctx2) => {
return middleware(req, ctx2, next);
};
},
async (request2, ctx2) => routeFn(request2, ctx2)
)(request, ctx);
}
function serializeToResponse(shouldValidateResponse, routeSpec, response) {
if (!shouldValidateResponse) {
return "serializeToResponse" in response ? response.serializeToResponse(z.any()) : response;
}
if (response instanceof EdgeSpecResponse) {
if (response instanceof EdgeSpecJsonResponse) {
return response.serializeToResponse(routeSpec.jsonResponse ?? z.any());
}
if (response instanceof EdgeSpecMultiPartFormDataResponse) {
return response.serializeToResponse(
routeSpec.multipartFormDataResponse ?? z.any()
);
}
if (response instanceof EdgeSpecCustomResponse) {
return response.serializeToResponse(z.any());
}
}
if ("serializeToResponse" in response) {
throw new Error("Unknown Response type");
}
return response;
}
function firstAuthMiddlewareThatSucceeds(authMiddlewares, onMultipleAuthMiddlewareFailures) {
return async (req, ctx, next) => {
if (authMiddlewares.length === 0) {
return next(req, ctx);
}
let errors = [];
let didAuthMiddlewareThrow = true;
for (const middleware of authMiddlewares) {
try {
return await middleware(req, ctx, (...args) => {
didAuthMiddlewareThrow = false;
return next(...args);
});
} catch (error) {
if (didAuthMiddlewareThrow) {
errors.push(error);
continue;
} else {
throw error;
}
}
}
if (onMultipleAuthMiddlewareFailures && didAuthMiddlewareThrow) {
onMultipleAuthMiddlewareFailures(errors);
}
throw errors[errors.length - 1];
};
}
// src/types/context.ts
var DEFAULT_CONTEXT = {
json: EdgeSpecResponse.json,
multipartFormData: EdgeSpecResponse.multipartFormData,
custom: EdgeSpecResponse.custom
};
var getDefaultContext = () => ({ ...DEFAULT_CONTEXT });
// src/types/edge-spec.ts
function makeRequestAgainstEdgeSpec(edgeSpec, options = {}) {
return async (request) => {
const {
routeMatcher,
routeMapWithHandlers,
handle404 = () => new Response("Not found", {
status: 404
})
} = edgeSpec;
const { removePathnamePrefix, automaticallyRemovePathnamePrefix = true } = options;
let pathname = new URL(request.url).pathname;
if (removePathnamePrefix) {
if (automaticallyRemovePathnamePrefix) {
throw new Error(
"automaticallyRemovePathnamePrefix and removePathnamePrefix cannot both be specified"
);
}
pathname = pathname.replace(removePathnamePrefix, "");
} else {
if (request.routeParams) {
const routeParams2 = request.routeParams;
const wildcardRouteParameters = Object.values(routeParams2).filter(
(p) => Array.isArray(p)
);
if (wildcardRouteParameters.length === 0) {
throw new Error("No wildcard route parameters found");
}
if (wildcardRouteParameters.length > 1) {
throw new Error("Only one wildcard route parameter is supported");
}
const wildcardRouteParameter = wildcardRouteParameters[0];
pathname = `/${wildcardRouteParameter.join("/")}`;
}
}
const { matchedRoute, routeParams } = routeMatcher(pathname) ?? {};
let routeFn = matchedRoute && routeMapWithHandlers[matchedRoute];
const edgeSpecRequest = createEdgeSpecRequest(request, {
edgeSpec,
routeParams: routeParams ?? {}
});
if (!routeFn) {
return await handle404(edgeSpecRequest, getDefaultContext());
}
return wrapMiddlewares(
options.middleware ?? [],
routeFn,
edgeSpecRequest,
getDefaultContext()
);
};
}
export { EdgeSpecCustomResponse, EdgeSpecJsonResponse, EdgeSpecResponse, createWithEdgeSpec, makeRequestAgainstEdgeSpec, wrapMiddlewares };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map