Graceful error recovery in the dev server (#5198)
* Graceful error recovery in the dev server Move dev-container to dev Update for the lockfile Invalidate modules in an error state Test invalidation of broken modules Remove unused error state Normalize for windows try a larger timeout Fixes build just for testing more testing Keep it posix fully posix * Fix up Windows path for testing * some debugging * use posix join * finally fixed * Remove leftover debugging * Reset the timeout * Adding a changeset
This commit is contained in:
parent
06c5d51b37
commit
c77a6cbe34
39 changed files with 1629 additions and 642 deletions
7
.changeset/thin-trains-run.md
Normal file
7
.changeset/thin-trains-run.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
HMR - Improved error recovery
|
||||
|
||||
This improves error recovery for HMR. Now when the dev server finds itself in an error state (because a route contained an error), it will recover from that state and refresh the page when the user has corrected the mistake.
|
|
@ -93,7 +93,7 @@
|
|||
"dev": "astro-scripts dev --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.ts\"",
|
||||
"postbuild": "astro-scripts copy \"src/**/*.astro\"",
|
||||
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
|
||||
"test:unit": "mocha --exit --timeout 2000 ./test/units/**/*.test.js",
|
||||
"test:unit": "mocha --exit --timeout 30000 ./test/units/**/*.test.js",
|
||||
"test": "pnpm run test:unit && mocha --exit --timeout 20000 --ignore **/lit-element.test.js && mocha --timeout 20000 **/lit-element.test.js",
|
||||
"test:match": "mocha --timeout 20000 -g",
|
||||
"test:e2e": "playwright test",
|
||||
|
@ -189,8 +189,10 @@
|
|||
"astro-scripts": "workspace:*",
|
||||
"chai": "^4.3.6",
|
||||
"cheerio": "^1.0.0-rc.11",
|
||||
"memfs": "^3.4.7",
|
||||
"mocha": "^9.2.2",
|
||||
"node-fetch": "^3.2.5",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"rehype-toc": "^3.0.2",
|
||||
|
|
49
packages/astro/src/@types/typed-emitter.ts
Normal file
49
packages/astro/src/@types/typed-emitter.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* The MIT License (MIT)
|
||||
* Copyright (c) 2018 Andy Wermke
|
||||
* https://github.com/andywer/typed-emitter/blob/9a139b6fa0ec6b0db6141b5b756b784e4f7ef4e4/LICENSE
|
||||
*/
|
||||
|
||||
export type EventMap = {
|
||||
[key: string]: (...args: any[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event emitter.
|
||||
*
|
||||
* Use it like this:
|
||||
*
|
||||
* ```typescript
|
||||
* type MyEvents = {
|
||||
* error: (error: Error) => void;
|
||||
* message: (from: string, content: string) => void;
|
||||
* }
|
||||
*
|
||||
* const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
|
||||
*
|
||||
* myEmitter.emit("error", "x") // <- Will catch this type error;
|
||||
* ```
|
||||
*/
|
||||
interface TypedEventEmitter<Events extends EventMap> {
|
||||
addListener<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
on<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
once<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
prependListener<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
prependOnceListener<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
|
||||
off<E extends keyof Events>(event: E, listener: Events[E]): this
|
||||
removeAllListeners<E extends keyof Events> (event?: E): this
|
||||
removeListener<E extends keyof Events> (event: E, listener: Events[E]): this
|
||||
|
||||
emit<E extends keyof Events> (event: E, ...args: Parameters<Events[E]>): boolean
|
||||
// The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
|
||||
eventNames (): (keyof Events | string | symbol)[]
|
||||
rawListeners<E extends keyof Events> (event: E): Events[E][]
|
||||
listeners<E extends keyof Events> (event: E): Events[E][]
|
||||
listenerCount<E extends keyof Events> (event: E): number
|
||||
|
||||
getMaxListeners (): number
|
||||
setMaxListeners (maxListeners: number): this
|
||||
}
|
||||
|
||||
export default TypedEventEmitter
|
|
@ -31,8 +31,7 @@ export const LEGACY_ASTRO_CONFIG_KEYS = new Set([
|
|||
export async function validateConfig(
|
||||
userConfig: any,
|
||||
root: string,
|
||||
cmd: string,
|
||||
logging: LogOptions
|
||||
cmd: string
|
||||
): Promise<AstroConfig> {
|
||||
const fileProtocolRoot = pathToFileURL(root + path.sep);
|
||||
// Manual deprecation checks
|
||||
|
@ -195,8 +194,7 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise<Open
|
|||
userConfig,
|
||||
root,
|
||||
flags,
|
||||
configOptions.cmd,
|
||||
configOptions.logging
|
||||
configOptions.cmd
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -302,7 +300,7 @@ export async function loadConfig(configOptions: LoadConfigOptions): Promise<Astr
|
|||
if (config) {
|
||||
userConfig = config.value;
|
||||
}
|
||||
return resolveConfig(userConfig, root, flags, configOptions.cmd, configOptions.logging);
|
||||
return resolveConfig(userConfig, root, flags, configOptions.cmd);
|
||||
}
|
||||
|
||||
/** Attempt to resolve an Astro configuration object. Normalize, validate, and return. */
|
||||
|
@ -310,15 +308,21 @@ export async function resolveConfig(
|
|||
userConfig: AstroUserConfig,
|
||||
root: string,
|
||||
flags: CLIFlags = {},
|
||||
cmd: string,
|
||||
logging: LogOptions
|
||||
cmd: string
|
||||
): Promise<AstroConfig> {
|
||||
const mergedConfig = mergeCLIFlags(userConfig, flags, cmd);
|
||||
const validatedConfig = await validateConfig(mergedConfig, root, cmd, logging);
|
||||
const validatedConfig = await validateConfig(mergedConfig, root, cmd);
|
||||
|
||||
return validatedConfig;
|
||||
}
|
||||
|
||||
export function createDefaultDevConfig(
|
||||
userConfig: AstroUserConfig = {},
|
||||
root: string = process.cwd(),
|
||||
) {
|
||||
return resolveConfig(userConfig, root, undefined, 'dev');
|
||||
}
|
||||
|
||||
function mergeConfigRecursively(
|
||||
defaults: Record<string, any>,
|
||||
overrides: Record<string, any>,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export {
|
||||
createDefaultDevConfig,
|
||||
openConfig,
|
||||
resolveConfigPath,
|
||||
resolveFlags,
|
||||
|
@ -6,5 +7,5 @@ export {
|
|||
validateConfig,
|
||||
} from './config.js';
|
||||
export type { AstroConfigSchema } from './schema';
|
||||
export { createSettings } from './settings.js';
|
||||
export { createSettings, createDefaultDevSettings } from './settings.js';
|
||||
export { loadTSConfig, updateTSConfigForFramework } from './tsconfig.js';
|
||||
|
|
|
@ -1,22 +1,43 @@
|
|||
import type { AstroConfig, AstroSettings } from '../../@types/astro';
|
||||
import type { AstroConfig, AstroSettings, AstroUserConfig } from '../../@types/astro';
|
||||
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../constants.js';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createDefaultDevConfig } from './config.js';
|
||||
import jsxRenderer from '../../jsx/renderer.js';
|
||||
import { loadTSConfig } from './tsconfig.js';
|
||||
|
||||
export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
|
||||
const tsconfig = loadTSConfig(cwd);
|
||||
|
||||
export function createBaseSettings(config: AstroConfig): AstroSettings {
|
||||
return {
|
||||
config,
|
||||
tsConfig: tsconfig?.config,
|
||||
tsConfigPath: tsconfig?.path,
|
||||
tsConfig: undefined,
|
||||
tsConfigPath: undefined,
|
||||
|
||||
adapter: undefined,
|
||||
injectedRoutes: [],
|
||||
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],
|
||||
renderers: [jsxRenderer],
|
||||
scripts: [],
|
||||
watchFiles: tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [],
|
||||
watchFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createSettings(config: AstroConfig, cwd?: string): AstroSettings {
|
||||
const tsconfig = loadTSConfig(cwd);
|
||||
const settings = createBaseSettings(config);
|
||||
settings.tsConfig = tsconfig?.config;
|
||||
settings.tsConfigPath = tsconfig?.path;
|
||||
settings.watchFiles = tsconfig?.exists ? [tsconfig.path, ...tsconfig.extendedPaths] : [];
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function createDefaultDevSettings(
|
||||
userConfig: AstroUserConfig = {},
|
||||
root?: string | URL
|
||||
): Promise<AstroSettings> {
|
||||
if(root && typeof root !== 'string') {
|
||||
root = fileURLToPath(root);
|
||||
}
|
||||
const config = await createDefaultDevConfig(userConfig, root);
|
||||
return createBaseSettings(config);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { AstroSettings } from '../@types/astro';
|
||||
import type { LogOptions } from './logger/core';
|
||||
|
||||
import nodeFs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as vite from 'vite';
|
||||
import { crawlFrameworkPkgs } from 'vitefu';
|
||||
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
|
||||
import astroViteServerPlugin from '../vite-plugin-astro-server/index.js';
|
||||
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
|
||||
import astroVitePlugin from '../vite-plugin-astro/index.js';
|
||||
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
|
||||
import envVitePlugin from '../vite-plugin-env/index.js';
|
||||
|
@ -17,12 +18,14 @@ import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
|||
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
|
||||
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
|
||||
import { createCustomViteLogger } from './errors/dev/index.js';
|
||||
import astroLoadFallbackPlugin from '../vite-plugin-load-fallback/index.js';
|
||||
import { resolveDependency } from './util.js';
|
||||
|
||||
interface CreateViteOptions {
|
||||
settings: AstroSettings;
|
||||
logging: LogOptions;
|
||||
mode: 'dev' | 'build' | string;
|
||||
fs?: typeof nodeFs;
|
||||
}
|
||||
|
||||
const ALWAYS_NOEXTERNAL = new Set([
|
||||
|
@ -54,7 +57,7 @@ function getSsrNoExternalDeps(projectRoot: URL): string[] {
|
|||
/** Return a common starting point for all Vite actions */
|
||||
export async function createVite(
|
||||
commandConfig: vite.InlineConfig,
|
||||
{ settings, logging, mode }: CreateViteOptions
|
||||
{ settings, logging, mode, fs = nodeFs }: CreateViteOptions
|
||||
): Promise<vite.InlineConfig> {
|
||||
const astroPkgsConfig = await crawlFrameworkPkgs({
|
||||
root: fileURLToPath(settings.config.root),
|
||||
|
@ -97,7 +100,7 @@ export async function createVite(
|
|||
astroScriptsPlugin({ settings }),
|
||||
// The server plugin is for dev only and having it run during the build causes
|
||||
// the build to run very slow as the filewatcher is triggered often.
|
||||
mode !== 'build' && astroViteServerPlugin({ settings, logging }),
|
||||
mode !== 'build' && vitePluginAstroServer({ settings, logging, fs }),
|
||||
envVitePlugin({ settings }),
|
||||
settings.config.legacy.astroFlavoredMarkdown
|
||||
? legacyMarkdownVitePlugin({ settings, logging })
|
||||
|
@ -107,6 +110,7 @@ export async function createVite(
|
|||
astroPostprocessVitePlugin({ settings }),
|
||||
astroIntegrationsContainerPlugin({ settings, logging }),
|
||||
astroScriptsPageSSRPlugin({ settings }),
|
||||
astroLoadFallbackPlugin({ fs })
|
||||
],
|
||||
publicDir: fileURLToPath(settings.config.publicDir),
|
||||
root: fileURLToPath(settings.config.root),
|
||||
|
|
124
packages/astro/src/core/dev/container.ts
Normal file
124
packages/astro/src/core/dev/container.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
|
||||
import type { AddressInfo } from 'net';
|
||||
import type { AstroSettings, AstroUserConfig } from '../../@types/astro';
|
||||
import * as http from 'http';
|
||||
|
||||
import {
|
||||
runHookConfigDone,
|
||||
runHookConfigSetup,
|
||||
runHookServerSetup,
|
||||
runHookServerStart,
|
||||
} from '../../integrations/index.js';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import { LogOptions } from '../logger/core.js';
|
||||
import { nodeLogDestination } from '../logger/node.js';
|
||||
import nodeFs from 'fs';
|
||||
import * as vite from 'vite';
|
||||
import { createDefaultDevSettings } from '../config/index.js';
|
||||
import { apply as applyPolyfill } from '../polyfill.js';
|
||||
|
||||
|
||||
const defaultLogging: LogOptions = {
|
||||
dest: nodeLogDestination,
|
||||
level: 'error',
|
||||
};
|
||||
|
||||
export interface Container {
|
||||
fs: typeof nodeFs;
|
||||
logging: LogOptions;
|
||||
settings: AstroSettings;
|
||||
viteConfig: vite.InlineConfig;
|
||||
viteServer: vite.ViteDevServer;
|
||||
handle: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateContainerParams {
|
||||
isRestart?: boolean;
|
||||
logging?: LogOptions;
|
||||
userConfig?: AstroUserConfig;
|
||||
settings?: AstroSettings;
|
||||
fs?: typeof nodeFs;
|
||||
root?: string | URL;
|
||||
}
|
||||
|
||||
export async function createContainer(params: CreateContainerParams = {}): Promise<Container> {
|
||||
let {
|
||||
isRestart = false,
|
||||
logging = defaultLogging,
|
||||
settings = await createDefaultDevSettings(params.userConfig, params.root),
|
||||
fs = nodeFs
|
||||
} = params;
|
||||
|
||||
// Initialize
|
||||
applyPolyfill();
|
||||
settings = await runHookConfigSetup({
|
||||
settings,
|
||||
command: 'dev',
|
||||
logging,
|
||||
isRestart,
|
||||
});
|
||||
const { host } = settings.config.server;
|
||||
|
||||
// The client entrypoint for renderers. Since these are imported dynamically
|
||||
// we need to tell Vite to preoptimize them.
|
||||
const rendererClientEntries = settings.renderers
|
||||
.map((r) => r.clientEntrypoint)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const viteConfig = await createVite(
|
||||
{
|
||||
mode: 'development',
|
||||
server: { host },
|
||||
optimizeDeps: {
|
||||
include: rendererClientEntries,
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.BASE_URL': settings.config.base
|
||||
? `'${settings.config.base}'`
|
||||
: 'undefined',
|
||||
},
|
||||
},
|
||||
{ settings, logging, mode: 'dev', fs }
|
||||
);
|
||||
await runHookConfigDone({ settings, logging });
|
||||
const viteServer = await vite.createServer(viteConfig);
|
||||
runHookServerSetup({ config: settings.config, server: viteServer, logging });
|
||||
|
||||
return {
|
||||
fs,
|
||||
logging,
|
||||
settings,
|
||||
viteConfig,
|
||||
viteServer,
|
||||
|
||||
handle(req, res) {
|
||||
viteServer.middlewares.handle(req, res, Function.prototype);
|
||||
},
|
||||
close() {
|
||||
return viteServer.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function startContainer({ settings, viteServer, logging }: Container): Promise<AddressInfo> {
|
||||
const { port } = settings.config.server;
|
||||
await viteServer.listen(port);
|
||||
const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo;
|
||||
await runHookServerStart({
|
||||
config: settings.config,
|
||||
address: devServerAddressInfo,
|
||||
logging,
|
||||
});
|
||||
|
||||
return devServerAddressInfo;
|
||||
}
|
||||
|
||||
export async function runInContainer(params: CreateContainerParams, callback: (container: Container) => Promise<void> | void) {
|
||||
const container = await createContainer(params);
|
||||
try {
|
||||
await callback(container);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
74
packages/astro/src/core/dev/dev.ts
Normal file
74
packages/astro/src/core/dev/dev.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import type { AstroTelemetry } from '@astrojs/telemetry';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { performance } from 'perf_hooks';
|
||||
import * as vite from 'vite';
|
||||
import type { AstroSettings } from '../../@types/astro';
|
||||
import { runHookServerDone } from '../../integrations/index.js';
|
||||
import { info, LogOptions, warn } from '../logger/core.js';
|
||||
import * as msg from '../messages.js';
|
||||
import { createContainer, startContainer } from './container.js';
|
||||
|
||||
export interface DevOptions {
|
||||
logging: LogOptions;
|
||||
telemetry: AstroTelemetry;
|
||||
isRestart?: boolean;
|
||||
}
|
||||
|
||||
export interface DevServer {
|
||||
address: AddressInfo;
|
||||
watcher: vite.FSWatcher;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `astro dev` */
|
||||
export default async function dev(
|
||||
settings: AstroSettings,
|
||||
options: DevOptions
|
||||
): Promise<DevServer> {
|
||||
const devStart = performance.now();
|
||||
await options.telemetry.record([]);
|
||||
|
||||
// Create a container which sets up the Vite server.
|
||||
const container = await createContainer({
|
||||
settings,
|
||||
logging: options.logging,
|
||||
isRestart: options.isRestart,
|
||||
});
|
||||
|
||||
// Start listening to the port
|
||||
const devServerAddressInfo = await startContainer(container);
|
||||
|
||||
const site = settings.config.site
|
||||
? new URL(settings.config.base, settings.config.site)
|
||||
: undefined;
|
||||
info(
|
||||
options.logging,
|
||||
null,
|
||||
msg.serverStart({
|
||||
startupTime: performance.now() - devStart,
|
||||
resolvedUrls: container.viteServer.resolvedUrls || { local: [], network: [] },
|
||||
host: settings.config.server.host,
|
||||
site,
|
||||
isRestart: options.isRestart,
|
||||
})
|
||||
);
|
||||
|
||||
const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
|
||||
if (currentVersion.includes('-')) {
|
||||
warn(options.logging, null, msg.prerelease({ currentVersion }));
|
||||
}
|
||||
if (container.viteConfig.server?.fs?.strict === false) {
|
||||
warn(options.logging, null, msg.fsStrictWarning());
|
||||
}
|
||||
|
||||
return {
|
||||
address: devServerAddressInfo,
|
||||
get watcher() {
|
||||
return container.viteServer.watcher;
|
||||
},
|
||||
stop: async () => {
|
||||
await container.close();
|
||||
await runHookServerDone({ config: settings.config, logging: options.logging });
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,113 +1,9 @@
|
|||
import type { AstroTelemetry } from '@astrojs/telemetry';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { performance } from 'perf_hooks';
|
||||
import * as vite from 'vite';
|
||||
import type { AstroSettings } from '../../@types/astro';
|
||||
import {
|
||||
runHookConfigDone,
|
||||
runHookConfigSetup,
|
||||
runHookServerDone,
|
||||
runHookServerSetup,
|
||||
runHookServerStart,
|
||||
} from '../../integrations/index.js';
|
||||
import { createVite } from '../create-vite.js';
|
||||
import { info, LogOptions, warn } from '../logger/core.js';
|
||||
import * as msg from '../messages.js';
|
||||
import { apply as applyPolyfill } from '../polyfill.js';
|
||||
export {
|
||||
createContainer,
|
||||
startContainer,
|
||||
runInContainer
|
||||
} from './container.js';
|
||||
|
||||
export interface DevOptions {
|
||||
logging: LogOptions;
|
||||
telemetry: AstroTelemetry;
|
||||
isRestart?: boolean;
|
||||
}
|
||||
|
||||
export interface DevServer {
|
||||
address: AddressInfo;
|
||||
watcher: vite.FSWatcher;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/** `astro dev` */
|
||||
export default async function dev(
|
||||
settings: AstroSettings,
|
||||
options: DevOptions
|
||||
): Promise<DevServer> {
|
||||
const devStart = performance.now();
|
||||
applyPolyfill();
|
||||
await options.telemetry.record([]);
|
||||
settings = await runHookConfigSetup({
|
||||
settings,
|
||||
command: 'dev',
|
||||
logging: options.logging,
|
||||
isRestart: options.isRestart,
|
||||
});
|
||||
const { host, port } = settings.config.server;
|
||||
const { isRestart = false } = options;
|
||||
|
||||
// The client entrypoint for renderers. Since these are imported dynamically
|
||||
// we need to tell Vite to preoptimize them.
|
||||
const rendererClientEntries = settings.renderers
|
||||
.map((r) => r.clientEntrypoint)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const viteConfig = await createVite(
|
||||
{
|
||||
mode: 'development',
|
||||
server: { host },
|
||||
optimizeDeps: {
|
||||
include: rendererClientEntries,
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.BASE_URL': settings.config.base
|
||||
? `'${settings.config.base}'`
|
||||
: 'undefined',
|
||||
},
|
||||
},
|
||||
{ settings, logging: options.logging, mode: 'dev' }
|
||||
);
|
||||
await runHookConfigDone({ settings, logging: options.logging });
|
||||
const viteServer = await vite.createServer(viteConfig);
|
||||
runHookServerSetup({ config: settings.config, server: viteServer, logging: options.logging });
|
||||
await viteServer.listen(port);
|
||||
|
||||
const site = settings.config.site
|
||||
? new URL(settings.config.base, settings.config.site)
|
||||
: undefined;
|
||||
info(
|
||||
options.logging,
|
||||
null,
|
||||
msg.serverStart({
|
||||
startupTime: performance.now() - devStart,
|
||||
resolvedUrls: viteServer.resolvedUrls || { local: [], network: [] },
|
||||
host: settings.config.server.host,
|
||||
site,
|
||||
isRestart,
|
||||
})
|
||||
);
|
||||
|
||||
const currentVersion = process.env.PACKAGE_VERSION ?? '0.0.0';
|
||||
if (currentVersion.includes('-')) {
|
||||
warn(options.logging, null, msg.prerelease({ currentVersion }));
|
||||
}
|
||||
if (viteConfig.server?.fs?.strict === false) {
|
||||
warn(options.logging, null, msg.fsStrictWarning());
|
||||
}
|
||||
|
||||
const devServerAddressInfo = viteServer.httpServer!.address() as AddressInfo;
|
||||
await runHookServerStart({
|
||||
config: settings.config,
|
||||
address: devServerAddressInfo,
|
||||
logging: options.logging,
|
||||
});
|
||||
|
||||
return {
|
||||
address: devServerAddressInfo,
|
||||
get watcher() {
|
||||
return viteServer.watcher;
|
||||
},
|
||||
stop: async () => {
|
||||
await viteServer.close();
|
||||
await runHookServerDone({ config: settings.config, logging: options.logging });
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
default
|
||||
} from './dev.js';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { ModuleLoader } from '../../module-loader/index.js';
|
||||
import * as fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
|
@ -5,7 +6,6 @@ import {
|
|||
type ErrorPayload,
|
||||
type Logger,
|
||||
type LogLevel,
|
||||
type ViteDevServer,
|
||||
} from 'vite';
|
||||
import { AstroErrorCodes } from '../codes.js';
|
||||
import { AstroError, type ErrorWithMetadata } from '../errors.js';
|
||||
|
@ -30,12 +30,12 @@ export function createCustomViteLogger(logLevel: LogLevel): Logger {
|
|||
export function enhanceViteSSRError(
|
||||
error: Error,
|
||||
filePath?: URL,
|
||||
viteServer?: ViteDevServer
|
||||
loader?: ModuleLoader,
|
||||
): AstroError {
|
||||
// Vite will give you better stacktraces, using sourcemaps.
|
||||
if (viteServer) {
|
||||
if (loader) {
|
||||
try {
|
||||
viteServer.ssrFixStacktrace(error);
|
||||
loader.fixStacktrace(error);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
14
packages/astro/src/core/module-loader/index.ts
Normal file
14
packages/astro/src/core/module-loader/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export type {
|
||||
ModuleInfo,
|
||||
ModuleLoader,
|
||||
ModuleNode,
|
||||
LoaderEvents
|
||||
} from './loader.js';
|
||||
|
||||
export {
|
||||
createLoader
|
||||
} from './loader.js';
|
||||
|
||||
export {
|
||||
createViteLoader
|
||||
} from './vite.js';
|
71
packages/astro/src/core/module-loader/loader.ts
Normal file
71
packages/astro/src/core/module-loader/loader.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import type TypedEmitter from '../../@types/typed-emitter';
|
||||
import type * as fs from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// This is a generic interface for a module loader. In the astro cli this is
|
||||
// fulfilled by Vite, see vite.ts
|
||||
|
||||
export type LoaderEvents = {
|
||||
'file-add': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
|
||||
'file-change': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
|
||||
'file-unlink': (msg: [path: string, stats?: fs.Stats | undefined]) => void;
|
||||
'hmr-error': (msg: {
|
||||
type: 'error',
|
||||
err: {
|
||||
message: string;
|
||||
stack: string
|
||||
};
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type ModuleLoaderEventEmitter = TypedEmitter<LoaderEvents>;
|
||||
|
||||
export interface ModuleLoader {
|
||||
import: (src: string) => Promise<Record<string, any>>;
|
||||
resolveId: (specifier: string, parentId: string | undefined) => Promise<string | undefined>;
|
||||
getModuleById: (id: string) => ModuleNode | undefined;
|
||||
getModulesByFile: (file: string) => Set<ModuleNode> | undefined;
|
||||
getModuleInfo: (id: string) => ModuleInfo | null;
|
||||
|
||||
eachModule(callbackfn: (value: ModuleNode, key: string) => void): void;
|
||||
invalidateModule(mod: ModuleNode): void;
|
||||
|
||||
fixStacktrace: (error: Error) => void;
|
||||
|
||||
clientReload: () => void;
|
||||
webSocketSend: (msg: any) => void;
|
||||
isHttps: () => boolean;
|
||||
events: TypedEmitter<LoaderEvents>;
|
||||
}
|
||||
|
||||
export interface ModuleNode {
|
||||
id: string | null;
|
||||
url: string;
|
||||
ssrModule: Record<string, any> | null;
|
||||
ssrError: Error | null;
|
||||
importedModules: Set<ModuleNode>;
|
||||
}
|
||||
|
||||
export interface ModuleInfo {
|
||||
id: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function createLoader(overrides: Partial<ModuleLoader>): ModuleLoader {
|
||||
return {
|
||||
import() { throw new Error(`Not implemented`); },
|
||||
resolveId(id) { return Promise.resolve(id); },
|
||||
getModuleById() {return undefined },
|
||||
getModulesByFile() { return undefined },
|
||||
getModuleInfo() { return null; },
|
||||
eachModule() { throw new Error(`Not implemented`); },
|
||||
invalidateModule() {},
|
||||
fixStacktrace() {},
|
||||
clientReload() {},
|
||||
webSocketSend() {},
|
||||
isHttps() { return true; },
|
||||
events: new EventEmitter() as ModuleLoaderEventEmitter,
|
||||
|
||||
...overrides
|
||||
};
|
||||
}
|
67
packages/astro/src/core/module-loader/vite.ts
Normal file
67
packages/astro/src/core/module-loader/vite.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import type * as vite from 'vite';
|
||||
import type { ModuleLoader, ModuleLoaderEventEmitter } from './loader';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export function createViteLoader(viteServer: vite.ViteDevServer): ModuleLoader {
|
||||
const events = new EventEmitter() as ModuleLoaderEventEmitter;
|
||||
|
||||
viteServer.watcher.on('add', (...args) => events.emit('file-add', args));
|
||||
viteServer.watcher.on('unlink', (...args) => events.emit('file-unlink', args));
|
||||
viteServer.watcher.on('change', (...args) => events.emit('file-change', args));
|
||||
|
||||
wrapMethod(viteServer.ws, 'send', msg => {
|
||||
if(msg?.type === 'error') {
|
||||
events.emit('hmr-error', msg);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
import(src) {
|
||||
return viteServer.ssrLoadModule(src);
|
||||
},
|
||||
async resolveId(spec, parent) {
|
||||
const ret = await viteServer.pluginContainer.resolveId(spec, parent);
|
||||
return ret?.id;
|
||||
},
|
||||
getModuleById(id) {
|
||||
return viteServer.moduleGraph.getModuleById(id);
|
||||
},
|
||||
getModulesByFile(file) {
|
||||
return viteServer.moduleGraph.getModulesByFile(file);
|
||||
},
|
||||
getModuleInfo(id) {
|
||||
return viteServer.pluginContainer.getModuleInfo(id);
|
||||
},
|
||||
eachModule(cb) {
|
||||
return viteServer.moduleGraph.idToModuleMap.forEach(cb);
|
||||
},
|
||||
invalidateModule(mod) {
|
||||
viteServer.moduleGraph.invalidateModule(mod as vite.ModuleNode);
|
||||
},
|
||||
fixStacktrace(err) {
|
||||
return viteServer.ssrFixStacktrace(err);
|
||||
},
|
||||
clientReload() {
|
||||
viteServer.ws.send({
|
||||
type: 'full-reload',
|
||||
path: '*'
|
||||
});
|
||||
},
|
||||
webSocketSend(msg) {
|
||||
return viteServer.ws.send(msg);
|
||||
},
|
||||
isHttps() {
|
||||
return !!viteServer.config.server.https;
|
||||
},
|
||||
events
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function wrapMethod(object: any, method: string, newFn: (...args: any[]) => void) {
|
||||
const orig = object[method];
|
||||
object[method] = function(...args: any[]) {
|
||||
newFn.apply(this, args);
|
||||
return orig.apply(this, args);
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type * as vite from 'vite';
|
||||
import type { ModuleLoader } from '../../module-loader/index';
|
||||
|
||||
import path from 'path';
|
||||
import { RuntimeMode } from '../../../@types/astro.js';
|
||||
|
@ -9,18 +9,18 @@ import { crawlGraph } from './vite.js';
|
|||
/** Given a filePath URL, crawl Vite’s module graph to find all style imports. */
|
||||
export async function getStylesForURL(
|
||||
filePath: URL,
|
||||
viteServer: vite.ViteDevServer,
|
||||
loader: ModuleLoader,
|
||||
mode: RuntimeMode
|
||||
): Promise<{ urls: Set<string>; stylesMap: Map<string, string> }> {
|
||||
const importedCssUrls = new Set<string>();
|
||||
const importedStylesMap = new Map<string, string>();
|
||||
|
||||
for await (const importedModule of crawlGraph(viteServer, viteID(filePath), true)) {
|
||||
for await (const importedModule of crawlGraph(loader, viteID(filePath), true)) {
|
||||
const ext = path.extname(importedModule.url).toLowerCase();
|
||||
if (STYLE_EXTENSIONS.has(ext)) {
|
||||
// The SSR module is possibly not loaded. Load it if it's null.
|
||||
const ssrModule =
|
||||
importedModule.ssrModule ?? (await viteServer.ssrLoadModule(importedModule.url));
|
||||
importedModule.ssrModule ?? (await loader.import(importedModule.url));
|
||||
if (
|
||||
mode === 'development' && // only inline in development
|
||||
typeof ssrModule?.default === 'string' // ignore JS module styles
|
||||
|
|
|
@ -2,19 +2,21 @@ import type { ViteDevServer } from 'vite';
|
|||
import type { AstroSettings, RuntimeMode } from '../../../@types/astro';
|
||||
import type { LogOptions } from '../../logger/core.js';
|
||||
import type { Environment } from '../index';
|
||||
import type { ModuleLoader } from '../../module-loader/index';
|
||||
|
||||
import { createEnvironment } from '../index.js';
|
||||
import { RouteCache } from '../route-cache.js';
|
||||
import { createResolve } from './resolve.js';
|
||||
|
||||
export type DevelopmentEnvironment = Environment & {
|
||||
loader: ModuleLoader;
|
||||
settings: AstroSettings;
|
||||
viteServer: ViteDevServer;
|
||||
};
|
||||
|
||||
export function createDevelopmentEnvironment(
|
||||
settings: AstroSettings,
|
||||
logging: LogOptions,
|
||||
viteServer: ViteDevServer
|
||||
loader: ModuleLoader
|
||||
): DevelopmentEnvironment {
|
||||
const mode: RuntimeMode = 'development';
|
||||
let env = createEnvironment({
|
||||
|
@ -27,7 +29,7 @@ export function createDevelopmentEnvironment(
|
|||
mode,
|
||||
// This will be overridden in the dev server
|
||||
renderers: [],
|
||||
resolve: createResolve(viteServer),
|
||||
resolve: createResolve(loader),
|
||||
routeCache: new RouteCache(logging, mode),
|
||||
site: settings.config.site,
|
||||
ssr: settings.config.output === 'server',
|
||||
|
@ -36,7 +38,7 @@ export function createDevelopmentEnvironment(
|
|||
|
||||
return {
|
||||
...env,
|
||||
viteServer,
|
||||
loader,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { fileURLToPath } from 'url';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
import type {
|
||||
AstroSettings,
|
||||
ComponentInstance,
|
||||
|
@ -8,6 +7,7 @@ import type {
|
|||
SSRElement,
|
||||
SSRLoadedRenderer,
|
||||
} from '../../../@types/astro';
|
||||
import type { ModuleLoader } from '../../module-loader/index';
|
||||
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
|
||||
import { enhanceViteSSRError } from '../../errors/dev/index.js';
|
||||
import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js';
|
||||
|
@ -39,26 +39,12 @@ export interface SSROptionsOld {
|
|||
route?: RouteData;
|
||||
/** pass in route cache because SSR can’t manage cache-busting */
|
||||
routeCache: RouteCache;
|
||||
/** Vite instance */
|
||||
viteServer: ViteDevServer;
|
||||
/** Module loader (Vite) */
|
||||
loader: ModuleLoader;
|
||||
/** Request */
|
||||
request: Request;
|
||||
}
|
||||
|
||||
/*
|
||||
filePath: options.filePath
|
||||
});
|
||||
|
||||
const ctx = createRenderContext({
|
||||
request: options.request,
|
||||
origin: options.origin,
|
||||
pathname: options.pathname,
|
||||
scripts,
|
||||
links,
|
||||
styles,
|
||||
route: options.route
|
||||
*/
|
||||
|
||||
export interface SSROptions {
|
||||
/** The environment instance */
|
||||
env: DevelopmentEnvironment;
|
||||
|
@ -79,10 +65,10 @@ export interface SSROptions {
|
|||
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];
|
||||
|
||||
export async function loadRenderers(
|
||||
viteServer: ViteDevServer,
|
||||
moduleLoader: ModuleLoader,
|
||||
settings: AstroSettings
|
||||
): Promise<SSRLoadedRenderer[]> {
|
||||
const loader = (entry: string) => viteServer.ssrLoadModule(entry);
|
||||
const loader = (entry: string) => moduleLoader.import(entry);
|
||||
const renderers = await Promise.all(settings.renderers.map((r) => loadRenderer(r, loader)));
|
||||
return filterFoundRenderers(renderers);
|
||||
}
|
||||
|
@ -92,11 +78,11 @@ export async function preload({
|
|||
filePath,
|
||||
}: Pick<SSROptions, 'env' | 'filePath'>): Promise<ComponentPreload> {
|
||||
// Important: This needs to happen first, in case a renderer provides polyfills.
|
||||
const renderers = await loadRenderers(env.viteServer, env.settings);
|
||||
const renderers = await loadRenderers(env.loader, env.settings);
|
||||
|
||||
try {
|
||||
// Load the module from the Vite SSR Runtime.
|
||||
const mod = (await env.viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
|
||||
const mod = (await env.loader.import(fileURLToPath(filePath))) as ComponentInstance;
|
||||
return [renderers, mod];
|
||||
} catch (err) {
|
||||
// If the error came from Markdown or CSS, we already handled it and there's no need to enhance it
|
||||
|
@ -104,7 +90,7 @@ export async function preload({
|
|||
throw err;
|
||||
}
|
||||
|
||||
throw enhanceViteSSRError(err as Error, filePath, env.viteServer);
|
||||
throw enhanceViteSSRError(err as Error, filePath, env.loader);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,7 +101,7 @@ interface GetScriptsAndStylesParams {
|
|||
|
||||
async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) {
|
||||
// Add hoisted script tags
|
||||
const scripts = await getScriptsForURL(filePath, env.viteServer);
|
||||
const scripts = await getScriptsForURL(filePath, env.loader);
|
||||
|
||||
// Inject HMR scripts
|
||||
if (isPage(filePath, env.settings) && env.mode === 'development') {
|
||||
|
@ -126,7 +112,7 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)
|
|||
scripts.add({
|
||||
props: {
|
||||
type: 'module',
|
||||
src: await resolveIdToUrl(env.viteServer, 'astro/runtime/client/hmr.js'),
|
||||
src: await resolveIdToUrl(env.loader, 'astro/runtime/client/hmr.js'),
|
||||
},
|
||||
children: '',
|
||||
});
|
||||
|
@ -148,7 +134,7 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)
|
|||
}
|
||||
|
||||
// Pass framework CSS in as style tags to be appended to the page.
|
||||
const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, env.viteServer, env.mode);
|
||||
const { urls: styleUrls, stylesMap } = await getStylesForURL(filePath, env.loader, env.mode);
|
||||
let links = new Set<SSRElement>();
|
||||
[...styleUrls].forEach((href) => {
|
||||
links.add({
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { ViteDevServer } from 'vite';
|
||||
import type { ModuleLoader } from '../../module-loader/index';
|
||||
import { resolveIdToUrl } from '../../util.js';
|
||||
|
||||
export function createResolve(viteServer: ViteDevServer) {
|
||||
export function createResolve(loader: ModuleLoader) {
|
||||
// Resolves specifiers in the inline hydrated scripts, such as:
|
||||
// - @astrojs/preact/client.js
|
||||
// - @/components/Foo.vue
|
||||
// - /Users/macos/project/src/Foo.vue
|
||||
// - C:/Windows/project/src/Foo.vue (normalized slash)
|
||||
return async function (s: string) {
|
||||
const url = await resolveIdToUrl(viteServer, s);
|
||||
const url = await resolveIdToUrl(loader, s);
|
||||
// Vite does not resolve .jsx -> .tsx when coming from hydration script import,
|
||||
// clip it so Vite is able to resolve implicitly.
|
||||
if (url.startsWith('/@fs') && url.endsWith('.jsx')) {
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import type { ModuleInfo } from 'rollup';
|
||||
import vite from 'vite';
|
||||
import type { SSRElement } from '../../../@types/astro';
|
||||
import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types';
|
||||
import type { ModuleInfo, ModuleLoader } from '../../module-loader/index';
|
||||
|
||||
import { viteID } from '../../util.js';
|
||||
import { createModuleScriptElementWithSrc } from '../ssr-element.js';
|
||||
import { crawlGraph } from './vite.js';
|
||||
|
||||
export async function getScriptsForURL(
|
||||
filePath: URL,
|
||||
viteServer: vite.ViteDevServer
|
||||
loader: ModuleLoader
|
||||
): Promise<Set<SSRElement>> {
|
||||
const elements = new Set<SSRElement>();
|
||||
const rootID = viteID(filePath);
|
||||
const modInfo = viteServer.pluginContainer.getModuleInfo(rootID);
|
||||
const modInfo = loader.getModuleInfo(rootID);
|
||||
addHoistedScripts(elements, modInfo);
|
||||
for await (const moduleNode of crawlGraph(viteServer, rootID, true)) {
|
||||
for await (const moduleNode of crawlGraph(loader, rootID, true)) {
|
||||
const id = moduleNode.id;
|
||||
if (id) {
|
||||
const info = viteServer.pluginContainer.getModuleInfo(id);
|
||||
const info = loader.getModuleInfo(id);
|
||||
addHoistedScripts(elements, info);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { ModuleLoader, ModuleNode } from '../../module-loader/index';
|
||||
|
||||
import npath from 'path';
|
||||
import vite from 'vite';
|
||||
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
|
||||
import { unwrapId } from '../../util.js';
|
||||
import { STYLE_EXTENSIONS } from '../util.js';
|
||||
|
@ -14,21 +15,21 @@ const STRIP_QUERY_PARAMS_REGEX = /\?.*$/;
|
|||
|
||||
/** recursively crawl the module graph to get all style files imported by parent id */
|
||||
export async function* crawlGraph(
|
||||
viteServer: vite.ViteDevServer,
|
||||
loader: ModuleLoader,
|
||||
_id: string,
|
||||
isRootFile: boolean,
|
||||
scanned = new Set<string>()
|
||||
): AsyncGenerator<vite.ModuleNode, void, unknown> {
|
||||
): AsyncGenerator<ModuleNode, void, unknown> {
|
||||
const id = unwrapId(_id);
|
||||
const importedModules = new Set<vite.ModuleNode>();
|
||||
const importedModules = new Set<ModuleNode>();
|
||||
const moduleEntriesForId = isRootFile
|
||||
? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail),
|
||||
// So we can get up-to-date info on initial server load.
|
||||
// Needed for slower CSS preprocessing like Tailwind
|
||||
viteServer.moduleGraph.getModulesByFile(id) ?? new Set()
|
||||
loader.getModulesByFile(id) ?? new Set()
|
||||
: // For non-root files, we're safe to pull from "getModuleById" based on testing.
|
||||
// TODO: Find better invalidation strat to use "getModuleById" in all cases!
|
||||
new Set([viteServer.moduleGraph.getModuleById(id)]);
|
||||
new Set([loader.getModuleById(id)]);
|
||||
|
||||
// Collect all imported modules for the module(s).
|
||||
for (const entry of moduleEntriesForId) {
|
||||
|
@ -57,10 +58,10 @@ export async function* crawlGraph(
|
|||
continue;
|
||||
}
|
||||
if (fileExtensionsToSSR.has(npath.extname(importedModulePathname))) {
|
||||
const mod = viteServer.moduleGraph.getModuleById(importedModule.id);
|
||||
const mod = loader.getModuleById(importedModule.id);
|
||||
if (!mod?.ssrModule) {
|
||||
try {
|
||||
await viteServer.ssrLoadModule(importedModule.id);
|
||||
await loader.import(importedModule.id);
|
||||
} catch {
|
||||
/** Likely an out-of-date module entry! Silently continue. */
|
||||
}
|
||||
|
@ -80,6 +81,6 @@ export async function* crawlGraph(
|
|||
}
|
||||
|
||||
yield importedModule;
|
||||
yield* crawlGraph(viteServer, importedModule.id, false, scanned);
|
||||
yield* crawlGraph(loader, importedModule.id, false, scanned);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import type {
|
|||
} from '../../../@types/astro';
|
||||
import type { LogOptions } from '../../logger/core';
|
||||
|
||||
import fs from 'fs';
|
||||
import nodeFs from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
import slash from 'slash';
|
||||
|
@ -200,9 +200,18 @@ function injectedRouteToItem(
|
|||
};
|
||||
}
|
||||
|
||||
export interface CreateRouteManifestParams {
|
||||
/** Astro Settings object */
|
||||
settings: AstroSettings;
|
||||
/** Current working directory */
|
||||
cwd?: string;
|
||||
/** fs module, for testing */
|
||||
fsMod?: typeof nodeFs;
|
||||
}
|
||||
|
||||
/** Create manifest of all static routes */
|
||||
export function createRouteManifest(
|
||||
{ settings, cwd }: { settings: AstroSettings; cwd?: string },
|
||||
{ settings, cwd, fsMod }: CreateRouteManifestParams,
|
||||
logging: LogOptions
|
||||
): ManifestData {
|
||||
const components: string[] = [];
|
||||
|
@ -213,8 +222,9 @@ export function createRouteManifest(
|
|||
...settings.pageExtensions,
|
||||
]);
|
||||
const validEndpointExtensions: Set<string> = new Set(['.js', '.ts']);
|
||||
const localFs = fsMod ?? nodeFs;
|
||||
|
||||
function walk(dir: string, parentSegments: RoutePart[][], parentParams: string[]) {
|
||||
function walk(fs: typeof nodeFs, dir: string, parentSegments: RoutePart[][], parentParams: string[]) {
|
||||
let items: Item[] = [];
|
||||
fs.readdirSync(dir).forEach((basename) => {
|
||||
const resolved = path.join(dir, basename);
|
||||
|
@ -291,7 +301,7 @@ export function createRouteManifest(
|
|||
params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content));
|
||||
|
||||
if (item.isDir) {
|
||||
walk(path.join(dir, item.basename), segments, params);
|
||||
walk(fsMod ?? fs, path.join(dir, item.basename), segments, params);
|
||||
} else {
|
||||
components.push(item.file);
|
||||
const component = item.file;
|
||||
|
@ -322,8 +332,8 @@ export function createRouteManifest(
|
|||
const { config } = settings;
|
||||
const pages = resolvePages(config);
|
||||
|
||||
if (fs.existsSync(pages)) {
|
||||
walk(fileURLToPath(pages), [], []);
|
||||
if (localFs.existsSync(pages)) {
|
||||
walk(localFs, fileURLToPath(pages), [], []);
|
||||
} else if (settings.injectedRoutes.length === 0) {
|
||||
const pagesDirRootRelative = pages.href.slice(settings.config.root.href.length);
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import type { ModuleLoader } from './module-loader';
|
||||
import eol from 'eol';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import resolve from 'resolve';
|
||||
import slash from 'slash';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { normalizePath, ViteDevServer } from 'vite';
|
||||
import { normalizePath } from 'vite';
|
||||
import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro';
|
||||
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js';
|
||||
import { prependForwardSlash, removeTrailingForwardSlash } from './path.js';
|
||||
|
@ -180,19 +182,19 @@ export function getLocalAddress(serverAddress: string, host: string | boolean):
|
|||
*/
|
||||
// NOTE: `/@id/` should only be used when the id is fully resolved
|
||||
// TODO: Export a helper util from Vite
|
||||
export async function resolveIdToUrl(viteServer: ViteDevServer, id: string) {
|
||||
let result = await viteServer.pluginContainer.resolveId(id, undefined);
|
||||
export async function resolveIdToUrl(loader: ModuleLoader, id: string) {
|
||||
let resultId = await loader.resolveId(id, undefined);
|
||||
// Try resolve jsx to tsx
|
||||
if (!result && id.endsWith('.jsx')) {
|
||||
result = await viteServer.pluginContainer.resolveId(id.slice(0, -4), undefined);
|
||||
if (!resultId && id.endsWith('.jsx')) {
|
||||
resultId = await loader.resolveId(id.slice(0, -4), undefined);
|
||||
}
|
||||
if (!result) {
|
||||
if (!resultId) {
|
||||
return VALID_ID_PREFIX + id;
|
||||
}
|
||||
if (path.isAbsolute(result.id)) {
|
||||
return '/@fs' + prependForwardSlash(result.id);
|
||||
if (path.isAbsolute(resultId)) {
|
||||
return '/@fs' + prependForwardSlash(resultId);
|
||||
}
|
||||
return VALID_ID_PREFIX + result.id;
|
||||
return VALID_ID_PREFIX + resultId;
|
||||
}
|
||||
|
||||
export function resolveJsToTs(filePath: string) {
|
||||
|
|
46
packages/astro/src/vite-plugin-astro-server/base.ts
Normal file
46
packages/astro/src/vite-plugin-astro-server/base.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import type * as vite from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro';
|
||||
|
||||
import { LogOptions } from '../core/logger/core.js';
|
||||
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
|
||||
import { log404 } from './common.js';
|
||||
import { writeHtmlResponse } from './response.js';
|
||||
|
||||
export function baseMiddleware(
|
||||
settings: AstroSettings,
|
||||
logging: LogOptions
|
||||
): vite.Connect.NextHandleFunction {
|
||||
const { config } = settings;
|
||||
const site = config.site ? new URL(config.base, config.site) : undefined;
|
||||
const devRoot = site ? site.pathname : '/';
|
||||
|
||||
return function devBaseMiddleware(req, res, next) {
|
||||
const url = req.url!;
|
||||
|
||||
const pathname = decodeURI(new URL(url, 'http://vitejs.dev').pathname);
|
||||
|
||||
if (pathname.startsWith(devRoot)) {
|
||||
req.url = url.replace(devRoot, '/');
|
||||
return next();
|
||||
}
|
||||
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
log404(logging, pathname);
|
||||
const html = subpathNotUsedTemplate(devRoot, pathname);
|
||||
return writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
if (req.headers.accept?.includes('text/html')) {
|
||||
log404(logging, pathname);
|
||||
const html = notFoundTemplate({
|
||||
statusCode: 404,
|
||||
title: 'Not found',
|
||||
tabTitle: '404: Not Found',
|
||||
pathname,
|
||||
});
|
||||
return writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
6
packages/astro/src/vite-plugin-astro-server/common.ts
Normal file
6
packages/astro/src/vite-plugin-astro-server/common.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { info, LogOptions } from '../core/logger/core.js';
|
||||
import * as msg from '../core/messages.js';
|
||||
|
||||
export function log404(logging: LogOptions, pathname: string) {
|
||||
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 }));
|
||||
}
|
100
packages/astro/src/vite-plugin-astro-server/controller.ts
Normal file
100
packages/astro/src/vite-plugin-astro-server/controller.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import type { ServerState } from './server-state';
|
||||
import type { LoaderEvents, ModuleLoader } from '../core/module-loader/index';
|
||||
|
||||
import { createServerState, setRouteError, setServerError, clearRouteError } from './server-state.js';
|
||||
|
||||
type ReloadFn = () => void;
|
||||
|
||||
export interface DevServerController {
|
||||
state: ServerState;
|
||||
onFileChange: LoaderEvents['file-change'];
|
||||
onHMRError: LoaderEvents['hmr-error'];
|
||||
}
|
||||
|
||||
export type CreateControllerParams = {
|
||||
loader: ModuleLoader;
|
||||
} | {
|
||||
reload: ReloadFn;
|
||||
};
|
||||
|
||||
export function createController(params: CreateControllerParams): DevServerController {
|
||||
if('loader' in params) {
|
||||
return createLoaderController(params.loader);
|
||||
} else {
|
||||
return createBaseController(params);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBaseController({ reload }: { reload: ReloadFn }): DevServerController {
|
||||
const serverState = createServerState();
|
||||
|
||||
const onFileChange: LoaderEvents['file-change'] = () => {
|
||||
if(serverState.state === 'error') {
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
const onHMRError: LoaderEvents['hmr-error'] = (payload) => {
|
||||
let msg = payload?.err?.message ?? 'Unknown error';
|
||||
let stack = payload?.err?.stack ?? 'Unknown stack';
|
||||
let error = new Error(msg);
|
||||
Object.defineProperty(error, 'stack', {
|
||||
value: stack
|
||||
});
|
||||
setServerError(serverState, error);
|
||||
};
|
||||
|
||||
return {
|
||||
state: serverState,
|
||||
onFileChange,
|
||||
onHMRError
|
||||
};
|
||||
}
|
||||
|
||||
export function createLoaderController(loader: ModuleLoader): DevServerController {
|
||||
const controller = createBaseController({
|
||||
reload() {
|
||||
loader.clientReload();
|
||||
}
|
||||
});
|
||||
const baseOnFileChange = controller.onFileChange;
|
||||
controller.onFileChange = (...args) => {
|
||||
if(controller.state.state === 'error') {
|
||||
// If we are in an error state, check if there are any modules with errors
|
||||
// and if so invalidate them so that they will be updated on refresh.
|
||||
loader.eachModule(mod => {
|
||||
if(mod.ssrError) {
|
||||
loader.invalidateModule(mod);
|
||||
}
|
||||
});
|
||||
}
|
||||
baseOnFileChange(...args);
|
||||
}
|
||||
|
||||
loader.events.on('file-change', controller.onFileChange);
|
||||
loader.events.on('hmr-error', controller.onHMRError);
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
export interface RunWithErrorHandlingParams {
|
||||
controller: DevServerController;
|
||||
pathname: string;
|
||||
run: () => Promise<any>;
|
||||
onError: (error: unknown) => Error;
|
||||
}
|
||||
|
||||
export async function runWithErrorHandling({
|
||||
controller: { state },
|
||||
pathname,
|
||||
run,
|
||||
onError
|
||||
}: RunWithErrorHandlingParams) {
|
||||
try {
|
||||
await run();
|
||||
clearRouteError(state, pathname);
|
||||
} catch(err) {
|
||||
const error = onError(err);
|
||||
setRouteError(state, pathname, error);
|
||||
}
|
||||
}
|
|
@ -1,439 +1,10 @@
|
|||
import type http from 'http';
|
||||
import mime from 'mime';
|
||||
import type * as vite from 'vite';
|
||||
import type { AstroSettings, ManifestData } from '../@types/astro';
|
||||
import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
import { attachToResponse, getSetCookiesFromResponse } from '../core/cookies/index.js';
|
||||
import { call as callEndpoint } from '../core/endpoint/dev/index.js';
|
||||
import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js';
|
||||
import { collectErrorMetadata, getViteErrorPayload } from '../core/errors/dev/index.js';
|
||||
import type { ErrorWithMetadata } from '../core/errors/index.js';
|
||||
import { createSafeError } from '../core/errors/index.js';
|
||||
import { error, info, LogOptions, warn } from '../core/logger/core.js';
|
||||
import * as msg from '../core/messages.js';
|
||||
import { appendForwardSlash } from '../core/path.js';
|
||||
import { createDevelopmentEnvironment, preload, renderPage } from '../core/render/dev/index.js';
|
||||
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
|
||||
import { createRequest } from '../core/request.js';
|
||||
import { createRouteManifest, matchAllRoutes } from '../core/routing/index.js';
|
||||
import { resolvePages } from '../core/util.js';
|
||||
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
|
||||
|
||||
interface AstroPluginOptions {
|
||||
settings: AstroSettings;
|
||||
logging: LogOptions;
|
||||
}
|
||||
|
||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
|
||||
...args: any
|
||||
) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
|
||||
function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(html, 'utf-8'),
|
||||
});
|
||||
res.write(html);
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function writeWebResponse(res: http.ServerResponse, webResponse: Response) {
|
||||
const { status, headers, body } = webResponse;
|
||||
|
||||
let _headers = {};
|
||||
if ('raw' in headers) {
|
||||
// Node fetch allows you to get the raw headers, which includes multiples of the same type.
|
||||
// This is needed because Set-Cookie *must* be called for each cookie, and can't be
|
||||
// concatenated together.
|
||||
type HeadersWithRaw = Headers & {
|
||||
raw: () => Record<string, string[]>;
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries((headers as HeadersWithRaw).raw())) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
} else {
|
||||
_headers = Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
// Attach any set-cookie headers added via Astro.cookies.set()
|
||||
const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse));
|
||||
if (setCookieHeaders.length) {
|
||||
res.setHeader('Set-Cookie', setCookieHeaders);
|
||||
}
|
||||
res.writeHead(status, _headers);
|
||||
if (body) {
|
||||
if (Symbol.for('astro.responseBody') in webResponse) {
|
||||
let stream = (webResponse as any)[Symbol.for('astro.responseBody')];
|
||||
for await (const chunk of stream) {
|
||||
res.write(chunk.toString());
|
||||
}
|
||||
} else if (body instanceof Readable) {
|
||||
body.pipe(res);
|
||||
return;
|
||||
} else if (typeof body === 'string') {
|
||||
res.write(body);
|
||||
} else {
|
||||
const reader = body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
res.write(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function writeSSRResult(webResponse: Response, res: http.ServerResponse) {
|
||||
return writeWebResponse(res, webResponse);
|
||||
}
|
||||
|
||||
async function handle404Response(
|
||||
origin: string,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
) {
|
||||
const pathname = decodeURI(new URL(origin + req.url).pathname);
|
||||
|
||||
const html = notFoundTemplate({
|
||||
statusCode: 404,
|
||||
title: 'Not found',
|
||||
tabTitle: '404: Not Found',
|
||||
pathname,
|
||||
});
|
||||
writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
async function handle500Response(
|
||||
viteServer: vite.ViteDevServer,
|
||||
origin: string,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
err: ErrorWithMetadata
|
||||
) {
|
||||
res.on('close', () => setTimeout(() => viteServer.ws.send(getViteErrorPayload(err)), 200));
|
||||
if (res.headersSent) {
|
||||
res.write(`<script type="module" src="/@vite/client"></script>`);
|
||||
res.end();
|
||||
} else {
|
||||
writeHtmlResponse(
|
||||
res,
|
||||
500,
|
||||
`<title>${err.name}</title><script type="module" src="/@vite/client"></script>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCustom404Route({ config }: AstroSettings, manifest: ManifestData) {
|
||||
// For Windows compat, use relative page paths to match the 404 route
|
||||
const relPages = resolvePages(config).href.replace(config.root.href, '');
|
||||
const pattern = new RegExp(`${appendForwardSlash(relPages)}404.(astro|md)`);
|
||||
return manifest.routes.find((r) => r.component.match(pattern));
|
||||
}
|
||||
|
||||
function log404(logging: LogOptions, pathname: string) {
|
||||
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 }));
|
||||
}
|
||||
|
||||
export function baseMiddleware(
|
||||
settings: AstroSettings,
|
||||
logging: LogOptions
|
||||
): vite.Connect.NextHandleFunction {
|
||||
const { config } = settings;
|
||||
const site = config.site ? new URL(config.base, config.site) : undefined;
|
||||
const devRoot = site ? site.pathname : '/';
|
||||
|
||||
return function devBaseMiddleware(req, res, next) {
|
||||
const url = req.url!;
|
||||
|
||||
const pathname = decodeURI(new URL(url, 'http://vitejs.dev').pathname);
|
||||
|
||||
if (pathname.startsWith(devRoot)) {
|
||||
req.url = url.replace(devRoot, '/');
|
||||
return next();
|
||||
}
|
||||
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
log404(logging, pathname);
|
||||
const html = subpathNotUsedTemplate(devRoot, pathname);
|
||||
return writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
if (req.headers.accept?.includes('text/html')) {
|
||||
log404(logging, pathname);
|
||||
const html = notFoundTemplate({
|
||||
statusCode: 404,
|
||||
title: 'Not found',
|
||||
tabTitle: '404: Not Found',
|
||||
pathname,
|
||||
});
|
||||
return writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
async function matchRoute(pathname: string, env: DevelopmentEnvironment, manifest: ManifestData) {
|
||||
const { logging, settings, routeCache } = env;
|
||||
const matches = matchAllRoutes(pathname, manifest);
|
||||
|
||||
for await (const maybeRoute of matches) {
|
||||
const filePath = new URL(`./${maybeRoute.component}`, settings.config.root);
|
||||
const preloadedComponent = await preload({ env, filePath });
|
||||
const [, mod] = preloadedComponent;
|
||||
// attempt to get static paths
|
||||
// if this fails, we have a bad URL match!
|
||||
const paramsAndPropsRes = await getParamsAndProps({
|
||||
mod,
|
||||
route: maybeRoute,
|
||||
routeCache,
|
||||
pathname: pathname,
|
||||
logging,
|
||||
ssr: settings.config.output === 'server',
|
||||
});
|
||||
|
||||
if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) {
|
||||
return {
|
||||
route: maybeRoute,
|
||||
filePath,
|
||||
preloadedComponent,
|
||||
mod,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length) {
|
||||
warn(
|
||||
logging,
|
||||
'getStaticPaths',
|
||||
`Route pattern matched, but no matching static path found. (${pathname})`
|
||||
);
|
||||
}
|
||||
|
||||
log404(logging, pathname);
|
||||
const custom404 = getCustom404Route(settings, manifest);
|
||||
|
||||
if (custom404) {
|
||||
const filePath = new URL(`./${custom404.component}`, settings.config.root);
|
||||
const preloadedComponent = await preload({ env, filePath });
|
||||
const [, mod] = preloadedComponent;
|
||||
|
||||
return {
|
||||
route: custom404,
|
||||
filePath,
|
||||
preloadedComponent,
|
||||
mod,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The main logic to route dev server requests to pages in Astro. */
|
||||
async function handleRequest(
|
||||
env: DevelopmentEnvironment,
|
||||
manifest: ManifestData,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
) {
|
||||
const { settings, viteServer } = env;
|
||||
const { config } = settings;
|
||||
const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`;
|
||||
const buildingToSSR = config.output === 'server';
|
||||
// Ignore `.html` extensions and `index.html` in request URLS to ensure that
|
||||
// routing behavior matches production builds. This supports both file and directory
|
||||
// build formats, and is necessary based on how the manifest tracks build targets.
|
||||
const url = new URL(origin + req.url?.replace(/(index)?\.html$/, ''));
|
||||
const pathname = decodeURI(url.pathname);
|
||||
|
||||
// Add config.base back to url before passing it to SSR
|
||||
url.pathname = config.base.substring(0, config.base.length - 1) + url.pathname;
|
||||
|
||||
// HACK! @astrojs/image uses query params for the injected route in `dev`
|
||||
if (!buildingToSSR && pathname !== '/_image') {
|
||||
// Prevent user from depending on search params when not doing SSR.
|
||||
// NOTE: Create an array copy here because deleting-while-iterating
|
||||
// creates bugs where not all search params are removed.
|
||||
const allSearchParams = Array.from(url.searchParams);
|
||||
for (const [key] of allSearchParams) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
let body: ArrayBuffer | undefined = undefined;
|
||||
if (!(req.method === 'GET' || req.method === 'HEAD')) {
|
||||
let bytes: Uint8Array[] = [];
|
||||
await new Promise((resolve) => {
|
||||
req.on('data', (part) => {
|
||||
bytes.push(part);
|
||||
});
|
||||
req.on('end', resolve);
|
||||
});
|
||||
body = Buffer.concat(bytes);
|
||||
}
|
||||
|
||||
try {
|
||||
const matchedRoute = await matchRoute(pathname, env, manifest);
|
||||
return await handleRoute(matchedRoute, url, pathname, body, origin, env, manifest, req, res);
|
||||
} catch (_err) {
|
||||
// This is our last line of defense regarding errors where we still might have some information about the request
|
||||
// Our error should already be complete, but let's try to add a bit more through some guesswork
|
||||
const err = createSafeError(_err);
|
||||
const errorWithMetadata = collectErrorMetadata(err);
|
||||
|
||||
error(env.logging, null, msg.formatErrorMessage(errorWithMetadata));
|
||||
handle500Response(viteServer, origin, req, res, errorWithMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoute(
|
||||
matchedRoute: AsyncReturnType<typeof matchRoute>,
|
||||
url: URL,
|
||||
pathname: string,
|
||||
body: ArrayBuffer | undefined,
|
||||
origin: string,
|
||||
env: DevelopmentEnvironment,
|
||||
manifest: ManifestData,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
const { logging, settings } = env;
|
||||
if (!matchedRoute) {
|
||||
return handle404Response(origin, req, res);
|
||||
}
|
||||
|
||||
const { config } = settings;
|
||||
const filePath: URL | undefined = matchedRoute.filePath;
|
||||
const { route, preloadedComponent, mod } = matchedRoute;
|
||||
const buildingToSSR = config.output === 'server';
|
||||
|
||||
// Headers are only available when using SSR.
|
||||
const request = createRequest({
|
||||
url,
|
||||
headers: buildingToSSR ? req.headers : new Headers(),
|
||||
method: req.method,
|
||||
body,
|
||||
logging,
|
||||
ssr: buildingToSSR,
|
||||
clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined,
|
||||
});
|
||||
|
||||
// attempt to get static paths
|
||||
// if this fails, we have a bad URL match!
|
||||
const paramsAndPropsRes = await getParamsAndProps({
|
||||
mod,
|
||||
route,
|
||||
routeCache: env.routeCache,
|
||||
pathname: pathname,
|
||||
logging,
|
||||
ssr: config.output === 'server',
|
||||
});
|
||||
|
||||
const options: SSROptions = {
|
||||
env,
|
||||
filePath,
|
||||
origin,
|
||||
preload: preloadedComponent,
|
||||
pathname,
|
||||
request,
|
||||
route,
|
||||
};
|
||||
|
||||
// Route successfully matched! Render it.
|
||||
if (route.type === 'endpoint') {
|
||||
const result = await callEndpoint(options);
|
||||
if (result.type === 'response') {
|
||||
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {
|
||||
const fourOhFourRoute = await matchRoute('/404', env, manifest);
|
||||
return handleRoute(
|
||||
fourOhFourRoute,
|
||||
new URL('/404', url),
|
||||
'/404',
|
||||
body,
|
||||
origin,
|
||||
env,
|
||||
manifest,
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
throwIfRedirectNotAllowed(result.response, config);
|
||||
await writeWebResponse(res, result.response);
|
||||
} else {
|
||||
let contentType = 'text/plain';
|
||||
// Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
|
||||
const filepath =
|
||||
route.pathname ||
|
||||
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/');
|
||||
const computedMimeType = mime.getType(filepath);
|
||||
if (computedMimeType) {
|
||||
contentType = computedMimeType;
|
||||
}
|
||||
const response = new Response(result.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': `${contentType};charset=utf-8`,
|
||||
},
|
||||
});
|
||||
attachToResponse(response, result.cookies);
|
||||
await writeWebResponse(res, response);
|
||||
}
|
||||
} else {
|
||||
const result = await renderPage(options);
|
||||
throwIfRedirectNotAllowed(result, config);
|
||||
return await writeSSRResult(result, res);
|
||||
}
|
||||
}
|
||||
|
||||
export default function createPlugin({ settings, logging }: AstroPluginOptions): vite.Plugin {
|
||||
return {
|
||||
name: 'astro:server',
|
||||
configureServer(viteServer) {
|
||||
let env = createDevelopmentEnvironment(settings, logging, viteServer);
|
||||
let manifest: ManifestData = createRouteManifest({ settings }, logging);
|
||||
|
||||
/** rebuild the route cache + manifest, as needed. */
|
||||
function rebuildManifest(needsManifestRebuild: boolean, file: string) {
|
||||
env.routeCache.clearAll();
|
||||
if (needsManifestRebuild) {
|
||||
manifest = createRouteManifest({ settings }, logging);
|
||||
}
|
||||
}
|
||||
// Rebuild route manifest on file change, if needed.
|
||||
viteServer.watcher.on('add', rebuildManifest.bind(null, true));
|
||||
viteServer.watcher.on('unlink', rebuildManifest.bind(null, true));
|
||||
viteServer.watcher.on('change', rebuildManifest.bind(null, false));
|
||||
return () => {
|
||||
// Push this middleware to the front of the stack so that it can intercept responses.
|
||||
if (settings.config.base !== '/') {
|
||||
viteServer.middlewares.stack.unshift({
|
||||
route: '',
|
||||
handle: baseMiddleware(settings, logging),
|
||||
});
|
||||
}
|
||||
viteServer.middlewares.use(async (req, res) => {
|
||||
if (!req.url || !req.method) {
|
||||
throw new Error('Incomplete request');
|
||||
}
|
||||
handleRequest(env, manifest, req, res);
|
||||
});
|
||||
};
|
||||
},
|
||||
// HACK: hide `.tip` in Vite's ErrorOverlay and replace [vite] messages with [astro]
|
||||
transform(code, id, opts = {}) {
|
||||
if (opts.ssr) return;
|
||||
if (!id.includes('vite/dist/client/client.mjs')) return;
|
||||
return code
|
||||
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
|
||||
.replace(/\[vite\]/g, '[astro]');
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
createController,
|
||||
runWithErrorHandling
|
||||
} from './controller.js';
|
||||
export {
|
||||
default as vitePluginAstroServer
|
||||
} from './plugin.js';
|
||||
export {
|
||||
handleRequest
|
||||
} from './request.js';
|
||||
|
|
66
packages/astro/src/vite-plugin-astro-server/plugin.ts
Normal file
66
packages/astro/src/vite-plugin-astro-server/plugin.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
|
||||
import type * as vite from 'vite';
|
||||
import type { AstroSettings, ManifestData } from '../@types/astro';
|
||||
|
||||
import { LogOptions } from '../core/logger/core.js';
|
||||
import { createDevelopmentEnvironment } from '../core/render/dev/index.js';
|
||||
import { createRouteManifest } from '../core/routing/index.js';
|
||||
import { createViteLoader } from '../core/module-loader/index.js';
|
||||
import { baseMiddleware } from './base.js';
|
||||
import { handleRequest } from './request.js';
|
||||
import { createController } from './controller.js';
|
||||
import type fs from 'fs';
|
||||
|
||||
export interface AstroPluginOptions {
|
||||
settings: AstroSettings;
|
||||
logging: LogOptions;
|
||||
fs: typeof fs;
|
||||
}
|
||||
|
||||
export default function createVitePluginAstroServer({ settings, logging, fs: fsMod }: AstroPluginOptions): vite.Plugin {
|
||||
return {
|
||||
name: 'astro:server',
|
||||
configureServer(viteServer) {
|
||||
const loader = createViteLoader(viteServer);
|
||||
let env = createDevelopmentEnvironment(settings, logging, loader);
|
||||
let manifest: ManifestData = createRouteManifest({ settings, fsMod }, logging);
|
||||
const serverController = createController({ loader });
|
||||
|
||||
/** rebuild the route cache + manifest, as needed. */
|
||||
function rebuildManifest(needsManifestRebuild: boolean, _file: string) {
|
||||
env.routeCache.clearAll();
|
||||
if (needsManifestRebuild) {
|
||||
manifest = createRouteManifest({ settings }, logging);
|
||||
}
|
||||
}
|
||||
// Rebuild route manifest on file change, if needed.
|
||||
viteServer.watcher.on('add', rebuildManifest.bind(null, true));
|
||||
viteServer.watcher.on('unlink', rebuildManifest.bind(null, true));
|
||||
viteServer.watcher.on('change', rebuildManifest.bind(null, false));
|
||||
|
||||
return () => {
|
||||
// Push this middleware to the front of the stack so that it can intercept responses.
|
||||
if (settings.config.base !== '/') {
|
||||
viteServer.middlewares.stack.unshift({
|
||||
route: '',
|
||||
handle: baseMiddleware(settings, logging),
|
||||
});
|
||||
}
|
||||
viteServer.middlewares.use(async (req, res) => {
|
||||
if (!req.url || !req.method) {
|
||||
throw new Error('Incomplete request');
|
||||
}
|
||||
handleRequest(env, manifest, serverController, req, res);
|
||||
});
|
||||
};
|
||||
},
|
||||
// HACK: hide `.tip` in Vite's ErrorOverlay and replace [vite] messages with [astro]
|
||||
transform(code, id, opts = {}) {
|
||||
if (opts.ssr) return;
|
||||
if (!id.includes('vite/dist/client/client.mjs')) return;
|
||||
return code
|
||||
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
|
||||
.replace(/\[vite\]/g, '[astro]');
|
||||
},
|
||||
};
|
||||
}
|
78
packages/astro/src/vite-plugin-astro-server/request.ts
Normal file
78
packages/astro/src/vite-plugin-astro-server/request.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import type http from 'http';
|
||||
import type { ManifestData, RouteData } from '../@types/astro';
|
||||
import type { DevServerController } from './controller';
|
||||
import type { DevelopmentEnvironment } from '../core/render/dev/index';
|
||||
|
||||
import { collectErrorMetadata } from '../core/errors/dev/index.js';
|
||||
import { error } from '../core/logger/core.js';
|
||||
import * as msg from '../core/messages.js';
|
||||
import { handleRoute, matchRoute } from './route.js';
|
||||
import { handle500Response } from './response.js';
|
||||
import { runWithErrorHandling } from './controller.js';
|
||||
import { createSafeError } from '../core/errors/index.js';
|
||||
|
||||
/** The main logic to route dev server requests to pages in Astro. */
|
||||
export async function handleRequest(
|
||||
env: DevelopmentEnvironment,
|
||||
manifest: ManifestData,
|
||||
controller: DevServerController,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
) {
|
||||
const { settings, loader: moduleLoader } = env;
|
||||
const { config } = settings;
|
||||
const origin = `${moduleLoader.isHttps() ? 'https' : 'http'}://${req.headers.host}`;
|
||||
const buildingToSSR = config.output === 'server';
|
||||
// Ignore `.html` extensions and `index.html` in request URLS to ensure that
|
||||
// routing behavior matches production builds. This supports both file and directory
|
||||
// build formats, and is necessary based on how the manifest tracks build targets.
|
||||
const url = new URL(origin + req.url?.replace(/(index)?\.html$/, ''));
|
||||
const pathname = decodeURI(url.pathname);
|
||||
|
||||
// Add config.base back to url before passing it to SSR
|
||||
url.pathname = config.base.substring(0, config.base.length - 1) + url.pathname;
|
||||
|
||||
// HACK! @astrojs/image uses query params for the injected route in `dev`
|
||||
if (!buildingToSSR && pathname !== '/_image') {
|
||||
// Prevent user from depending on search params when not doing SSR.
|
||||
// NOTE: Create an array copy here because deleting-while-iterating
|
||||
// creates bugs where not all search params are removed.
|
||||
const allSearchParams = Array.from(url.searchParams);
|
||||
for (const [key] of allSearchParams) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
let body: ArrayBuffer | undefined = undefined;
|
||||
if (!(req.method === 'GET' || req.method === 'HEAD')) {
|
||||
let bytes: Uint8Array[] = [];
|
||||
await new Promise((resolve) => {
|
||||
req.on('data', (part) => {
|
||||
bytes.push(part);
|
||||
});
|
||||
req.on('end', resolve);
|
||||
});
|
||||
body = Buffer.concat(bytes);
|
||||
}
|
||||
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname,
|
||||
async run() {
|
||||
const matchedRoute = await matchRoute(pathname, env, manifest);
|
||||
|
||||
return await handleRoute(matchedRoute, url, pathname, body, origin, env, manifest, req, res);
|
||||
},
|
||||
onError(_err) {
|
||||
const err = createSafeError(_err);
|
||||
// This is our last line of defense regarding errors where we still might have some information about the request
|
||||
// Our error should already be complete, but let's try to add a bit more through some guesswork
|
||||
const errorWithMetadata = collectErrorMetadata(err);
|
||||
|
||||
error(env.logging, null, msg.formatErrorMessage(errorWithMetadata));
|
||||
handle500Response(moduleLoader, res, errorWithMetadata);
|
||||
|
||||
return err;
|
||||
}
|
||||
});
|
||||
}
|
106
packages/astro/src/vite-plugin-astro-server/response.ts
Normal file
106
packages/astro/src/vite-plugin-astro-server/response.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import type http from 'http';
|
||||
import type { ModuleLoader } from '../core/module-loader/index';
|
||||
import type { ErrorWithMetadata } from '../core/errors/index.js';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
import { getSetCookiesFromResponse } from '../core/cookies/index.js';
|
||||
import { getViteErrorPayload } from '../core/errors/dev/index.js';
|
||||
import notFoundTemplate from '../template/4xx.js';
|
||||
|
||||
|
||||
export async function handle404Response(
|
||||
origin: string,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
) {
|
||||
const pathname = decodeURI(new URL(origin + req.url).pathname);
|
||||
|
||||
const html = notFoundTemplate({
|
||||
statusCode: 404,
|
||||
title: 'Not found',
|
||||
tabTitle: '404: Not Found',
|
||||
pathname,
|
||||
});
|
||||
writeHtmlResponse(res, 404, html);
|
||||
}
|
||||
|
||||
export async function handle500Response(
|
||||
loader: ModuleLoader,
|
||||
res: http.ServerResponse,
|
||||
err: ErrorWithMetadata
|
||||
) {
|
||||
res.on('close', () => setTimeout(() => loader.webSocketSend(getViteErrorPayload(err)), 200));
|
||||
if (res.headersSent) {
|
||||
res.write(`<script type="module" src="/@vite/client"></script>`);
|
||||
res.end();
|
||||
} else {
|
||||
writeHtmlResponse(
|
||||
res,
|
||||
500,
|
||||
`<title>${err.name}</title><script type="module" src="/@vite/client"></script>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHtmlResponse(res: http.ServerResponse, statusCode: number, html: string) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(html, 'utf-8'),
|
||||
});
|
||||
res.write(html);
|
||||
res.end();
|
||||
}
|
||||
|
||||
export async function writeWebResponse(res: http.ServerResponse, webResponse: Response) {
|
||||
const { status, headers, body } = webResponse;
|
||||
|
||||
let _headers = {};
|
||||
if ('raw' in headers) {
|
||||
// Node fetch allows you to get the raw headers, which includes multiples of the same type.
|
||||
// This is needed because Set-Cookie *must* be called for each cookie, and can't be
|
||||
// concatenated together.
|
||||
type HeadersWithRaw = Headers & {
|
||||
raw: () => Record<string, string[]>;
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries((headers as HeadersWithRaw).raw())) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
} else {
|
||||
_headers = Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
// Attach any set-cookie headers added via Astro.cookies.set()
|
||||
const setCookieHeaders = Array.from(getSetCookiesFromResponse(webResponse));
|
||||
if (setCookieHeaders.length) {
|
||||
res.setHeader('Set-Cookie', setCookieHeaders);
|
||||
}
|
||||
res.writeHead(status, _headers);
|
||||
if (body) {
|
||||
if (Symbol.for('astro.responseBody') in webResponse) {
|
||||
let stream = (webResponse as any)[Symbol.for('astro.responseBody')];
|
||||
for await (const chunk of stream) {
|
||||
res.write(chunk.toString());
|
||||
}
|
||||
} else if (body instanceof Readable) {
|
||||
body.pipe(res);
|
||||
return;
|
||||
} else if (typeof body === 'string') {
|
||||
res.write(body);
|
||||
} else {
|
||||
const reader = body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
res.write(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
export async function writeSSRResult(webResponse: Response, res: http.ServerResponse) {
|
||||
return writeWebResponse(res, webResponse);
|
||||
}
|
185
packages/astro/src/vite-plugin-astro-server/route.ts
Normal file
185
packages/astro/src/vite-plugin-astro-server/route.ts
Normal file
|
@ -0,0 +1,185 @@
|
|||
import type http from 'http';
|
||||
import mime from 'mime';
|
||||
import type { AstroConfig, AstroSettings, ManifestData } from '../@types/astro';
|
||||
import { DevelopmentEnvironment, SSROptions } from '../core/render/dev/index';
|
||||
|
||||
import { attachToResponse } from '../core/cookies/index.js';
|
||||
import { call as callEndpoint } from '../core/endpoint/dev/index.js';
|
||||
import { warn } from '../core/logger/core.js';
|
||||
import { appendForwardSlash } from '../core/path.js';
|
||||
import { preload, renderPage } from '../core/render/dev/index.js';
|
||||
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
|
||||
import { createRequest } from '../core/request.js';
|
||||
import { matchAllRoutes } from '../core/routing/index.js';
|
||||
import { resolvePages } from '../core/util.js';
|
||||
import { log404 } from './common.js';
|
||||
import { handle404Response, writeWebResponse, writeSSRResult } from './response.js';
|
||||
import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js';
|
||||
|
||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
|
||||
...args: any
|
||||
) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
|
||||
function getCustom404Route({ config }: AstroSettings, manifest: ManifestData) {
|
||||
// For Windows compat, use relative page paths to match the 404 route
|
||||
const relPages = resolvePages(config).href.replace(config.root.href, '');
|
||||
const pattern = new RegExp(`${appendForwardSlash(relPages)}404.(astro|md)`);
|
||||
return manifest.routes.find((r) => r.component.match(pattern));
|
||||
}
|
||||
|
||||
export async function matchRoute(pathname: string, env: DevelopmentEnvironment, manifest: ManifestData) {
|
||||
const { logging, settings, routeCache } = env;
|
||||
const matches = matchAllRoutes(pathname, manifest);
|
||||
|
||||
for await (const maybeRoute of matches) {
|
||||
const filePath = new URL(`./${maybeRoute.component}`, settings.config.root);
|
||||
const preloadedComponent = await preload({ env, filePath });
|
||||
const [, mod] = preloadedComponent;
|
||||
// attempt to get static paths
|
||||
// if this fails, we have a bad URL match!
|
||||
const paramsAndPropsRes = await getParamsAndProps({
|
||||
mod,
|
||||
route: maybeRoute,
|
||||
routeCache,
|
||||
pathname: pathname,
|
||||
logging,
|
||||
ssr: settings.config.output === 'server',
|
||||
});
|
||||
|
||||
if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) {
|
||||
return {
|
||||
route: maybeRoute,
|
||||
filePath,
|
||||
preloadedComponent,
|
||||
mod,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length) {
|
||||
warn(
|
||||
logging,
|
||||
'getStaticPaths',
|
||||
`Route pattern matched, but no matching static path found. (${pathname})`
|
||||
);
|
||||
}
|
||||
|
||||
log404(logging, pathname);
|
||||
const custom404 = getCustom404Route(settings, manifest);
|
||||
|
||||
if (custom404) {
|
||||
const filePath = new URL(`./${custom404.component}`, settings.config.root);
|
||||
const preloadedComponent = await preload({ env, filePath });
|
||||
const [, mod] = preloadedComponent;
|
||||
|
||||
return {
|
||||
route: custom404,
|
||||
filePath,
|
||||
preloadedComponent,
|
||||
mod,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleRoute(
|
||||
matchedRoute: AsyncReturnType<typeof matchRoute>,
|
||||
url: URL,
|
||||
pathname: string,
|
||||
body: ArrayBuffer | undefined,
|
||||
origin: string,
|
||||
env: DevelopmentEnvironment,
|
||||
manifest: ManifestData,
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
const { logging, settings } = env;
|
||||
if (!matchedRoute) {
|
||||
return handle404Response(origin, req, res);
|
||||
}
|
||||
|
||||
const { config } = settings;
|
||||
const filePath: URL | undefined = matchedRoute.filePath;
|
||||
const { route, preloadedComponent, mod } = matchedRoute;
|
||||
const buildingToSSR = config.output === 'server';
|
||||
|
||||
// Headers are only available when using SSR.
|
||||
const request = createRequest({
|
||||
url,
|
||||
headers: buildingToSSR ? req.headers : new Headers(),
|
||||
method: req.method,
|
||||
body,
|
||||
logging,
|
||||
ssr: buildingToSSR,
|
||||
clientAddress: buildingToSSR ? req.socket.remoteAddress : undefined,
|
||||
});
|
||||
|
||||
// attempt to get static paths
|
||||
// if this fails, we have a bad URL match!
|
||||
const paramsAndPropsRes = await getParamsAndProps({
|
||||
mod,
|
||||
route,
|
||||
routeCache: env.routeCache,
|
||||
pathname: pathname,
|
||||
logging,
|
||||
ssr: config.output === 'server',
|
||||
});
|
||||
|
||||
const options: SSROptions = {
|
||||
env,
|
||||
filePath,
|
||||
origin,
|
||||
preload: preloadedComponent,
|
||||
pathname,
|
||||
request,
|
||||
route,
|
||||
};
|
||||
|
||||
// Route successfully matched! Render it.
|
||||
if (route.type === 'endpoint') {
|
||||
const result = await callEndpoint(options);
|
||||
if (result.type === 'response') {
|
||||
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {
|
||||
const fourOhFourRoute = await matchRoute('/404', env, manifest);
|
||||
return handleRoute(
|
||||
fourOhFourRoute,
|
||||
new URL('/404', url),
|
||||
'/404',
|
||||
body,
|
||||
origin,
|
||||
env,
|
||||
manifest,
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
throwIfRedirectNotAllowed(result.response, config);
|
||||
await writeWebResponse(res, result.response);
|
||||
} else {
|
||||
let contentType = 'text/plain';
|
||||
// Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
|
||||
const filepath =
|
||||
route.pathname ||
|
||||
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/');
|
||||
const computedMimeType = mime.getType(filepath);
|
||||
if (computedMimeType) {
|
||||
contentType = computedMimeType;
|
||||
}
|
||||
const response = new Response(result.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': `${contentType};charset=utf-8`,
|
||||
},
|
||||
});
|
||||
attachToResponse(response, result.cookies);
|
||||
await writeWebResponse(res, response);
|
||||
}
|
||||
} else {
|
||||
const result = await renderPage(options);
|
||||
throwIfRedirectNotAllowed(result, config);
|
||||
return await writeSSRResult(result, res);
|
||||
}
|
||||
}
|
52
packages/astro/src/vite-plugin-astro-server/server-state.ts
Normal file
52
packages/astro/src/vite-plugin-astro-server/server-state.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
export type ErrorState = 'fresh' | 'error';
|
||||
|
||||
export interface RouteState {
|
||||
state: ErrorState;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface ServerState {
|
||||
routes: Map<string, RouteState>;
|
||||
state: ErrorState;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export function createServerState(): ServerState {
|
||||
return {
|
||||
routes: new Map(),
|
||||
state: 'fresh'
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAnyFailureState(serverState: ServerState) {
|
||||
return serverState.state !== 'fresh';
|
||||
}
|
||||
|
||||
export function setRouteError(serverState: ServerState, pathname: string, error: Error) {
|
||||
if(serverState.routes.has(pathname)) {
|
||||
const routeState = serverState.routes.get(pathname)!;
|
||||
routeState.state = 'error';
|
||||
routeState.error = error;
|
||||
} else {
|
||||
const routeState: RouteState = {
|
||||
state: 'error',
|
||||
error: error
|
||||
};
|
||||
serverState.routes.set(pathname, routeState);
|
||||
}
|
||||
serverState.state = 'error';
|
||||
serverState.error = error;
|
||||
}
|
||||
|
||||
export function setServerError(serverState: ServerState, error: Error) {
|
||||
serverState.state = 'error';
|
||||
serverState.error = error;
|
||||
}
|
||||
|
||||
export function clearRouteError(serverState: ServerState, pathname: string) {
|
||||
if(serverState.routes.has(pathname)) {
|
||||
serverState.routes.delete(pathname);
|
||||
}
|
||||
serverState.state = 'fresh';
|
||||
serverState.error = undefined;
|
||||
}
|
38
packages/astro/src/vite-plugin-load-fallback/index.ts
Normal file
38
packages/astro/src/vite-plugin-load-fallback/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import type * as vite from 'vite';
|
||||
import nodeFs from 'fs';
|
||||
|
||||
type NodeFileSystemModule = typeof nodeFs;
|
||||
|
||||
export interface LoadFallbackPluginParams {
|
||||
fs?: NodeFileSystemModule;
|
||||
}
|
||||
|
||||
export default function loadFallbackPlugin({ fs }: LoadFallbackPluginParams): vite.Plugin | false {
|
||||
// Only add this plugin if a custom fs implementation is provided.
|
||||
if(!fs || fs === nodeFs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'astro:load-fallback',
|
||||
enforce: 'post',
|
||||
async load(id) {
|
||||
try {
|
||||
// await is necessary for the catch
|
||||
return await fs.promises.readFile(cleanUrl(id), 'utf-8')
|
||||
} catch (e) {
|
||||
try {
|
||||
return await fs.promises.readFile(id, 'utf-8');
|
||||
} catch(e2) {
|
||||
// Let fall through to the next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryRE = /\?.*$/s;
|
||||
const hashRE = /#.*$/s;
|
||||
|
||||
const cleanUrl = (url: string): string =>
|
||||
url.replace(hashRE, '').replace(queryRE, '');
|
|
@ -19,7 +19,7 @@ polyfill(globalThis, {
|
|||
|
||||
/**
|
||||
* @typedef {import('node-fetch').Response} Response
|
||||
* @typedef {import('../src/core/dev/index').DedvServer} DevServer
|
||||
* @typedef {import('../src/core/dev/dev').DedvServer} DevServer
|
||||
* @typedef {import('../src/@types/astro').AstroConfig} AstroConfig
|
||||
* @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer
|
||||
* @typedef {import('../src/core/app/index').App} App
|
||||
|
|
70
packages/astro/test/units/correct-path.js
Normal file
70
packages/astro/test/units/correct-path.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* correctPath.js <https://github.com/streamich/fs-monkey/blob/af36a890d8070b25b9eae7178824f653bad5621f/src/correctPath.js>
|
||||
* Taken from:
|
||||
* https://github.com/streamich/fs-monkeys
|
||||
*/
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
/*!
|
||||
* removeTrailingSeparator <https://github.com/darsain/remove-trailing-separator>
|
||||
*
|
||||
* Inlined from:
|
||||
* Copyright (c) darsain.
|
||||
* Released under the ISC License.
|
||||
*/
|
||||
function removeTrailingSeparator(str) {
|
||||
let i = str.length - 1;
|
||||
if (i < 2) {
|
||||
return str;
|
||||
}
|
||||
while (isSeparator(str, i)) {
|
||||
i--;
|
||||
}
|
||||
return str.substr(0, i + 1);
|
||||
}
|
||||
|
||||
function isSeparator(str, i) {
|
||||
let char = str[i];
|
||||
return i > 0 && (char === '/' || (isWin && char === '\\'));
|
||||
}
|
||||
|
||||
/*!
|
||||
* normalize-path <https://github.com/jonschlinkert/normalize-path>
|
||||
*
|
||||
* Inlined from:
|
||||
* Copyright (c) 2014-2017, Jon Schlinkert.
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
function normalizePath(str, stripTrailing) {
|
||||
if (typeof str !== 'string') {
|
||||
throw new TypeError('expected a string');
|
||||
}
|
||||
str = str.replace(/[\\\/]+/g, '/');
|
||||
if (stripTrailing !== false) {
|
||||
str = removeTrailingSeparator(str);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/*!
|
||||
* unixify <https://github.com/jonschlinkert/unixify>
|
||||
*
|
||||
* Inlined from:
|
||||
* Copyright (c) 2014, 2017, Jon Schlinkert.
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
export function unixify(filepath, stripTrailing = true) {
|
||||
if(isWin) {
|
||||
filepath = normalizePath(filepath, stripTrailing);
|
||||
return filepath.replace(/^([a-zA-Z]+:|\.\/)/, '');
|
||||
}
|
||||
return filepath;
|
||||
}
|
||||
|
||||
/*
|
||||
* Corrects a windows path to unix format (including \\?\c:...)
|
||||
*/
|
||||
export function correctPath(filepath) {
|
||||
return unixify(filepath.replace(/^\\\\\?\\.:\\/,'\\'));
|
||||
}
|
38
packages/astro/test/units/dev/dev.test.js
Normal file
38
packages/astro/test/units/dev/dev.test.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { expect } from 'chai';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
import { runInContainer } from '../../../dist/core/dev/index.js';
|
||||
import { createFs, createRequestAndResponse } from '../test-utils.js';
|
||||
|
||||
const root = new URL('../../fixtures/alias/', import.meta.url);
|
||||
|
||||
describe('dev container', () => {
|
||||
it('can render requests', async () => {
|
||||
|
||||
const fs = createFs({
|
||||
'/src/pages/index.astro': `
|
||||
---
|
||||
const name = 'Testing';
|
||||
---
|
||||
<html>
|
||||
<head><title>{name}</title></head>
|
||||
<body>
|
||||
<h1>{name}</h1>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}, root);
|
||||
|
||||
await runInContainer({ fs, root }, async container => {
|
||||
const { req, res, text } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
container.handle(req, res);
|
||||
const html = await text();
|
||||
const $ = cheerio.load(html);
|
||||
expect(res.statusCode).to.equal(200);
|
||||
expect($('h1')).to.have.a.lengthOf(1);
|
||||
});
|
||||
});
|
||||
});
|
87
packages/astro/test/units/test-utils.js
Normal file
87
packages/astro/test/units/test-utils.js
Normal file
|
@ -0,0 +1,87 @@
|
|||
import httpMocks from 'node-mocks-http';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Volume } from 'memfs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import npath from 'path';
|
||||
import { unixify } from './correct-path.js';
|
||||
|
||||
class MyVolume extends Volume {
|
||||
existsSync(p) {
|
||||
if(p instanceof URL) {
|
||||
p = fileURLToPath(p);
|
||||
}
|
||||
return super.existsSync(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function createFs(json, root) {
|
||||
if(typeof root !== 'string') {
|
||||
root = unixify(fileURLToPath(root));
|
||||
}
|
||||
|
||||
const structure = {};
|
||||
for(const [key, value] of Object.entries(json)) {
|
||||
const fullpath = npath.posix.join(root, key);
|
||||
structure[fullpath] = value;
|
||||
}
|
||||
|
||||
const fs = new MyVolume();
|
||||
fs.fromJSON(structure);
|
||||
return fs;
|
||||
}
|
||||
|
||||
export function createRequestAndResponse(reqOptions = {}) {
|
||||
const req = httpMocks.createRequest(reqOptions);
|
||||
|
||||
const res = httpMocks.createResponse({
|
||||
eventEmitter: EventEmitter,
|
||||
req,
|
||||
});
|
||||
|
||||
// When the response is complete.
|
||||
const done = toPromise(res);
|
||||
|
||||
// Get the response as text
|
||||
const text = async () => {
|
||||
let chunks = await done;
|
||||
return buffersToString(chunks);
|
||||
};
|
||||
|
||||
// Get the response as json
|
||||
const json = async () => {
|
||||
const raw = await text();
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
return { req, res, done, json, text };
|
||||
}
|
||||
|
||||
export function toPromise(res) {
|
||||
return new Promise((resolve) => {
|
||||
// node-mocks-http doesn't correctly handle non-Buffer typed arrays,
|
||||
// so override the write method to fix it.
|
||||
const write = res.write;
|
||||
res.write = function(data, encoding) {
|
||||
if(ArrayBuffer.isView(data) && !Buffer.isBuffer(data)) {
|
||||
data = Buffer.from(data);
|
||||
}
|
||||
return write.call(this, data, encoding);
|
||||
};
|
||||
res.on('end', () => {
|
||||
let chunks = res._getChunks();
|
||||
resolve(chunks);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buffersToString(buffers) {
|
||||
let decoder = new TextDecoder();
|
||||
let str = '';
|
||||
for(const buffer of buffers) {
|
||||
str += decoder.decode(buffer);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// A convenience method for creating an astro module from a component
|
||||
export const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
|
|
@ -0,0 +1,131 @@
|
|||
import { expect } from 'chai';
|
||||
import { createLoader } from '../../../dist/core/module-loader/index.js';
|
||||
import { createController, runWithErrorHandling } from '../../../dist/vite-plugin-astro-server/index.js';
|
||||
|
||||
describe('vite-plugin-astro-server', () => {
|
||||
describe('controller', () => {
|
||||
it('calls the onError method when an error occurs in the handler', async () => {
|
||||
const controller = createController({ loader: createLoader() });
|
||||
let error = undefined;
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
run() {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
onError(err) {
|
||||
error = err;
|
||||
}
|
||||
});
|
||||
expect(error).to.not.be.an('undefined');
|
||||
expect(error).to.be.an.instanceOf(Error);
|
||||
});
|
||||
|
||||
it('sets the state to error when an error occurs in the handler', async () => {
|
||||
const controller = createController({ loader: createLoader() });
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
run() {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
onError(){}
|
||||
});
|
||||
expect(controller.state.state).to.equal('error');
|
||||
});
|
||||
|
||||
it('calls reload when a file change occurs when in an error state', async () => {
|
||||
let reloads = 0;
|
||||
const loader = createLoader({
|
||||
eachModule() {},
|
||||
clientReload() {
|
||||
reloads++;
|
||||
}
|
||||
});
|
||||
const controller = createController({ loader });
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(0);
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
run() {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
onError(){}
|
||||
});
|
||||
expect(reloads).to.equal(0);
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(1);
|
||||
});
|
||||
|
||||
it('does not call reload on file change if not in an error state', async () => {
|
||||
let reloads = 0;
|
||||
const loader = createLoader({
|
||||
eachModule() {},
|
||||
clientReload() {
|
||||
reloads++;
|
||||
}
|
||||
});
|
||||
const controller = createController({ loader });
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(0);
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
run() {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
onError(){}
|
||||
});
|
||||
expect(reloads).to.equal(0);
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(1);
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(2);
|
||||
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
// No error here
|
||||
run() {}
|
||||
});
|
||||
loader.events.emit('file-change');
|
||||
expect(reloads).to.equal(2);
|
||||
});
|
||||
|
||||
it('Invalidates broken modules when a change occurs in an error state', async () => {
|
||||
const mods = [
|
||||
{ id: 'one', ssrError: new Error('one') },
|
||||
{ id: 'two', ssrError: null },
|
||||
{ id: 'three', ssrError: new Error('three') },
|
||||
];
|
||||
|
||||
const loader = createLoader({
|
||||
eachModule(cb) {
|
||||
return mods.forEach(cb);
|
||||
},
|
||||
invalidateModule(mod) {
|
||||
mod.ssrError = null;
|
||||
}
|
||||
});
|
||||
const controller = createController({ loader });
|
||||
|
||||
await runWithErrorHandling({
|
||||
controller,
|
||||
pathname: '/',
|
||||
run() {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
onError(){}
|
||||
});
|
||||
|
||||
loader.events.emit('file-change');
|
||||
|
||||
expect(mods).to.deep.equal([
|
||||
{ id: 'one', ssrError: null },
|
||||
{ id: 'two', ssrError: null },
|
||||
{ id: 'three', ssrError: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
import { expect } from 'chai';
|
||||
|
||||
import { createLoader } from '../../../dist/core/module-loader/index.js';
|
||||
import { createController, handleRequest } from '../../../dist/vite-plugin-astro-server/index.js';
|
||||
import { createDefaultDevSettings } from '../../../dist/core/config/index.js';
|
||||
import { createBasicEnvironment } from '../../../dist/core/render/index.js';
|
||||
import { createRouteManifest } from '../../../dist/core/routing/index.js';
|
||||
import { defaultLogging as logging } from '../../test-utils.js';
|
||||
import { createComponent, render } from '../../../dist/runtime/server/index.js';
|
||||
import { createRequestAndResponse, createFs, createAstroModule } from '../test-utils.js';
|
||||
|
||||
async function createDevEnvironment(overrides = {}) {
|
||||
const env = createBasicEnvironment({
|
||||
logging,
|
||||
renderers: []
|
||||
});
|
||||
env.settings = await createDefaultDevSettings({}, '/');
|
||||
env.settings.renderers = [];
|
||||
env.loader = createLoader();
|
||||
Object.assign(env, overrides);
|
||||
return env;
|
||||
}
|
||||
|
||||
describe('vite-plugin-astro-server', () => {
|
||||
describe('request', () => {
|
||||
it('renders a request', async () => {
|
||||
const env = await createDevEnvironment({
|
||||
loader: createLoader({
|
||||
import(id) {
|
||||
const Page = createComponent(() => {
|
||||
return render`<div id="test">testing</div>`;
|
||||
});
|
||||
return createAstroModule(Page);
|
||||
}
|
||||
})
|
||||
});
|
||||
const controller = createController({ loader: env.loader });
|
||||
const { req, res, text } = createRequestAndResponse();
|
||||
const fs = createFs({
|
||||
// Note that the content doesn't matter here because we are using a custom loader.
|
||||
'/src/pages/index.astro': ''
|
||||
}, '/');
|
||||
const manifest = createRouteManifest({
|
||||
fsMod: fs,
|
||||
settings: env.settings
|
||||
}, logging);
|
||||
|
||||
try {
|
||||
await handleRequest(
|
||||
env,
|
||||
manifest,
|
||||
controller,
|
||||
req,
|
||||
res
|
||||
);
|
||||
const html = await text();
|
||||
expect(html).to.include('<div id="test">');
|
||||
} catch(err) {
|
||||
expect(err).to.be.undefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -422,9 +422,11 @@ importers:
|
|||
import-meta-resolve: ^2.1.0
|
||||
kleur: ^4.1.4
|
||||
magic-string: ^0.25.9
|
||||
memfs: ^3.4.7
|
||||
mime: ^3.0.0
|
||||
mocha: ^9.2.2
|
||||
node-fetch: ^3.2.5
|
||||
node-mocks-http: ^1.11.0
|
||||
ora: ^6.1.0
|
||||
path-browserify: ^1.0.1
|
||||
path-to-regexp: ^6.2.1
|
||||
|
@ -547,8 +549,10 @@ importers:
|
|||
astro-scripts: link:../../scripts
|
||||
chai: 4.3.6
|
||||
cheerio: 1.0.0-rc.12
|
||||
memfs: 3.4.7
|
||||
mocha: 9.2.2
|
||||
node-fetch: 3.2.10
|
||||
node-mocks-http: 1.11.0
|
||||
rehype-autolink-headings: 6.1.1
|
||||
rehype-slug: 5.1.0
|
||||
rehype-toc: 3.0.2
|
||||
|
@ -12761,6 +12765,10 @@ packages:
|
|||
minipass: 3.3.4
|
||||
dev: false
|
||||
|
||||
/fs-monkey/1.0.3:
|
||||
resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==}
|
||||
dev: true
|
||||
|
||||
/fs.realpath/1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
|
@ -14270,6 +14278,13 @@ packages:
|
|||
engines: {node: '>= 0.6'}
|
||||
dev: true
|
||||
|
||||
/memfs/3.4.7:
|
||||
resolution: {integrity: sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
dependencies:
|
||||
fs-monkey: 1.0.3
|
||||
dev: true
|
||||
|
||||
/meow/6.1.1:
|
||||
resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
Loading…
Reference in a new issue