Adds support for Astro.clientAddress (#3973)

* Adds support for Astro.clientAddress

* Pass through mode and adapterName in SSG

* Pass through the mode provided

* Provide an adapter specific error message when possible
This commit is contained in:
Matthew Phillips 2022-07-19 16:10:15 -04:00 committed by GitHub
parent d73c04a9e5
commit 5a23483efb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 311 additions and 49 deletions

View file

@ -0,0 +1,18 @@
---
'astro': minor
'@astrojs/cloudflare': minor
'@astrojs/deno': minor
'@astrojs/netlify': minor
'@astrojs/vercel': minor
'@astrojs/node': minor
---
Adds support for Astro.clientAddress
The new `Astro.clientAddress` property allows you to get the IP address of the requested user.
```astro
<div>Your address { Astro.clientAddress }</div>
```
This property is only available when building for SSR, and only if the adapter you are using supports providing the IP address. If you attempt to access the property in a SSG app it will throw an error.

View file

@ -92,6 +92,10 @@ export interface AstroGlobal extends AstroGlobalPartial {
* [Astro reference](https://docs.astro.build/en/reference/api-reference/#astrocanonicalurl)
*/
canonicalURL: URL;
/** The address (usually IP address) of the user. Used with SSR only.
*
*/
clientAddress: string;
/** Parameters passed to a dynamic page generated using [getStaticPaths](https://docs.astro.build/en/reference/api-reference/#getstaticpaths)
*
* Example usage:

View file

@ -10,6 +10,7 @@ import type { RouteInfo, SSRManifest as Manifest } from './types';
import mime from 'mime';
import { call as callEndpoint } from '../endpoint/index.js';
import { error } from '../logger/core.js';
import { consoleLogDestination } from '../logger/console.js';
import { joinPaths, prependForwardSlash } from '../path.js';
import { render } from '../render/core.js';
@ -96,33 +97,43 @@ export class App {
}
}
const response = await render({
links,
logging: this.#logging,
markdown: manifest.markdown,
mod,
origin: url.origin,
pathname: url.pathname,
scripts,
renderers,
async resolve(specifier: string) {
if (!(specifier in manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
}
const bundlePath = manifest.entryModules[specifier];
return bundlePath.startsWith('data:')
? bundlePath
: prependForwardSlash(joinPaths(manifest.base, bundlePath));
},
route: routeData,
routeCache: this.#routeCache,
site: this.#manifest.site,
ssr: true,
request,
streaming: this.#streaming,
});
return response;
try {
const response = await render({
adapterName: manifest.adapterName,
links,
logging: this.#logging,
markdown: manifest.markdown,
mod,
mode: 'production',
origin: url.origin,
pathname: url.pathname,
scripts,
renderers,
async resolve(specifier: string) {
if (!(specifier in manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
}
const bundlePath = manifest.entryModules[specifier];
return bundlePath.startsWith('data:')
? bundlePath
: prependForwardSlash(joinPaths(manifest.base, bundlePath));
},
route: routeData,
routeCache: this.#routeCache,
site: this.#manifest.site,
ssr: true,
request,
streaming: this.#streaming,
});
return response;
} catch(err) {
error(this.#logging, 'ssr', err);
return new Response(null, {
status: 500,
statusText: 'Internal server error'
});
}
}
async #callEndpoint(

View file

@ -5,13 +5,19 @@ import { IncomingMessage } from 'http';
import { deserializeManifest } from './common.js';
import { App } from './index.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
function createRequestFromNodeRequest(req: IncomingMessage): Request {
let url = `http://${req.headers.host}${req.url}`;
const entries = Object.entries(req.headers as Record<string, any>);
let rawHeaders = req.headers as Record<string, any>;
const entries = Object.entries(rawHeaders);
let request = new Request(url, {
method: req.method || 'GET',
headers: new Headers(entries),
});
if(req.socket.remoteAddress) {
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
}
return request;
}

View file

@ -25,6 +25,7 @@ export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
};
export interface SSRManifest {
adapterName: string;
routes: RouteInfo[];
site?: string;
base?: string;

View file

@ -210,10 +210,12 @@ async function generatePath(
const ssr = isBuildingToSSR(opts.astroConfig);
const url = new URL(opts.astroConfig.base + removeLeadingForwardSlash(pathname), origin);
const options: RenderOptions = {
adapterName: undefined,
links,
logging,
markdown: astroConfig.markdown,
mod,
mode: opts.mode,
origin,
pathname,
scripts,

View file

@ -1,5 +1,5 @@
import type { AstroTelemetry } from '@astrojs/telemetry';
import type { AstroConfig, BuildConfig, ManifestData } from '../../@types/astro';
import type { AstroConfig, BuildConfig, ManifestData, RuntimeMode } from '../../@types/astro';
import type { LogOptions } from '../logger/core';
import fs from 'fs';
@ -24,7 +24,7 @@ import { staticBuild } from './static-build.js';
import { getTimeStat } from './util.js';
export interface BuildOptions {
mode?: string;
mode?: RuntimeMode;
logging: LogOptions;
telemetry: AstroTelemetry;
}
@ -39,7 +39,7 @@ export default async function build(config: AstroConfig, options: BuildOptions):
class AstroBuilder {
private config: AstroConfig;
private logging: LogOptions;
private mode = 'production';
private mode: RuntimeMode = 'production';
private origin: string;
private routeCache: RouteCache;
private manifest: ManifestData;
@ -129,17 +129,25 @@ class AstroBuilder {
colors.dim(`Completed in ${getTimeStat(this.timer.init, performance.now())}.`)
);
await staticBuild({
allPages,
astroConfig: this.config,
logging: this.logging,
manifest: this.manifest,
origin: this.origin,
pageNames,
routeCache: this.routeCache,
viteConfig,
buildConfig,
});
try {
await staticBuild({
allPages,
astroConfig: this.config,
logging: this.logging,
manifest: this.manifest,
mode: this.mode,
origin: this.origin,
pageNames,
routeCache: this.routeCache,
viteConfig,
buildConfig,
});
} catch(err: unknown) {
// If the build doesn't complete, still shutdown the Vite server so the process doesn't hang.
await viteServer.close();
throw err;
}
// Write any additionally generated assets to disk.
this.timer.assetsStart = performance.now();

View file

@ -145,7 +145,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
...(viteConfig.plugins || []),
// SSR needs to be last
isBuildingToSSR(opts.astroConfig) &&
vitePluginSSR(opts, internals, opts.astroConfig._ctx.adapter!),
vitePluginSSR(internals, opts.astroConfig._ctx.adapter!),
vitePluginAnalyzer(opts.astroConfig, internals),
],
publicDir: ssr ? false : viteConfig.publicDir,

View file

@ -4,6 +4,7 @@ import type {
ComponentInstance,
ManifestData,
RouteData,
RuntimeMode,
SSRLoadedRenderer,
} from '../../@types/astro';
import type { ViteConfigWithSSR } from '../create-vite';
@ -30,6 +31,7 @@ export interface StaticBuildOptions {
buildConfig: BuildConfig;
logging: LogOptions;
manifest: ManifestData;
mode: RuntimeMode;
origin: string;
pageNames: string[];
routeCache: RouteCache;

View file

@ -20,7 +20,6 @@ const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g');
export function vitePluginSSR(
buildOpts: StaticBuildOptions,
internals: BuildInternals,
adapter: AstroAdapter
): VitePlugin {
@ -153,6 +152,7 @@ function buildManifest(
'data:text/javascript;charset=utf-8,//[no before-hydration script]';
const ssrManifest: SerializedSSRManifest = {
adapterName: opts.astroConfig._ctx.adapter!.name,
routes,
site: astroConfig.site,
base: astroConfig.base,

View file

@ -4,6 +4,7 @@ import type {
Params,
Props,
RouteData,
RuntimeMode,
SSRElement,
SSRLoadedRenderer,
} from '../../@types/astro';
@ -66,11 +67,13 @@ export async function getParamsAndProps(
}
export interface RenderOptions {
adapterName: string | undefined;
logging: LogOptions;
links: Set<SSRElement>;
styles?: Set<SSRElement>;
markdown: MarkdownRenderingOptions;
mod: ComponentInstance;
mode: RuntimeMode;
origin: string;
pathname: string;
scripts: Set<SSRElement>;
@ -86,12 +89,14 @@ export interface RenderOptions {
export async function render(opts: RenderOptions): Promise<Response> {
const {
adapterName,
links,
styles,
logging,
origin,
markdown,
mod,
mode,
pathname,
scripts,
renderers,
@ -126,10 +131,12 @@ export async function render(opts: RenderOptions): Promise<Response> {
throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
const result = createResult({
adapterName,
links,
styles,
logging,
markdown,
mode,
origin,
params,
props: pageProps,

View file

@ -161,11 +161,13 @@ export async function render(
});
let response = await coreRender({
adapterName: astroConfig.adapter?.name,
links,
styles,
logging,
markdown: astroConfig.markdown,
mod,
mode,
origin,
pathname,
scripts,

View file

@ -6,6 +6,7 @@ import type {
Page,
Params,
Props,
RuntimeMode,
SSRElement,
SSRLoadedRenderer,
SSRResult,
@ -15,6 +16,8 @@ import { LogOptions, warn } from '../logger/core.js';
import { isScriptRequest } from './script.js';
import { createCanonicalURL, isCSSRequest } from './util.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
function onlyAvailableInSSR(name: string) {
return function _onlyAvailableInSSR() {
// TODO add more guidance when we have docs and adapters.
@ -23,11 +26,13 @@ function onlyAvailableInSSR(name: string) {
}
export interface CreateResultArgs {
adapterName: string | undefined;
ssr: boolean;
streaming: boolean;
logging: LogOptions;
origin: string;
markdown: MarkdownRenderingOptions;
mode: RuntimeMode;
params: Params;
pathname: string;
props: Props;
@ -151,6 +156,17 @@ export function createResult(args: CreateResultArgs): SSRResult {
const Astro = {
__proto__: astroGlobal,
canonicalURL,
get clientAddress() {
if(!(clientAddressSymbol in request)) {
if(args.adapterName) {
throw new Error(`Astro.clientAddress is not available in the ${args.adapterName} adapter. File an issue with the adapter to add support.`);
} else {
throw new Error(`Astro.clientAddress is not available in your environment. Ensure that you are using an SSR adapter that supports this feature.`)
}
}
return Reflect.get(request, clientAddressSymbol);
},
params,
props,
request,

View file

@ -7,6 +7,7 @@ type RequestBody = ArrayBuffer | Blob | ReadableStream | URLSearchParams | FormD
export interface CreateRequestOptions {
url: URL | string;
clientAddress?: string | undefined;
headers: HeaderType;
method?: string;
body?: RequestBody | undefined;
@ -14,9 +15,12 @@ export interface CreateRequestOptions {
ssr: boolean;
}
const clientAddressSymbol = Symbol.for('astro.clientAddress');
export function createRequest({
url,
headers,
clientAddress,
method = 'GET',
body = undefined,
logging,
@ -67,6 +71,8 @@ export function createRequest({
return _headers;
},
});
} else if(clientAddress) {
Reflect.set(request, clientAddressSymbol, clientAddress);
}
return request;

View file

@ -229,6 +229,7 @@ async function handleRequest(
body,
logging,
ssr: buildingToSSR,
clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined,
});
try {

View file

@ -0,0 +1,130 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
import { nodeLogDestination } from '../dist/core/logger/node.js';
import * as cheerio from 'cheerio';
describe('Astro.clientAddress', () => {
describe('SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/client-address/',
experimental: {
ssr: true,
},
adapter: testAdapter(),
});
});
describe('Production', () => {
before(async () => {
await fixture.build();
});
it('Can get the address', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
expect($('#address').text()).to.equal('0.0.0.0');
});
});
describe('Development', () => {
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('Gets the address', async () => {
let res = await fixture.fetch('/');
expect(res.status).to.equal(200);
let html = await res.text();
let $ = cheerio.load(html);
let address = $('#address');
// Just checking that something is here. Not specifying address as it
// might differ per machine.
expect(address.length).to.be.greaterThan(0);
});
});
});
describe('SSR adapter not implemented', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/client-address/',
experimental: {
ssr: true,
},
adapter: testAdapter({ provideAddress: false }),
});
await fixture.build();
});
it('Gets an error message', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
expect(response.status).to.equal(500);
});
})
describe('SSG', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/client-address/',
});
});
describe('Build', () => {
it('throws during generation', async () => {
try {
await fixture.build();
expect(false).to.equal(true, 'Build should not have completed');
} catch(err) {
expect(err.message).to.match(/Astro\.clientAddress/, 'Error message mentions Astro.clientAddress');
}
})
});
describe('Development', () => {
/** @type {import('./test-utils').DevServer} */
let devServer;
before(async () => {
// We expect an error, so silence the output
const logging = {
dest: nodeLogDestination,
level: 'silent',
};
devServer = await fixture.startDevServer({ logging });
});
after(async () => {
await devServer.stop();
});
it('is not accessible', async () => {
let res = await fixture.fetch('/');
expect(res.status).to.equal(500);
});
})
});
});

View file

@ -0,0 +1,12 @@
---
const address = Astro.clientAddress;
---
<html>
<head>
<title>Astro.clientAddress</title>
</head>
<body>
<h1>Astro.clientAddress</h1>
<div id="address">{ address }</div>
</body>
</html>

View file

@ -4,7 +4,7 @@ import { viteID } from '../dist/core/util.js';
*
* @returns {import('../src/@types/astro').AstroIntegration}
*/
export default function () {
export default function ({ provideAddress } = { provideAddress: true }) {
return {
name: 'my-ssr-adapter',
hooks: {
@ -23,7 +23,23 @@ export default function () {
},
load(id) {
if (id === '@my-ssr') {
return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: (streaming) => new App(manifest, streaming) }; }`;
return `
import { App } from 'astro/app';
class MyApp extends App {
render(request) {
${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''}
return super.render(request);
}
}
export function createExports(manifest) {
return {
manifest,
createApp: (streaming) => new MyApp(manifest, streaming)
};
}
`;
}
},
},

View file

@ -127,6 +127,7 @@ export async function loadFixture(inlineConfig) {
// Also do it on process exit, just in case.
process.on('exit', resetAllFiles);
let fixtureId = new Date().valueOf();
let devServer;
return {
@ -150,7 +151,7 @@ export async function loadFixture(inlineConfig) {
await fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true });
},
loadTestAdapterApp: async (streaming) => {
const url = new URL('./server/entry.mjs', config.outDir);
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir);
const { createApp, manifest } = await import(url);
const app = createApp(streaming);
app.manifest = manifest;

View file

@ -20,6 +20,7 @@ export function createExports(manifest: SSRManifest) {
}
if (app.match(request)) {
Reflect.set(request, Symbol.for('astro.clientAddress'), request.headers.get('cf-connecting-ip'));
return app.render(request);
}

View file

@ -22,8 +22,10 @@ export function start(manifest: SSRManifest, options: Options) {
const clientRoot = new URL('../client/', import.meta.url);
const app = new App(manifest);
const handler = async (request: Request) => {
const handler = async (request: Request, connInfo: any) => {
if (app.match(request)) {
let ip = connInfo?.remoteAddr?.hostname;
Reflect.set(request, Symbol.for('astro.clientAddress'), ip);
return await app.render(request);
}

View file

@ -1,6 +1,8 @@
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
export function createExports(manifest: SSRManifest) {
const app = new App(manifest);
@ -13,6 +15,8 @@ export function createExports(manifest: SSRManifest) {
return;
}
if (app.match(request)) {
const ip = request.headers.get('x-nf-client-connection-ip');
Reflect.set(request, clientAddressSymbol, ip);
return app.render(request);
}

View file

@ -15,6 +15,8 @@ function parseContentType(header?: string) {
return header?.split(';')[0] ?? '';
}
const clientAddressSymbol = Symbol.for('astro.clientAddress');
export const createExports = (manifest: SSRManifest, args: Args) => {
const app = new App(manifest);
@ -71,6 +73,9 @@ export const createExports = (manifest: SSRManifest, args: Args) => {
};
}
const ip = headers['x-nf-client-connection-ip'];
Reflect.set(request, clientAddressSymbol, ip);
const response: Response = await app.render(request);
const responseHeaders = Object.fromEntries(response.headers.entries());

View file

@ -7,11 +7,14 @@ import './shim.js';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
export function createExports(manifest: SSRManifest) {
const app = new App(manifest);
const handler = async (request: Request): Promise<Response> => {
if (app.match(request)) {
Reflect.set(request, clientAddressSymbol, request.headers.get('x-forwarded-for'));
return await app.render(request);
}

View file

@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
/*
Credits to the SvelteKit team
https://github.com/sveltejs/kit/blob/69913e9fda054fa6a62a80e2bb4ee7dca1005796/packages/kit/src/node.js
@ -66,11 +68,13 @@ export async function getRequest(base: string, req: IncomingMessage): Promise<Re
delete headers[':authority'];
delete headers[':scheme'];
}
return new Request(base + req.url, {
const request = new Request(base + req.url, {
method: req.method,
headers,
body: await get_raw_body(req), // TODO stream rather than buffer
});
Reflect.set(request, clientAddressSymbol, headers['x-forwarded-for']);
return request;
}
export async function setResponse(res: ServerResponse, response: Response): Promise<void> {