Skip to content

Commit

Permalink
Deprecate simple objects from endpoints (#8132)
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy committed Aug 21, 2023
1 parent fd6261d commit 767eb68
Show file tree
Hide file tree
Showing 20 changed files with 243 additions and 201 deletions.
26 changes: 26 additions & 0 deletions .changeset/yellow-tips-cover.md
Original file line number Diff line number Diff line change
@@ -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');
}
```
4 changes: 3 additions & 1 deletion packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1963,12 +1964,13 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a
* ```
*/
locals: App.Locals;
ResponseWithEncoding: typeof ResponseWithEncoding;
}

export type EndpointOutput =
| {
body: Body;
encoding?: Exclude<BufferEncoding, 'binary'>;
encoding?: BufferEncoding;
}
| {
body: Uint8Array;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 4 additions & 30 deletions packages/astro/src/core/app/ssrPipeline.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<Response> {
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<Response> {
if (response.headers.get('X-Astro-Response') === 'Not-Found') {
throw new EndpointNotFoundError(response);
}
return response
}
}
54 changes: 3 additions & 51 deletions packages/astro/src/core/build/buildPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,10 +18,6 @@ export class BuildPipeline extends Pipeline {
#internals: BuildInternals;
#staticBuildOptions: StaticBuildOptions;
#manifest: SSRManifest;
#currentEndpointBody?: {
body: string | Uint8Array;
encoding: BufferEncoding;
};

constructor(
staticBuildOptions: StaticBuildOptions,
Expand Down Expand Up @@ -163,49 +157,7 @@ export class BuildPipeline extends Pipeline {
return pages;
}

async #handleEndpointResult(request: Request, response: EndpointCallResult): Promise<Response> {
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<Response> {
return response;
}
}
12 changes: 4 additions & 8 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
125 changes: 104 additions & 21 deletions packages/astro/src/core/endpoint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -62,6 +55,7 @@ export function createAPIContext({
},
});
},
ResponseWithEncoding,
url: new URL(request.url),
get clientAddress() {
if (!(clientAddressSymbol in request)) {
Expand Down Expand Up @@ -96,12 +90,37 @@ export function createAPIContext({
return context;
}

type ResponseParameters = ConstructorParameters<typeof Response>;

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<MiddlewareResult = Response | EndpointOutput>(
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
onRequest?: MiddlewareHandler<MiddlewareResult> | undefined
): Promise<EndpointCallResult> {
): Promise<Response> {
const context = createAPIContext({
request: ctx.request,
params: ctx.params,
Expand All @@ -124,15 +143,30 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
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,
Expand All @@ -150,9 +184,58 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
}
}

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

0 comments on commit 767eb68

Please sign in to comment.