Skip to content

Commit b3c6f79

Browse files
authored
feat: development server commands and search docs (#65)
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`!_
1 parent b16430e commit b3c6f79

File tree

8 files changed

+651
-50
lines changed

8 files changed

+651
-50
lines changed

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@vercel/analytics": "^1.3.1",
1717
"@vercel/speed-insights": "^1.0.12",
1818
"@vitejs/plugin-react-swc": "^3.7.0",
19+
"algoliasearch": "^4.24.0",
1920
"highlight.js": "^11.9.0",
2021
"lucide-react": "^0.408.0",
2122
"rehype-highlight": "^7.0.0",

packages/react-server/config/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export async function loadConfig(initialConfig, options = {}) {
3030
if (options.onChange) {
3131
const watcher = watch(configPatterns, { cwd, ignoreInitial: true });
3232
const handler = () => {
33-
watcher.close();
3433
options.onChange();
3534
};
35+
options.onWatch?.(watcher);
3636
watcher.on("add", handler);
3737
watcher.on("unlink", handler);
3838
watcher.on("change", handler);

packages/react-server/lib/dev/action.mjs

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isIPv6 } from "node:net";
2+
import { setTimeout } from "node:timers/promises";
23

34
import open from "open";
45
import colors from "picocolors";
@@ -20,6 +21,7 @@ import { getEnv } from "../sys.mjs";
2021
import banner from "../utils/banner.mjs";
2122
import { formatDuration } from "../utils/format.mjs";
2223
import getServerAddresses from "../utils/server-address.mjs";
24+
import { command } from "./command.mjs";
2325
import createServer from "./create-server.mjs";
2426

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

3032
let server;
33+
let configWatcher;
34+
let showHelp = true;
3135
const restart = async () => {
3236
await runtime_init$(async () => {
3337
try {
38+
const restartServer = async () => {
39+
try {
40+
configWatcher?.close?.();
41+
globalThis.__react_server_ready__ = [];
42+
globalThis.__react_server_start__ = Date.now();
43+
await Promise.all(
44+
server?.handlers?.map(
45+
(handler) => handler.close?.() ?? handler.terminate?.()
46+
)
47+
);
48+
await server?.close();
49+
await restart?.();
50+
} catch (e) {
51+
console.error(colors.red(e.stack));
52+
}
53+
};
54+
3455
let config = await loadConfig(
3556
{},
3657
options.watch ?? true
3758
? {
3859
...options,
39-
onChange() {
60+
onWatch(watcher) {
61+
configWatcher = watcher;
62+
},
63+
async onChange() {
4064
getRuntime(LOGGER_CONTEXT)?.warn?.(
41-
`config changed, restarting server...`
42-
);
43-
globalThis.__react_server_ready__ = [];
44-
globalThis.__react_server_start__ = Date.now();
45-
server?.handlers?.forEach(
46-
(handler) => handler.close?.() ?? handler.terminate?.()
65+
`Configuration changed, restarting server...`
4766
);
48-
server?.close();
49-
restart?.();
67+
await restartServer();
5068
},
5169
}
5270
: options
@@ -121,9 +139,25 @@ export default async function dev(root, options) {
121139
}
122140

123141
server.printUrls(resolvedUrls);
124-
getRuntime(LOGGER_CONTEXT)?.info?.(
125-
`${colors.green("✔")} Ready in ${formatDuration(Date.now() - globalThis.__react_server_start__)}`
142+
143+
const logger = getRuntime(LOGGER_CONTEXT);
144+
logger?.info?.(
145+
`${colors.green("✔")} Ready in ${formatDuration(Date.now() - globalThis.__react_server_start__)} 🚀`
126146
);
147+
148+
if (showHelp) {
149+
logger.info?.("Press any key to open the command menu 💻");
150+
logger.info?.("Start typing to search the docs 🔍");
151+
logger.info?.("Ctrl+C to exit 🚫");
152+
showHelp = false;
153+
}
154+
155+
command({
156+
logger: getRuntime(LOGGER_CONTEXT),
157+
server,
158+
resolvedUrls,
159+
restartServer,
160+
});
127161
})
128162
.on("error", (e) => {
129163
if (e.code === "EADDRINUSE") {
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import readline from "node:readline";
2+
import { PassThrough } from "node:stream";
3+
import { setTimeout } from "node:timers/promises";
4+
5+
import { search } from "@inquirer/prompts";
6+
import { algoliasearch } from "algoliasearch";
7+
import open from "open";
8+
import colors from "picocolors";
9+
10+
const algolia = {
11+
appId: "OVQLOZDOSH",
12+
apiKey: "5a8224f70c312c69121f92482ff2df82",
13+
indexName: "react-server",
14+
};
15+
16+
let algoliaClient;
17+
let stdin;
18+
export async function command({ logger, server, resolvedUrls, restartServer }) {
19+
if (!process.stdin.isTTY) return;
20+
21+
if (!stdin) {
22+
stdin = new PassThrough();
23+
process.stdin.pipe(stdin);
24+
25+
// catch SIGINT and exit
26+
process.stdin.on("data", (key) => {
27+
if (key == "\u0003") {
28+
process.exit(0);
29+
}
30+
});
31+
32+
readline.emitKeypressEvents(process.stdin);
33+
process.stdin.setRawMode(true);
34+
35+
algoliaClient = algoliasearch(algolia.appId, algolia.apiKey);
36+
}
37+
38+
const controller = new AbortController();
39+
const availableCommands = {
40+
r: {
41+
name: "Restart the development server 🔄",
42+
async execute() {
43+
logger?.warn?.(`Restarting server... 🚧`);
44+
controller.abort();
45+
},
46+
},
47+
l: {
48+
name: "Reload the application 🔥",
49+
execute: () => {
50+
server.environments.client.hot.send({
51+
type: "full-reload",
52+
path: "*",
53+
});
54+
},
55+
},
56+
u: {
57+
name: "Print the server URLs 🔗",
58+
execute: () => {
59+
server.printUrls(resolvedUrls);
60+
},
61+
},
62+
c: {
63+
name: "Clear the console 🧹",
64+
execute: () => {
65+
console.clear();
66+
logger?.info?.(`${colors.green("✔")} Console cleared! 🧹`);
67+
},
68+
},
69+
o: {
70+
name: "Open application in the default browser 🌐",
71+
execute: () => {
72+
open(resolvedUrls[0].toString());
73+
},
74+
},
75+
q: {
76+
name: "Quit the development server 🚫",
77+
execute: () => {
78+
process.exit(0);
79+
},
80+
},
81+
};
82+
const timeFormatter = new Intl.DateTimeFormat(undefined, {
83+
hour: "numeric",
84+
minute: "numeric",
85+
second: "numeric",
86+
});
87+
let activeCommand = false;
88+
let searchCommands = {};
89+
const command = async () => {
90+
if (activeCommand) return;
91+
try {
92+
activeCommand = true;
93+
94+
const answer = await search(
95+
{
96+
message: "",
97+
theme: {
98+
prefix: {
99+
idle:
100+
colors.gray(timeFormatter.format(new Date())) +
101+
colors.bold(colors.cyan(" [react-server]")),
102+
done:
103+
colors.gray(timeFormatter.format(new Date())) +
104+
colors.bold(colors.cyan(" [react-server]")),
105+
},
106+
style: {
107+
answer: colors.white,
108+
highlight: (message) => colors.bold(colors.magenta(message)),
109+
message: () => colors.green("➜"),
110+
},
111+
},
112+
source: async (input, { signal }) => {
113+
if (!input) {
114+
return Object.entries(availableCommands).map(
115+
([value, command]) => ({ ...command, value })
116+
);
117+
}
118+
119+
const term = input.toLowerCase().trim();
120+
121+
let results = [];
122+
if (term.length > 2) {
123+
await setTimeout(300);
124+
if (signal.aborted) return [];
125+
126+
const { hits } = await algoliaClient.searchSingleIndex({
127+
indexName: algolia.indexName,
128+
searchParams: {
129+
query: term,
130+
},
131+
});
132+
133+
searchCommands = {};
134+
results = hits.map((hit) => {
135+
const command = {
136+
value: hit.url,
137+
name: `Open ${Object.values(hit.hierarchy).reduce(
138+
(acc, value) =>
139+
value
140+
? acc.length > 0
141+
? `${acc} > ${value}`
142+
: colors.bold("https://react-server.dev")
143+
: acc,
144+
""
145+
)} 🔍`,
146+
execute: () => {
147+
open(hit.url);
148+
},
149+
};
150+
searchCommands[command.value] = command;
151+
return command;
152+
});
153+
}
154+
155+
return [
156+
...Object.entries(availableCommands)
157+
.reduce((source, [value, command]) => {
158+
const name = command.name.toLowerCase().trim();
159+
if (name.startsWith(term) || name.includes(term)) {
160+
source.push({ ...command, value });
161+
}
162+
return source;
163+
}, [])
164+
.toSorted((a, b) => {
165+
// if the term is at the beginning of the name, it should be sorted first
166+
if (a.name.toLowerCase().trim().startsWith(term)) {
167+
return -1;
168+
}
169+
if (b.name.toLowerCase().trim().startsWith(term)) {
170+
return 1;
171+
}
172+
return a.name.localeCompare(b.name);
173+
}),
174+
...results,
175+
];
176+
},
177+
},
178+
{
179+
input: stdin,
180+
signal: controller.signal,
181+
}
182+
);
183+
184+
const selectedCommand =
185+
availableCommands[answer] ?? searchCommands[answer];
186+
if (selectedCommand) {
187+
try {
188+
await selectedCommand.execute();
189+
} catch {
190+
logger?.error?.(
191+
`✖︎ ${selectedCommand.name.slice(0, -3)} failed! 🚑`
192+
);
193+
}
194+
}
195+
if (controller.signal.aborted) {
196+
restartServer();
197+
} else {
198+
process.stdin.once("keypress", command);
199+
}
200+
} catch {
201+
// prompt was cancelled
202+
} finally {
203+
process.stdout.removeAllListeners();
204+
activeCommand = false;
205+
}
206+
};
207+
208+
process.stdin.once("keypress", command);
209+
}

packages/react-server/lib/dev/create-server.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,5 +508,6 @@ export default async function createServer(root, options) {
508508
);
509509
viteDevServer.printUrls();
510510
},
511+
environments: viteDevServer.environments,
511512
};
512513
}

packages/react-server/lib/plugins/file-router/plugin.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ export default function viteReactServerRouter(options = {}) {
451451

452452
async function config_init$() {
453453
if (viteCommand !== "build")
454-
logger.info("Initializing router configuration");
454+
logger.info("Initializing router configuration 🚦");
455455
try {
456456
while (config_destroy.length > 0) {
457457
await config_destroy.pop()();
@@ -556,7 +556,7 @@ export default function viteReactServerRouter(options = {}) {
556556
await setupMdx();
557557
createManifest();
558558
} else {
559-
logger.info(`Router configuration ${colors.green("successful")}`);
559+
logger.info(`Router configuration ${colors.green("successful")}`);
560560

561561
const initialFiles = new Set(
562562
await glob(
@@ -595,7 +595,7 @@ export default function viteReactServerRouter(options = {}) {
595595
watcherTimeout = null;
596596
if (initialFiles.size > 0) {
597597
logger.warn(
598-
`Router configuration still waiting for source files watcher to finish...`
598+
`Router configuration still waiting for source files watcher to finish...`
599599
);
600600
}
601601
}, 500);
@@ -636,7 +636,7 @@ export default function viteReactServerRouter(options = {}) {
636636

637637
if (includeInRouter) {
638638
logger.info(
639-
`Adding source file ${colors.cyan(sys.normalizePath(relative(rootDir, src)))} to router`
639+
`Adding source file ${colors.cyan(sys.normalizePath(relative(rootDir, src)))} to router 📁`
640640
);
641641
}
642642

@@ -655,7 +655,7 @@ export default function viteReactServerRouter(options = {}) {
655655
if (initialFiles.has(src)) {
656656
initialFiles.delete(src);
657657
if (initialFiles.size === 0) {
658-
logger.info(`Router configuration ${colors.green("ready")}`);
658+
logger.info(`Router configuration ${colors.green("ready")} 📦`);
659659
reactServerRouterReadyResolve?.();
660660
reactServerRouterReadyResolve = null;
661661
}
@@ -699,7 +699,7 @@ export default function viteReactServerRouter(options = {}) {
699699

700700
if (includeInRouter) {
701701
logger.info(
702-
`Removing source file ${colors.red(relative(rootDir, src))} from router`
702+
`Removing source file ${colors.red(relative(rootDir, src))} from router 🗑️`
703703
);
704704
}
705705

@@ -723,7 +723,8 @@ export default function viteReactServerRouter(options = {}) {
723723
});
724724
}
725725
} catch (e) {
726-
if (viteCommand !== "build") logger.error("Router configuration failed");
726+
if (viteCommand !== "build")
727+
logger.error("Router configuration failed ❌");
727728
else throw e;
728729
}
729730
}

0 commit comments

Comments
 (0)