feat: expose locals to render api and from requests in dev mode (#7385)

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: wrapperup <wrapperup4@gmail.com>
This commit is contained in:
Emanuele Stoppa 2023-06-21 13:07:16 +01:00 committed by GitHub
parent 61d6e45cef
commit 8e2923cc62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 254 additions and 87 deletions

View file

@ -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.

View file

@ -115,7 +115,7 @@ export class App {
return undefined; return undefined;
} }
} }
async render(request: Request, routeData?: RouteData): Promise<Response> { async render(request: Request, routeData?: RouteData, locals?: object): Promise<Response> {
let defaultStatus = 200; let defaultStatus = 200;
if (!routeData) { if (!routeData) {
routeData = this.match(request); 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 // Use the 404 status code for 404.astro components
if (routeData.route === '/404') { if (routeData.route === '/404') {
@ -243,7 +243,7 @@ export class App {
page.onRequest as MiddlewareResponseHandler, page.onRequest as MiddlewareResponseHandler,
apiContext, apiContext,
() => { () => {
return renderPage({ mod, renderContext, env: this.#env, apiContext }); return renderPage({ mod, renderContext, env: this.#env, cookies: apiContext.cookies });
} }
); );
} else { } else {
@ -251,7 +251,7 @@ export class App {
mod, mod,
renderContext, renderContext,
env: this.#env, env: this.#env,
apiContext, cookies: apiContext.cookies,
}); });
} }
Reflect.set(request, responseSentSymbol, true); Reflect.set(request, responseSentSymbol, true);

View file

@ -41,11 +41,12 @@ export class NodeApp extends App {
match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) { match(req: NodeIncomingMessage | Request, opts: MatchOptions = {}) {
return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req), opts); 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) { if (typeof req.body === 'string' && req.body.length > 0) {
return super.render( return super.render(
req instanceof Request ? req : createRequestFromNodeRequest(req, Buffer.from(req.body)), 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 instanceof Request
? req ? req
: createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))), : createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))),
routeData routeData,
locals
); );
} }
@ -75,13 +77,15 @@ export class NodeApp extends App {
return reqBodyComplete.then(() => { return reqBodyComplete.then(() => {
return super.render( return super.render(
req instanceof Request ? req : createRequestFromNodeRequest(req, body), req instanceof Request ? req : createRequestFromNodeRequest(req, body),
routeData routeData,
locals
); );
}); });
} }
return super.render( return super.render(
req instanceof Request ? req : createRequestFromNodeRequest(req), req instanceof Request ? req : createRequestFromNodeRequest(req),
routeData routeData,
locals
); );
} }
} }

View file

@ -603,8 +603,8 @@ async function generatePath(
mod, mod,
renderContext, renderContext,
env, env,
apiContext,
isCompressHTML: settings.config.compressHTML, isCompressHTML: settings.config.compressHTML,
cookies: apiContext.cookies,
}); });
} }
); );
@ -613,8 +613,8 @@ async function generatePath(
mod, mod,
renderContext, renderContext,
env, env,
apiContext,
isCompressHTML: settings.config.compressHTML, isCompressHTML: settings.config.compressHTML,
cookies: apiContext.cookies,
}); });
} }
} catch (err) { } catch (err) {

View file

@ -78,6 +78,7 @@ export function createAPIContext({
// We define a custom property, so we can check the value passed to locals // We define a custom property, so we can check the value passed to locals
Object.defineProperty(context, 'locals', { Object.defineProperty(context, 'locals', {
enumerable: true,
get() { get() {
return Reflect.get(request, clientLocalsSymbol); return Reflect.get(request, clientLocalsSymbol);
}, },

View file

@ -7,9 +7,12 @@ import type {
SSRElement, SSRElement,
SSRResult, SSRResult,
} from '../../@types/astro'; } from '../../@types/astro';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { getParamsAndPropsOrThrow } from './core.js'; import { getParamsAndPropsOrThrow } from './core.js';
import type { Environment } from './environment'; import type { Environment } from './environment';
const clientLocalsSymbol = Symbol.for('astro.locals');
/** /**
* The RenderContext represents the parts of rendering that are specific to one request. * The RenderContext represents the parts of rendering that are specific to one request.
*/ */
@ -27,6 +30,7 @@ export interface RenderContext {
cookies?: AstroCookies; cookies?: AstroCookies;
params: Params; params: Params;
props: Props; props: Props;
locals?: object;
} }
export type CreateRenderContextArgs = Partial<RenderContext> & { export type CreateRenderContextArgs = Partial<RenderContext> & {
@ -51,7 +55,8 @@ export async function createRenderContext(
logging: options.env.logging, logging: options.env.logging,
ssr: options.env.ssr, ssr: options.env.ssr,
}); });
return {
let context = {
...options, ...options,
origin, origin,
pathname, pathname,
@ -59,4 +64,21 @@ export async function createRenderContext(
params, params,
props, 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;
} }

View file

@ -1,5 +1,5 @@
import type { APIContext, ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; import type { AstroCookies, 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 { attachToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import type { LogOptions } from '../logger/core.js'; import type { LogOptions } from '../logger/core.js';
@ -108,15 +108,15 @@ export type RenderPage = {
mod: ComponentInstance; mod: ComponentInstance;
renderContext: RenderContext; renderContext: RenderContext;
env: Environment; env: Environment;
apiContext?: APIContext;
isCompressHTML?: boolean; isCompressHTML?: boolean;
cookies: AstroCookies;
}; };
export async function renderPage({ export async function renderPage({
mod, mod,
renderContext, renderContext,
env, env,
apiContext, cookies,
isCompressHTML = false, isCompressHTML = false,
}: RenderPage) { }: RenderPage) {
if (routeIsRedirect(renderContext.route)) { if (routeIsRedirect(renderContext.route)) {
@ -133,8 +133,6 @@ export async function renderPage({
if (!Component) if (!Component)
throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
let locals = apiContext?.locals ?? {};
const result = createResult({ const result = createResult({
adapterName: env.adapterName, adapterName: env.adapterName,
links: renderContext.links, links: renderContext.links,
@ -155,8 +153,8 @@ export async function renderPage({
scripts: renderContext.scripts, scripts: renderContext.scripts,
ssr: env.ssr, ssr: env.ssr,
status: renderContext.status ?? 200, status: renderContext.status ?? 200,
cookies: apiContext?.cookies, cookies,
locals, locals: renderContext.locals ?? {},
}); });
// Support `export const components` for `MDX` pages // Support `export const components` for `MDX` pages

View file

@ -180,22 +180,31 @@ export async function renderPage(options: SSROptions): Promise<Response> {
mod, mod,
env, env,
}); });
const apiContext = createAPIContext({
request: options.request,
params: renderContext.params,
props: renderContext.props,
adapterName: options.env.adapterName,
});
if (options.middleware) { if (options.middleware) {
if (options.middleware && options.middleware.onRequest) { 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 onRequest = options.middleware.onRequest as MiddlewareResponseHandler;
const response = await callMiddleware<Response>(env.logging, onRequest, apiContext, () => { const response = await callMiddleware<Response>(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 response;
} }
} }
return await coreRenderPage({ mod, renderContext, env: options.env }); // NOTE: without "await", errors wont get caught below return await coreRenderPage({
mod,
renderContext,
env: options.env,
cookies: apiContext.cookies,
}); // NOTE: without "await", errors wont get caught below
} }

View file

@ -13,6 +13,7 @@ export interface CreateRequestOptions {
body?: RequestBody | undefined; body?: RequestBody | undefined;
logging: LogOptions; logging: LogOptions;
ssr: boolean; ssr: boolean;
locals?: object | undefined;
} }
const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientAddressSymbol = Symbol.for('astro.clientAddress');
@ -26,6 +27,7 @@ export function createRequest({
body = undefined, body = undefined,
logging, logging,
ssr, ssr,
locals,
}: CreateRequestOptions): Request { }: CreateRequestOptions): Request {
let headersObj = let headersObj =
headers instanceof Headers headers instanceof Headers
@ -66,7 +68,7 @@ export function createRequest({
Reflect.set(request, clientAddressSymbol, clientAddress); Reflect.set(request, clientAddressSymbol, clientAddress);
} }
Reflect.set(request, clientLocalsSymbol, {}); Reflect.set(request, clientLocalsSymbol, locals ?? {});
return request; return request;
} }

View file

@ -21,6 +21,8 @@ import { isServerLikeOutput } from '../prerender/utils.js';
import { log404 } from './common.js'; import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends ( type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
...args: any ...args: any
) => Promise<infer R> ) => Promise<infer R>
@ -153,6 +155,7 @@ export async function handleRoute(
logging, logging,
ssr: buildingToSSR, ssr: buildingToSSR,
clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined, 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. // Set user specified headers to response object.

View file

@ -0,0 +1,8 @@
{
"name": "@test/ssr-locals",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,10 @@
export async function get({ locals }) {
let out = { ...locals };
return new Response(JSON.stringify(out), {
headers: {
'Content-Type': 'application/json'
}
});
}

View file

@ -0,0 +1,4 @@
---
const { foo } = Astro.locals;
---
<h1 id="foo">{ foo }</h1>

View file

@ -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');
});
});

View file

@ -34,7 +34,7 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd
this.#manifest = manifest; this.#manifest = manifest;
} }
async render(request, routeData) { async render(request, routeData, locals) {
const url = new URL(request.url); const url = new URL(request.url);
if(this.#manifest.assets.has(url.pathname)) { if(this.#manifest.assets.has(url.pathname)) {
const filePath = new URL('../client/' + this.removeBase(url.pathname), import.meta.url); 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); return new Response(data);
} }
Reflect.set(request, Symbol.for('astro.locals'), {});
${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''} ${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''}
return super.render(request, routeData); return super.render(request, routeData, locals);
} }
} }

View file

@ -122,6 +122,25 @@ app.use(ssrHandler);
app.listen({ port: 8080 }); 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/`. 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 ### Standalone

View file

@ -9,14 +9,15 @@ export default function (app: NodeApp, mode: Options['mode']) {
return async function ( return async function (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
next?: (err?: unknown) => void next?: (err?: unknown) => void,
locals?: object
) { ) {
try { try {
const route = const route =
mode === 'standalone' ? app.match(req, { matchNotFound: true }) : app.match(req); mode === 'standalone' ? app.match(req, { matchNotFound: true }) : app.match(req);
if (route) { if (route) {
try { try {
const response = await app.render(req); const response = await app.render(req, route, locals);
await writeWebResponse(app, res, response); await writeWebResponse(app, res, response);
} catch (err: unknown) { } catch (err: unknown) {
if (next) { if (next) {

View file

@ -0,0 +1,9 @@
{
"name": "@test/locals",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/node": "workspace:*"
}
}

View file

@ -0,0 +1,10 @@
export async function post({ locals }) {
let out = { ...locals };
return new Response(JSON.stringify(out), {
headers: {
'Content-Type': 'application/json'
}
});
}

View file

@ -0,0 +1,4 @@
---
const { foo } = Astro.locals;
---
<h1>{foo}</h1>

View file

@ -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('<h1>bar</h1>');
});
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');
});
});

View file

@ -3252,6 +3252,12 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../.. version: link:../../..
packages/astro/test/fixtures/ssr-locals:
dependencies:
astro:
specifier: workspace:*
version: link:../../..
packages/astro/test/fixtures/ssr-manifest: packages/astro/test/fixtures/ssr-manifest:
dependencies: dependencies:
astro: astro:
@ -4415,7 +4421,7 @@ importers:
version: 9.2.2 version: 9.2.2
vite: vite:
specifier: ^4.3.1 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: packages/integrations/netlify/test/edge-functions/fixtures/dynimport:
dependencies: dependencies:
@ -4550,6 +4556,15 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../../../../../astro 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: packages/integrations/node/test/fixtures/node-middleware:
dependencies: dependencies:
'@astrojs/node': '@astrojs/node':
@ -4930,7 +4945,7 @@ importers:
version: 3.0.0(vite@4.3.1)(vue@3.2.47) version: 3.0.0(vite@4.3.1)(vue@3.2.47)
'@vue/babel-plugin-jsx': '@vue/babel-plugin-jsx':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1(@babel/core@7.21.8)
'@vue/compiler-sfc': '@vue/compiler-sfc':
specifier: ^3.2.39 specifier: ^3.2.39
version: 3.2.39 version: 3.2.39
@ -9317,23 +9332,6 @@ packages:
resolution: {integrity: sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA==} resolution: {integrity: sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA==}
dev: false 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): /@vue/babel-plugin-jsx@1.1.1(@babel/core@7.21.8):
resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==}
dependencies: dependencies:
@ -17665,39 +17663,6 @@ packages:
- supports-color - supports-color
dev: false 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): /vite@4.3.1(@types/node@18.16.3)(sass@1.52.2):
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}