edgespec
Version:
Write Winter-CG compatible routes with filesystem routing and tons of features
658 lines (647 loc) • 22.2 kB
JavaScript
;
var zod = require('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/lib/format-zod-error.ts
var zodIssueToString = (issue) => {
if (issue.path.join(".") === "") {
return issue.message;
}
if (issue.message === "Required") {
return `\`${issue.path.join(".")}\` is required`;
}
return `${issue.message} for "${issue.path.join(".")}"`;
};
var formatZodError = (error) => {
let message;
if (error.issues.length === 1) {
const issue = error.issues[0];
message = zodIssueToString(issue);
} else {
const message_components = [];
for (const issue of error.issues) {
message_components.push(zodIssueToString(issue));
}
message = `${error.issues.length} Zod validation issues: ` + message_components.join(", ");
}
message += `. Full Zod error: ${JSON.stringify(error.issues, null, 2)}`;
return message;
};
// src/middleware/http-exceptions.ts
var EdgeSpecMiddlewareError = class extends Error {
constructor(message, status = 500) {
super(message);
this.message = message;
this.status = status;
this._isHttpException = true;
this.name = this.constructor.name;
}
};
var MethodNotAllowedError = class extends EdgeSpecMiddlewareError {
constructor(allowedMethods) {
super(`only ${allowedMethods.join(",")} accepted`, 405);
}
};
var BadRequestError = class extends EdgeSpecMiddlewareError {
constructor(message) {
super(message, 400);
}
};
var InvalidQueryParamsError = class extends BadRequestError {
constructor(message) {
super(message);
}
};
var InvalidContentTypeError = class extends BadRequestError {
constructor(message) {
super(message);
}
};
var InputParsingError = class extends BadRequestError {
constructor(message) {
super(message);
}
};
var InputValidationError = class extends BadRequestError {
constructor(error) {
super(formatZodError(error));
}
};
var ResponseValidationError = class extends EdgeSpecMiddlewareError {
constructor(error) {
super(formatZodError(error), 500);
}
};
// 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 zod.z.ZodEffects) {
currentSchema = currentSchema._def.schema;
}
return currentSchema;
};
var getZodDefFromZodSchemaHelpers = (schema) => {
const special_zod_types = [
zod.ZodFirstPartyTypeKind.ZodOptional,
zod.ZodFirstPartyTypeKind.ZodDefault,
zod.ZodFirstPartyTypeKind.ZodEffects
];
while (special_zod_types.includes(schema._def.typeName)) {
if (schema._def.typeName === zod.ZodFirstPartyTypeKind.ZodOptional || schema._def.typeName === zod.ZodFirstPartyTypeKind.ZodDefault) {
schema = schema._def.innerType;
continue;
}
if (schema._def.typeName === zod.ZodFirstPartyTypeKind.ZodEffects) {
schema = schema._def.schema;
continue;
}
}
return schema._def;
};
var tryGetZodSchemaAsObject = (schema) => {
const isZodEffect = schema._def.typeName === zod.ZodFirstPartyTypeKind.ZodEffects;
const safe_schema = getZodObjectSchemaFromZodEffectSchema(isZodEffect, schema);
const isZodObject = safe_schema._def.typeName === zod.ZodFirstPartyTypeKind.ZodObject;
if (!isZodObject) {
return void 0;
}
return safe_schema;
};
var isZodSchemaArray = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === zod.ZodFirstPartyTypeKind.ZodArray;
};
var isZodSchemaBoolean = (schema) => {
const def = getZodDefFromZodSchemaHelpers(schema);
return def.typeName === zod.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 zod.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(zod.z.any()) : response;
}
if (response instanceof EdgeSpecResponse) {
if (response instanceof EdgeSpecJsonResponse) {
return response.serializeToResponse(routeSpec.jsonResponse ?? zod.z.any());
}
if (response instanceof EdgeSpecMultiPartFormDataResponse) {
return response.serializeToResponse(
routeSpec.multipartFormDataResponse ?? zod.z.any()
);
}
if (response instanceof EdgeSpecCustomResponse) {
return response.serializeToResponse(zod.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()
);
};
}
var edgeSpecConfigSchema = zod.z.object({
/**
* Defaults to the current working directory.
*/
rootDirectory: zod.z.string().optional(),
/**
* If this path is relative, it's resolved relative to the `rootDirectory` option.
*/
tsconfigPath: zod.z.string().optional(),
/**
* If this path is relative, it's resolved relative to the `rootDirectory` option.
*/
routesDirectory: zod.z.string().optional(),
/**
* The platform you're targeting.
*
* Defaults to `wintercg-minimal`, and you should use this whenever possible for maximal compatibility.
*
* Check [the docs](https://github.com/seamapi/edgespec/blob/main/docs/edgespec-config.md) for more information.
*/
platform: zod.z.enum(["node", "wintercg-minimal"]).default("wintercg-minimal").optional()
}).strict();
var defineConfig = (config) => {
const parsedConfig = edgeSpecConfigSchema.safeParse(config);
if (parsedConfig.success) {
return parsedConfig.data;
}
throw new Error(`Invalid config: ${parsedConfig.error}`);
};
// src/helpers.ts
var loadBundle = async (bundlePath) => {
const bundle = await import(bundlePath);
return bundle.default.default ?? bundle.default;
};
exports.EdgeSpecCustomResponse = EdgeSpecCustomResponse;
exports.EdgeSpecJsonResponse = EdgeSpecJsonResponse;
exports.EdgeSpecResponse = EdgeSpecResponse;
exports.createWithEdgeSpec = createWithEdgeSpec;
exports.defineConfig = defineConfig;
exports.loadBundle = loadBundle;
exports.makeRequestAgainstEdgeSpec = makeRequestAgainstEdgeSpec;
exports.wrapMiddlewares = wrapMiddlewares;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map