Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: use a fresh Vite dev server for each previewed component #1686

Merged
merged 18 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function onUrlChanged() {
if (!componentId.includes(":")) {
return;
}
iframeController.resetIframe();
iframeController.resetIframe(componentId);
const computePropsResponse = await rpcApi.request(RPCs.ComputeProps, {
componentIds: [componentId],
});
Expand Down
7 changes: 4 additions & 3 deletions chromeless/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,23 @@ export async function startPreview({
let onRenderingError = (_e: any) => {
// No-op by default.
};
let failOnErrorLog = false;
let renderSucceeded = false;
let lastErrorLog: string | null = null;
const events = await setupPreviewEventListener(page, (event) => {
if (event.kind === "rendering-done") {
if (event.success) {
renderSucceeded = true;
onRenderingDone();
} else {
if (lastErrorLog) {
onRenderingError(new Error(lastErrorLog));
} else {
// The error log should be coming straight after.
failOnErrorLog = true;
}
}
} else if (event.kind === "log-message" && event.level === "error") {
lastErrorLog = event.message;
if (failOnErrorLog) {
if (!renderSucceeded && !event.message.startsWith("[Vue warn]")) {
onRenderingError(new Error(event.message));
}
}
Expand Down Expand Up @@ -150,6 +150,7 @@ export async function startPreview({
onRenderingError = reject;
});
await waitUntilNetworkIdle(page);
renderSucceeded = false;
await page.evaluate(
async (component) => {
// It's possible that window.renderComponent isn't ready yet.
Expand Down
115 changes: 77 additions & 38 deletions core/src/previewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Reader, ReaderListenerInfo } from "@previewjs/vfs";
import { createFileSystemReader, createStackedReader } from "@previewjs/vfs";
import assertNever from "assert-never";
import axios from "axios";
import { exclusivePromiseRunner } from "exclusive-promises";
import express from "express";
import { escape } from "html-escaper";
import path from "path";
Expand Down Expand Up @@ -58,12 +59,13 @@ const SHUTDOWN_AFTER_INACTIVITY = 10000;
export class Previewer {
private readonly transformingReader: Reader;
private appServer: Server | null = null;
private viteManager: ViteManager | null = null;
private viteManagers: Record<string, ViteManager> = {};
private status: PreviewerStatus = { kind: "stopped" };
private shutdownCheckInterval: NodeJS.Timeout | null = null;
private disposeObserver: (() => Promise<void>) | null = null;
private config: PreviewConfig | null = null;
private lastPingTimestamp = 0;
private locking = exclusivePromiseRunner();

constructor(
private readonly options: {
Expand Down Expand Up @@ -158,30 +160,78 @@ export class Previewer {
// PostCSS requires the current directory to change because it relies
// on the `import-cwd` package to resolve plugins.
process.chdir(this.options.rootDirPath);
const config = await readConfig(this.options.rootDirPath);
this.config = {
...config,
wrapper: config.wrapper || {
const configFromProject = await readConfig(this.options.rootDirPath);
const config = (this.config = {
...configFromProject,
wrapper: configFromProject.wrapper || {
path: this.options.frameworkPlugin.defaultWrapperPath,
},
};
});
const globalCssAbsoluteFilePaths = await findFiles(
this.options.rootDirPath,
`**/@(${GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT.join(
"|"
)}).@(${GLOBAL_CSS_EXTS.join("|")})`
);
const router = express.Router();
router.get("/preview/", async (req, res) => {
if (!this.viteManager) {
res.status(404).end(`Uh-Oh! Vite server is not running.`);
router.get(/^\/preview\/.*:[^/]+\/$/, async (req, res) => {
if (req.header("Accept") === "text/x-vite-ping") {
// This is triggered as part of HMR. It may come after we've
// switched to rendering a different component, so we want to
// make sure this doesn't accidentally restart the Vite manager for it.
res.writeHead(204).end();
return;
}
try {
const server = this.appServer?.server;
if (!server) {
throw new Error(`No server running`);
}
const componentId = req.path.substring(9, req.path.length - 1);
// Note: we only ever have a single active Vite manager.
// We must prevent race conditions here when we quickly switch back to a
// component whose Vite manager hasn't stopped yet.
let viteManager!: ViteManager;
await this.locking(async () => {
const existingViteManager = this.viteManagers[componentId];
if (existingViteManager) {
viteManager = existingViteManager;
} else {
await Promise.all(
Object.values(this.viteManagers).map((v) => v.stop())
);
this.viteManagers = {};
this.options.logger.debug(`Initializing Vite manager`);
viteManager = new ViteManager({
componentId,
base: req.path,
rootDirPath: this.options.rootDirPath,
shadowHtmlFilePath: path.join(
this.options.previewDirPath,
"index.html"
),
detectedGlobalCssFilePaths: globalCssAbsoluteFilePaths.map(
(absoluteFilePath) =>
path.relative(this.options.rootDirPath, absoluteFilePath)
),
reader: this.transformingReader,
cacheDir: path.join(
getCacheDir(this.options.rootDirPath),
"vite"
),
config,
logger: this.options.logger,
frameworkPlugin: this.options.frameworkPlugin,
});
this.viteManagers[componentId] = viteManager;
this.options.logger.debug(`Starting Vite manager`);
await viteManager.start(server, port);
}
});
res
.status(200)
.set({ "Content-Type": "text/html" })
.end(await this.viteManager.loadIndexHtml(req.originalUrl));
.end(await viteManager.loadIndexHtml(req.originalUrl));
} catch (e: any) {
res
.status(500)
Expand Down Expand Up @@ -223,7 +273,11 @@ export class Previewer {
...(this.options.middlewares || []),
router,
(req, res, next) => {
this.viteManager?.middleware(req, res, next);
// Note: this isn't a problem because there is only ever at most one
// Vite manager at any given time.
for (const viteManager of Object.values(this.viteManagers)) {
viteManager.middleware(req, res, next);
}
},
],
});
Expand All @@ -238,27 +292,8 @@ export class Previewer {
}
this.transformingReader.listeners.add(this.onFileChangeListener);
}
this.options.logger.debug(`Initializing Vite manager`);
this.viteManager = new ViteManager({
rootDirPath: this.options.rootDirPath,
shadowHtmlFilePath: path.join(
this.options.previewDirPath,
"index.html"
),
detectedGlobalCssFilePaths: globalCssAbsoluteFilePaths.map(
(absoluteFilePath) =>
path.relative(this.options.rootDirPath, absoluteFilePath)
),
reader: this.transformingReader,
cacheDir: path.join(getCacheDir(this.options.rootDirPath), "vite"),
config: this.config,
logger: this.options.logger,
frameworkPlugin: this.options.frameworkPlugin,
});
this.options.logger.debug(`Starting server`);
const server = await this.appServer.start(port);
this.options.logger.debug(`Starting Vite manager`);
await this.viteManager.start(server, port);
await this.appServer.start(port);
this.options.logger.debug(`Previewer ready`);
this.status = {
kind: "started",
Expand Down Expand Up @@ -346,10 +381,10 @@ export class Previewer {
this.disposeObserver = null;
}
}
if (this.viteManager) {
await this.viteManager.stop();
this.viteManager = null;
}
await Promise.all(
Object.values(this.viteManagers).map((v) => v.stop())
);
this.viteManagers = {};
if (this.appServer) {
await this.appServer.stop();
this.appServer = null;
Expand All @@ -371,7 +406,9 @@ export class Previewer {
absoluteFilePath ===
path.resolve(this.options.rootDirPath, this.config.wrapper.path)
) {
this.viteManager?.triggerFullReload();
for (const viteManager of Object.values(this.viteManagers)) {
viteManager.triggerFullReload();
}
}
if (
!info.virtual &&
Expand All @@ -394,8 +431,10 @@ export class Previewer {
return;
}
if (info.virtual) {
this.viteManager?.triggerReload(absoluteFilePath);
this.viteManager?.triggerReload(absoluteFilePath + ".ts");
for (const viteManager of Object.values(this.viteManagers)) {
viteManager.triggerReload(absoluteFilePath);
viteManager.triggerReload(absoluteFilePath + ".ts");
}
} else if (this.options.onFileChanged) {
this.options.onFileChanged(absoluteFilePath);
}
Expand Down
8 changes: 8 additions & 0 deletions core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export class Server {
private readonly logger: Logger;
private serverTerminator: HttpTerminator | null = null;

#server: http.Server | null = null;

constructor(options: {
logger: Logger;
middlewares: express.RequestHandler[];
Expand All @@ -28,6 +30,10 @@ export class Server {
this.app = app;
}

get server() {
return this.#server;
}

async start(port: number) {
return new Promise<http.Server>((resolve) => {
const server = this.app.listen(port, () => {
Expand All @@ -40,6 +46,7 @@ export class Server {
server,
gracefulTerminationTimeout: 0,
});
this.#server = server;
});
}

Expand All @@ -48,6 +55,7 @@ export class Server {
await this.serverTerminator.terminate();
this.serverTerminator = null;
}
this.#server = null;
this.logger.info(`Preview.js server stopped.`);
}
}
Loading