sveltekit-superforms
Version:
Making SvelteKit validation and displaying of forms easier than ever!
444 lines (443 loc) • 16.2 kB
JavaScript
import { fail, json } from '@sveltejs/kit';
import { parse, stringify } from 'devalue';
import { SuperFormError } from './index.js';
import { entityData, unwrapZodType, valueOrDefault } from './schemaEntity.js';
import { traversePath } from './traversal.js';
import { splitPath } from './stringPath.js';
import { clone } from './utils.js';
import { mapErrors } from './errors.js';
export { defaultValues } from './schemaEntity.js';
/**
* Sends a message with a form, with an optional HTTP status code that will set
* form.valid to false if status >= 400. A status lower than 400 cannot be sent.
*/
export function message(form, message, options) {
if (options?.status && options.status >= 400) {
form.valid = false;
}
form.message = message;
return !form.valid ? fail(options?.status ?? 400, { form }) : { form };
}
export const setMessage = message;
export function setError(form, path, error, options) {
// Unify signatures
if (error == undefined ||
(typeof error !== 'string' && !Array.isArray(error))) {
options = error;
error = path;
path = '';
}
if (options === undefined)
options = {};
const errArr = Array.isArray(error) ? error : [error];
if (!form.errors)
form.errors = {};
if (path === null || path === '') {
if (!form.errors._errors)
form.errors._errors = [];
form.errors._errors = options.overwrite
? errArr
: form.errors._errors.concat(errArr);
}
else {
const realPath = splitPath(path);
const leaf = traversePath(form.errors, realPath, ({ parent, key, value }) => {
if (value === undefined)
parent[key] = {};
return parent[key];
});
if (leaf) {
leaf.parent[leaf.key] =
Array.isArray(leaf.value) && !options.overwrite
? leaf.value.concat(errArr)
: errArr;
}
}
form.valid = false;
return fail(options.status ?? 400, { form });
}
function formDataToValidation(data, schemaData) {
const output = {};
const { schemaKeys, entityInfo } = schemaData;
function parseSingleEntry(key, entry, typeInfo) {
if (entry && typeof entry !== 'string') {
// File object, not supported
return undefined;
}
else {
return parseFormDataEntry(key, entry, typeInfo);
}
}
for (const key of schemaKeys) {
const typeInfo = entityInfo.typeInfo[key];
const entries = data.getAll(key);
if (!(typeInfo.zodType._def.typeName == 'ZodArray')) {
output[key] = parseSingleEntry(key, entries[0], typeInfo);
}
else {
const arrayType = unwrapZodType(typeInfo.zodType._def.type);
output[key] = entries.map((e) => parseSingleEntry(key, e, arrayType));
}
}
function parseFormDataEntry(field, value, typeInfo) {
const newValue = valueOrDefault(value, false, true, typeInfo);
const zodType = typeInfo.zodType;
// If the value was empty, it now contains the default value,
// so it can be returned immediately, unless it's boolean, which
// means it could have been posted as a checkbox.
if (!value && zodType._def.typeName != 'ZodBoolean') {
return newValue;
}
/*
console.log(
`FormData field "${field}" (${zodType._def.typeName}): ${value}`
);
*/
if (zodType._def.typeName == 'ZodString') {
return value;
}
else if (zodType._def.typeName == 'ZodNumber') {
return zodType.isInt
? parseInt(value ?? '', 10)
: parseFloat(value ?? '');
}
else if (zodType._def.typeName == 'ZodBoolean') {
return Boolean(value == 'false' ? '' : value).valueOf();
}
else if (zodType._def.typeName == 'ZodDate') {
return new Date(value ?? '');
}
else if (zodType._def.typeName == 'ZodArray') {
const arrayType = unwrapZodType(zodType._def.type);
return parseFormDataEntry(field, value, arrayType);
}
else if (zodType._def.typeName == 'ZodBigInt') {
try {
return BigInt(value ?? '.');
}
catch {
return NaN;
}
}
else if (zodType._def.typeName == 'ZodLiteral') {
const literalType = typeof zodType.value;
if (literalType === 'string')
return value;
else if (literalType === 'number')
return parseFloat(value ?? '');
else if (literalType === 'boolean')
return Boolean(value).valueOf();
else {
throw new SuperFormError('Unsupported ZodLiteral type: ' + literalType);
}
}
else if (zodType._def.typeName == 'ZodUnion' ||
zodType._def.typeName == 'ZodEnum' ||
zodType._def.typeName == 'ZodAny') {
return value;
}
else if (zodType._def.typeName == 'ZodNativeEnum') {
const zodEnum = zodType;
if (value !== null && value in zodEnum.enum) {
const enumValue = zodEnum.enum[value];
if (typeof enumValue === 'number')
return enumValue;
else if (enumValue in zodEnum.enum)
return zodEnum.enum[enumValue];
}
else if (value !== null &&
Object.values(zodEnum.enum).includes(value)) {
return value;
}
return undefined;
}
else if (zodType._def.typeName == 'ZodSymbol') {
return Symbol(String(value));
}
if (zodType._def.typeName == 'ZodObject') {
throw new SuperFormError(`Object found in form field "${field}". ` +
`Set the dataType option to "json" and add use:enhance on the client to use nested data structures. ` +
`More information: https://superforms.rocks/concepts/nested-data`);
}
throw new SuperFormError('Unsupported Zod default type: ' + zodType.constructor.name);
}
return output;
}
/**
* Check what data to validate. If no parsed data, the default entity
* may still have to be validated if there are side-effects or errors
* should be displayed.
*/
function dataToValidate(parsed, schemaData) {
if (!parsed.data) {
return schemaData.hasEffects || schemaData.opts.errors === true
? schemaData.entityInfo.defaultEntity
: undefined;
}
else {
return parsed.data;
}
}
function parseFormData(formData, schemaData) {
function tryParseSuperJson() {
if (formData.has('__superform_json')) {
try {
const output = parse(formData.getAll('__superform_json').join('') ?? '');
if (typeof output === 'object') {
return output;
}
}
catch {
//
}
}
return null;
}
const data = tryParseSuperJson();
const id = formData.get('__superform_id')?.toString() ?? undefined;
return data
? { id, data, posted: true }
: {
id,
data: formDataToValidation(formData, schemaData),
posted: true
};
}
function parseSearchParams(data, schemaData) {
if (data instanceof URL)
data = data.searchParams;
const convert = new FormData();
for (const [key, value] of data.entries()) {
convert.append(key, value);
}
// Only FormData can be posted.
const output = parseFormData(convert, schemaData);
output.posted = false;
return output;
}
function validateResult(parsed, schemaData, result) {
const { opts: options, entityInfo } = schemaData;
const posted = parsed.posted;
// Determine id for form
// 1. options.id
// 2. formData.__superform_id
// 3. schema hash
const id = parsed.data
? options.id ?? parsed.id ?? entityInfo.hash
: options.id ?? entityInfo.hash;
if (!parsed.data) {
let data = undefined;
let errors = {};
const valid = result?.success ?? false;
const { opts: options, entityInfo } = schemaData;
if (result) {
if (result.success) {
data = result.data;
}
else if (options.errors === true) {
errors = mapErrors(result.error.format(), entityInfo.errorShape);
}
}
return {
id,
valid,
posted,
errors,
// Copy the default entity so it's not modified
data: data ?? clone(entityInfo.defaultEntity),
constraints: entityInfo.constraints
};
}
else {
const { opts: options, schemaKeys, entityInfo, unwrappedSchema } = schemaData;
if (!result) {
throw new SuperFormError('Validation data exists without validation result.');
}
if (!result.success) {
const partialData = parsed.data;
const errors = options.errors !== false
? mapErrors(result.error.format(), entityInfo.errorShape)
: {};
// passthrough, strip, strict
const zodKeyStatus = unwrappedSchema._def.unknownKeys;
const data = zodKeyStatus == 'passthrough'
? { ...clone(entityInfo.defaultEntity), ...partialData }
: Object.fromEntries(schemaKeys.map((key) => [
key,
key in partialData
? partialData[key]
: clone(entityInfo.defaultEntity[key])
]));
return {
id,
valid: false,
posted,
errors,
data,
constraints: entityInfo.constraints
};
}
else {
return {
id,
valid: true,
posted,
errors: {},
data: result.data,
constraints: entityInfo.constraints
};
}
}
}
function getSchemaData(schema, options) {
const originalSchema = schema;
let unwrappedSchema = schema;
let hasEffects = false;
while (unwrappedSchema._def.typeName == 'ZodEffects') {
hasEffects = true;
unwrappedSchema = unwrappedSchema._def.schema;
}
if (!(unwrappedSchema._def.typeName == 'ZodObject')) {
throw new SuperFormError('Only Zod schema objects can be used with superValidate. ' +
'Define the schema with z.object({ ... }) and optionally refine/superRefine/transform at the end.');
}
const entityInfo = entityData(unwrappedSchema, options?.warnings);
return {
originalSchema,
unwrappedSchema: unwrappedSchema,
hasEffects,
entityInfo,
schemaKeys: entityInfo.keys,
opts: options ?? {}
};
}
/**
* Validates a Zod schema for usage in a SvelteKit form.
* @param data Data structure for a Zod schema, or RequestEvent/FormData/URL. If falsy, the schema's defaultEntity will be used.
* @param schema The Zod schema to validate against.
*/
export async function superValidate(data, schema, options) {
if (data && typeof data === 'object' && 'safeParseAsync' in data) {
options = schema;
schema = data;
data = null;
}
const schemaData = getSchemaData(schema, options);
async function tryParseFormData(request) {
let formData = undefined;
try {
formData = await request.formData();
}
catch (e) {
if (e instanceof TypeError &&
e.message.includes('already been consumed')) {
// Pass through the "body already consumed" error, which applies to
// POST requests when event/request is used after formData has been fetched.
throw e;
}
// No data found, return an empty form
return { id: undefined, data: undefined, posted: false };
}
return parseFormData(formData, schemaData);
}
async function parseRequest() {
let parsed;
if (data instanceof FormData) {
parsed = parseFormData(data, schemaData);
}
else if (data instanceof URL || data instanceof URLSearchParams) {
parsed = parseSearchParams(data, schemaData);
}
else if (data instanceof Request) {
parsed = await tryParseFormData(data);
}
else if (data &&
typeof data === 'object' &&
'request' in data &&
data.request instanceof Request) {
parsed = await tryParseFormData(data.request);
}
else {
parsed = {
id: undefined,
data: data,
posted: false
};
}
//////////////////////////////////////////////////////////////////////
// This logic is shared between superValidate and superValidateSync //
const toValidate = dataToValidate(parsed, schemaData);
const result = toValidate
? await schemaData.originalSchema.safeParseAsync(toValidate)
: undefined;
//////////////////////////////////////////////////////////////////////
return { parsed, result };
}
const { parsed, result } = await parseRequest();
return validateResult(parsed, schemaData, result);
}
/**
* Validates a Zod schema for usage in a SvelteKit form.
* @param data Data structure for a Zod schema, or RequestEvent/FormData/URL. If falsy, the schema's defaultEntity will be used.
* @param schema The Zod schema to validate against.
*/
export function superValidateSync(data, schema, options) {
if (data && typeof data === 'object' && 'safeParse' in data) {
options = schema;
schema = data;
data = null;
}
const schemaData = getSchemaData(schema, options);
const parsed = data instanceof FormData
? parseFormData(data, schemaData)
: data instanceof URL || data instanceof URLSearchParams
? parseSearchParams(data, schemaData)
: {
id: undefined,
data: data,
posted: false
}; // Only schema, null or undefined left
//////////////////////////////////////////////////////////////////////
// This logic is shared between superValidate and superValidateSync //
const toValidate = dataToValidate(parsed, schemaData);
const result = toValidate
? schemaData.originalSchema.safeParse(toValidate)
: undefined;
//////////////////////////////////////////////////////////////////////
return validateResult(parsed, schemaData, result);
}
///////////////////////////////////////////////////////////////////////////////
export function actionResult(type, data, options) {
const status = options && typeof options !== 'number' ? options.status : options;
const result = (struct) => {
return json({ type, ...struct }, {
status: struct.status,
headers: typeof options === 'object' && options.message
? {
'Set-Cookie': `flash=${encodeURIComponent(JSON.stringify(options.message))}; Path=/; Max-Age=120`
}
: undefined
});
};
if (type == 'error') {
return result({
status: status || 500,
error: typeof data === 'string' ? { message: data } : data
});
}
else if (type == 'redirect') {
return result({
status: status || 303,
location: data
});
}
else if (type == 'failure') {
return result({
status: status || 400,
data: stringify(data)
});
}
else {
return result({ status: status || 200, data: stringify(data) });
}
}