diff --git a/.changeset/yellow-tips-cover.md b/.changeset/yellow-tips-cover.md new file mode 100644 index 000000000000..fb373374ef11 --- /dev/null +++ b/.changeset/yellow-tips-cover.md @@ -0,0 +1,26 @@ +--- +'astro': patch +--- + +Deprecate returning simple objects from endpoints. Endpoints should only return a `Response`. + +To return a result with a custom encoding not supported by a `Response`, you can use the `ResponseWithEncoding` utility class instead. + +Before: + +```ts +export function GET() { + return { + body: '...', + encoding: 'binary', + }; +} +``` + +After: + +```ts +export function GET({ ResponseWithEncoding }) { + return new ResponseWithEncoding('...', undefined, 'binary'); +} +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 1198b26be133..1d8875b4b0b3 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -23,6 +23,7 @@ import type { LogOptions, LoggerLevel } from '../core/logger/core'; import type { AstroIntegrationLogger } from '../core/logger/core'; import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server'; import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js'; +import type { ResponseWithEncoding } from '../core/endpoint/index.js'; export type { MarkdownHeading, @@ -1963,12 +1964,13 @@ export interface APIContext = Record; + encoding?: BufferEncoding; } | { body: Uint8Array; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 86b9c9d41b6f..95abac957579 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -170,7 +170,7 @@ export class App { } } - if (SSRRoutePipeline.isResponse(response, routeData.type)) { + if (routeData.type === 'page' || routeData.type === 'redirect') { if (STATUS_CODES.has(response.status)) { return this.#renderError(request, { response, diff --git a/packages/astro/src/core/app/ssrPipeline.ts b/packages/astro/src/core/app/ssrPipeline.ts index 5f135e42df63..6ee135197ccb 100644 --- a/packages/astro/src/core/app/ssrPipeline.ts +++ b/packages/astro/src/core/app/ssrPipeline.ts @@ -1,7 +1,4 @@ import type { Environment } from '../render'; -import type { EndpointCallResult } from '../endpoint/index.js'; -import mime from 'mime'; -import { attachCookiesToResponse } from '../cookies/index.js'; import { Pipeline } from '../pipeline.js'; /** @@ -16,39 +13,16 @@ export class EndpointNotFoundError extends Error { } export class SSRRoutePipeline extends Pipeline { - #encoder = new TextEncoder(); - constructor(env: Environment) { super(env); this.setEndpointHandler(this.#ssrEndpointHandler); } // This function is responsible for handling the result coming from an endpoint. - async #ssrEndpointHandler(request: Request, response: EndpointCallResult): Promise { - if (response.type === 'response') { - if (response.response.headers.get('X-Astro-Response') === 'Not-Found') { - throw new EndpointNotFoundError(response.response); - } - return response.response; - } else { - const url = new URL(request.url); - const headers = new Headers(); - const mimeType = mime.getType(url.pathname); - if (mimeType) { - headers.set('Content-Type', `${mimeType};charset=utf-8`); - } else { - headers.set('Content-Type', 'text/plain;charset=utf-8'); - } - const bytes = - response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body; - headers.set('Content-Length', bytes.byteLength.toString()); - - const newResponse = new Response(bytes, { - status: 200, - headers, - }); - attachCookiesToResponse(newResponse, response.cookies); - return newResponse; + async #ssrEndpointHandler(request: Request, response: Response): Promise { + if (response.headers.get('X-Astro-Response') === 'Not-Found') { + throw new EndpointNotFoundError(response); } + return response } } diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts index 4ebf48a9afde..52d694077c8e 100644 --- a/packages/astro/src/core/build/buildPipeline.ts +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -5,13 +5,11 @@ import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { SSRManifest } from '../app/types'; -import type { AstroConfig, AstroSettings, RouteType, SSRLoadedRenderer } from '../../@types/astro'; +import type { AstroConfig, AstroSettings, SSRLoadedRenderer } from '../../@types/astro'; import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; -import type { EndpointCallResult } from '../endpoint'; import { createEnvironment } from '../render/index.js'; import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { createAssetLink } from '../render/ssr-element.js'; -import type { BufferEncoding } from 'vfile'; /** * This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. @@ -20,10 +18,6 @@ export class BuildPipeline extends Pipeline { #internals: BuildInternals; #staticBuildOptions: StaticBuildOptions; #manifest: SSRManifest; - #currentEndpointBody?: { - body: string | Uint8Array; - encoding: BufferEncoding; - }; constructor( staticBuildOptions: StaticBuildOptions, @@ -163,49 +157,7 @@ export class BuildPipeline extends Pipeline { return pages; } - async #handleEndpointResult(request: Request, response: EndpointCallResult): Promise { - if (response.type === 'response') { - if (!response.response.body) { - return new Response(null); - } - const ab = await response.response.arrayBuffer(); - const body = new Uint8Array(ab); - this.#currentEndpointBody = { - body: body, - encoding: 'utf-8', - }; - return response.response; - } else { - if (response.encoding) { - this.#currentEndpointBody = { - body: response.body, - encoding: response.encoding, - }; - const headers = new Headers(); - headers.set('X-Astro-Encoding', response.encoding); - return new Response(response.body, { - headers, - }); - } else { - return new Response(response.body); - } - } - } - - async computeBodyAndEncoding( - routeType: RouteType, - response: Response - ): Promise<{ - body: string | Uint8Array; - encoding: BufferEncoding; - }> { - const encoding = response.headers.get('X-Astro-Encoding') ?? 'utf-8'; - if (this.#currentEndpointBody) { - const currentEndpointBody = this.#currentEndpointBody; - this.#currentEndpointBody = undefined; - return currentEndpointBody; - } else { - return { body: await response.text(), encoding: encoding as BufferEncoding }; - } + async #handleEndpointResult(_: Request, response: Response): Promise { + return response; } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a3bc80295fbc..7545fa5772d2 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -567,20 +567,16 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli } else { // If there's no body, do nothing if (!response.body) return; - const result = await pipeline.computeBodyAndEncoding(renderContext.route.type, response); - body = result.body; - encoding = result.encoding; + body = Buffer.from(await response.arrayBuffer()); + encoding = (response.headers.get('X-Astro-Encoding') as BufferEncoding | null) ?? 'utf-8'; } const outFolder = getOutFolder(pipeline.getConfig(), pathname, pageData.route.type); const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, pageData.route.type); pageData.route.distURL = outFile; - const possibleEncoding = response.headers.get('X-Astro-Encoding'); - if (possibleEncoding) { - encoding = possibleEncoding as BufferEncoding; - } + await fs.promises.mkdir(outFolder, { recursive: true }); - await fs.promises.writeFile(outFile, body, encoding ?? 'utf-8'); + await fs.promises.writeFile(outFile, body, encoding); } /** diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 9298e7cbed19..9c3b5713323b 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -6,6 +6,7 @@ import type { MiddlewareHandler, Params, } from '../../@types/astro'; +import mime from 'mime'; import type { Environment, RenderContext } from '../render/index'; import { renderEndpoint } from '../../runtime/server/index.js'; import { ASTRO_VERSION } from '../constants.js'; @@ -14,19 +15,11 @@ import { AstroError, AstroErrorData } from '../errors/index.js'; import { warn } from '../logger/core.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; +const encoder = new TextEncoder(); + const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientLocalsSymbol = Symbol.for('astro.locals'); -export type EndpointCallResult = - | (EndpointOutput & { - type: 'simple'; - cookies: AstroCookies; - }) - | { - type: 'response'; - response: Response; - }; - type CreateAPIContext = { request: Request; params: Params; @@ -62,6 +55,7 @@ export function createAPIContext({ }, }); }, + ResponseWithEncoding, url: new URL(request.url), get clientAddress() { if (!(clientAddressSymbol in request)) { @@ -96,12 +90,37 @@ export function createAPIContext({ return context; } +type ResponseParameters = ConstructorParameters; + +export class ResponseWithEncoding extends Response { + constructor(body: ResponseParameters[0], init: ResponseParameters[1], encoding?: BufferEncoding) { + // If a body string is given, try to encode it to preserve the behaviour as simple objects. + // We don't do the full handling as simple objects so users can control how headers are set instead. + if (typeof body === 'string') { + // In NodeJS, we can use Buffer.from which supports all BufferEncoding + if (typeof Buffer !== 'undefined' && Buffer.from) { + body = Buffer.from(body, encoding); + } + // In non-NodeJS, use the web-standard TextEncoder for utf-8 strings + else if (encoding == null || encoding === 'utf8' || encoding === 'utf-8') { + body = encoder.encode(body); + } + } + + super(body, init); + + if (encoding) { + this.headers.set('X-Astro-Encoding', encoding); + } + } +} + export async function callEndpoint( mod: EndpointHandler, env: Environment, ctx: RenderContext, onRequest?: MiddlewareHandler | undefined -): Promise { +): Promise { const context = createAPIContext({ request: ctx.request, params: ctx.params, @@ -124,15 +143,30 @@ export async function callEndpoint response = await renderEndpoint(mod, context, env.ssr, env.logging); } + const isEndpointSSR = env.ssr && !ctx.route?.prerender; + if (response instanceof Response) { + if (isEndpointSSR && response.headers.get('X-Astro-Encoding')) { + warn( + env.logging, + 'ssr', + '`ResponseWithEncoding` is ignored in SSR. Please return an instance of Response. See https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes for more information.' + ); + } attachCookiesToResponse(response, context.cookies); - return { - type: 'response', - response, - }; + return response; } - if (env.ssr && !ctx.route?.prerender) { + // The endpoint returned a simple object, convert it to a Response + + // TODO: Remove in Astro 4.0 + warn( + env.logging, + 'astro', + `${ctx.route.component} returns a simple object which is deprecated. Please return an instance of Response. See https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes for more information.` + ); + + if (isEndpointSSR) { if (response.hasOwnProperty('headers')) { warn( env.logging, @@ -150,9 +184,58 @@ export async function callEndpoint } } - return { - ...response, - type: 'simple', - cookies: context.cookies, - }; + let body: BodyInit; + const headers = new Headers(); + + // Try to get the MIME type for this route + const pathname = ctx.route + ? // Try the static route `pathname` + ctx.route.pathname ?? + // Dynamic routes don't include `pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg') + ctx.route.segments.map((s) => s.map((p) => p.content).join('')).join('/') + : // Fallback to pathname of the request + ctx.pathname; + const mimeType = mime.getType(pathname) || 'text/plain'; + headers.set('Content-Type', `${mimeType};charset=utf-8`); + + // Save encoding to X-Astro-Encoding to be used later during SSG with `fs.writeFile`. + // It won't work in SSR and is already warned above. + if (response.encoding) { + headers.set('X-Astro-Encoding', response.encoding); + } + + // For Uint8Array (binary), it can passed to Response directly + if (response.body instanceof Uint8Array) { + body = response.body; + headers.set('Content-Length', body.byteLength.toString()); + } + // In NodeJS, we can use Buffer.from which supports all BufferEncoding + else if (typeof Buffer !== 'undefined' && Buffer.from) { + body = Buffer.from(response.body, response.encoding); + headers.set('Content-Length', body.byteLength.toString()); + } + // In non-NodeJS, use the web-standard TextEncoder for utf-8 strings only + // to calculate the content length + else if ( + response.encoding == null || + response.encoding === 'utf8' || + response.encoding === 'utf-8' + ) { + body = encoder.encode(response.body); + headers.set('Content-Length', body.byteLength.toString()); + } + // Fallback pass it to Response directly. It will mainly rely on X-Astro-Encoding + // to be further processed in SSG. + else { + body = response.body; + // NOTE: Can't calculate the content length as we can't encode to figure out the real length. + // But also because we don't need the length for SSG as it's only being written to disk. + } + + response = new Response(body, { + status: 200, + headers, + }); + attachCookiesToResponse(response, context.cookies); + return response; } diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts index 42ed7d5da2bc..19e1ef82b1b4 100644 --- a/packages/astro/src/core/pipeline.ts +++ b/packages/astro/src/core/pipeline.ts @@ -1,11 +1,10 @@ import { type RenderContext, type Environment } from './render/index.js'; -import { type EndpointCallResult, callEndpoint, createAPIContext } from './endpoint/index.js'; +import { callEndpoint, createAPIContext } from './endpoint/index.js'; import type { MiddlewareHandler, MiddlewareResponseHandler, ComponentInstance, MiddlewareEndpointHandler, - RouteType, EndpointHandler, } from '../@types/astro'; import { callMiddleware } from './middleware/callMiddleware.js'; @@ -13,7 +12,7 @@ import { renderPage } from './render/core.js'; type EndpointResultHandler = ( originalRequest: Request, - result: EndpointCallResult + result: Response ) => Promise | Response; /** @@ -76,7 +75,7 @@ export class Pipeline { componentInstance, this.#onRequest ); - if (Pipeline.isEndpointResult(result, renderContext.route.type)) { + if (renderContext.route.type === 'endpoint') { if (!this.#endpointHandler) { throw new Error( 'You created a pipeline that does not know how to handle the result coming from an endpoint.' @@ -103,7 +102,7 @@ export class Pipeline { env: Readonly, mod: Readonly, onRequest?: MiddlewareHandler - ): Promise { + ): Promise { const apiContext = createAPIContext({ request: renderContext.request, params: renderContext.params, @@ -151,15 +150,4 @@ export class Pipeline { throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); } } - - /** - * Use this function - */ - static isEndpointResult(result: any, routeType: RouteType): result is EndpointCallResult { - return !(result instanceof Response) && routeType === 'endpoint'; - } - - static isResponse(result: any, routeType: RouteType): result is Response { - return result instanceof Response && (routeType === 'page' || routeType === 'redirect'); - } } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 9de046278382..24c411e0b9c0 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -4,11 +4,10 @@ import type { EndpointHandler, MiddlewareHandler, MiddlewareResponseHandler, - RouteType, } from '../../@types/astro'; import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; import { attachCookiesToResponse } from '../cookies/index.js'; -import { callEndpoint, createAPIContext, type EndpointCallResult } from '../endpoint/index.js'; +import { callEndpoint, createAPIContext } from '../endpoint/index.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js'; import type { RenderContext } from './context.js'; @@ -92,7 +91,7 @@ export async function tryRenderRoute( env: Readonly, mod: Readonly, onRequest?: MiddlewareHandler -): Promise { +): Promise { const apiContext = createAPIContext({ request: renderContext.request, params: renderContext.params, @@ -140,11 +139,3 @@ export async function tryRenderRoute( throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); } } - -export function isEndpointResult(result: any, routeType: RouteType): result is EndpointCallResult { - return !(result instanceof Response) && routeType === 'endpoint'; -} - -export function isResponse(result: any, routeType: RouteType): result is Response { - return result instanceof Response && (routeType === 'page' || routeType === 'redirect'); -} diff --git a/packages/astro/src/vite-plugin-astro-server/devPipeline.ts b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts index eae6cc1c66e4..a17472895f27 100644 --- a/packages/astro/src/vite-plugin-astro-server/devPipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/devPipeline.ts @@ -1,5 +1,5 @@ import { Pipeline } from '../core/pipeline.js'; -import type { AstroConfig, AstroSettings, RouteData } from '../@types/astro'; +import type { AstroConfig, AstroSettings } from '../@types/astro'; import type { ModuleLoader } from '../core/module-loader'; import type { Environment } from '../core/render'; import { createEnvironment, loadRenderer } from '../core/render/index.js'; @@ -9,15 +9,11 @@ import { isServerLikeOutput } from '../prerender/utils.js'; import type { RuntimeMode, SSRManifest, SSRLoadedRenderer } from '../@types/astro'; import type { LogOptions } from '../core/logger/core'; import { Logger } from '../core/logger/core.js'; -import type { EndpointCallResult } from '../core/endpoint/index.js'; -import mime from 'mime'; -import { attachCookiesToResponse } from '../core/cookies/index.js'; export default class DevPipeline extends Pipeline { #settings: AstroSettings; #loader: ModuleLoader; #devLogger: Logger; - #currentMatchedRoute: RouteData | undefined; constructor({ manifest, @@ -38,10 +34,6 @@ export default class DevPipeline extends Pipeline { this.setEndpointHandler(this.#handleEndpointResult); } - setCurrentMatchedRoute(route: RouteData) { - this.#currentMatchedRoute = route; - } - clearRouteCache() { this.env.routeCache.clearAll(); } @@ -93,36 +85,7 @@ export default class DevPipeline extends Pipeline { }); } - async #handleEndpointResult(_: Request, result: EndpointCallResult): Promise { - if (result.type === 'simple') { - if (!this.#currentMatchedRoute) { - throw new Error( - 'In development mode, you must set the current matched route before handling a endpoint.' - ); - } - let contentType = 'text/plain'; - // Dynamic routes don't include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg') - const filepath = - this.#currentMatchedRoute.pathname || - this.#currentMatchedRoute.segments - .map((segment) => segment.map((p) => p.content).join('')) - .join('/'); - const computedMimeType = mime.getType(filepath); - if (computedMimeType) { - contentType = computedMimeType; - } - const response = new Response( - result.encoding !== 'binary' ? Buffer.from(result.body, result.encoding) : result.body, - { - status: 200, - headers: { - 'Content-Type': `${contentType};charset=utf-8`, - }, - } - ); - attachCookiesToResponse(response, result.cookies); - return response; - } - return result.response; + async #handleEndpointResult(_: Request, response: Response): Promise { + return response; } } diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index ae4318092c3e..beab313d4e18 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -216,7 +216,6 @@ export async function handleRoute({ if (onRequest) { pipeline.setMiddlewareFunction(onRequest); } - pipeline.setCurrentMatchedRoute(route); let response = await pipeline.renderRoute(renderContext, mod); if (response.status === 404) { diff --git a/packages/astro/test/fixtures/non-html-pages/src/pages/about-object.json.ts b/packages/astro/test/fixtures/non-html-pages/src/pages/about-object.json.ts new file mode 100644 index 000000000000..3936cbe85ba7 --- /dev/null +++ b/packages/astro/test/fixtures/non-html-pages/src/pages/about-object.json.ts @@ -0,0 +1,12 @@ +// NOTE: test deprecated object form +// Returns the file body for this non-HTML file. +// The content type is based off of the extension in the filename, +// in this case: about.json. +export async function GET() { + return { + body: JSON.stringify({ + name: 'Astro', + url: 'https://astro.build/', + }), + }; +} diff --git a/packages/astro/test/fixtures/non-html-pages/src/pages/about.json.ts b/packages/astro/test/fixtures/non-html-pages/src/pages/about.json.ts index 0c3ec18ea449..817446fb5953 100644 --- a/packages/astro/test/fixtures/non-html-pages/src/pages/about.json.ts +++ b/packages/astro/test/fixtures/non-html-pages/src/pages/about.json.ts @@ -1,11 +1,12 @@ // Returns the file body for this non-HTML file. -// The content type is based off of the extension in the filename, -// in this case: about.json. export async function GET() { - return { - body: JSON.stringify({ - name: 'Astro', - url: 'https://astro.build/', - }), - }; + const data = JSON.stringify({ + name: 'Astro', + url: 'https://astro.build/', + }) + return new Response(data, { + headers: { + 'Content-Type': 'application/json' + } + }) } diff --git a/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder-object.png.ts b/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder-object.png.ts new file mode 100644 index 000000000000..11415900400c --- /dev/null +++ b/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder-object.png.ts @@ -0,0 +1,18 @@ +import { promises as fs } from 'node:fs'; + +import type { APIRoute } from 'astro'; + +// NOTE: test deprecated object form +export const GET: APIRoute = async function get() { + try { + // Image is in the public domain. Sourced from + // https://en.wikipedia.org/wiki/File:Portrait_placeholder.png + const buffer = await fs.readFile('./test/fixtures/non-html-pages/src/images/placeholder.png'); + return { + body: buffer.toString('binary'), + encoding: 'binary', + } as const; + } catch (error: unknown) { + throw new Error(`Something went wrong in placeholder.png route!: ${error as string}`); + } +}; diff --git a/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder.png.ts b/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder.png.ts index 3ee26f0bfc2d..50c6b877b09f 100644 --- a/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder.png.ts +++ b/packages/astro/test/fixtures/non-html-pages/src/pages/placeholder.png.ts @@ -2,15 +2,12 @@ import { promises as fs } from 'node:fs'; import type { APIRoute } from 'astro'; -export const GET: APIRoute = async function get() { +export const GET: APIRoute = async function get({ ResponseWithEncoding }) { try { // Image is in the public domain. Sourced from // https://en.wikipedia.org/wiki/File:Portrait_placeholder.png const buffer = await fs.readFile('./test/fixtures/non-html-pages/src/images/placeholder.png'); - return { - body: buffer.toString('binary'), - encoding: 'binary', - } as const; + return new ResponseWithEncoding(buffer.toString('binary'), undefined, 'binary') } catch (error: unknown) { throw new Error(`Something went wrong in placeholder.png route!: ${error as string}`); } diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js index 407c45666af0..796bf4616be3 100644 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js @@ -1,9 +1,7 @@ import fs from 'node:fs'; export function GET() { - return { - body: 'ok' - }; + return new Response('ok') } export async function post({ request }) { diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/food-object.json.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/food-object.json.js new file mode 100644 index 000000000000..7992e697aacc --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/food-object.json.js @@ -0,0 +1,10 @@ +// NOTE: test deprecated object form +export function GET() { + return { + body: JSON.stringify([ + { name: 'lettuce' }, + { name: 'broccoli' }, + { name: 'pizza' } + ]) + }; +} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js index f4021c9e5442..2d6fb6d1b236 100644 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js @@ -1,12 +1,12 @@ export function GET() { - return { - body: JSON.stringify([ + return new Response( + JSON.stringify([ { name: 'lettuce' }, { name: 'broccoli' }, { name: 'pizza' } ]) - }; + ) } export async function POST({ params, request }) { diff --git a/packages/astro/test/non-html-pages.test.js b/packages/astro/test/non-html-pages.test.js index e1b89ee6a36d..3e873032e9eb 100644 --- a/packages/astro/test/non-html-pages.test.js +++ b/packages/astro/test/non-html-pages.test.js @@ -15,6 +15,12 @@ describe('Non-HTML Pages', () => { expect(json).to.have.property('name', 'Astro'); expect(json).to.have.property('url', 'https://astro.build/'); }); + + it('should match contents (deprecated object form)', async () => { + const json = JSON.parse(await fixture.readFile('/about-object.json')); + expect(json).to.have.property('name', 'Astro'); + expect(json).to.have.property('url', 'https://astro.build/'); + }); }); describe('png', () => { @@ -34,5 +40,22 @@ describe('Non-HTML Pages', () => { 'iVBORw0KGgoAAAANSUhEUgAAAGQAAACWCAYAAAAouC1GAAAD10lEQVR4Xu3ZbW4iMRCE4c1RuP+ZEEfZFZHIAgHGH9Xtsv3m94yx6qHaM+HrfD7//cOfTQJfgNhYfG8EEC8PQMw8AAHELQGz/XCGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbWe6hpxOp6oIL5dL1fWjL54CpBbhXagz4FiDqCCegZxhLEGiIGaAsQPJwrjhuLXFBiQbwrUtFiCjMZzaMhzEBcMFZSiIG4YDyjAQV4zRKENA3DFGoqSDzIIxCgWQgn9eZb6rpILM1o57qyyUNJCZMTLHFyAFI2s5kBXakYWS0hBAymsYDrISRkZLACn/8j5cGfXUFQqyYjuiWwJIY0Out0W0JAxk5XZEtgQQGtKRgOGt6rEV0pAdxlXU2AKks3U0pDPAiNuVKDREIGQNstP5EXGOyBsCSF/lAOnL7/tuRpYgRPUSKhQaIpIBRBSkahlAVEmK1gFEFKRqGUuQHR951e8i0kMdkP6+SUGu29kVxXJkAUJD+hMQrUBDREGqlgFElaRgHRXGdSsc6oAIEjBbgoYAUpfAbu8i1g3Z7V1EiRFyqANSN02er5Y/Zd0+YJexNUVDdmmJGiNsZAHSPrbCRtYOKFM1ZHWQCIzQkbX64Q5I+1iW3xmFkdKQFUcXIPLvePuCkRhpDVmpJcuArIASjZHakNmfujIwAKk4SpYFmXF0ZWEMachsoysTYyjIDE3JxhgO4owyAsMCxBFlFIYNiBPKSAxAnh57R2PYgLj9/j4SJvQXw5L3LjeM+z2PgBkG4gzx/EXKhEkHmQliRFvSQGaFyEZJAVkB4wYTPb7CQVbCyEAJA1kRImN8hYCsjhHZFDnILhhRKICUvL0eXKM86KUgu7Uj4kyRgeyMoRxfEhAw/neld3x1g4Dx+4DpQQFEcKi/WqIVpQuEdrzXTAcB47haLSjNDQHkGOR6RS1KEwgYZRgtj8PVIGDUYdS2BJD6fJvuKB1dVSC0o8ni56YSFED6Mq66WwpCO6qyf3vxEUpxQwAxAgFDg1HyGFzUEECMQMDQYhy15LAhgBiBgBGD8ent/WNDAIkDeYcCSGzmH1d/9U7yFoR25Eg9owCSk3vxmzsgM4AwrnKV7sfWy4YAAkhuAmaf9rEhtCNfC5D8zA8/8Yby6wyhIYfZhVwASEis7Yu+BKEd7YH23glIb4IB919RHs4QGhKQcsWSgFSElXEpIBkpV3zGAwjjqiK5oEsBCQq2Z9l/4WuAC09sfQEAAAAASUVORK5CYII=' ); }); + + it('should not have had its encoding mangled (deprecated object form)', async () => { + const buffer = await fixture.readFile('/placeholder-object.png', 'base64'); + + // Sanity check the first byte + const hex = Buffer.from(buffer, 'base64').toString('hex'); + const firstHexByte = hex.slice(0, 2); + // If we accidentally utf8 encode the png, the first byte (in hex) will be 'c2' + expect(firstHexByte).to.not.equal('c2'); + // and if correctly encoded in binary, it should be '89' + expect(firstHexByte).to.equal('89'); + + // Make sure the whole buffer (in base64) matches this snapshot + expect(buffer).to.equal( + 'iVBORw0KGgoAAAANSUhEUgAAAGQAAACWCAYAAAAouC1GAAAD10lEQVR4Xu3ZbW4iMRCE4c1RuP+ZEEfZFZHIAgHGH9Xtsv3m94yx6qHaM+HrfD7//cOfTQJfgNhYfG8EEC8PQMw8AAHELQGz/XCGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbYeGAGKWgNl2aAggZgmYbWe6hpxOp6oIL5dL1fWjL54CpBbhXagz4FiDqCCegZxhLEGiIGaAsQPJwrjhuLXFBiQbwrUtFiCjMZzaMhzEBcMFZSiIG4YDyjAQV4zRKENA3DFGoqSDzIIxCgWQgn9eZb6rpILM1o57qyyUNJCZMTLHFyAFI2s5kBXakYWS0hBAymsYDrISRkZLACn/8j5cGfXUFQqyYjuiWwJIY0Out0W0JAxk5XZEtgQQGtKRgOGt6rEV0pAdxlXU2AKks3U0pDPAiNuVKDREIGQNstP5EXGOyBsCSF/lAOnL7/tuRpYgRPUSKhQaIpIBRBSkahlAVEmK1gFEFKRqGUuQHR951e8i0kMdkP6+SUGu29kVxXJkAUJD+hMQrUBDREGqlgFElaRgHRXGdSsc6oAIEjBbgoYAUpfAbu8i1g3Z7V1EiRFyqANSN02er5Y/Zd0+YJexNUVDdmmJGiNsZAHSPrbCRtYOKFM1ZHWQCIzQkbX64Q5I+1iW3xmFkdKQFUcXIPLvePuCkRhpDVmpJcuArIASjZHakNmfujIwAKk4SpYFmXF0ZWEMachsoysTYyjIDE3JxhgO4owyAsMCxBFlFIYNiBPKSAxAnh57R2PYgLj9/j4SJvQXw5L3LjeM+z2PgBkG4gzx/EXKhEkHmQliRFvSQGaFyEZJAVkB4wYTPb7CQVbCyEAJA1kRImN8hYCsjhHZFDnILhhRKICUvL0eXKM86KUgu7Uj4kyRgeyMoRxfEhAw/neld3x1g4Dx+4DpQQFEcKi/WqIVpQuEdrzXTAcB47haLSjNDQHkGOR6RS1KEwgYZRgtj8PVIGDUYdS2BJD6fJvuKB1dVSC0o8ni56YSFED6Mq66WwpCO6qyf3vxEUpxQwAxAgFDg1HyGFzUEECMQMDQYhy15LAhgBiBgBGD8ent/WNDAIkDeYcCSGzmH1d/9U7yFoR25Eg9owCSk3vxmzsgM4AwrnKV7sfWy4YAAkhuAmaf9rEhtCNfC5D8zA8/8Yby6wyhIYfZhVwASEis7Yu+BKEd7YH23glIb4IB919RHs4QGhKQcsWSgFSElXEpIBkpV3zGAwjjqiK5oEsBCQq2Z9l/4WuAC09sfQEAAAAASUVORK5CYII=' + ); + }); }); }); diff --git a/packages/astro/test/ssr-api-route.test.js b/packages/astro/test/ssr-api-route.test.js index 899404b1e4ee..fbaafc82275c 100644 --- a/packages/astro/test/ssr-api-route.test.js +++ b/packages/astro/test/ssr-api-route.test.js @@ -29,6 +29,15 @@ describe('API routes in SSR', () => { const request = new Request('http://example.com/food.json'); const response = await app.render(request); expect(response.status).to.equal(200); + const body = await response.json(); + expect(body.length).to.equal(3); + }); + + it('Can load the API route too (deprecated object form)', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/food-object.json'); + const response = await app.render(request); + expect(response.status).to.equal(200); expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8'); expect(response.headers.get('Content-Length')).to.not.be.empty; const body = await response.json(); @@ -87,8 +96,8 @@ describe('API routes in SSR', () => { expect(res.status).to.equal(200); }); - it('Infer content type with charset for { body } shorthand', async () => { - const response = await fixture.fetch('/food.json', { + it('Infer content type with charset for { body } shorthand (deprecated object form)', async () => { + const response = await fixture.fetch('/food-object.json', { method: 'GET', }); expect(response.headers.get('Content-Type')).to.equal('application/json;charset=utf-8');