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:
parent
3f27c9d934
commit
81d7150e02
12 changed files with 260 additions and 190 deletions
7
.changeset/happy-zebras-clean.md
Normal file
7
.changeset/happy-zebras-clean.md
Normal 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.
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
26
packages/astro/src/actions/runtime/virtual/get-action.ts
Normal file
26
packages/astro/src/actions/runtime/virtual/get-action.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -106,7 +106,7 @@ function createContext({
|
|||
};
|
||||
return Object.assign(context, {
|
||||
getActionResult: createGetActionResult(context.locals),
|
||||
callAction: createCallAction(context.locals),
|
||||
callAction: createCallAction(context),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue