Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actions: Add devalue for serializing complex values #11593

Merged
merged 15 commits into from
Aug 5, 2024
Merged
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
7 changes: 7 additions & 0 deletions .changeset/happy-zebras-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Adds support for `Date()`, `Map()`, and `Set()` from action results. See [devalue](https://github.com/Rich-Harris/devalue) for a complete list of supported values.

Also fixes serialization exceptions when deploying Actions with edge middleware on Netlify and Vercel.
94 changes: 30 additions & 64 deletions packages/astro/src/actions/runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import {
} from '../../core/errors/errors-data.js';
import { AstroError } from '../../core/errors/errors.js';
import { defineMiddleware } from '../../core/middleware/index.js';
import { formContentTypes, getAction, hasContentType } from './utils.js';
import { getActionQueryString } from './virtual/shared.js';
import { formContentTypes, hasContentType } from './utils.js';
import {
type SafeResult,
type SerializedActionResult,
serializeActionResult,
} from './virtual/shared.js';
import { getAction } from './virtual/get-action.js';

export type Locals = {
_actionsInternal: {
getActionResult: APIContext['getActionResult'];
callAction: APIContext['callAction'];
actionResult?: ReturnType<APIContext['getActionResult']>;
actionResult: SerializedActionResult;
actionName: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the getActionResult constructor to render-context. This ensures only serializable values are sent through locals for edge middleware support. It also simplified our code in the process by removing that nextLocalsStub utility!

};
};

Expand All @@ -24,16 +28,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
// `_actionsInternal` is the same for every page,
// so short circuit if already defined.
if (locals._actionsInternal) {
// Re-bind `callAction` with the new API context
locals._actionsInternal.callAction = createCallAction(context);
return next();
}
if (locals._actionsInternal) return next();

// Heuristic: If body is null, Astro might've reset this for prerendering.
// Stub with warning when `getActionResult()` is used.
if (request.method === 'POST' && request.body === null) {
return nextWithStaticStub(next, context);
if (import.meta.env.DEV && request.method === 'POST' && request.body === null) {
console.warn(
yellow('[astro:actions]'),
'POST requests should not be sent to prerendered pages. If you\'re using Actions, disable prerendering with `export const prerender = "false".'
);
return next();
}

const actionName = context.url.searchParams.get('_astroAction');
Expand All @@ -53,7 +56,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
return handlePostLegacy({ context, next });
}

return nextWithLocalsStub(next, context);
return next();
});

async function handlePost({
Expand Down Expand Up @@ -87,19 +90,17 @@ async function handleResult({
next,
actionName,
actionResult,
}: { context: APIContext; next: MiddlewareNext; actionName: string; actionResult: any }) {
const actionsInternal: Locals['_actionsInternal'] = {
getActionResult: (actionFn) => {
if (actionFn.toString() !== getActionQueryString(actionName)) {
return Promise.resolve(undefined);
}
return actionResult;
},
callAction: createCallAction(context),
actionResult,
};
}: {
context: APIContext;
next: MiddlewareNext;
actionName: string;
actionResult: SafeResult<any, any>;
}) {
const locals = context.locals as Locals;
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
locals._actionsInternal = {
actionName,
actionResult: serializeActionResult(actionResult),
};

const response = await next();
if (actionResult.error) {
Expand All @@ -118,18 +119,18 @@ async function handlePostLegacy({ context, next }: { context: APIContext; next:
// We should not run a middleware handler for fetch()
// requests directly to the /_actions URL.
// Otherwise, we may handle the result twice.
if (context.url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
if (context.url.pathname.startsWith('/_actions')) return next();

const contentType = request.headers.get('content-type');
let formData: FormData | undefined;
if (contentType && hasContentType(contentType, formContentTypes)) {
formData = await request.clone().formData();
}

if (!formData) return nextWithLocalsStub(next, context);
if (!formData) return next();

const actionName = formData.get('_astroAction') as string;
if (!actionName) return nextWithLocalsStub(next, context);
if (!actionName) return next();

const baseAction = await getAction(actionName);
if (!baseAction) {
Expand All @@ -143,38 +144,3 @@ async function handlePostLegacy({ context, next }: { context: APIContext; next:
const actionResult = await action(formData);
return handleResult({ context, next, actionName, actionResult });
}

function nextWithStaticStub(next: MiddlewareNext, context: APIContext) {
Object.defineProperty(context.locals, '_actionsInternal', {
writable: false,
value: {
getActionResult: () => {
console.warn(
yellow('[astro:actions]'),
'`getActionResult()` should not be called on prerendered pages. Astro can only handle actions for pages rendered on-demand.'
);
return undefined;
},
callAction: createCallAction(context),
},
});
return next();
}

function nextWithLocalsStub(next: MiddlewareNext, context: APIContext) {
Object.defineProperty(context.locals, '_actionsInternal', {
writable: false,
value: {
getActionResult: () => undefined,
callAction: createCallAction(context),
},
});
return next();
}

function createCallAction(context: APIContext): APIContext['callAction'] {
return (baseAction, input) => {
const action = baseAction.bind(context);
return action(input) as any;
};
}
31 changes: 13 additions & 18 deletions packages/astro/src/actions/runtime/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { APIRoute } from '../../@types/astro.js';
import { formContentTypes, getAction, hasContentType } from './utils.js';
import { formContentTypes, hasContentType } from './utils.js';
import { getAction } from './virtual/get-action.js';
import { serializeActionResult } from './virtual/shared.js';

export const POST: APIRoute = async (context) => {
const { request, url } = context;
Expand All @@ -23,25 +25,18 @@ export const POST: APIRoute = async (context) => {
}
const action = baseAction.bind(context);
const result = await action(args);
if (result.error) {
return new Response(
JSON.stringify({
...result.error,
message: result.error.message,
stack: import.meta.env.PROD ? undefined : result.error.stack,
}),
{
status: result.error.status,
headers: {
'Content-Type': 'application/json',
},
}
);
const serialized = serializeActionResult(result);

if (serialized.type === 'empty') {
return new Response(null, {
status: serialized.status,
});
}
return new Response(JSON.stringify(result.data), {
status: result.data !== undefined ? 200 : 204,

return new Response(serialized.body, {
status: serialized.status,
headers: {
'Content-Type': 'application/json',
'Content-Type': serialized.contentType,
},
});
};
26 changes: 0 additions & 26 deletions packages/astro/src/actions/runtime/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { ZodType } from 'zod';
import type { APIContext } from '../../@types/astro.js';
import type { ActionAccept, ActionClient } from './virtual/server.js';

export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];

Expand All @@ -15,30 +13,6 @@ export function hasContentType(contentType: string, expected: string[]) {
export type ActionAPIContext = Omit<APIContext, 'getActionResult' | 'callAction' | 'props'>;
export type MaybePromise<T> = T | Promise<T>;

/**
* Get server-side action based on the route path.
* Imports from the virtual module `astro:internal-actions`, which maps to
* the user's `src/actions/index.ts` file at build-time.
*/
export async function getAction(
path: string
): Promise<ActionClient<unknown, ActionAccept, ZodType> | undefined> {
const pathKeys = path.replace('/_actions/', '').split('.');
// @ts-expect-error virtual module
let { server: actionLookup } = await import('astro:internal-actions');

for (const key of pathKeys) {
if (!(key in actionLookup)) {
return undefined;
}
actionLookup = actionLookup[key];
}
if (typeof actionLookup !== 'function') {
return undefined;
}
return actionLookup;
}

/**
* Used to preserve the input schema type in the error object.
* This allows for type inference on the `fields` property
Expand Down
26 changes: 26 additions & 0 deletions packages/astro/src/actions/runtime/virtual/get-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ZodType } from 'zod';
import type { ActionAccept, ActionClient } from './server.js';

/**
* Get server-side action based on the route path.
* Imports from the virtual module `astro:internal-actions`, which maps to
* the user's `src/actions/index.ts` file at build-time.
*/
export async function getAction(
path: string
): Promise<ActionClient<unknown, ActionAccept, ZodType> | undefined> {
const pathKeys = path.replace('/_actions/', '').split('.');
// @ts-expect-error virtual module
let { server: actionLookup } = await import('astro:internal-actions');

for (const key of pathKeys) {
if (!(key in actionLookup)) {
return undefined;
}
actionLookup = actionLookup[key];
}
if (typeof actionLookup !== 'function') {
return undefined;
}
return actionLookup;
}
96 changes: 85 additions & 11 deletions packages/astro/src/actions/runtime/virtual/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { z } from 'zod';
import type { ErrorInferenceObject, MaybePromise } from '../utils.js';
import { stringify as devalueStringify, parse as devalueParse } from 'devalue';

export const ACTION_ERROR_CODES = [
'BAD_REQUEST',
Expand Down Expand Up @@ -68,33 +69,43 @@ export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject>
return statusToCodeMap[status] ?? 'INTERNAL_SERVER_ERROR';
}

static async fromResponse(res: Response) {
const body = await res.clone().json();
if (
typeof body === 'object' &&
body?.type === 'AstroActionInputError' &&
Array.isArray(body.issues)
) {
static fromJson(body: any) {
if (isInputError(body)) {
return new ActionInputError(body.issues);
}
if (typeof body === 'object' && body?.type === 'AstroActionError') {
if (isActionError(body)) {
return new ActionError(body);
}
return new ActionError({
message: res.statusText,
code: ActionError.statusToCode(res.status),
code: 'INTERNAL_SERVER_ERROR',
});
}
}

export function isActionError(error?: unknown): error is ActionError {
return (
typeof error === 'object' &&
error != null &&
'type' in error &&
error.type === 'AstroActionError'
);
}

export function isInputError<T extends ErrorInferenceObject>(
error?: ActionError<T>
): error is ActionInputError<T>;
export function isInputError(error?: unknown): error is ActionInputError<ErrorInferenceObject>;
export function isInputError<T extends ErrorInferenceObject>(
error?: unknown | ActionError<T>
): error is ActionInputError<T> {
return error instanceof ActionInputError;
return (
typeof error === 'object' &&
error != null &&
'type' in error &&
error.type === 'AstroActionInputError' &&
'issues' in error &&
Array.isArray(error.issues)
);
}

export type SafeResult<TInput extends ErrorInferenceObject, TOutput> =
Expand Down Expand Up @@ -178,3 +189,66 @@ export function getActionProps<T extends (args: FormData) => MaybePromise<unknow
value: actionName,
} as const;
}

export type SerializedActionResult =
| {
type: 'data';
contentType: 'application/json+devalue';
status: 200;
body: string;
}
| {
type: 'error';
contentType: 'application/json';
status: number;
body: string;
}
| {
type: 'empty';
status: 204;
};

export function serializeActionResult(res: SafeResult<any, any>): SerializedActionResult {
if (res.error) {
return {
type: 'error',
status: res.error.status,
contentType: 'application/json',
body: JSON.stringify({
...res.error,
message: res.error.message,
stack: import.meta.env.PROD ? undefined : res.error.stack,
}),
};
}
if (res.data === undefined) {
return {
type: 'empty',
status: 204,
};
}
return {
type: 'data',
status: 200,
contentType: 'application/json+devalue',
body: devalueStringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href,
}),
};
}

export function deserializeActionResult(res: SerializedActionResult): SafeResult<any, any> {
if (res.type === 'error') {
return { error: ActionError.fromJson(JSON.parse(res.body)), data: undefined };
}
if (res.type === 'empty') {
return { data: undefined, error: undefined };
}
return {
data: devalueParse(res.body, {
URL: (href) => new URL(href),
}),
error: undefined,
};
}
Loading
Loading