Skip to content

Commit

Permalink
feat: development server commands and search docs (#65)
Browse files Browse the repository at this point in the history
This PR implements commands and integrated documentation search when
running the development server. The developer can start typing in the
terminal, and the server auto-suggests commands and search results. When
the search term is more than 2 characters it will search the
documentation using Algolia search and selecting a search result will
open it in the default browser.

#### Available commands

- Restart the development server 🔄
- Reload the application 🔥
- Print the server URLs 🔗
- Clear the console 🧹
- Open application in the default browser 🌐
- Quit the development server 🚫

_This PR also decorates core development server messages with emojis to
have more fun while using `@lazarv/react-server`!_
  • Loading branch information
lazarv authored Oct 24, 2024
1 parent b16430e commit b3c6f79
Show file tree
Hide file tree
Showing 8 changed files with 651 additions and 50 deletions.
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@vercel/analytics": "^1.3.1",
"@vercel/speed-insights": "^1.0.12",
"@vitejs/plugin-react-swc": "^3.7.0",
"algoliasearch": "^4.24.0",
"highlight.js": "^11.9.0",
"lucide-react": "^0.408.0",
"rehype-highlight": "^7.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/config/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export async function loadConfig(initialConfig, options = {}) {
if (options.onChange) {
const watcher = watch(configPatterns, { cwd, ignoreInitial: true });
const handler = () => {
watcher.close();
options.onChange();
};
options.onWatch?.(watcher);
watcher.on("add", handler);
watcher.on("unlink", handler);
watcher.on("change", handler);
Expand Down
56 changes: 45 additions & 11 deletions packages/react-server/lib/dev/action.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isIPv6 } from "node:net";
import { setTimeout } from "node:timers/promises";

import open from "open";
import colors from "picocolors";
Expand All @@ -20,6 +21,7 @@ import { getEnv } from "../sys.mjs";
import banner from "../utils/banner.mjs";
import { formatDuration } from "../utils/format.mjs";
import getServerAddresses from "../utils/server-address.mjs";
import { command } from "./command.mjs";
import createServer from "./create-server.mjs";

export default async function dev(root, options) {
Expand All @@ -28,25 +30,41 @@ export default async function dev(root, options) {
banner("starting development server");

let server;
let configWatcher;
let showHelp = true;
const restart = async () => {
await runtime_init$(async () => {
try {
const restartServer = async () => {
try {
configWatcher?.close?.();
globalThis.__react_server_ready__ = [];
globalThis.__react_server_start__ = Date.now();
await Promise.all(
server?.handlers?.map(
(handler) => handler.close?.() ?? handler.terminate?.()
)
);
await server?.close();
await restart?.();
} catch (e) {
console.error(colors.red(e.stack));
}
};

let config = await loadConfig(
{},
options.watch ?? true
? {
...options,
onChange() {
onWatch(watcher) {
configWatcher = watcher;
},
async onChange() {
getRuntime(LOGGER_CONTEXT)?.warn?.(
`config changed, restarting server...`
);
globalThis.__react_server_ready__ = [];
globalThis.__react_server_start__ = Date.now();
server?.handlers?.forEach(
(handler) => handler.close?.() ?? handler.terminate?.()
`Configuration changed, restarting server...`
);
server?.close();
restart?.();
await restartServer();
},
}
: options
Expand Down Expand Up @@ -121,9 +139,25 @@ export default async function dev(root, options) {
}

server.printUrls(resolvedUrls);
getRuntime(LOGGER_CONTEXT)?.info?.(
`${colors.green("✔")} Ready in ${formatDuration(Date.now() - globalThis.__react_server_start__)}`

const logger = getRuntime(LOGGER_CONTEXT);
logger?.info?.(
`${colors.green("✔")} Ready in ${formatDuration(Date.now() - globalThis.__react_server_start__)} 🚀`
);

if (showHelp) {
logger.info?.("Press any key to open the command menu 💻");
logger.info?.("Start typing to search the docs 🔍");
logger.info?.("Ctrl+C to exit 🚫");
showHelp = false;
}

command({
logger: getRuntime(LOGGER_CONTEXT),
server,
resolvedUrls,
restartServer,
});
})
.on("error", (e) => {
if (e.code === "EADDRINUSE") {
Expand Down
209 changes: 209 additions & 0 deletions packages/react-server/lib/dev/command.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import readline from "node:readline";
import { PassThrough } from "node:stream";
import { setTimeout } from "node:timers/promises";

import { search } from "@inquirer/prompts";
import { algoliasearch } from "algoliasearch";
import open from "open";
import colors from "picocolors";

const algolia = {
appId: "OVQLOZDOSH",
apiKey: "5a8224f70c312c69121f92482ff2df82",
indexName: "react-server",
};

let algoliaClient;
let stdin;
export async function command({ logger, server, resolvedUrls, restartServer }) {
if (!process.stdin.isTTY) return;

if (!stdin) {
stdin = new PassThrough();
process.stdin.pipe(stdin);

// catch SIGINT and exit
process.stdin.on("data", (key) => {
if (key == "\u0003") {
process.exit(0);
}
});

readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);

algoliaClient = algoliasearch(algolia.appId, algolia.apiKey);
}

const controller = new AbortController();
const availableCommands = {
r: {
name: "Restart the development server 🔄",
async execute() {
logger?.warn?.(`Restarting server... 🚧`);
controller.abort();
},
},
l: {
name: "Reload the application 🔥",
execute: () => {
server.environments.client.hot.send({
type: "full-reload",
path: "*",
});
},
},
u: {
name: "Print the server URLs 🔗",
execute: () => {
server.printUrls(resolvedUrls);
},
},
c: {
name: "Clear the console 🧹",
execute: () => {
console.clear();
logger?.info?.(`${colors.green("✔")} Console cleared! 🧹`);
},
},
o: {
name: "Open application in the default browser 🌐",
execute: () => {
open(resolvedUrls[0].toString());
},
},
q: {
name: "Quit the development server 🚫",
execute: () => {
process.exit(0);
},
},
};
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
second: "numeric",
});
let activeCommand = false;
let searchCommands = {};
const command = async () => {
if (activeCommand) return;
try {
activeCommand = true;

const answer = await search(
{
message: "",
theme: {
prefix: {
idle:
colors.gray(timeFormatter.format(new Date())) +
colors.bold(colors.cyan(" [react-server]")),
done:
colors.gray(timeFormatter.format(new Date())) +
colors.bold(colors.cyan(" [react-server]")),
},
style: {
answer: colors.white,
highlight: (message) => colors.bold(colors.magenta(message)),
message: () => colors.green("➜"),
},
},
source: async (input, { signal }) => {
if (!input) {
return Object.entries(availableCommands).map(
([value, command]) => ({ ...command, value })
);
}

const term = input.toLowerCase().trim();

let results = [];
if (term.length > 2) {
await setTimeout(300);
if (signal.aborted) return [];

const { hits } = await algoliaClient.searchSingleIndex({
indexName: algolia.indexName,
searchParams: {
query: term,
},
});

searchCommands = {};
results = hits.map((hit) => {
const command = {
value: hit.url,
name: `Open ${Object.values(hit.hierarchy).reduce(
(acc, value) =>
value
? acc.length > 0
? `${acc} > ${value}`
: colors.bold("https://react-server.dev")
: acc,
""
)} 🔍`,
execute: () => {
open(hit.url);
},
};
searchCommands[command.value] = command;
return command;
});
}

return [
...Object.entries(availableCommands)
.reduce((source, [value, command]) => {
const name = command.name.toLowerCase().trim();
if (name.startsWith(term) || name.includes(term)) {
source.push({ ...command, value });
}
return source;
}, [])
.toSorted((a, b) => {
// if the term is at the beginning of the name, it should be sorted first
if (a.name.toLowerCase().trim().startsWith(term)) {
return -1;
}
if (b.name.toLowerCase().trim().startsWith(term)) {
return 1;
}
return a.name.localeCompare(b.name);
}),
...results,
];
},
},
{
input: stdin,
signal: controller.signal,
}
);

const selectedCommand =
availableCommands[answer] ?? searchCommands[answer];
if (selectedCommand) {
try {
await selectedCommand.execute();
} catch {
logger?.error?.(
`✖︎ ${selectedCommand.name.slice(0, -3)} failed! 🚑`
);
}
}
if (controller.signal.aborted) {
restartServer();
} else {
process.stdin.once("keypress", command);
}
} catch {
// prompt was cancelled
} finally {
process.stdout.removeAllListeners();
activeCommand = false;
}
};

process.stdin.once("keypress", command);
}
1 change: 1 addition & 0 deletions packages/react-server/lib/dev/create-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -508,5 +508,6 @@ export default async function createServer(root, options) {
);
viteDevServer.printUrls();
},
environments: viteDevServer.environments,
};
}
15 changes: 8 additions & 7 deletions packages/react-server/lib/plugins/file-router/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ export default function viteReactServerRouter(options = {}) {

async function config_init$() {
if (viteCommand !== "build")
logger.info("Initializing router configuration");
logger.info("Initializing router configuration 🚦");
try {
while (config_destroy.length > 0) {
await config_destroy.pop()();
Expand Down Expand Up @@ -556,7 +556,7 @@ export default function viteReactServerRouter(options = {}) {
await setupMdx();
createManifest();
} else {
logger.info(`Router configuration ${colors.green("successful")}`);
logger.info(`Router configuration ${colors.green("successful")}`);

const initialFiles = new Set(
await glob(
Expand Down Expand Up @@ -595,7 +595,7 @@ export default function viteReactServerRouter(options = {}) {
watcherTimeout = null;
if (initialFiles.size > 0) {
logger.warn(
`Router configuration still waiting for source files watcher to finish...`
`Router configuration still waiting for source files watcher to finish...`
);
}
}, 500);
Expand Down Expand Up @@ -636,7 +636,7 @@ export default function viteReactServerRouter(options = {}) {

if (includeInRouter) {
logger.info(
`Adding source file ${colors.cyan(sys.normalizePath(relative(rootDir, src)))} to router`
`Adding source file ${colors.cyan(sys.normalizePath(relative(rootDir, src)))} to router 📁`
);
}

Expand All @@ -655,7 +655,7 @@ export default function viteReactServerRouter(options = {}) {
if (initialFiles.has(src)) {
initialFiles.delete(src);
if (initialFiles.size === 0) {
logger.info(`Router configuration ${colors.green("ready")}`);
logger.info(`Router configuration ${colors.green("ready")} 📦`);
reactServerRouterReadyResolve?.();
reactServerRouterReadyResolve = null;
}
Expand Down Expand Up @@ -699,7 +699,7 @@ export default function viteReactServerRouter(options = {}) {

if (includeInRouter) {
logger.info(
`Removing source file ${colors.red(relative(rootDir, src))} from router`
`Removing source file ${colors.red(relative(rootDir, src))} from router 🗑️`
);
}

Expand All @@ -723,7 +723,8 @@ export default function viteReactServerRouter(options = {}) {
});
}
} catch (e) {
if (viteCommand !== "build") logger.error("Router configuration failed");
if (viteCommand !== "build")
logger.error("Router configuration failed ❌");
else throw e;
}
}
Expand Down
Loading

0 comments on commit b3c6f79

Please sign in to comment.