Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Astro.locals to be set by adapters #7049

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/smooth-jokes-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'astro': minor
'@astrojs/node': minor
---

Astro.locals are now exposed to the adapter API. Node Adapter can now pass in a `locals` object in the SSR handler middleware.
wrapperup marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 3 additions & 4 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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') {
Expand Down Expand Up @@ -227,15 +227,14 @@ export class App {
onRequest as MiddlewareResponseHandler,
apiContext,
() => {
return renderPage({ mod, renderContext, env: this.#env, apiContext });
return renderPage({ mod, renderContext, env: this.#env });
}
);
} else {
response = await renderPage({
mod,
renderContext,
env: this.#env,
apiContext,
});
}
Reflect.set(request, responseSentSymbol, true);
Expand Down
14 changes: 9 additions & 5 deletions packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand All @@ -53,7 +54,8 @@ export class NodeApp extends App {
req instanceof Request
? req
: createRequestFromNodeRequest(req, Buffer.from(JSON.stringify(req.body))),
routeData
routeData,
locals
);
}

Expand All @@ -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
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,11 +498,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') {
Expand Down
23 changes: 22 additions & 1 deletion packages/astro/src/core/render/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -25,6 +28,7 @@ export interface RenderContext {
status?: number;
params: Params;
props: Props;
locals?: object;
}

export type CreateRenderContextArgs = Partial<RenderContext> & {
Expand All @@ -49,12 +53,29 @@ export async function createRenderContext(
logging: options.env.logging,
ssr: options.env.ssr,
});
return {

let context = {
...options,
origin,
pathname,
url,
params,
props,
};

// We define a custom property, so we can check the value passed to locals
Object.defineProperty(context, 'locals', {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed this is the same code we use somewhere else. Maybe it's worth having a shared function and making it more "DRY". This would allow us to not have repeated logic to maintain.

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;
}
14 changes: 6 additions & 8 deletions packages/astro/src/core/render/core.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -107,25 +107,23 @@ 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 = {};
if (apiContext) {
if (env.mode === 'development' && !isValueSerializable(apiContext.locals)) {
if (renderContext.locals) {
if (env.mode === 'development' && !isValueSerializable(renderContext.locals)) {
throw new AstroError({
...AstroErrorData.LocalsNotSerializable,
message: AstroErrorData.LocalsNotSerializable.message(renderContext.pathname),
});
}
locals = apiContext.locals;
}

const result = createResult({
adapterName: env.adapterName,
links: renderContext.links,
Expand All @@ -145,7 +143,7 @@ export async function renderPage({ mod, renderContext, env, apiContext }: Render
scripts: renderContext.scripts,
ssr: env.ssr,
status: renderContext.status ?? 200,
locals,
locals: renderContext.locals ?? {},
});

// Support `export const components` for `MDX` pages
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/render/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export async function renderPage(options: SSROptions): Promise<Response> {

const onRequest = options.middleware.onRequest as MiddlewareResponseHandler;
const response = await callMiddleware<Response>(onRequest, apiContext, () => {
return coreRenderPage({ mod, renderContext, env: options.env, apiContext });
return coreRenderPage({ mod, renderContext, env: options.env });
});

return response;
Expand Down
4 changes: 3 additions & 1 deletion packages/astro/src/core/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface CreateRequestOptions {
body?: RequestBody | undefined;
logging: LogOptions;
ssr: boolean;
locals?: object | undefined;
}

const clientAddressSymbol = Symbol.for('astro.clientAddress');
Expand All @@ -26,6 +27,7 @@ export function createRequest({
body = undefined,
logging,
ssr,
locals,
}: CreateRequestOptions): Request {
let headersObj =
headers instanceof Headers
Expand Down Expand Up @@ -66,7 +68,7 @@ export function createRequest({
Reflect.set(request, clientAddressSymbol, clientAddress);
}

Reflect.set(request, clientLocalsSymbol, {});
Reflect.set(request, clientLocalsSymbol, locals ?? {});

return request;
}
3 changes: 3 additions & 0 deletions packages/astro/src/vite-plugin-astro-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { matchAllRoutes } from '../core/routing/index.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>
Expand Down Expand Up @@ -142,6 +144,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.
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/test/fixtures/ssr-locals/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/ssr-locals",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
10 changes: 10 additions & 0 deletions packages/astro/test/fixtures/ssr-locals/src/pages/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

export async function get({ locals }) {
let out = { ...locals };

return new Response(JSON.stringify(out), {
headers: {
'Content-Type': 'application/json'
}
});
}
4 changes: 4 additions & 0 deletions packages/astro/test/fixtures/ssr-locals/src/pages/foo.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
const { foo } = Astro.locals;
---
<h1 id="foo">{ foo }</h1>
40 changes: 40 additions & 0 deletions packages/astro/test/ssr-locals.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
5 changes: 2 additions & 3 deletions packages/astro/test/test-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,16 @@ 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);
const data = await fs.promises.readFile(filePath);
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);
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/integrations/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would love if this didn't use foo: bar but instead something real? (In docs, we've been using title: "New title" to demonstrate updating a property.)

But, non-blocking!

};

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
Expand Down
5 changes: 3 additions & 2 deletions packages/integrations/node/src/nodeMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/integrations/node/test/fixtures/locals/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/locals",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/node": "workspace:*"
}
}
10 changes: 10 additions & 0 deletions packages/integrations/node/test/fixtures/locals/src/pages/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

export async function post({ locals }) {
let out = { ...locals };

return new Response(JSON.stringify(out), {
headers: {
'Content-Type': 'application/json'
}
});
}
Loading