0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-17 23:11:29 -05:00

Actions: Add devalue for serializing complex values (#11593)

* 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 ef2b40991f90ff64c063cb4364eb2affcb2328c3.

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

This reverts commit 40deaeda1dd350b27fa3da994a7c37005ae7a187.

* 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
This commit is contained in:
Ben Holmes 2024-08-05 08:22:38 -04:00 committed by GitHub
parent 3f27c9d934
commit 81d7150e02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 260 additions and 190 deletions

View file

@ -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.

View file

@ -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;
};
};
@ -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');
@ -53,7 +56,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
return handlePostLegacy({ context, next });
}
return nextWithLocalsStub(next, context);
return next();
});
async function handlePost({
@ -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) {
@ -118,7 +119,7 @@ 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;
@ -126,10 +127,10 @@ async function handlePostLegacy({ context, next }: { context: APIContext; next:
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) {
@ -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;
};
}

View file

@ -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;
@ -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,
},
});
};

View file

@ -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'];
@ -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

View file

@ -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;
}

View file

@ -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',
@ -68,25 +69,28 @@ 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>;
@ -94,7 +98,14 @@ export function isInputError(error?: unknown): error is ActionInputError<ErrorIn
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> =
@ -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,
};
}

View file

@ -1,33 +1,27 @@
import type { APIContext } from '../@types/astro.js';
import { AstroError } from '../core/errors/errors.js';
import type { Locals } from './runtime/middleware.js';
import { type ActionAPIContext } from './runtime/utils.js';
import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js';
export function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
return '_actionsInternal' in locals;
}
export function createGetActionResult(locals: APIContext['locals']): APIContext['getActionResult'] {
return (actionFn) => {
if (!hasActionsInternal(locals))
throw new AstroError({
name: 'AstroActionError',
message: 'Experimental actions are not enabled in your project.',
hint: 'See https://docs.astro.build/en/reference/configuration-reference/#experimental-flags',
});
return locals._actionsInternal.getActionResult(actionFn);
return (actionFn): any => {
if (
!hasActionsInternal(locals) ||
actionFn.toString() !== getActionQueryString(locals._actionsInternal.actionName)
) {
return undefined;
}
return deserializeActionResult(locals._actionsInternal.actionResult);
};
}
export function createCallAction(locals: APIContext['locals']): APIContext['callAction'] {
return (actionFn, input) => {
if (!hasActionsInternal(locals))
throw new AstroError({
name: 'AstroActionError',
message: 'Experimental actions are not enabled in your project.',
hint: 'See https://docs.astro.build/en/reference/configuration-reference/#experimental-flags',
});
return locals._actionsInternal.callAction(actionFn, input);
export function createCallAction(context: ActionAPIContext): APIContext['callAction'] {
return (baseAction, input) => {
const action = baseAction.bind(context);
return action(input) as any;
};
}

View file

@ -106,7 +106,7 @@ function createContext({
};
return Object.assign(context, {
getActionResult: createGetActionResult(context.locals),
callAction: createCallAction(context.locals),
callAction: createCallAction(context),
});
}

View file

@ -1,3 +1,4 @@
import { deserializeActionResult } from '../actions/runtime/virtual/shared.js';
import type {
APIContext,
AstroGlobal,
@ -216,7 +217,7 @@ export class RenderContext {
return Object.assign(context, {
props,
getActionResult: createGetActionResult(context.locals),
callAction: createCallAction(context.locals),
callAction: createCallAction(context),
});
}
@ -314,7 +315,7 @@ export class RenderContext {
} satisfies AstroGlobal['response'];
const actionResult = hasActionsInternal(this.locals)
? this.locals._actionsInternal?.actionResult
? deserializeActionResult(this.locals._actionsInternal.actionResult)
: undefined;
// Create the result object that will be passed into the renderPage function.
@ -458,10 +459,12 @@ export class RenderContext {
redirect,
rewrite,
request: this.request,
getActionResult: createGetActionResult(locals),
callAction: createCallAction(locals),
response,
site: pipeline.site,
getActionResult: createGetActionResult(locals),
get callAction() {
return createCallAction(this);
},
url,
};
}

View file

@ -1,4 +1,4 @@
import { ActionError, callSafely, getActionQueryString } from 'astro:actions';
import { ActionError, getActionQueryString, deserializeActionResult } from 'astro:actions';
function toActionProxy(actionCallback = {}, aggregatedPath = '') {
return new Proxy(actionCallback, {
@ -8,7 +8,7 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
}
const path = aggregatedPath + objKey.toString();
function action(param) {
return callSafely(() => handleActionOrThrow(param, path, this));
return handleAction(param, path, this);
}
Object.assign(action, {
@ -28,8 +28,10 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
// Note: `orThrow` does not have progressive enhancement info.
// If you want to throw exceptions,
// you must handle those exceptions with client JS.
orThrow(param) {
return handleActionOrThrow(param, path, this);
async orThrow(param) {
const { data, error } = await handleAction(param, path, this);
if (error) throw error;
return data;
},
});
@ -43,17 +45,18 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
/**
* @param {*} param argument passed to the action when called server or client-side.
* @param {string} path Built path to call action by path name.
* @param {import('../src/@types/astro.d.ts').APIContext | undefined} context Injected API context when calling actions from the server.
* @param {import('../dist/@types/astro.d.ts').APIContext | undefined} context Injected API context when calling actions from the server.
* Usage: `actions.[name](param)`.
* @returns {Promise<import('../dist/actions/runtime/virtual/shared.js').SafeResult<any, any>>}
*/
async function handleActionOrThrow(param, path, context) {
async function handleAction(param, path, context) {
// When running server-side, import the action and call it.
if (import.meta.env.SSR) {
const { getAction } = await import('astro/actions/runtime/utils.js');
const { getAction } = await import('astro/actions/runtime/virtual/get-action.js');
const action = await getAction(path);
if (!action) throw new Error(`Action not found: ${path}`);
return action.orThrow.bind(context)(param);
return action.bind(context)(param);
}
// When running client-side, make a fetch request to the action path.
@ -72,19 +75,17 @@ async function handleActionOrThrow(param, path, context) {
headers.set('Content-Type', 'application/json');
headers.set('Content-Length', body?.length.toString() ?? '0');
}
const res = await fetch(`/_actions/${path}`, {
const rawResult = await fetch(`/_actions/${path}`, {
method: 'POST',
body,
headers,
});
if (!res.ok) {
throw await ActionError.fromResponse(res);
}
// Check if response body is empty before parsing.
if (res.status === 204) return;
if (rawResult.status === 204) return;
const json = await res.json();
return json;
return deserializeActionResult({
type: rawResult.ok ? 'data' : 'error',
body: await rawResult.text(),
});
}
export const actions = toActionProxy();

View file

@ -3,6 +3,7 @@ import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';
import * as devalue from 'devalue';
describe('Astro Actions', () => {
let fixture;
@ -34,11 +35,11 @@ describe('Astro Actions', () => {
});
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.channel, 'bholmesdev');
assert.equal(json.subscribeButtonState, 'smashed');
const data = devalue.parse(await res.text());
assert.equal(data.channel, 'bholmesdev');
assert.equal(data.subscribeButtonState, 'smashed');
});
it('Exposes comment action', async () => {
@ -51,11 +52,11 @@ describe('Astro Actions', () => {
});
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.channel, 'bholmesdev');
assert.equal(json.comment, 'Hello, World!');
const data = devalue.parse(await res.text());
assert.equal(data.channel, 'bholmesdev');
assert.equal(data.comment, 'Hello, World!');
});
it('Raises validation error on bad form data', async () => {
@ -70,8 +71,8 @@ describe('Astro Actions', () => {
assert.equal(res.status, 400);
assert.equal(res.headers.get('Content-Type'), 'application/json');
const json = await res.json();
assert.equal(json.type, 'AstroActionInputError');
const data = await res.json();
assert.equal(data.type, 'AstroActionInputError');
});
it('Exposes plain formData action', async () => {
@ -84,11 +85,11 @@ describe('Astro Actions', () => {
});
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.success, true);
assert.equal(json.isFormData, true, 'Should receive plain FormData');
const data = devalue.parse(await res.text());
assert.equal(data.success, true);
assert.equal(data.isFormData, true, 'Should receive plain FormData');
});
});
@ -111,11 +112,11 @@ describe('Astro Actions', () => {
const res = await app.render(req);
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.channel, 'bholmesdev');
assert.equal(json.subscribeButtonState, 'smashed');
const data = devalue.parse(await res.text());
assert.equal(data.channel, 'bholmesdev');
assert.equal(data.subscribeButtonState, 'smashed');
});
it('Exposes comment action', async () => {
@ -129,11 +130,11 @@ describe('Astro Actions', () => {
const res = await app.render(req);
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.channel, 'bholmesdev');
assert.equal(json.comment, 'Hello, World!');
const data = devalue.parse(await res.text());
assert.equal(data.channel, 'bholmesdev');
assert.equal(data.comment, 'Hello, World!');
});
it('Raises validation error on bad form data', async () => {
@ -149,8 +150,8 @@ describe('Astro Actions', () => {
assert.equal(res.status, 400);
assert.equal(res.headers.get('Content-Type'), 'application/json');
const json = await res.json();
assert.equal(json.type, 'AstroActionInputError');
const data = await res.json();
assert.equal(data.type, 'AstroActionInputError');
});
it('Exposes plain formData action', async () => {
@ -164,11 +165,11 @@ describe('Astro Actions', () => {
const res = await app.render(req);
assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json');
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const json = await res.json();
assert.equal(json.success, true);
assert.equal(json.isFormData, true, 'Should receive plain FormData');
const data = devalue.parse(await res.text());
assert.equal(data.success, true);
assert.equal(data.isFormData, true, 'Should receive plain FormData');
});
it('Response middleware fallback', async () => {
@ -266,7 +267,7 @@ describe('Astro Actions', () => {
});
const res = await app.render(req);
assert.equal(res.status, 200);
const value = await res.json();
const value = devalue.parse(await res.text());
assert.equal(value, 0);
});
@ -280,8 +281,28 @@ describe('Astro Actions', () => {
});
const res = await app.render(req);
assert.equal(res.status, 200);
const value = await res.json();
const value = devalue.parse(await res.text());
assert.equal(value, false);
});
it('Supports complex values: Date, Set, URL', async () => {
const req = new Request('http://example.com/_actions/complexValues', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '0',
},
});
const res = await app.render(req);
assert.equal(res.status, 200);
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const value = devalue.parse(await res.text(), {
URL: (href) => new URL(href),
});
assert.ok(value.date instanceof Date);
assert.ok(value.set instanceof Set);
});
});
});

View file

@ -73,5 +73,14 @@ export const server = {
handler: async () => {
return false;
}
}),
complexValues: defineAction({
handler: async () => {
return {
date: new Date(),
set: new Set(),
url: new URL('https://example.com'),
}
}
})
};