Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
|
a63ec9d59d | ||
|
e48f3fed4b | ||
|
a140fb0ded | ||
|
c8299b24fb | ||
|
bc437dce60 | ||
|
48efa4beda | ||
|
e12e1e61c1 | ||
|
c0febd56f9 | ||
|
26458cf4f4 | ||
|
4ce0b918b1 |
10 changed files with 320 additions and 109 deletions
5
.changeset/fresh-rivers-allow.md
Normal file
5
.changeset/fresh-rivers-allow.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Add a DevApp export
|
|
@ -44,6 +44,7 @@
|
|||
},
|
||||
"./app": "./dist/core/app/index.js",
|
||||
"./app/node": "./dist/core/app/node.js",
|
||||
"./app/dev": "./dist/core/app/dev.js",
|
||||
"./client/*": "./dist/runtime/client/*",
|
||||
"./components": "./components/index.ts",
|
||||
"./components/*": "./components/*",
|
||||
|
|
189
packages/astro/src/core/app/dev.ts
Normal file
189
packages/astro/src/core/app/dev.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
import type { ComponentInstance, RouteData } from '../../@types/astro';
|
||||
import type { SSRManifest as Manifest } from './types';
|
||||
import type { LogOptions } from '../logger/core';
|
||||
import type http from 'http';
|
||||
import { posix } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createContainer, type CreateContainerParams } from '../dev/index.js';
|
||||
import { openConfig, createSettings } from '../config/index.js';
|
||||
import { createViteLoader } from '../module-loader/index.js';
|
||||
import { createRouteManifest } from '../routing/index.js';
|
||||
import {
|
||||
createDevelopmentEnvironment,
|
||||
getScriptsAndStyles,
|
||||
preload,
|
||||
type DevelopmentEnvironment
|
||||
} from '../render/dev/index.js';
|
||||
import {
|
||||
renderPage as coreRenderPage
|
||||
} from '../render/index.js';
|
||||
import { App } from './index.js';
|
||||
import { RenderContext, Environment } from '../render';
|
||||
import { nodeLogDestination } from '../logger/node.js';
|
||||
|
||||
export const logging: LogOptions = {
|
||||
dest: nodeLogDestination,
|
||||
level: 'error',
|
||||
};
|
||||
|
||||
export type DevAppParams = Partial<CreateContainerParams> & {
|
||||
root: URL;
|
||||
}
|
||||
|
||||
export class DevApp extends App {
|
||||
#createContainerParams: CreateContainerParams;
|
||||
#manifest: Manifest;
|
||||
#env: DevelopmentEnvironment | null = null;
|
||||
#root: URL;
|
||||
#modToRoute = new Map<ComponentInstance, RouteData>();
|
||||
|
||||
// TODO don't expose this entire API
|
||||
container: Awaited<ReturnType<typeof createContainer>> | null = null;
|
||||
constructor(params: DevAppParams) {
|
||||
const { root, userConfig } = params;
|
||||
const manifest: Manifest = {
|
||||
adapterName: 'development',
|
||||
base: userConfig?.base,
|
||||
routes: [],
|
||||
markdown: {
|
||||
contentDir: root
|
||||
},
|
||||
pageMap: new Map(),
|
||||
renderers: [],
|
||||
// Temporary hack
|
||||
entryModules: new Proxy({}, {
|
||||
has() {
|
||||
return true;
|
||||
},
|
||||
get(target, key) {
|
||||
return key;
|
||||
}
|
||||
}),
|
||||
assets: new Set(),
|
||||
propagation: new Map(),
|
||||
trailingSlash: userConfig?.trailingSlash ?? 'ignore'
|
||||
};
|
||||
super(manifest, true);
|
||||
this.#manifest = manifest;
|
||||
this.#root = root;
|
||||
this.#createContainerParams = params;
|
||||
}
|
||||
|
||||
get loaded() {
|
||||
return !!this.container;
|
||||
}
|
||||
|
||||
url(pathname: string): string | undefined {
|
||||
if(!this.loaded) {
|
||||
return undefined;
|
||||
}
|
||||
const { host, port } = this.container!.settings.config.server
|
||||
return new URL(pathname, `http://${host}:${port}`).toString();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if(this.loaded) {
|
||||
await this.close();
|
||||
this.container = null;
|
||||
this.#env = null;
|
||||
}
|
||||
|
||||
const params: CreateContainerParams = {
|
||||
...this.#createContainerParams,
|
||||
};
|
||||
|
||||
if(!this.#createContainerParams.userConfig) {
|
||||
const configResult = await openConfig({
|
||||
cmd: 'dev',
|
||||
logging,
|
||||
});
|
||||
params.settings = createSettings(configResult.astroConfig);
|
||||
}
|
||||
|
||||
const container = this.container = await createContainer(params);
|
||||
this.#manifest.trailingSlash = container.settings.config.trailingSlash;
|
||||
|
||||
const loader = createViteLoader(container.viteServer);
|
||||
|
||||
const routeManifest = createRouteManifest({
|
||||
settings: container.settings,
|
||||
fsMod: this.#createContainerParams.fs
|
||||
}, container.logging);
|
||||
const routes = routeManifest.routes.map(routeData => {
|
||||
return {
|
||||
routeData,
|
||||
file: routeData.component,
|
||||
links: [],
|
||||
scripts: []
|
||||
}
|
||||
});
|
||||
this.updateRoutes(routes);
|
||||
this.#env = createDevelopmentEnvironment(container.settings, container.logging, loader);
|
||||
return this;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.container?.close();
|
||||
}
|
||||
|
||||
fileChanged(path: string) {
|
||||
const container = this.container!;
|
||||
const fs = this.#createContainerParams.fs!;
|
||||
const root = fileURLToPath(this.#root);
|
||||
const fullPath = posix.join(root, path);
|
||||
container.viteServer.watcher.emit('change', fullPath);
|
||||
|
||||
if (!fileURLToPath(container.settings.config.root).startsWith('/')) {
|
||||
const drive = fileURLToPath(container.settings.config.root).slice(0, 2);
|
||||
container.viteServer.watcher.emit('change', drive + fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
handle(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
this.container!.handle(req, res);
|
||||
}
|
||||
|
||||
async render(request: Request, route?: RouteData | undefined): Promise<Response> {
|
||||
if(!this.loaded) {
|
||||
await this.load();
|
||||
}
|
||||
if(!route) {
|
||||
route = this.match(request, { matchNotFound: false });
|
||||
}
|
||||
if(route) {
|
||||
const filePath = new URL(route.component, this.#root);
|
||||
|
||||
// Always run preload so that if there has been a change in the file, the new
|
||||
// version will run.
|
||||
const [renderers, mod] = await preload({
|
||||
env: this.#env!,
|
||||
filePath
|
||||
});
|
||||
|
||||
// Always reset the renderers as they might have changed.
|
||||
this.#manifest.renderers.length = 0;
|
||||
this.#manifest.renderers.push(...renderers);
|
||||
|
||||
// Save this module in the pageMap, so that super.render() finds it.
|
||||
this.#manifest.pageMap.set(route.component, mod);
|
||||
this.#modToRoute.set(mod, route);
|
||||
}
|
||||
return super.render(request, route);
|
||||
}
|
||||
|
||||
renderPage = async (mod: ComponentInstance, ctx: RenderContext, env: Environment) => {
|
||||
const route = this.#modToRoute.get(mod)!;
|
||||
|
||||
const { scripts, links, styles, propagationMap } = await getScriptsAndStyles({
|
||||
env: this.#env!,
|
||||
filePath: new URL(route.component, this.#root),
|
||||
});
|
||||
|
||||
ctx.scripts = scripts;
|
||||
ctx.links = links;
|
||||
ctx.styles = styles;
|
||||
ctx.propagation = propagationMap;
|
||||
|
||||
return coreRenderPage(mod, ctx, env);
|
||||
};
|
||||
}
|
|
@ -38,8 +38,8 @@ export interface MatchOptions {
|
|||
export class App {
|
||||
#env: Environment;
|
||||
#manifest: Manifest;
|
||||
#manifestData: ManifestData;
|
||||
#routeDataToRouteInfo: Map<RouteData, RouteInfo>;
|
||||
#manifestData: ManifestData = { routes: [] };
|
||||
#routeDataToRouteInfo: Map<RouteData, RouteInfo> = new Map();
|
||||
#encoder = new TextEncoder();
|
||||
#logging: LogOptions = {
|
||||
dest: consoleLogDestination,
|
||||
|
@ -47,13 +47,11 @@ export class App {
|
|||
};
|
||||
#base: string;
|
||||
#baseWithoutTrailingSlash: string;
|
||||
renderPage: typeof renderPage;
|
||||
|
||||
constructor(manifest: Manifest, streaming = true) {
|
||||
this.#manifest = manifest;
|
||||
this.#manifestData = {
|
||||
routes: manifest.routes.map((route) => route.routeData),
|
||||
};
|
||||
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
|
||||
this.updateRoutes(manifest.routes);
|
||||
this.#env = createEnvironment({
|
||||
adapterName: manifest.adapterName,
|
||||
logging: this.#logging,
|
||||
|
@ -83,6 +81,16 @@ export class App {
|
|||
|
||||
this.#base = this.#manifest.base || '/';
|
||||
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#base);
|
||||
this.renderPage = renderPage;
|
||||
}
|
||||
updateRoutes(routes: RouteInfo[]) {
|
||||
this.#manifestData = {
|
||||
routes: routes.map((route) => route.routeData),
|
||||
};
|
||||
this.#routeDataToRouteInfo = new Map(routes.map((route) => [route.routeData, route]));
|
||||
}
|
||||
get routes() {
|
||||
return this.#manifestData.routes;
|
||||
}
|
||||
removeBase(pathname: string) {
|
||||
if (pathname.startsWith(this.#base)) {
|
||||
|
@ -96,7 +104,14 @@ export class App {
|
|||
if (this.#manifest.assets.has(url.pathname)) {
|
||||
return undefined;
|
||||
}
|
||||
let pathname = '/' + this.removeBase(url.pathname);
|
||||
let hasTrailingSlash = url.pathname.endsWith('/');
|
||||
let noBase = this.removeBase(url.pathname);
|
||||
let pathname: string;
|
||||
if(this.#manifest.trailingSlash === 'never' && noBase === '' && !hasTrailingSlash) {
|
||||
pathname = noBase;
|
||||
} else {
|
||||
pathname = prependForwardSlash(noBase);
|
||||
}
|
||||
let routeData = matchRoute(pathname, this.#manifestData);
|
||||
|
||||
if (routeData) {
|
||||
|
@ -200,7 +215,7 @@ export class App {
|
|||
status,
|
||||
});
|
||||
|
||||
const response = await renderPage(mod, ctx, this.#env);
|
||||
const response = await this.renderPage(mod, ctx, this.#env);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
error(this.#logging, 'ssr', err.stack || err.message || String(err));
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
|
||||
import type {
|
||||
AstroConfig,
|
||||
ComponentInstance,
|
||||
PropagationHint,
|
||||
RouteData,
|
||||
|
@ -37,6 +38,7 @@ export interface SSRManifest {
|
|||
entryModules: Record<string, string>;
|
||||
assets: Set<string>;
|
||||
propagation: SSRResult['propagation'];
|
||||
trailingSlash: AstroConfig['trailingSlash'];
|
||||
}
|
||||
|
||||
export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'propagation'> & {
|
||||
|
|
|
@ -215,6 +215,7 @@ function buildManifest(
|
|||
renderers: [],
|
||||
entryModules,
|
||||
assets: staticFiles.map((s) => settings.config.base + s),
|
||||
trailingSlash: opts.settings.config.trailingSlash,
|
||||
};
|
||||
|
||||
return ssrManifest;
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
export { createContainer, isStarted, runInContainer, startContainer } from './container.js';
|
||||
export { createContainer, isStarted, runInContainer, startContainer, type CreateContainerParams } from './container.js';
|
||||
export { default } from './dev.js';
|
||||
export { createContainerWithAutomaticRestart } from './restart.js';
|
||||
|
|
|
@ -74,7 +74,7 @@ interface GetScriptsAndStylesParams {
|
|||
filePath: URL;
|
||||
}
|
||||
|
||||
async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) {
|
||||
export async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams) {
|
||||
// Add hoisted script tags
|
||||
const scripts = await getScriptsForURL(filePath, env.loader);
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { expect } from 'chai';
|
||||
|
||||
import { runInContainer } from '../../../dist/core/dev/index.js';
|
||||
import { createFs, createRequestAndResponse } from '../test-utils.js';
|
||||
import { DevApp } from '../../../dist/core/app/dev.js';
|
||||
import { createFs } from '../test-utils.js';
|
||||
|
||||
const root = new URL('../../fixtures/alias/', import.meta.url);
|
||||
|
||||
|
@ -16,25 +16,23 @@ describe('base configuration', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer(
|
||||
{
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
const app = new DevApp({
|
||||
root,
|
||||
fs,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
async (container) => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/docs/',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
expect(res.statusCode).to.equal(404);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request(`http://localhost:8080/docs/`);
|
||||
const response = await app.render(request);
|
||||
|
||||
expect(response.status).to.equal(404);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('Requests that exclude a trailing slash 200', async () => {
|
||||
|
@ -45,25 +43,23 @@ describe('base configuration', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer(
|
||||
{
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
const app = new DevApp({
|
||||
root,
|
||||
fs,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
async (container) => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/docs',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
expect(res.statusCode).to.equal(200);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request(`http://localhost:8080/docs`);
|
||||
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -76,25 +72,23 @@ describe('base configuration', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer(
|
||||
{
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
const app = new DevApp({
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
async (container) => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/docs/sub/',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
expect(res.statusCode).to.equal(404);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request(`http://localhost:8080/docs/sub/`);
|
||||
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(404);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('Requests that exclude a trailing slash 200', async () => {
|
||||
|
@ -105,25 +99,23 @@ describe('base configuration', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer(
|
||||
{
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
const app = new DevApp({
|
||||
fs,
|
||||
root,
|
||||
userConfig: {
|
||||
base: '/docs',
|
||||
trailingSlash: 'never',
|
||||
},
|
||||
async (container) => {
|
||||
const { req, res, done } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/docs/sub',
|
||||
});
|
||||
container.handle(req, res);
|
||||
await done;
|
||||
expect(res.statusCode).to.equal(200);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const request = new Request(`http://localhost:8080/docs/sub`);
|
||||
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { expect } from 'chai';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
import { DevApp } from '../../../dist/core/app/dev.js';
|
||||
import { runInContainer } from '../../../dist/core/dev/index.js';
|
||||
import { createFs, createRequestAndResponse, triggerFSEvent } from '../test-utils.js';
|
||||
|
||||
|
@ -25,17 +26,21 @@ describe('dev container', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer({ fs, root }, async (container) => {
|
||||
const { req, res, text } = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
});
|
||||
container.handle(req, res);
|
||||
const html = await text();
|
||||
const app = new DevApp({ fs, root });
|
||||
try {
|
||||
await app.load();
|
||||
const request = new Request(app.url('/'));
|
||||
|
||||
const response = await app.render(request);
|
||||
expect(response.status).to.equal(200);
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
expect(res.statusCode).to.equal(200);
|
||||
|
||||
expect($('h1')).to.have.a.lengthOf(1);
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('HMR only short circuits on previously cached modules', async () => {
|
||||
|
@ -60,13 +65,14 @@ describe('dev container', () => {
|
|||
root
|
||||
);
|
||||
|
||||
await runInContainer({ fs, root }, async (container) => {
|
||||
let r = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
});
|
||||
container.handle(r.req, r.res);
|
||||
let html = await r.text();
|
||||
const app = new DevApp({ fs, root });
|
||||
try {
|
||||
await app.load();
|
||||
|
||||
let request = new Request(app.url('/'));
|
||||
let response = await app.render(request);
|
||||
|
||||
let html = await response.text();
|
||||
let $ = cheerio.load(html);
|
||||
expect($('body.one')).to.have.a.lengthOf(1);
|
||||
|
||||
|
@ -76,7 +82,7 @@ describe('dev container', () => {
|
|||
<h1>{Astro.props.title}</h1>
|
||||
`
|
||||
);
|
||||
triggerFSEvent(container, fs, '/src/components/Header.astro', 'change');
|
||||
app.fileChanged('/src/components/Header.astro');
|
||||
|
||||
fs.writeFileFromRootSync(
|
||||
'/src/pages/index.astro',
|
||||
|
@ -93,18 +99,18 @@ describe('dev container', () => {
|
|||
</html>
|
||||
`
|
||||
);
|
||||
triggerFSEvent(container, fs, '/src/pages/index.astro', 'change');
|
||||
app.fileChanged('/src/pages/index.astro');
|
||||
|
||||
r = createRequestAndResponse({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
});
|
||||
container.handle(r.req, r.res);
|
||||
html = await r.text();
|
||||
request = new Request(app.url('/'));
|
||||
response = await app.render(request);
|
||||
html = await response.text();
|
||||
$ = cheerio.load(html);
|
||||
expect($('body.one')).to.have.a.lengthOf(0);
|
||||
expect($('body.two')).to.have.a.lengthOf(1);
|
||||
});
|
||||
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('Allows dynamic segments in injected routes', async () => {
|
||||
|
|
Loading…
Reference in a new issue