Skip to content

Commit

Permalink
Actions: Add devalue for serializing complex values (#11593)
Browse files Browse the repository at this point in the history
* wip: move getActionResult setup to render

* feat: serialize action data for edge

* refactor: serializeActionResult util

* feat: introduce devalue for body parsing

* refactor: orthrow -> main

* feat(test): Date and Set

* refactor: move getAction to separate file for bundling

* docs: changeset

* Revert "refactor: move getAction to separate file for bundling"

This reverts commit ef2b409.

* Revert "Revert "refactor: move getAction to separate file for bundling""

This reverts commit 40deaed.

* fix: actions import from client

* feat: add support for URL objects

* refactor: new isActionError utility

* refactor: reuse isInputError in fromJson

* fix: use INTERNAL_SERVER_ERROR for unknown errors
  • Loading branch information
bholmesdev authored Aug 5, 2024
1 parent 3f27c9d commit 81d7150
Show file tree
Hide file tree
Showing 12 changed files with 260 additions and 190 deletions.
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;
};
};

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

0 comments on commit 81d7150

Please sign in to comment.