From 81d7150e02472430eab555dfc4f053738bf99bb6 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Mon, 5 Aug 2024 08:22:38 -0400 Subject: [PATCH] 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 --- .changeset/happy-zebras-clean.md | 7 ++ .../astro/src/actions/runtime/middleware.ts | 94 ++++++------------ packages/astro/src/actions/runtime/route.ts | 31 +++--- packages/astro/src/actions/runtime/utils.ts | 26 ----- .../src/actions/runtime/virtual/get-action.ts | 26 +++++ .../src/actions/runtime/virtual/shared.ts | 96 ++++++++++++++++--- packages/astro/src/actions/utils.ts | 34 +++---- packages/astro/src/core/middleware/index.ts | 2 +- packages/astro/src/core/render-context.ts | 11 ++- packages/astro/templates/actions.mjs | 33 +++---- packages/astro/test/actions.test.js | 81 ++++++++++------ .../fixtures/actions/src/actions/index.ts | 9 ++ 12 files changed, 260 insertions(+), 190 deletions(-) create mode 100644 .changeset/happy-zebras-clean.md create mode 100644 packages/astro/src/actions/runtime/virtual/get-action.ts diff --git a/.changeset/happy-zebras-clean.md b/.changeset/happy-zebras-clean.md new file mode 100644 index 000000000000..7033865e3a73 --- /dev/null +++ b/.changeset/happy-zebras-clean.md @@ -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. diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index 1e5231218dcf..cec01b8dd7e8 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -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; + 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; +}) { 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; - }; -} diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts index 33467a4b76b6..a295fba611ba 100644 --- a/packages/astro/src/actions/runtime/route.ts +++ b/packages/astro/src/actions/runtime/route.ts @@ -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, }, }); }; diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index 4c1555cbd0ef..776171aa2ac5 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -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; export type MaybePromise = T | Promise; -/** - * 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 | 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 diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts new file mode 100644 index 000000000000..052849c482a7 --- /dev/null +++ b/packages/astro/src/actions/runtime/virtual/get-action.ts @@ -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 | 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; +} diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 98f75025a49a..354b39478a03 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -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 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( error?: ActionError ): error is ActionInputError; @@ -94,7 +98,14 @@ export function isInputError(error?: unknown): error is ActionInputError( error?: unknown | ActionError ): error is ActionInputError { - 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 = @@ -178,3 +189,66 @@ export function getActionProps MaybePromise): 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 { + 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, + }; +} diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts index cb00e4b79f80..b08146e8ae70 100644 --- a/packages/astro/src/actions/utils.ts +++ b/packages/astro/src/actions/utils.ts @@ -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; }; } diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index 5499232b16be..56ce0b76c364 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -106,7 +106,7 @@ function createContext({ }; return Object.assign(context, { getActionResult: createGetActionResult(context.locals), - callAction: createCallAction(context.locals), + callAction: createCallAction(context), }); } diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index f8d805b09f9d..259097705d49 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -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, }; } diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index 73ee8396df09..b5ac6dea405b 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -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>} */ -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(); diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 7c84c458d937..8e5919b3159e 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -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); + }); }); }); diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index 03de31d0c989..bc61ade3ab63 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -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'), + } + } }) };