Feat: expose server on local network with new --host flag (#2760)

* feat: update config to support bool --hostname

* fix: show localhost for --hostname=true

* feat: address logging feature parity w/ Vite

* chore: update type docs

* refactor: extract local, network prefs to variable

* feat: add --host to --help output

* feat: deprecate --hostname, add --host

* feat: add --host tests

* feat: update preview to support new flags

* fix: show --host in dev server log

* feat: update config tests for --host flag

* chore: test lint

* chore: update lock with new fixture

* chore: add changeset

* refactor: add more details to JSdocs

* fix: update path tests

* feat: only expose when --host is not local

* fix: make flag --help less verbose

* fix: address @types comments

* fix: lint

* chore: remove unused import

* fix: use host flag for config test

* fix: ensure local logs come before network

* refactor: switch up that network logging one last time!

* feat: update unit tests

* chore: remove debugging block

* fix: only parse network logs if network is present
This commit is contained in:
Ben Holmes 2022-03-11 17:25:26 -05:00 committed by GitHub
parent 2bb2c2f7d1
commit 77b9c95352
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 239 additions and 86 deletions

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Introduce a new --host flag + host devOption to expose your server on a network IP

View file

@ -24,6 +24,7 @@ export interface CLIFlags {
projectRoot?: string;
site?: string;
sitemap?: boolean;
host?: string | boolean;
hostname?: string;
port?: number;
config?: string;
@ -314,12 +315,29 @@ export interface AstroUserConfig {
* @name Dev Options
*/
devOptions?: {
/**
* @docs
* @name devOptions.host
* @type {string | boolean}
* @default `false`
* @version 0.24.0
* @description
* Set which network IP addresses the dev server should listen on (i.e. non-localhost IPs).
* - `false` - do not expose on a network IP address
* - `true` - listen on all addresses, including LAN and public addresses
* - `[custom-address]` - expose on a network IP address at `[custom-address]`
*/
host?: string | boolean;
/**
* @docs
* @name devOptions.hostname
* @type {string}
* @default `'localhost'`
* @deprecated Use `host` instead
* @description
* > **This option is deprecated.** Consider using `host` instead.
*
* Set which IP addresses the dev server should listen on. Set this to 0.0.0.0 to listen on all addresses, including LAN and public addresses.
*/
hostname?: string;

View file

@ -38,6 +38,7 @@ function printHelp() {
title('Flags');
table(
[
['--host [optional IP]', 'Expose server on network'],
['--config <path>', 'Specify the path to the Astro config file.'],
['--project-root <path>', 'Specify the path to the project root folder.'],
['--no-sitemap', 'Disable sitemap generation (build only).'],

View file

@ -65,6 +65,7 @@ export const AstroConfigSchema = z.object({
.default({}),
devOptions: z
.object({
host: z.union([z.string(), z.boolean()]).optional().default(false),
hostname: z.string().optional().default('localhost'),
port: z.number().optional().default(3000),
trailingSlash: z
@ -125,6 +126,7 @@ function resolveFlags(flags: Partial<Flags>): CLIFlags {
port: typeof flags.port === 'number' ? flags.port : undefined,
config: typeof flags.config === 'string' ? flags.config : undefined,
hostname: typeof flags.hostname === 'string' ? flags.hostname : undefined,
host: typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined,
legacyBuild: typeof flags.legacyBuild === 'boolean' ? flags.legacyBuild : false,
experimentalSsr: typeof flags.experimentalSsr === 'boolean' ? flags.experimentalSsr : false,
drafts: typeof flags.drafts === 'boolean' ? flags.drafts : false,
@ -138,6 +140,7 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags) {
if (typeof flags.sitemap === 'boolean') astroConfig.buildOptions.sitemap = flags.sitemap;
if (typeof flags.site === 'string') astroConfig.buildOptions.site = flags.site;
if (typeof flags.port === 'number') astroConfig.devOptions.port = flags.port;
if (typeof flags.host === 'string' || typeof flags.host === 'boolean') astroConfig.devOptions.host = flags.host;
if (typeof flags.hostname === 'string') astroConfig.devOptions.hostname = flags.hostname;
if (typeof flags.legacyBuild === 'boolean') astroConfig.buildOptions.legacyBuild = flags.legacyBuild;
if (typeof flags.experimentalSsr === 'boolean') {

View file

@ -6,7 +6,7 @@ import { createVite } from '../create-vite.js';
import { defaultLogOptions, info, warn, LogOptions } from '../logger.js';
import * as vite from 'vite';
import * as msg from '../messages.js';
import { getLocalAddress } from './util.js';
import { getResolvedHostForVite } from './util.js';
export interface DevOptions {
logging: LogOptions;
@ -24,13 +24,13 @@ export default async function dev(config: AstroConfig, options: DevOptions = { l
polyfill(globalThis, {
exclude: 'window document',
});
// start the server
// TODO: remove call once --hostname is baselined
const host = getResolvedHostForVite(config);
const viteUserConfig = vite.mergeConfig(
{
mode: 'development',
server: {
host: config.devOptions.hostname,
},
server: { host },
},
config.vite || {}
);
@ -38,15 +38,9 @@ export default async function dev(config: AstroConfig, options: DevOptions = { l
const viteServer = await vite.createServer(viteConfig);
await viteServer.listen(config.devOptions.port);
const address = viteServer.httpServer!.address() as AddressInfo;
const localAddress = getLocalAddress(address.address, config.devOptions.hostname);
// Log to console
const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo;
const site = config.buildOptions.site ? new URL(config.buildOptions.site) : undefined;
info(
options.logging,
null,
msg.devStart({ startupTime: performance.now() - devStart, port: address.port, localAddress, networkAddress: address.address, site, https: !!viteUserConfig.server?.https })
);
info(options.logging, null, msg.devStart({ startupTime: performance.now() - devStart, config, devServerAddressInfo, site, https: !!viteUserConfig.server?.https }));
const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
if (currentVersion.includes('-')) {
@ -54,7 +48,7 @@ export default async function dev(config: AstroConfig, options: DevOptions = { l
}
return {
address,
address: devServerAddressInfo,
stop: () => viteServer.close(),
};
}

View file

@ -1,3 +1,7 @@
import type { AstroConfig } from '../../@types/astro';
export const localIps = new Set(['localhost', '127.0.0.1']);
/** Pad string () */
export function pad(input: string, minLength: number, dir?: 'left' | 'right'): string {
let output = input;
@ -11,10 +15,36 @@ export function emoji(char: string, fallback: string) {
return process.platform !== 'win32' ? char : fallback;
}
export function getLocalAddress(serverAddress: string, configHostname: string): string {
if (configHostname === 'localhost' || serverAddress === '127.0.0.1' || serverAddress === '0.0.0.0') {
// TODO: remove once --hostname is baselined
export function getResolvedHostForVite(config: AstroConfig) {
if (config.devOptions.host === false && config.devOptions.hostname !== 'localhost') {
return config.devOptions.hostname;
} else {
return config.devOptions.host;
}
}
export function getLocalAddress(serverAddress: string, config: AstroConfig): string {
// TODO: remove once --hostname is baselined
const host = getResolvedHostForVite(config);
if (typeof host === 'boolean' || host === 'localhost') {
return 'localhost';
} else {
return serverAddress;
}
}
export type NetworkLogging = 'none' | 'host-to-expose' | 'visible';
export function getNetworkLogging(config: AstroConfig): NetworkLogging {
// TODO: remove once --hostname is baselined
const host = getResolvedHostForVite(config);
if (host === false) {
return 'host-to-expose';
} else if (typeof host === 'string' && localIps.has(host)) {
return 'none';
} else {
return 'visible';
}
}

View file

@ -2,10 +2,12 @@
* Dev server messages (organized here to prevent clutter)
*/
import type { AddressInfo } from 'net';
import stripAnsi from 'strip-ansi';
import { bold, dim, red, green, underline, yellow, bgYellow, cyan, bgGreen, black } from 'kleur/colors';
import { pad, emoji } from './dev/util.js';
import { pad, emoji, getLocalAddress, getNetworkLogging } from './dev/util.js';
import os from 'os';
import type { AddressInfo } from 'net';
import type { AstroConfig } from '../@types/astro';
const PREFIX_PADDING = 6;
@ -30,31 +32,50 @@ export function hmr({ file }: { file: string }): string {
/** Display dev server host and startup time */
export function devStart({
startupTime,
port,
localAddress,
networkAddress,
devServerAddressInfo,
config,
https,
site,
}: {
startupTime: number;
port: number;
localAddress: string;
networkAddress: string;
devServerAddressInfo: AddressInfo;
config: AstroConfig;
https: boolean;
site: URL | undefined;
}): string {
// PACAKGE_VERSION is injected at build-time
// PACKAGE_VERSION is injected at build-time
const version = process.env.PACKAGE_VERSION ?? '0.0.0';
const rootPath = site ? site.pathname : '/';
const toDisplayUrl = (hostname: string) => `${https ? 'https' : 'http'}://${hostname}:${port}${rootPath}`;
const localPrefix = `${dim('┃')} Local `;
const networkPrefix = `${dim('┃')} Network `;
const messages = [
`${emoji('🚀 ', '')}${bgGreen(black(` astro `))} ${green(`v${version}`)} ${dim(`started in ${Math.round(startupTime)}ms`)}`,
'',
`${dim('┃')} Local ${bold(cyan(toDisplayUrl(localAddress)))}`,
`${dim('┃')} Network ${bold(cyan(toDisplayUrl(networkAddress)))}`,
'',
];
const { address: networkAddress, port } = devServerAddressInfo;
const localAddress = getLocalAddress(networkAddress, config);
const networkLogging = getNetworkLogging(config);
const toDisplayUrl = (hostname: string) => `${https ? 'https' : 'http'}://${hostname}:${port}${rootPath}`;
let addresses = [];
if (networkLogging === 'none') {
addresses = [`${localPrefix}${bold(cyan(toDisplayUrl(localAddress)))}`];
} else if (networkLogging === 'host-to-expose') {
addresses = [`${localPrefix}${bold(cyan(toDisplayUrl(localAddress)))}`, `${networkPrefix}${dim('use --host to expose')}`];
} else {
addresses = Object.values(os.networkInterfaces())
.flatMap((networkInterface) => networkInterface ?? [])
.filter((networkInterface) => networkInterface?.address && networkInterface?.family === 'IPv4')
.map(({ address }) => {
if (address.includes('127.0.0.1')) {
const displayAddress = address.replace('127.0.0.1', localAddress);
return `${localPrefix}${bold(cyan(toDisplayUrl(displayAddress)))}`;
} else {
return `${networkPrefix}${bold(cyan(toDisplayUrl(address)))}`;
}
})
// ensure Local logs come before Network
.sort((msg) => (msg.startsWith(localPrefix) ? -1 : 1));
}
const messages = [`${emoji('🚀 ', '')}${bgGreen(black(` astro `))} ${green(`v${version}`)} ${dim(`started in ${Math.round(startupTime)}ms`)}`, '', ...addresses, ''];
return messages.map((msg) => ` ${msg}`).join('\n');
}

View file

@ -1,6 +1,5 @@
import type { AstroConfig } from '../../@types/astro';
import type { LogOptions } from '../logger';
import type { Stats } from 'fs';
import type { AddressInfo } from 'net';
import http from 'http';
import sirv from 'sirv';
@ -8,16 +7,15 @@ import { performance } from 'perf_hooks';
import { fileURLToPath } from 'url';
import * as msg from '../messages.js';
import { error, info } from '../logger.js';
import { appendForwardSlash, trimSlashes } from '../path.js';
import { getLocalAddress } from '../dev/util.js';
import { subpathNotUsedTemplate, notFoundTemplate } from '../../template/4xx.js';
import { getResolvedHostForHttpServer } from './util.js';
interface PreviewOptions {
logging: LogOptions;
}
export interface PreviewServer {
hostname: string;
host?: string;
port: number;
server: http.Server;
stop(): Promise<void>;
@ -75,7 +73,8 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
}
});
let { hostname, port } = config.devOptions;
let { port } = config.devOptions;
const host = getResolvedHostForHttpServer(config);
let httpServer: http.Server;
@ -85,12 +84,10 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
let showedListenMsg = false;
return new Promise<void>((resolve, reject) => {
const listen = () => {
httpServer = server.listen(port, hostname, async () => {
httpServer = server.listen(port, host, async () => {
if (!showedListenMsg) {
const { address: networkAddress } = server.address() as AddressInfo;
const localAddress = getLocalAddress(networkAddress, hostname);
info(logging, null, msg.devStart({ startupTime: performance.now() - timerStart, port, localAddress, networkAddress, https: false, site: baseURL }));
const devServerAddressInfo = server.address() as AddressInfo;
info(logging, null, msg.devStart({ startupTime: performance.now() - timerStart, config, devServerAddressInfo, https: false, site: baseURL }));
}
showedListenMsg = true;
resolve();
@ -121,7 +118,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO
await startServer(startServerTime);
return {
hostname,
host,
port,
server: httpServer!,
stop: async () => {

View file

@ -0,0 +1,17 @@
import type { AstroConfig } from '../../@types/astro';
export function getResolvedHostForHttpServer(config: AstroConfig) {
const { host, hostname } = config.devOptions;
if (host === false && hostname === 'localhost') {
// Use a secure default
return '127.0.0.1';
} else if (host === true) {
// If passed --host in the CLI without arguments
return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
} else if (typeof host === 'string') {
return host;
} else {
return hostname;
}
}

View file

@ -1,9 +1,15 @@
import { expect } from 'chai';
import { cli, parseCliDevStart } from './test-utils.js';
import { cli, parseCliDevStart, cliServerLogSetup } from './test-utils.js';
import { promises as fs } from 'fs';
import { fileURLToPath } from 'url';
import { isIPv4 } from 'net';
describe('astro cli', () => {
const cliServerLogSetupWithFixture = (flags, cmd) => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
return cliServerLogSetup(['--project-root', fileURLToPath(projectRootURL), ...flags], cmd);
};
it('astro', async () => {
const proc = await cli();
@ -32,28 +38,52 @@ describe('astro cli', () => {
expect(messages[0]).to.contain('started in');
});
const hostnames = [undefined, '0.0.0.0', '127.0.0.1'];
['dev', 'preview'].forEach((cmd) => {
const networkLogFlags = [['--host'], ['--host', '0.0.0.0'], ['--hostname', '0.0.0.0']];
networkLogFlags.forEach(([flag, flagValue]) => {
it(`astro ${cmd} ${flag} ${flagValue ?? ''} - network log`, async () => {
const { local, network } = await cliServerLogSetupWithFixture(flagValue ? [flag, flagValue] : [flag], cmd);
hostnames.forEach((hostname) => {
const hostnameArgs = hostname ? ['--hostname', hostname] : [];
it(`astro dev ${hostnameArgs.join(' ') || '(no --hostname)'}`, async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const proc = cli('dev', '--project-root', fileURLToPath(projectRootURL), ...hostnameArgs);
expect(local).to.not.be.undefined;
expect(network).to.not.be.undefined;
const { messages } = await parseCliDevStart(proc);
const localURL = new URL(local);
const networkURL = new URL(network);
const local = messages[1].replace(/Local\s*/g, '');
const network = messages[2].replace(/Network\s*/g, '');
expect(localURL.hostname).to.be.equal(flagValue ?? 'localhost', `Expected local URL to be on localhost`);
// Note: our tests run in parallel so this could be 3000+!
expect(Number.parseInt(localURL.port)).to.be.greaterThanOrEqual(3000, `Expected Port to be >= 3000`);
expect(networkURL.port).to.be.equal(localURL.port, `Expected local and network ports to be equal`);
expect(isIPv4(networkURL.hostname)).to.be.equal(true, `Expected network URL to respect --host flag`);
});
});
expect(local).to.not.be.undefined;
expect(network).to.not.be.undefined;
const localURL = new URL(local);
const networkURL = new URL(network);
expect(localURL.hostname).to.be.equal('localhost', `Expected local URL to be on localhost`);
// Note: our tests run in parallel so this could be 3000+!
expect(Number.parseInt(localURL.port)).to.be.greaterThanOrEqual(3000, `Expected Port to be >= 3000`);
expect(networkURL.hostname).to.be.equal(hostname ?? '127.0.0.1', `Expected Network URL to use passed hostname`);
const hostToExposeFlags = [['', ''], ['--hostname', 'localhost']];
hostToExposeFlags.forEach(([flag, flagValue]) => {
it(`astro ${cmd} ${flag} ${flagValue} - host to expose`, async () => {
const { local, network } = await cliServerLogSetupWithFixture([flag, flagValue], cmd);
expect(local).to.not.be.undefined;
expect(network).to.not.be.undefined;
const localURL = new URL(local);
expect(localURL.hostname).to.be.equal('localhost', `Expected local URL to be on localhost`);
expect(() => new URL(networkURL)).to.throw();
});
});
const noNetworkLogFlags = [['--host', 'localhost'], ['--host', '127.0.0.1'], ['--hostname', '127.0.0.1']];
noNetworkLogFlags.forEach(([flag, flagValue]) => {
it(`astro ${cmd} ${flag} ${flagValue} - no network log`, async () => {
const { local, network } = await cliServerLogSetupWithFixture([flag, flagValue], cmd);
expect(local).to.not.be.undefined;
expect(network).to.be.undefined;
const localURL = new URL(local);
expect(localURL.hostname).to.be.equal(flagValue, `Expected local URL to be on localhost`);
});
});
});

View file

@ -1,15 +1,22 @@
import { expect } from 'chai';
import { cli, loadFixture } from './test-utils.js';
import { cli, loadFixture, cliServerLogSetup } from './test-utils.js';
import { fileURLToPath } from 'url';
import { isIPv4 } from 'net';
describe('config', () => {
let hostnameFixture;
let hostFixture;
let portFixture;
before(async () => {
[hostnameFixture, portFixture] = await Promise.all([loadFixture({ projectRoot: './fixtures/config-hostname/' }), loadFixture({ projectRoot: './fixtures/config-port/' })]);
[hostnameFixture, hostFixture, portFixture] = await Promise.all([
loadFixture({ projectRoot: './fixtures/config-hostname/' }),
loadFixture({ projectRoot: './fixtures/config-host/' }),
loadFixture({ projectRoot: './fixtures/config-port/' }),
]);
});
// TODO: remove test once --hostname is baselined
describe('hostname', () => {
it('can be specified in astro.config.mjs', async () => {
expect(hostnameFixture.config.devOptions.hostname).to.equal('0.0.0.0');
@ -17,19 +24,24 @@ describe('config', () => {
it('can be specified via --hostname flag', async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const proc = cli('dev', '--project-root', fileURLToPath(projectRootURL), '--hostname', '127.0.0.1');
const { network } = await cliServerLogSetup(['--project-root', fileURLToPath(projectRootURL), '--hostname', '0.0.0.0']);
let stdout = '';
const networkURL = new URL(network);
expect(isIPv4(networkURL.hostname)).to.be.equal(true, `Expected network URL to respect --hostname flag`);
});
});
for await (const chunk of proc.stdout) {
stdout += chunk;
describe('host', () => {
it('can be specified in astro.config.mjs', async () => {
expect(hostFixture.config.devOptions.host).to.equal(true);
});
if (chunk.includes('Local')) break;
}
it('can be specified via --host flag', async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const { network } = await cliServerLogSetup(['--project-root', fileURLToPath(projectRootURL), '--host']);
proc.kill();
expect(stdout).to.include('127.0.0.1');
const networkURL = new URL(network);
expect(isIPv4(networkURL.hostname)).to.be.equal(true, `Expected network URL to respect --hostname flag`);
});
});
@ -37,19 +49,10 @@ describe('config', () => {
it('can be passed via --config', async () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
const configFileURL = new URL('./fixtures/config-path/config/my-config.mjs', import.meta.url);
const proc = cli('dev', '--project-root', fileURLToPath(projectRootURL), '--config', configFileURL.pathname);
const { network } = await cliServerLogSetup(['--project-root', fileURLToPath(projectRootURL), '--config', configFileURL.pathname]);
let stdout = '';
for await (const chunk of proc.stdout) {
stdout += chunk;
if (chunk.includes('Local')) break;
}
proc.kill();
expect(stdout).to.include('127.0.0.1');
const networkURL = new URL(network);
expect(isIPv4(networkURL.hostname)).to.be.equal(true, `Expected network URL to respect --hostname flag`);
});
});

View file

@ -0,0 +1,6 @@
export default {
devOptions: {
host: true
}
}

View file

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

View file

@ -1,6 +1,6 @@
export default {
devOptions: {
hostname: '127.0.0.1',
host: true,
port: 8080,
},
}

View file

@ -143,4 +143,18 @@ export async function parseCliDevStart(proc) {
return { messages };
}
export async function cliServerLogSetup(flags = [], cmd = 'dev') {
const proc = cli(cmd, ...flags);
const { messages } = await parseCliDevStart(proc);
const localRaw = (messages[1] ?? '').includes('Local') ? messages[1] : undefined;
const networkRaw = (messages[2] ?? '').includes('Network') ? messages[2] : undefined;
const local = localRaw?.replace(/Local\s*/g, '');
const network = networkRaw?.replace(/Network\s*/g, '');
return { local, network };
}
export const isWindows = os.platform() === 'win32';

View file

@ -754,6 +754,12 @@ importers:
dependencies:
astro: link:../../..
packages/astro/test/fixtures/config-host:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/config-hostname:
specifiers:
astro: workspace:*