Compare commits

...

10 commits
main ... devapp

Author SHA1 Message Date
Matthew Phillips
a63ec9d59d Temp hack 2023-02-13 11:10:03 -05:00
Matthew Phillips
e48f3fed4b tmp fix 2023-02-13 10:59:17 -05:00
Matthew Phillips
a140fb0ded Load the astro config 2023-02-13 10:41:33 -05:00
Matthew Phillips
c8299b24fb Expose raw container just in case 2023-02-13 10:20:58 -05:00
Matthew Phillips
bc437dce60 Add a changeset 2023-02-13 10:02:41 -05:00
Matthew Phillips
48efa4beda Remove some more containerisms 2023-02-13 09:47:44 -05:00
Matthew Phillips
e12e1e61c1 Fix remaining tests 2023-02-13 09:22:47 -05:00
Matthew Phillips
c0febd56f9 Get scripts and styles in devapp 2023-02-13 08:36:16 -05:00
Matthew Phillips
26458cf4f4 Expose a way to get routes 2023-02-13 08:21:09 -05:00
Matthew Phillips
4ce0b918b1 Start of devapp exploration 2023-02-10 09:12:24 -05:00
10 changed files with 320 additions and 109 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Add a DevApp export

View file

@ -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/*",

View 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);
};
}

View file

@ -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));

View file

@ -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'> & {

View file

@ -215,6 +215,7 @@ function buildManifest(
renderers: [],
entryModules,
assets: staticFiles.map((s) => settings.config.base + s),
trailingSlash: opts.settings.config.trailingSlash,
};
return ssrManifest;

View file

@ -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';

View file

@ -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);

View file

@ -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,
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,
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(
{
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(
{
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();
}
);
});
});
});

View file

@ -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 () => {