Allow renderers configuration to update (#489)

* Start of dynamic renderers

* Implementation
This commit is contained in:
Matthew Phillips 2021-06-21 08:44:45 -04:00 committed by GitHub
parent f04b82d47e
commit 491ff66603
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 218 additions and 98 deletions

View file

@ -4,8 +4,22 @@ const transformPromise = import('./dist/compiler/index.js');
const DEFAULT_HMR_PORT = 12321;
/** @type {import('snowpack').SnowpackPluginFactory<any>} */
module.exports = (snowpackConfig, { resolvePackageUrl, renderers, astroConfig, mode } = {}) => {
/**
* @typedef {Object} PluginOptions - creates a new type named 'SpecialType'
* @prop {import('./src/config_manager').ConfigManager} configManager
* @prop {'development' | 'production'} mode
*/
/**
* @type {import('snowpack').SnowpackPluginFactory<PluginOptions>}
*/
module.exports = (snowpackConfig, options = {}) => {
const {
resolvePackageUrl,
astroConfig,
configManager,
mode
} = options;
let hmrPort = DEFAULT_HMR_PORT;
return {
name: 'snowpack-astro',
@ -14,36 +28,18 @@ module.exports = (snowpackConfig, { resolvePackageUrl, renderers, astroConfig, m
input: ['.astro', '.md'],
output: ['.js', '.css'],
},
/**
* This injects our renderer plugins to the Astro runtime (as a bit of a hack).
*
* In a world where Snowpack supports virtual files, this won't be necessary and
* should be refactored to a virtual file that is imported by the runtime.
*
* Take a look at `/src/frontend/__astro_component.ts`. It relies on both
* `__rendererSources` and `__renderers` being defined, so we're creating those here.
*
* The output of this is the following (or something very close to it):
*
* ```js
* import * as __renderer_0 from '/_snowpack/link/packages/renderers/vue/index.js';
* import * as __renderer_1 from '/_snowpack/link/packages/renderers/svelte/index.js';
* import * as __renderer_2 from '/_snowpack/link/packages/renderers/preact/index.js';
* import * as __renderer_3 from '/_snowpack/link/packages/renderers/react/index.js';
* let __rendererSources = ["/_snowpack/link/packages/renderers/vue/client.js", "/_snowpack/link/packages/renderers/svelte/client.js", "/_snowpack/link/packages/renderers/preact/client.js", "/_snowpack/link/packages/renderers/react/client.js"];
* let __renderers = [__renderer_0, __renderer_1, __renderer_2, __renderer_3];
* // the original file contents
* ```
*/
async transform({contents, id, fileExt}) {
if (fileExt === '.js' && /__astro_component\.js/g.test(id)) {
const rendererServerPackages = renderers.map(({ server }) => server);
const rendererClientPackages = await Promise.all(renderers.map(({ client }) => resolvePackageUrl(client)));
const result = `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')}
let __rendererSources = [${rendererClientPackages.map(pkg => `"${pkg}"`).join(', ')}];
let __renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}];
${contents}`;
return result;
if(configManager.isConfigModule(fileExt, id)) {
configManager.configModuleId = id;
const source = await configManager.buildSource(contents);
return source;
}
},
onChange({ filePath }) {
// If the astro.config.mjs file changes, mark the generated config module as changed.
if(configManager.isAstroConfig(filePath) && configManager.configModuleId) {
this.markChanged(configManager.configModuleId);
configManager.markDirty();
}
},
config(snowpackConfig) {
@ -55,12 +51,13 @@ ${contents}`;
const { compileComponent } = await transformPromise;
const projectRoot = snowpackConfig.root;
const contents = await readFile(filePath, 'utf-8');
/** @type {import('./src/@types/compiler').CompileOptions} */
const compileOptions = {
astroConfig,
hmrPort,
mode,
resolvePackageUrl,
renderers,
};
const result = await compileComponent(contents, { compileOptions, filename: filePath, projectRoot });
const output = {

View file

@ -176,3 +176,16 @@ export interface ComponentInfo {
}
export type Components = Map<string, ComponentInfo>;
type AsyncRendererComponentFn<U> = (
Component: any,
props: any,
children: string | undefined
) => Promise<U>;
export interface Renderer {
check: AsyncRendererComponentFn<boolean>;
renderToStaticMarkup: AsyncRendererComponentFn<{
html: string;
}>;
}

View file

@ -0,0 +1,140 @@
import type { ServerRuntime as SnowpackServerRuntime } from 'snowpack';
import type { AstroConfig } from './@types/astro';
import { posix as path } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import resolve from 'resolve';
import { loadConfig } from './config.js';
type RendererSnowpackPlugin = string | [string, any] | undefined;
interface RendererInstance {
name: string;
snowpackPlugin: RendererSnowpackPlugin;
client: string;
server: string;
}
const CONFIG_MODULE_BASE_NAME = '__astro_config.js';
const CONFIG_MODULE_URL = `/_astro_frontend/${CONFIG_MODULE_BASE_NAME}`;
const DEFAULT_RENDERERS = [
'@astrojs/renderer-vue',
'@astrojs/renderer-svelte',
'@astrojs/renderer-react',
'@astrojs/renderer-preact'
];
export class ConfigManager {
private state: 'initial' | 'dirty' | 'clean' = 'initial';
public snowpackRuntime: SnowpackServerRuntime | null = null;
public configModuleId: string | null = null;
private rendererNames!: string[];
private version = 1;
constructor(
private astroConfig: AstroConfig,
private resolvePackageUrl: (pkgName: string) => Promise<string>,
) {
this.setRendererNames(this.astroConfig);
}
markDirty() {
this.state = 'dirty';
}
async update() {
if(this.needsUpdate() && this.snowpackRuntime) {
// astro.config.mjs has changed, reload it.
if(this.state === 'dirty') {
const version = this.version++;
const astroConfig = await loadConfig(this.astroConfig.projectRoot.pathname, `astro.config.mjs?version=${version}`);
this.setRendererNames(astroConfig);
}
await this.importModule(this.snowpackRuntime);
this.state = 'clean';
}
}
isConfigModule(fileExt: string, filename: string) {
return fileExt === '.js' && filename.endsWith(CONFIG_MODULE_BASE_NAME);
}
isAstroConfig(filename: string) {
const { projectRoot } = this.astroConfig;
return new URL('./astro.config.mjs', projectRoot).pathname === filename;
}
async buildRendererInstances(): Promise<RendererInstance[]> {
const { projectRoot } = this.astroConfig;
const rendererNames = this.rendererNames;
const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) });
const rendererInstances = (
await Promise.all(
rendererNames.map((rendererName) => {
const entrypoint = pathToFileURL(resolveDependency(rendererName)).toString();
return import(entrypoint);
})
)
).map(({ default: raw }, i) => {
const { name = rendererNames[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw;
if (typeof client !== 'string') {
throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`);
}
if (typeof server !== 'string') {
throw new Error(`Expected "server" from ${name} to be a relative path to the server-side renderer!`);
}
let snowpackPlugin: RendererSnowpackPlugin;
if (typeof snowpackPluginName === 'string') {
if (snowpackPluginOptions) {
snowpackPlugin = [resolveDependency(snowpackPluginName), snowpackPluginOptions];
} else {
snowpackPlugin = resolveDependency(snowpackPluginName);
}
} else if (snowpackPluginName) {
throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`);
}
return {
name,
snowpackPlugin,
client: path.join(name, raw.client),
server: path.join(name, raw.server),
};
});
return rendererInstances;
}
async buildSource(contents: string): Promise<string> {
const renderers = await this.buildRendererInstances();
const rendererServerPackages = renderers.map(({ server }) => server);
const rendererClientPackages = await Promise.all(renderers.map(({ client }) => this.resolvePackageUrl(client)));
const result = /* js */ `${rendererServerPackages.map((pkg, i) => `import __renderer_${i} from "${pkg}";`).join('\n')}
import { setRenderers } from 'astro/dist/internal/__astro_component.js';
let rendererSources = [${rendererClientPackages.map(pkg => `"${pkg}"`).join(', ')}];
let renderers = [${rendererServerPackages.map((_, i) => `__renderer_${i}`).join(', ')}];
${contents}
`;
return result;
}
needsUpdate(): boolean {
return this.state === 'initial' || this.state === 'dirty';
}
private setRendererNames(astroConfig: AstroConfig) {
this.rendererNames = astroConfig.renderers || DEFAULT_RENDERERS;
}
private async importModule(snowpackRuntime: SnowpackServerRuntime): Promise<void> {
await snowpackRuntime!.importModule(CONFIG_MODULE_URL);
}
}

View file

@ -0,0 +1,6 @@
declare function setRenderers(sources: string[], renderers: any[]): void;
declare let rendererSources: string[];
declare let renderers: any[];
setRenderers(rendererSources, renderers);

View file

@ -1,3 +1,4 @@
import type { Renderer } from '../@types/astro';
import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring';
@ -7,22 +8,13 @@ import * as astro from './renderer-astro';
// see https://github.com/remcohaszing/estree-util-value-to-estree#readme
const serialize = (value: Value) => generate(valueToEstree(value));
/**
* These values are dynamically injected by Snowpack.
* See comment in `snowpack-plugin.cjs`!
*
* In a world where Snowpack supports virtual files, this won't be necessary.
* It would ideally look something like:
*
* ```ts
* import { __rendererSources, __renderers } from "virtual:astro/runtime"
* ```
*/
declare let __rendererSources: string[];
declare let __renderers: any[];
let rendererSources: string[] = [];
let renderers: Renderer[] = [];
__rendererSources = ['', ...__rendererSources];
__renderers = [astro, ...__renderers];
export function setRenderers(_rendererSources: string[], _renderers: Renderer[]) {
rendererSources = [''].concat(_rendererSources);
renderers = [astro as Renderer].concat(_renderers);
}
const rendererCache = new WeakMap();
@ -33,7 +25,7 @@ async function resolveRenderer(Component: any, props: any = {}, children?: strin
}
const errors: Error[] = [];
for (const __renderer of __renderers) {
for (const __renderer of renderers) {
// Yes, we do want to `await` inside of this loop!
// __renderer.check can't be run in parallel, it
// returns the first match and skips any subsequent checks
@ -64,7 +56,7 @@ interface AstroComponentProps {
/** For hydrated components, generate a <script type="module"> to load the component */
async function generateHydrateScript({ renderer, astroId, props }: any, { hydrate, componentUrl, componentExport }: Required<AstroComponentProps>) {
const rendererSource = __rendererSources[__renderers.findIndex((r) => r === renderer)];
const rendererSource = rendererSources[renderers.findIndex((r) => r === renderer)];
const script = `<script type="module">
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
@ -104,7 +96,7 @@ export const __astro_component = (Component: any, componentProps: AstroComponent
if (!renderer) {
// If the user only specifies a single renderer, but the check failed
// for some reason... just default to their preferred renderer.
renderer = __rendererSources.length === 2 ? __renderers[1] : null;
renderer = rendererSources.length === 2 ? renderers[1] : null;
if (!renderer) {
const name = getComponentName(Component, componentProps);

View file

@ -4,7 +4,7 @@ import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Pa
import resolve from 'resolve';
import { existsSync, promises as fs } from 'fs';
import { fileURLToPath, pathToFileURL } from 'url';
import { fileURLToPath } from 'url';
import { posix as path } from 'path';
import { performance } from 'perf_hooks';
import {
@ -22,6 +22,7 @@ import { debug, info } from './logger.js';
import { configureSnowpackLogger } from './snowpack-logger.js';
import { searchForPage } from './search.js';
import snowpackExternals from './external.js';
import { ConfigManager } from './config_manager.js';
interface RuntimeConfig {
astroConfig: AstroConfig;
@ -30,6 +31,7 @@ interface RuntimeConfig {
snowpack: SnowpackDevServer;
snowpackRuntime: SnowpackServerRuntime;
snowpackConfig: SnowpackConfig;
configManager: ConfigManager;
}
// info needed for collection generation
@ -54,7 +56,7 @@ configureSnowpackLogger(snowpackLogger);
/** Pass a URL to Astro to resolve and build */
async function load(config: RuntimeConfig, rawPathname: string | undefined): Promise<LoadResult> {
const { logging, snowpackRuntime, snowpack } = config;
const { logging, snowpackRuntime, snowpack, configManager } = config;
const { buildOptions, devOptions } = config.astroConfig;
let origin = buildOptions.site ? new URL(buildOptions.site).origin : `http://localhost:${devOptions.port}`;
@ -92,6 +94,9 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
let rss: { data: any[] & CollectionRSS } = {} as any;
try {
if(configManager.needsUpdate()) {
await configManager.update();
}
const mod = await snowpackRuntime.importModule(snowpackURL);
debug(logging, 'resolve', `${reqPath} -> ${snowpackURL}`);
@ -306,31 +311,33 @@ interface RuntimeOptions {
interface CreateSnowpackOptions {
mode: RuntimeMode;
resolvePackageUrl?: (pkgName: string) => Promise<string>;
resolvePackageUrl: (pkgName: string) => Promise<string>;
}
const DEFAULT_RENDERERS = ['@astrojs/renderer-vue', '@astrojs/renderer-svelte', '@astrojs/renderer-react', '@astrojs/renderer-preact'];
/** Create a new Snowpack instance to power Astro */
async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackOptions) {
const { projectRoot, renderers = DEFAULT_RENDERERS } = astroConfig;
const { projectRoot } = astroConfig;
const { mode, resolvePackageUrl } = options;
const frontendPath = new URL('./frontend/', import.meta.url);
const resolveDependency = (dep: string) => resolve.sync(dep, { basedir: fileURLToPath(projectRoot) });
const isHmrEnabled = mode === 'development';
// The config manager takes care of the runtime config module (that handles setting renderers, mostly)
const configManager = new ConfigManager(astroConfig, resolvePackageUrl);
let snowpack: SnowpackDevServer;
let astroPluginOptions: {
resolvePackageUrl?: (s: string) => Promise<string>;
renderers?: { name: string; client: string; server: string }[];
astroConfig: AstroConfig;
hmrPort?: number;
mode: RuntimeMode;
configManager: ConfigManager;
} = {
astroConfig,
resolvePackageUrl,
mode,
resolvePackageUrl,
configManager,
};
const mountOptions = {
@ -344,46 +351,8 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
(process.env as any).TAILWIND_DISABLE_TOUCH = true;
}
const rendererInstances = (
await Promise.all(
renderers.map((renderer) => {
const entrypoint = pathToFileURL(resolveDependency(renderer)).toString();
return import(entrypoint);
})
)
).map(({ default: raw }, i) => {
const { name = renderers[i], client, server, snowpackPlugin: snowpackPluginName, snowpackPluginOptions } = raw;
if (typeof client !== 'string') {
throw new Error(`Expected "client" from ${name} to be a relative path to the client-side renderer!`);
}
if (typeof server !== 'string') {
throw new Error(`Expected "server" from ${name} to be a relative path to the server-side renderer!`);
}
let snowpackPlugin: string | [string, any] | undefined;
if (typeof snowpackPluginName === 'string') {
if (snowpackPluginOptions) {
snowpackPlugin = [resolveDependency(snowpackPluginName), snowpackPluginOptions];
} else {
snowpackPlugin = resolveDependency(snowpackPluginName);
}
} else if (snowpackPluginName) {
throw new Error(`Expected the snowpackPlugin from ${name} to be a "string" but encountered "${typeof snowpackPluginName}"!`);
}
return {
name,
snowpackPlugin,
client: path.join(name, raw.client),
server: path.join(name, raw.server),
};
});
astroPluginOptions.renderers = rendererInstances;
// Make sure that Snowpack builds our renderer plugins
const rendererInstances = await configManager.buildRendererInstances();
const knownEntrypoints = [].concat(...(rendererInstances.map((renderer) => [renderer.server, renderer.client]) as any));
const rendererSnowpackPlugins = rendererInstances.filter((renderer) => renderer.snowpackPlugin).map((renderer) => renderer.snowpackPlugin) as string | [string, any];
@ -434,8 +403,9 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
}
);
const snowpackRuntime = snowpack.getServerRuntime();
astroPluginOptions.configManager.snowpackRuntime = snowpackRuntime;
return { snowpack, snowpackRuntime, snowpackConfig };
return { snowpack, snowpackRuntime, snowpackConfig, configManager };
}
/** Core Astro runtime */
@ -449,6 +419,7 @@ export async function createRuntime(astroConfig: AstroConfig, { mode, logging }:
snowpack: snowpackInstance,
snowpackRuntime,
snowpackConfig,
configManager,
} = await createSnowpack(astroConfig, {
mode,
resolvePackageUrl,
@ -463,6 +434,7 @@ export async function createRuntime(astroConfig: AstroConfig, { mode, logging }:
snowpack,
snowpackRuntime,
snowpackConfig,
configManager,
};
return {