diff --git a/.changeset/smooth-jokes-watch.md b/.changeset/smooth-jokes-watch.md new file mode 100644 index 000000000..c28c51d2b --- /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 d9926f70c..ae83b3016 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -115,7 +115,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); @@ -131,7 +131,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') { @@ -243,7 +243,7 @@ export class App { page.onRequest as MiddlewareResponseHandler, apiContext, () => { - return renderPage({ mod, renderContext, env: this.#env, apiContext }); + return renderPage({ mod, renderContext, env: this.#env, cookies: apiContext.cookies }); } ); } else { @@ -251,7 +251,7 @@ export class App { mod, renderContext, env: this.#env, - apiContext, + cookies: apiContext.cookies, }); } Reflect.set(request, responseSentSymbol, true); diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 6bd2677a7..40b7b4e7c 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -41,11 +41,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 ); } @@ -54,7 +55,8 @@ export class NodeApp extends App { req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))), - routeData + routeData, + locals ); } @@ -75,13 +77,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 8eabd260e..551f7afa8 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -603,8 +603,8 @@ async function generatePath( mod, renderContext, env, - apiContext, isCompressHTML: settings.config.compressHTML, + cookies: apiContext.cookies, }); } ); @@ -613,8 +613,8 @@ async function generatePath( mod, renderContext, env, - apiContext, isCompressHTML: settings.config.compressHTML, + cookies: apiContext.cookies, }); } } catch (err) { diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index ef5b514c6..dde07cd9c 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -78,6 +78,7 @@ export function createAPIContext({ // We define a custom property, so we can check the value passed to locals Object.defineProperty(context, 'locals', { + enumerable: true, get() { return Reflect.get(request, clientLocalsSymbol); }, diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 90aaae0d2..a43650a55 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -7,9 +7,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. */ @@ -27,6 +30,7 @@ export interface RenderContext { cookies?: AstroCookies; params: Params; props: Props; + locals?: object; } export type CreateRenderContextArgs = Partial & { @@ -51,7 +55,8 @@ export async function createRenderContext( logging: options.env.logging, ssr: options.env.ssr, }); - return { + + let context = { ...options, origin, pathname, @@ -59,4 +64,21 @@ export async function createRenderContext( params, props, }; + + // We define a custom property, so we can check the value passed to locals + Object.defineProperty(context, 'locals', { + enumerable: true, + 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 282c78f2b..0ed599548 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 type { AstroCookies, ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; +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'; @@ -108,15 +108,15 @@ export type RenderPage = { mod: ComponentInstance; renderContext: RenderContext; env: Environment; - apiContext?: APIContext; isCompressHTML?: boolean; + cookies: AstroCookies; }; export async function renderPage({ mod, renderContext, env, - apiContext, + cookies, isCompressHTML = false, }: RenderPage) { if (routeIsRedirect(renderContext.route)) { @@ -133,8 +133,6 @@ export async function renderPage({ if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); - let locals = apiContext?.locals ?? {}; - const result = createResult({ adapterName: env.adapterName, links: renderContext.links, @@ -155,8 +153,8 @@ export async function renderPage({ scripts: renderContext.scripts, ssr: env.ssr, status: renderContext.status ?? 200, - cookies: apiContext?.cookies, - locals, + cookies, + locals: renderContext.locals ?? {}, }); // Support `export const components` for `MDX` pages diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index c01137392..67d0b0581 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -180,22 +180,31 @@ export async function renderPage(options: SSROptions): Promise { mod, env, }); + const apiContext = createAPIContext({ + request: options.request, + params: renderContext.params, + props: renderContext.props, + adapterName: options.env.adapterName, + }); if (options.middleware) { if (options.middleware && options.middleware.onRequest) { - const apiContext = createAPIContext({ - request: options.request, - params: renderContext.params, - props: renderContext.props, - adapterName: options.env.adapterName, - }); - 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, + cookies: apiContext.cookies, + }); }); return response; } } - return await coreRenderPage({ mod, renderContext, env: options.env }); // NOTE: without "await", errors won’t get caught below + return await coreRenderPage({ + mod, + renderContext, + env: options.env, + cookies: apiContext.cookies, + }); // NOTE: without "await", errors won’t get caught below } diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts index d8ac9033d..d229ceaa4 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 9be1c81af..a610b4b74 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 { isServerLikeOutput } 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 @@ -153,6 +155,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 000000000..ae9ee4649 --- /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 000000000..d4f7386fb --- /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 000000000..66b1f7a04 --- /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 000000000..41e5710fb --- /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 cc34e3c33..d74cfaf81 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 165a0733d..2464892a9 100644 --- a/packages/integrations/node/README.md +++ b/packages/integrations/node/README.md @@ -122,6 +122,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 = { + title: 'New title' + }; + + 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 c0d439ba0..63ba246bd 100644 --- a/packages/integrations/node/src/nodeMiddleware.ts +++ b/packages/integrations/node/src/nodeMiddleware.ts @@ -9,14 +9,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 000000000..35be7dc01 --- /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 000000000..8b209c582 --- /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 000000000..224a875ec --- /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 000000000..f7fc6b73f --- /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 f985a716b..29d581936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3252,6 +3252,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: @@ -4415,7 +4421,7 @@ importers: version: 9.2.2 vite: specifier: ^4.3.1 - version: 4.3.1(@types/node@14.18.21) + version: 4.3.1(@types/node@18.16.3)(sass@1.52.2) packages/integrations/netlify/test/edge-functions/fixtures/dynimport: dependencies: @@ -4550,6 +4556,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': @@ -4930,7 +4945,7 @@ importers: version: 3.0.0(vite@4.3.1)(vue@3.2.47) '@vue/babel-plugin-jsx': specifier: ^1.1.1 - version: 1.1.1 + version: 1.1.1(@babel/core@7.21.8) '@vue/compiler-sfc': specifier: ^3.2.39 version: 3.2.39 @@ -9317,23 +9332,6 @@ packages: resolution: {integrity: sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA==} dev: false - /@vue/babel-plugin-jsx@1.1.1: - resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} - dependencies: - '@babel/helper-module-imports': 7.21.4 - '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.18.2) - '@babel/template': 7.20.7 - '@babel/traverse': 7.18.2 - '@babel/types': 7.21.5 - '@vue/babel-helper-vue-transform-on': 1.0.2 - camelcase: 6.3.0 - html-tags: 3.3.1 - svg-tags: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - dev: false - /@vue/babel-plugin-jsx@1.1.1(@babel/core@7.21.8): resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} dependencies: @@ -17665,39 +17663,6 @@ packages: - supports-color dev: false - /vite@4.3.1(@types/node@14.18.21): - resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - '@types/node': 14.18.21 - esbuild: 0.17.18 - postcss: 8.4.23 - rollup: 3.21.8 - optionalDependencies: - fsevents: 2.3.2 - dev: true - /vite@4.3.1(@types/node@18.16.3)(sass@1.52.2): resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} engines: {node: ^14.18.0 || >=16.0.0}