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;
}
}
async render(request: Request, routeData?: RouteData): Promise<Response> {
async render(request: Request, routeData?: RouteData, locals?: object): Promise<Response> {
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);

View file

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

View file

@ -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) {

View file

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

View file

@ -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<RenderContext> & {
@ -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;
}

View file

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

View file

@ -180,22 +180,31 @@ export async function renderPage(options: SSROptions): Promise<Response> {
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<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 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;
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;
}

View file

@ -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<T extends (...args: any) => Promise<any>> = T extends (
...args: any
) => Promise<infer R>
@ -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.

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

View file

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

View file

@ -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) {

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:*
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}