diff --git a/.changeset/smooth-jokes-watch.md b/.changeset/smooth-jokes-watch.md new file mode 100644 index 000000000000..c28c51d2b4f8 --- /dev/null +++ b/.changeset/smooth-jokes-watch.md @@ -0,0 +1,6 @@ +--- +'astro': minor +'@astrojs/node': minor +--- + +`Astro.locals` is now exposed to the adapter API. Node Adapter can now pass in a `locals` object in the SSR handler middleware. diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 90e17f438cc6..d8491664c930 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -114,7 +114,7 @@ export class App { return undefined; } } - async render(request: Request, routeData?: RouteData): Promise { + async render(request: Request, routeData?: RouteData, locals?: object): Promise { let defaultStatus = 200; if (!routeData) { routeData = this.match(request); @@ -130,7 +130,7 @@ export class App { } } - Reflect.set(request, clientLocalsSymbol, {}); + Reflect.set(request, clientLocalsSymbol, locals ?? {}); // Use the 404 status code for 404.astro components if (routeData.route === '/404') { @@ -229,7 +229,7 @@ export class App { onRequest as MiddlewareResponseHandler, apiContext, () => { - return renderPage({ mod, renderContext, env: this.#env, apiContext }); + return renderPage({ mod, renderContext, env: this.#env }); } ); } else { @@ -237,7 +237,6 @@ export class App { mod, renderContext, env: this.#env, - apiContext, }); } Reflect.set(request, responseSentSymbol, true); diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 97d7b71d18dd..605e23817b56 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -40,11 +40,12 @@ export class NodeApp extends App { match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req), opts); } - render(req: NodeIncomingMessage | Request, routeData?: RouteData) { + render(req: NodeIncomingMessage | Request, routeData?: RouteData, locals?: object) { if (typeof req.body === 'string' && req.body.length > 0) { return super.render( req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(req.body)), - routeData + routeData, + locals ); } @@ -53,7 +54,8 @@ export class NodeApp extends App { req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))), - routeData + routeData, + locals ); } @@ -74,13 +76,15 @@ export class NodeApp extends App { return reqBodyComplete.then(() => { return super.render( req instanceof Request ? req : createRequestFromNodeRequest(req, body), - routeData + routeData, + locals ); }); } return super.render( req instanceof Request ? req : createRequestFromNodeRequest(req), - routeData + routeData, + locals ); } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a12313987bcf..5b4db830f50e 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -506,11 +506,11 @@ async function generatePath( onRequest as MiddlewareResponseHandler, apiContext, () => { - return renderPage({ mod, renderContext, env, apiContext }); + return renderPage({ mod, renderContext, env }); } ); } else { - response = await renderPage({ mod, renderContext, env, apiContext }); + response = await renderPage({ mod, renderContext, env }); } } catch (err) { if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index d4efe35df3b7..525853043c82 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -6,9 +6,12 @@ import type { SSRElement, SSRResult, } from '../../@types/astro'; +import { AstroError, AstroErrorData } from '../errors/index.js'; import { getParamsAndPropsOrThrow } from './core.js'; import type { Environment } from './environment'; +const clientLocalsSymbol = Symbol.for('astro.locals'); + /** * The RenderContext represents the parts of rendering that are specific to one request. */ @@ -25,6 +28,7 @@ export interface RenderContext { status?: number; params: Params; props: Props; + locals?: object; } export type CreateRenderContextArgs = Partial & { @@ -49,7 +53,8 @@ export async function createRenderContext( logging: options.env.logging, ssr: options.env.ssr, }); - return { + + let context = { ...options, origin, pathname, @@ -57,4 +62,20 @@ export async function createRenderContext( params, props, }; + + // We define a custom property, so we can check the value passed to locals + Object.defineProperty(context, 'locals', { + get() { + return Reflect.get(request, clientLocalsSymbol); + }, + set(val) { + if (typeof val !== 'object') { + throw new AstroError(AstroErrorData.LocalsNotAnObject); + } else { + Reflect.set(request, clientLocalsSymbol, val); + } + }, + }); + + return context; } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 24bec9d30e29..72a8300e2dd5 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -1,5 +1,5 @@ import type { APIContext, ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; -import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; +import { render, renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; import { attachToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import type { LogOptions } from '../logger/core.js'; @@ -107,16 +107,15 @@ export type RenderPage = { mod: ComponentInstance; renderContext: RenderContext; env: Environment; - apiContext?: APIContext; }; -export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) { +export async function renderPage({ mod, renderContext, env }: RenderPage) { // Validate the page component before rendering the page const Component = mod.default; if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); - let locals = apiContext?.locals ?? {}; + let locals = renderContext?.locals ?? {}; const result = createResult({ adapterName: env.adapterName, diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index c0113739285a..35468cc37ca2 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -191,7 +191,7 @@ export async function renderPage(options: SSROptions): Promise { const onRequest = options.middleware.onRequest as MiddlewareResponseHandler; const response = await callMiddleware(env.logging, onRequest, apiContext, () => { - return coreRenderPage({ mod, renderContext, env: options.env, apiContext }); + return coreRenderPage({ mod, renderContext, env: options.env }); }); return response; diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts index d8ac9033db1a..d229ceaa4c84 100644 --- a/packages/astro/src/core/request.ts +++ b/packages/astro/src/core/request.ts @@ -13,6 +13,7 @@ export interface CreateRequestOptions { body?: RequestBody | undefined; logging: LogOptions; ssr: boolean; + locals?: object | undefined; } const clientAddressSymbol = Symbol.for('astro.clientAddress'); @@ -26,6 +27,7 @@ export function createRequest({ body = undefined, logging, ssr, + locals, }: CreateRequestOptions): Request { let headersObj = headers instanceof Headers @@ -66,7 +68,7 @@ export function createRequest({ Reflect.set(request, clientAddressSymbol, clientAddress); } - Reflect.set(request, clientLocalsSymbol, {}); + Reflect.set(request, clientLocalsSymbol, locals ?? {}); return request; } diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index dae4162296f6..76c43114b391 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -21,6 +21,8 @@ import { isHybridOutput } from '../prerender/utils.js'; import { log404 } from './common.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; +const clientLocalsSymbol = Symbol.for('astro.locals'); + type AsyncReturnType Promise> = T extends ( ...args: any ) => Promise @@ -143,6 +145,7 @@ export async function handleRoute( logging, ssr: buildingToSSR, clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined, + locals: Reflect.get(req, clientLocalsSymbol), // Allows adapters to pass in locals in dev mode. }); // Set user specified headers to response object. diff --git a/packages/astro/test/fixtures/ssr-locals/package.json b/packages/astro/test/fixtures/ssr-locals/package.json new file mode 100644 index 000000000000..ae9ee4649690 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-locals/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/ssr-locals", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/ssr-locals/src/pages/api.js b/packages/astro/test/fixtures/ssr-locals/src/pages/api.js new file mode 100644 index 000000000000..d4f7386fb85c --- /dev/null +++ b/packages/astro/test/fixtures/ssr-locals/src/pages/api.js @@ -0,0 +1,10 @@ + +export async function get({ locals }) { + let out = { ...locals }; + + return new Response(JSON.stringify(out), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/astro/test/fixtures/ssr-locals/src/pages/foo.astro b/packages/astro/test/fixtures/ssr-locals/src/pages/foo.astro new file mode 100644 index 000000000000..66b1f7a045b1 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-locals/src/pages/foo.astro @@ -0,0 +1,4 @@ +--- +const { foo } = Astro.locals; +--- +

{ foo }

diff --git a/packages/astro/test/ssr-locals.test.js b/packages/astro/test/ssr-locals.test.js new file mode 100644 index 000000000000..41e5710fbbb8 --- /dev/null +++ b/packages/astro/test/ssr-locals.test.js @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; + +describe('SSR Astro.locals from server', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-locals/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + }); + + it('Can access Astro.locals in page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/foo'); + const locals = { foo: 'bar' }; + const response = await app.render(request, undefined, locals); + const html = await response.text(); + + const $ = cheerio.load(html); + expect($('#foo').text()).to.equal('bar'); + }); + + it('Can access Astro.locals in api context', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/api'); + const locals = { foo: 'bar' }; + const response = await app.render(request, undefined, locals); + expect(response.status).to.equal(200); + const body = await response.json(); + + expect(body.foo).to.equal('bar'); + }); +}); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index cc34e3c3373c..d74cfaf81a71 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -34,7 +34,7 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd this.#manifest = manifest; } - async render(request, routeData) { + async render(request, routeData, locals) { const url = new URL(request.url); if(this.#manifest.assets.has(url.pathname)) { const filePath = new URL('../client/' + this.removeBase(url.pathname), import.meta.url); @@ -42,9 +42,8 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd return new Response(data); } - Reflect.set(request, Symbol.for('astro.locals'), {}); ${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''} - return super.render(request, routeData); + return super.render(request, routeData, locals); } } diff --git a/packages/integrations/node/README.md b/packages/integrations/node/README.md index 3eb8d1caf09c..0dcc216fccef 100644 --- a/packages/integrations/node/README.md +++ b/packages/integrations/node/README.md @@ -119,6 +119,25 @@ app.use(ssrHandler); app.listen({ port: 8080 }); ``` +Additionally, you can also pass in an object to be accessed with `Astro.locals` or in Astro middleware: + +```js +import express from 'express'; +import { handler as ssrHandler } from './dist/server/entry.mjs'; + +const app = express(); +app.use(express.static('dist/client/')) +app.use((req, res, next) => { + const locals = { + foo: 'bar' + }; + + ssrHandler(req, res, next, locals); +); + +app.listen(8080); +``` + Note that middleware mode does not do file serving. You'll need to configure your HTTP framework to do that for you. By default the client assets are written to `./dist/client/`. ### Standalone diff --git a/packages/integrations/node/src/nodeMiddleware.ts b/packages/integrations/node/src/nodeMiddleware.ts index c23cdb89ca4f..aa55df782602 100644 --- a/packages/integrations/node/src/nodeMiddleware.ts +++ b/packages/integrations/node/src/nodeMiddleware.ts @@ -8,14 +8,15 @@ export default function (app: NodeApp, mode: Options['mode']) { return async function ( req: IncomingMessage, res: ServerResponse, - next?: (err?: unknown) => void + next?: (err?: unknown) => void, + locals?: object ) { try { const route = mode === 'standalone' ? app.match(req, { matchNotFound: true }) : app.match(req); if (route) { try { - const response = await app.render(req); + const response = await app.render(req, route, locals); await writeWebResponse(app, res, response); } catch (err: unknown) { if (next) { diff --git a/packages/integrations/node/test/fixtures/locals/package.json b/packages/integrations/node/test/fixtures/locals/package.json new file mode 100644 index 000000000000..35be7dc01ef2 --- /dev/null +++ b/packages/integrations/node/test/fixtures/locals/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/locals", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/node": "workspace:*" + } +} diff --git a/packages/integrations/node/test/fixtures/locals/src/pages/api.js b/packages/integrations/node/test/fixtures/locals/src/pages/api.js new file mode 100644 index 000000000000..8b209c5826c3 --- /dev/null +++ b/packages/integrations/node/test/fixtures/locals/src/pages/api.js @@ -0,0 +1,10 @@ + +export async function post({ locals }) { + let out = { ...locals }; + + return new Response(JSON.stringify(out), { + headers: { + 'Content-Type': 'application/json' + } + }); +} diff --git a/packages/integrations/node/test/fixtures/locals/src/pages/foo.astro b/packages/integrations/node/test/fixtures/locals/src/pages/foo.astro new file mode 100644 index 000000000000..224a875ecc8f --- /dev/null +++ b/packages/integrations/node/test/fixtures/locals/src/pages/foo.astro @@ -0,0 +1,4 @@ +--- +const { foo } = Astro.locals; +--- +

{foo}

diff --git a/packages/integrations/node/test/locals.test.js b/packages/integrations/node/test/locals.test.js new file mode 100644 index 000000000000..f7fc6b73f320 --- /dev/null +++ b/packages/integrations/node/test/locals.test.js @@ -0,0 +1,53 @@ +import nodejs from '../dist/index.js'; +import { loadFixture, createRequestAndResponse } from './test-utils.js'; +import { expect } from 'chai'; + +describe('API routes', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/locals/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + }); + + it('Can render locals in page', async () => { + const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); + let { req, res, text } = createRequestAndResponse({ + method: 'POST', + url: '/foo', + }); + + let locals = { foo: 'bar' }; + + handler(req, res, () => {}, locals); + req.send(); + + let html = await text(); + + expect(html).to.contain('

bar

'); + }); + + it('Can access locals in API', async () => { + const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); + let { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/api', + }); + + let locals = { foo: 'bar' }; + + handler(req, res, () => {}, locals); + req.send(); + + let [buffer] = await done; + + let json = JSON.parse(buffer.toString('utf-8')); + + expect(json.foo).to.equal('bar'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dab25439a503..78950f25467f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3281,6 +3281,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/ssr-locals: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/ssr-manifest: dependencies: astro: @@ -4552,6 +4558,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/node/test/fixtures/locals: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/node/test/fixtures/node-middleware: dependencies: '@astrojs/node':