Skip to content

Commit

Permalink
feat: add proper server reload on config file changes
Browse files Browse the repository at this point in the history
  • Loading branch information
Vexcited committed Jul 27, 2024
1 parent ab91b4c commit ce55f0e
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 398 deletions.
8 changes: 8 additions & 0 deletions .changeset/shiny-pets-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"stein-plugin-tailwindcss": minor
"stein-plugin-unocss": minor
"@steinjs/core": minor
"@steinjs/cli": minor
---

Add a proper server reload on config update method
7 changes: 3 additions & 4 deletions packages/cli/src/modules/dev.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type SteinConfig, dev, restartServer } from "@steinjs/core";
import { type SteinConfig, dev } from "@steinjs/core";
import { watchConfig } from "c12";
import type { Command } from "commander";

Expand All @@ -14,12 +14,11 @@ export const devModule = async (options: unknown, command: Command) => {
name: "stein",
onUpdate: async ({ newConfig: { config } }) => {
if (server) {
await restartServer(server, config);
server.container.config = config;
server.container.vite.stein?.restart();
}
},
});

server = await dev(cwd, config);
server.printUrls();
server.bindCLIShortcuts({ print: true });
};
22 changes: 11 additions & 11 deletions packages/cli/src/utils/createFileWithContent.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import path from "node:path";
import fs from "node:fs/promises";

export const createFileWithContent = async (
projectDir: string,
fileName: string,
content: string,
) => {
const filePath = path.join(projectDir, fileName);
await fs.writeFile(filePath, content);
};
import path from "node:path";
import fs from "node:fs/promises";

export const createFileWithContent = async (
projectDir: string,
fileName: string,
content: string,
) => {
const filePath = path.join(projectDir, fileName);
await fs.writeFile(filePath, content);
};
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"files": ["dist"],
"files": [
"dist"
],
"devDependencies": {
"@types/node": "^20.14.11",
"terser": "^5.31.3",
Expand All @@ -32,6 +34,7 @@
"typescript": "^5.5.3"
},
"dependencies": {
"c12": "^1.11.1",
"defu": "^6.1.4",
"vite": "^5.3.4",
"vite-plugin-solid": "^2.10.2"
Expand Down
248 changes: 196 additions & 52 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,203 @@ import {
type PluginOption as VitePluginOption,
build as createViteBuild,
createServer as createViteServer,
normalizePath,
} from "vite";

import { defu } from "defu";
import type { PartialDeep } from "type-fest";

import solid from "vite-plugin-solid";
export * from "./utils";

const sockets = new Set();
export interface Container {
vite: SteinDevServer;
config: SteinConfig;
restartInFlight: boolean;
close: () => Promise<void>;
}

export const createContainer = async (
cwd: string,
config: SteinConfig,
): Promise<Container> => {
const viteConfig = await convertToViteConfig(cwd, config);
const vite = (await createViteServer(viteConfig)) as SteinDevServer;

const container: Container = {
vite,
config,
restartInFlight: false,
close() {
return closeContainer(container);
},
};

return container;
};

export const closeContainer = async ({ vite }: Container) => {
await vite.close();
};

export const startContainer = async ({ vite, config }: Container) => {
await vite.listen(config.development.port);
const addr = `http://localhost:${config.development.port}`;
console.log("listening on", addr); // TODO: make this better ?

return addr;
};

const STEIN_CONFIG_RE = /.*stein.config.(?:mjs|cjs|js|ts)$/;
export function shouldRestartContainer(
watchFiles: string[],
restartInFlight: boolean,
changedFile: string,
): boolean {
if (restartInFlight) return false;
const normalizedChangedFile = normalizePath(changedFile);

// is handled manually in the CLI.
if (STEIN_CONFIG_RE.test(normalizedChangedFile)) return false;

return watchFiles.some(
(path) => normalizePath(path) === normalizedChangedFile,
);
}

async function createRestartedContainer(
cwd: string,
container: Container,
): Promise<Container> {
const { config } = container;
const newContainer = await createContainer(cwd, config);

await startContainer(newContainer);
return newContainer;
}

export async function restartContainer(
cwd: string,
container: Container,
): Promise<Container | Error> {
container.restartInFlight = true;

try {
await container.close();
return await createRestartedContainer(cwd, container);
} catch (_err) {
console.error("an error happened", _err);
container.restartInFlight = false;
return _err as Error;
}
}

interface Restart {
container: Container;
restarted: () => Promise<Error | null>;
}

export type SteinDevServer = ViteDevServer & {
stein?: {
restart: () => Promise<void>;
watcher: {
add: (pattern: string) => void;
};
};
};

export const createContainerWithAutomaticRestart = async (
cwd: string,
config: SteinConfig,
): Promise<Restart> => {
const initialContainer = await createContainer(cwd, config);
let resolveRestart: (value: Error | null) => void;
let restartComplete = new Promise<Error | null>((resolve) => {
resolveRestart = resolve;
});

const convertToViteConfig = async (
let watchFiles: string[] = [];

const restart: Restart = {
container: initialContainer,
restarted() {
return restartComplete;
},
};

async function handleServerRestart(logMsg = "") {
console.info(`${logMsg} Restarting...`.trim());
const container = restart.container;
watchFiles = []; // reset, will be filled at restart.

const result = await restartContainer(cwd, container);
if (result instanceof Error) {
// Failed to restart, use existing container
resolveRestart(result);
} else {
// Restart success. Add new watches because this is a new container with a new Vite server
restart.container = result;
setupContainer();
resolveRestart(null);
}
restartComplete = new Promise<Error | null>((resolve) => {
resolveRestart = resolve;
});
}

function handleChangeRestart(logMsg: string) {
return async (changedFile: string) => {
if (
shouldRestartContainer(
watchFiles,
restart.container.restartInFlight,
changedFile,
)
) {
handleServerRestart(logMsg);
}
};
}

// Set up watchers, vite restart API, and shortcuts
function setupContainer() {
const watcher = restart.container.vite.watcher;
watcher.on("change", handleChangeRestart("config file updated."));
watcher.on("unlink", handleChangeRestart("config file removed."));
watcher.on("add", handleChangeRestart("config file added."));

// Restart the Stein dev server instead of Vite's when the API is called by plugins.
// Ignore the `forceOptimize` parameter for now.
restart.container.vite.stein = {
restart: handleServerRestart,
watcher: {
add: (pattern) => void watchFiles.push(pattern),
},
};

// Set up shortcuts, overriding Vite's default shortcuts so it works for Astro
restart.container.vite.bindCLIShortcuts({
customShortcuts: [
// Disable Vite's builtin "r" (restart server), "u" (print server urls) and "c" (clear console) shortcuts
{ key: "r", description: "" },
{ key: "u", description: "" },
{ key: "c", description: "" },
],
});
}
setupContainer();
return restart;
};

export const convertToViteConfig = async (
cwd: string,
config: SteinConfig,
): Promise<ViteConfig> => {
let solidIndex = 0;
const plugins: VitePluginOption = [solid()];

for (const pluginPromise of config.plugins) {
const plugin = await pluginPromise;
for (const createPlugin of config.plugins) {
const plugin = await createPlugin();

// We need to register the plugins that were made for Vite.
for (const vitePlugin of plugin.extends ?? []) {
Expand Down Expand Up @@ -49,24 +228,16 @@ const convertToViteConfig = async (
};
};

export const dev = async (
cwd: string,
config: SteinConfig,
): Promise<ViteDevServer> => {
const server = await createViteServer(await convertToViteConfig(cwd, config));

await server.listen();
server.httpServer?.on("connection", (socket) => {
sockets.add(socket);
console.log("Socket connected");

server.httpServer?.on("close", () => {
console.log("Socket deleted");
sockets.delete(socket);
});
});
export const dev = async (cwd: string, config: SteinConfig) => {
const restart = await createContainerWithAutomaticRestart(cwd, config);
const devServerAddressInfo = await startContainer(restart.container);

return server;
return {
address: devServerAddressInfo,
get container() {
return restart.container;
},
};
};

export const build = async (
Expand All @@ -76,40 +247,12 @@ export const build = async (
await createViteBuild(await convertToViteConfig(cwd, config));
};

export const restartServer = async (
server: ViteDevServer,
config: SteinConfig,
): Promise<ViteDevServer> => {
await server.close();

// For some reason we have to do this.
server.httpServer?.removeAllListeners();
server.httpServer?.close();
server.httpServer?.emit("close");

//@ts-ignore
server.httpServer = undefined; // Override with undefined to force garbage collection.

try {
for (const socket of sockets) {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
(socket as any).destroy();
sockets.delete(socket);
}
} catch {}

// Add a small delay to ensure port is released (spoiler: it's not when this func called from the CLI)
await new Promise((resolve) => setTimeout(resolve, 1000));

return await dev(process.cwd(), config);
};

export interface SteinConfig {
/**
* Stein plugins to use in the project.
* @default []
*/
plugins: Promise<Plugin>[];
plugins: Array<() => Promise<Plugin> | Plugin>;

development: {
/**
Expand Down Expand Up @@ -145,5 +288,6 @@ export interface Plugin {
}

/** Helper to have types when making a new plugin. */
export const definePlugin = <T>(plugin: (config?: T) => Promise<Plugin>) =>
plugin;
export const definePlugin = <T>(
plugin: (config?: T) => () => Promise<Plugin> | Plugin,
) => plugin;
Loading

0 comments on commit ce55f0e

Please sign in to comment.