Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.6.0",
"astro": "^5.14.4",
"sharp": "^0.34.3"
"sharp": "^0.34.3",
"zod": "^4.0.0"
}
}
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@
"xxhash-wasm": "^1.1.0",
"yargs-parser": "^21.1.1",
"yocto-spinner": "^0.2.3",
"zod": "^3.25.76",
"zod": "^3.25.76 || ^4.0.0",
"zod-to-json-schema": "^3.24.6",
"zod-to-ts": "^1.2.0"
},
Expand Down
214 changes: 169 additions & 45 deletions packages/astro/src/actions/runtime/virtual/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4/core';
import type { Pipeline } from '../../../core/base-pipeline.js';
import { shouldAppendForwardSlash } from '../../../core/build/util.js';
import { AstroError } from '../../../core/errors/errors.js';
Expand Down Expand Up @@ -32,42 +33,60 @@ export * from './shared.js';

export type ActionAccept = 'form' | 'json';

export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
: (input: any, context: ActionAPIContext) => MaybePromise<TOutput>;
export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z3.ZodType
? (input: z3.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
: TInputSchema extends z4.$ZodType
? (input: z4.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
: (input: any, context: ActionAPIContext) => MaybePromise<TOutput>;

export type ActionReturnType<T extends ActionHandler<any, any>> = Awaited<ReturnType<T>>;

export type ActionClient<
TOutput,
TAccept extends ActionAccept | undefined,
TInputSchema extends z.ZodType | undefined,
> = TInputSchema extends z.ZodType
TInputSchema extends z3.ZodType | z4.$ZodType | undefined,
> = TInputSchema extends z3.ZodType
? ((
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
input: TAccept extends 'form' ? FormData : z3.input<TInputSchema>,
) => Promise<
SafeResult<
z.input<TInputSchema> extends ErrorInferenceObject
? z.input<TInputSchema>
z3.input<TInputSchema> extends ErrorInferenceObject
? z3.input<TInputSchema>
: ErrorInferenceObject,
Awaited<TOutput>
>
>) & {
queryString: string;
orThrow: (
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>,
input: TAccept extends 'form' ? FormData : z3.input<TInputSchema>,
) => Promise<Awaited<TOutput>>;
}
: ((input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>) & {
orThrow: (input?: any) => Promise<Awaited<TOutput>>;
};
: TInputSchema extends z4.$ZodType
? ((
input: TAccept extends 'form' ? FormData : z4.input<TInputSchema>,
) => Promise<
SafeResult<
z4.input<TInputSchema> extends ErrorInferenceObject
? z4.input<TInputSchema>
: ErrorInferenceObject,
Awaited<TOutput>
>
>) & {
queryString: string;
orThrow: (
input: TAccept extends 'form' ? FormData : z4.input<TInputSchema>,
) => Promise<Awaited<TOutput>>;
}
: ((input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>) & {
orThrow: (input?: any) => Promise<Awaited<TOutput>>;
};

export function defineAction<
TOutput,
TAccept extends ActionAccept | undefined = undefined,
TInputSchema extends z.ZodType | undefined = TAccept extends 'form'
TInputSchema extends z3.ZodType | z4.$ZodType | undefined = TAccept extends 'form'
? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
z.ZodType<FormData>
z3.ZodType<FormData>
: undefined,
>({
accept,
Expand Down Expand Up @@ -103,7 +122,7 @@ export function defineAction<
return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string;
}

function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(
function getFormServerHandler<TOutput, TInputSchema extends z3.ZodType | z4.$ZodType>(
handler: ActionHandler<TInputSchema, TOutput>,
inputSchema?: TInputSchema,
) {
Expand All @@ -117,20 +136,32 @@ function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(

if (!inputSchema) return await handler(unparsedInput, context);

const baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput);
const parsed = await inputSchema.safeParseAsync(
baseSchema instanceof z.ZodObject
? formDataToObject(unparsedInput, baseSchema)
: unparsedInput,
);
let parsed;
if ('_zod' in inputSchema) {
const baseSchema = unwrapBaseZ4ObjectSchema(inputSchema, unparsedInput);
parsed = await z4.safeParseAsync(
inputSchema,
baseSchema instanceof z4.$ZodObject
? formDataToZ4Object(unparsedInput, baseSchema)
: unparsedInput,
);
} else {
const baseSchema = unwrapBaseZ3ObjectSchema(inputSchema, unparsedInput);
parsed = await inputSchema.safeParseAsync(
baseSchema instanceof z3.ZodObject
? formDataToZ3Object(unparsedInput, baseSchema)
: unparsedInput,
);
}

if (!parsed.success) {
throw new ActionInputError(parsed.error.issues);
}
return await handler(parsed.data, context);
};
}

function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
function getJsonServerHandler<TOutput, TInputSchema extends z3.ZodType | z4.$ZodType>(
handler: ActionHandler<TInputSchema, TOutput>,
inputSchema?: TInputSchema,
) {
Expand All @@ -143,7 +174,10 @@ function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
}

if (!inputSchema) return await handler(unparsedInput, context);
const parsed = await inputSchema.safeParseAsync(unparsedInput);
const parsed =
'_zod' in inputSchema
? await z4.safeParseAsync(inputSchema, unparsedInput)
: await inputSchema.safeParseAsync(unparsedInput);
if (!parsed.success) {
throw new ActionInputError(parsed.error.issues);
}
Expand All @@ -152,7 +186,7 @@ function getJsonServerHandler<TOutput, TInputSchema extends z.ZodType>(
}

/** Transform form data to an object based on a Zod schema. */
export function formDataToObject<T extends z.AnyZodObject>(
export function formDataToZ3Object<T extends z3.AnyZodObject>(
formData: FormData,
schema: T,
): Record<string, unknown> {
Expand All @@ -162,12 +196,12 @@ export function formDataToObject<T extends z.AnyZodObject>(
let validator = baseValidator;

while (
validator instanceof z.ZodOptional ||
validator instanceof z.ZodNullable ||
validator instanceof z.ZodDefault
validator instanceof z3.ZodOptional ||
validator instanceof z3.ZodNullable ||
validator instanceof z3.ZodDefault
) {
// use default value when key is undefined
if (validator instanceof z.ZodDefault && !formData.has(key)) {
if (validator instanceof z3.ZodDefault && !formData.has(key)) {
obj[key] = validator._def.defaultValue();
}
validator = validator._def.innerType;
Expand All @@ -176,56 +210,128 @@ export function formDataToObject<T extends z.AnyZodObject>(
if (!formData.has(key) && key in obj) {
// continue loop if form input is not found and default value is set
continue;
} else if (validator instanceof z.ZodBoolean) {
} else if (validator instanceof z3.ZodBoolean) {
const val = formData.get(key);
obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key);
} else if (validator instanceof z.ZodArray) {
obj[key] = handleFormDataGetAll(key, formData, validator);
} else if (validator instanceof z3.ZodArray) {
obj[key] = handleZ3FormDataGetAll(key, formData, validator);
} else {
obj[key] = handleFormDataGet(key, formData, validator, baseValidator);
obj[key] = handleZ3FormDataGet(key, formData, validator, baseValidator);
}
}
return obj;
}

function handleFormDataGetAll(
/** Transform form data to an object based on a Zod schema. */
export function formDataToZ4Object<T extends z4.$ZodObject>(
formData: FormData,
schema: T,
): Record<string, unknown> {
schema._zod.def.catchall;
const obj: Record<string, unknown> =
// TODO:
// @ts-expect-error
schema._zod.def.unknownKeys === 'passthrough' ? Object.fromEntries(formData.entries()) : {};
for (const [key, baseValidator] of Object.entries(schema._zod.def.shape)) {
let validator = baseValidator;

while (
validator instanceof z4.$ZodOptional ||
validator instanceof z4.$ZodNullable ||
validator instanceof z4.$ZodDefault
) {
// use default value when key is undefined
if (validator instanceof z4.$ZodDefault && !formData.has(key)) {
obj[key] =
validator._zod.def.defaultValue instanceof Function
? validator._zod.def.defaultValue()
: validator._zod.def.defaultValue;
}
validator = validator._zod.def.innerType;
}


if (!formData.has(key) && key in obj) {
// continue loop if form input is not found and default value is set
continue;
} else if (validator instanceof z4.$ZodBoolean) {
const val = formData.get(key);
obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key);
} else if (validator instanceof z4.$ZodArray) {
obj[key] = handleZ4FormDataGetAll(key, formData, validator);
} else {
obj[key] = handleZ4FormDataGet(key, formData, validator, baseValidator);
}
}
return obj;
}

function handleZ3FormDataGetAll(
key: string,
formData: FormData,
validator: z.ZodArray<z.ZodUnknown>,
validator: z3.ZodArray<z3.ZodUnknown>,
) {
const entries = Array.from(formData.getAll(key));
const elementValidator = validator._def.type;
if (elementValidator instanceof z.ZodNumber) {
if (elementValidator instanceof z3.ZodNumber) {
return entries.map(Number);
} else if (elementValidator instanceof z.ZodBoolean) {
} else if (elementValidator instanceof z3.ZodBoolean) {
return entries.map(Boolean);
}
return entries;
}

function handleFormDataGet(
function handleZ4FormDataGetAll(
key: string,
formData: FormData,
validator: z4.$ZodArray,
) {
const entries = Array.from(formData.getAll(key));
const elementValidator = validator._zod.def.element;
if (elementValidator instanceof z4.$ZodNumber) {
return entries.map(Number);
} else if (elementValidator instanceof z4.$ZodBoolean) {
return entries.map(Boolean);
}
return entries;
}

function handleZ3FormDataGet(
key: string,
formData: FormData,
validator: unknown,
baseValidator: unknown,
) {
const value = formData.get(key);
if (!value) {
return baseValidator instanceof z3.ZodOptional ? undefined : null;
}
return validator instanceof z3.ZodNumber ? Number(value) : value;
}

function handleZ4FormDataGet(
key: string,
formData: FormData,
validator: unknown,
baseValidator: unknown,
) {
const value = formData.get(key);
if (!value) {
return baseValidator instanceof z.ZodOptional ? undefined : null;
return baseValidator instanceof z4.$ZodOptional ? undefined : null;
}
return validator instanceof z.ZodNumber ? Number(value) : value;
return validator instanceof z4.$ZodNumber ? Number(value) : value;
}

function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) {
if (schema instanceof z.ZodEffects) {
function unwrapBaseZ3ObjectSchema(schema: z3.ZodType, unparsedInput: FormData) {
while (schema instanceof z3.ZodEffects || schema instanceof z3.ZodPipeline) {
if (schema instanceof z3.ZodEffects) {
schema = schema._def.schema;
}
if (schema instanceof z.ZodPipeline) {
if (schema instanceof z3.ZodPipeline) {
schema = schema._def.in;
}
}
if (schema instanceof z.ZodDiscriminatedUnion) {
if (schema instanceof z3.ZodDiscriminatedUnion) {
const typeKey = schema._def.discriminator;
const typeValue = unparsedInput.get(typeKey);
if (typeof typeValue !== 'string') return schema;
Expand All @@ -238,6 +344,24 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
return schema;
}

function unwrapBaseZ4ObjectSchema(schema: z4.$ZodType, unparsedInput: FormData) {
// TODO: how to handle z4.$ZodTransform?
if (schema instanceof z4.$ZodDiscriminatedUnion) {
const typeKey = schema._zod.def.discriminator;
const typeValue = unparsedInput.get(typeKey);
if (typeof typeValue !== 'string') return schema;

// TODO: test
const objSchema = schema._zod.def.options.find(
(option) => (option as any)[typeKey] === typeValue,
);
if (!objSchema) return schema;

return objSchema;
}
return schema;
}

export type AstroActionContext = {
/** Information about an incoming action request. */
action?: {
Expand Down
11 changes: 6 additions & 5 deletions packages/astro/src/actions/runtime/virtual/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parse as devalueParse, stringify as devalueStringify } from 'devalue';
import type { z } from 'zod';
import type * as z3 from 'zod/v3';
import type * as z4 from 'zod/v4/core';
import { REDIRECT_STATUS_CODES } from '../../../core/constants.js';
import { AstroError } from '../../../core/errors/errors.js';
import {
Expand Down Expand Up @@ -191,10 +192,10 @@ export class ActionInputError<T extends ErrorInferenceObject> extends ActionErro
// Not all properties will serialize from server to client,
// and we don't want to import the full ZodError object into the client.

issues: z.ZodIssue[];
fields: z.ZodError<T>['formErrors']['fieldErrors'];
issues: z3.ZodIssue[] | z4.$ZodIssue[];
fields: { [P in keyof T]?: string[] | undefined };

constructor(issues: z.ZodIssue[]) {
constructor(issues: z3.ZodIssue[] | z4.$ZodIssue[]) {
super({
message: `Failed to validate: ${JSON.stringify(issues, null, 2)}`,
code: 'BAD_REQUEST',
Expand All @@ -213,7 +214,7 @@ export class ActionInputError<T extends ErrorInferenceObject> extends ActionErro

export async function callSafely<TOutput>(
handler: () => MaybePromise<TOutput>,
): Promise<SafeResult<z.ZodType, TOutput>> {
): Promise<SafeResult<z3.ZodType | z4.$ZodType, TOutput>> {
try {
const data = await handler();
return { data, error: undefined };
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/assets/fonts/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from 'zod/v3';
import { LOCAL_PROVIDER_NAME } from './constants.js';

const weightSchema = z.union([z.string(), z.number()]);
Expand Down
Loading
Loading