From 36224186e1b43f414c62f615f40f33aba4d4abaf Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Wed, 2 Aug 2023 23:13:33 +0200 Subject: [PATCH] feat: dev server with serve static support (#83) --- package.json | 2 +- playground/app.ts | 8 -- playground/index.ts | 10 +- playground/public/favicon.ico | Bin 0 -> 4966 bytes playground/public/test.txt | 1 + pnpm-lock.yaml | 22 ++-- src/_utils.ts | 33 ----- src/cli.ts | 28 +++-- src/index.ts | 2 +- src/server/_resolver.ts | 33 +++++ src/server/dev.ts | 230 ++++++++++++++++++++++++++++++++++ src/server/index.ts | 2 + src/server/watcher.ts | 82 ++++++++++++ src/types.ts | 8 -- src/watch.ts | 183 --------------------------- 15 files changed, 382 insertions(+), 262 deletions(-) delete mode 100644 playground/app.ts create mode 100644 playground/public/favicon.ico create mode 100644 playground/public/test.txt create mode 100644 src/server/_resolver.ts create mode 100644 src/server/dev.ts create mode 100644 src/server/index.ts create mode 100644 src/server/watcher.ts delete mode 100644 src/watch.ts diff --git a/package.json b/package.json index ed0904b..54ac9e5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "consola": "^3.2.3", "defu": "^6.1.2", "get-port-please": "^3.0.1", + "h3": "^1.8.0-rc.2", "http-shutdown": "^1.2.2", "jiti": "^1.19.1", "mlly": "^1.4.0", @@ -58,7 +59,6 @@ "changelogen": "^0.5.4", "eslint": "^8.46.0", "eslint-config-unjs": "^0.2.1", - "h3": "^1.8.0-rc.2", "ip-regex": "^5.0.0", "prettier": "^3.0.0", "typescript": "^5.1.6", diff --git a/playground/app.ts b/playground/app.ts deleted file mode 100644 index 80279eb..0000000 --- a/playground/app.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createApp, eventHandler } from "h3"; - -export const app = createApp(); - -app.use( - "/", - eventHandler(() => ({ hello: "world!!" })), -); diff --git a/playground/index.ts b/playground/index.ts index 234ab5c..8efaf75 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,4 +1,8 @@ -import { toNodeListener } from "h3"; -import { app } from "./app"; +import { createApp, eventHandler } from "h3"; -export default toNodeListener(app); +export const app = createApp(); + +app.use( + "/", + eventHandler(() => ({ hello: "world!" })), +); diff --git a/playground/public/favicon.ico b/playground/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e6a85d286c5b204c99f8763d50a98f9b96dc98b2 GIT binary patch literal 4966 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 CSVD9F literal 0 HcmV?d00001 diff --git a/playground/public/test.txt b/playground/public/test.txt new file mode 100644 index 0000000..f0f4afc --- /dev/null +++ b/playground/public/test.txt @@ -0,0 +1 @@ +Static asset works! diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf11d8a..6a9b4e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: get-port-please: specifier: ^3.0.1 version: 3.0.1 + h3: + specifier: ^1.8.0-rc.2 + version: 1.8.0-rc.2 http-shutdown: specifier: ^1.2.2 version: 1.2.2 @@ -61,9 +64,6 @@ devDependencies: eslint-config-unjs: specifier: ^0.2.1 version: 0.2.1(eslint@8.46.0)(typescript@5.1.6) - h3: - specifier: ^1.8.0-rc.2 - version: 1.8.0-rc.2 ip-regex: specifier: ^5.0.0 version: 5.0.0 @@ -1493,7 +1493,7 @@ packages: /cookie-es@1.0.0: resolution: {integrity: sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==} - dev: true + dev: false /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} @@ -1578,7 +1578,6 @@ packages: /destr@2.0.0: resolution: {integrity: sha512-FJ9RDpf3GicEBvzI3jxc2XhHzbqD8p4ANw/1kPsFBfTvP1b7Gn/Lg1vO7R9J4IVgoMbyUmFrFGZafJ1hPZpvlg==} - dev: true /detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} @@ -2420,7 +2419,7 @@ packages: ufo: 1.2.0 uncrypto: 0.1.3 unenv: 1.6.1 - dev: true + dev: false /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -2553,7 +2552,7 @@ packages: /iron-webcrypto@0.8.0: resolution: {integrity: sha512-gScdcWHjTGclCU15CIv2r069NoQrys1UeUFFfaO1hL++ytLHkVw7N5nXJmFf3J2LEDMz1PkrvC0m62JEeu1axQ==} - dev: true + dev: false /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} @@ -2936,7 +2935,7 @@ packages: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true - dev: true + dev: false /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -3060,7 +3059,6 @@ packages: /node-fetch-native@1.2.0: resolution: {integrity: sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ==} - dev: true /node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} @@ -3358,7 +3356,7 @@ packages: /radix3@1.0.1: resolution: {integrity: sha512-y+AcwZ3HcUIGc9zGsNVf5+BY/LxL+z+4h4J3/pp8jxSmy1STaCocPS3qrj4tA5ehUSzqtqK+0Aygvz/r/8vy4g==} - dev: true + dev: false /rc9@2.1.1: resolution: {integrity: sha512-lNeOl38Ws0eNxpO3+wD1I9rkHGQyj1NU1jlzv4go2CtEnEQEUfqnIvZG7W+bC/aXdJ27n5x/yUjb6RoT9tko+Q==} @@ -3922,7 +3920,7 @@ packages: /uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - dev: true + dev: false /unenv@1.6.1: resolution: {integrity: sha512-cjQnvJctZluBwOCBtFT4ZRR1cCJOVrcDK/TXzdqc6I+ZKWBFVDs6JjH0qkK6d8RsFSRHbQkWRgSzu66e52FHBA==} @@ -3932,7 +3930,7 @@ packages: mime: 3.0.0 node-fetch-native: 1.2.0 pathe: 1.1.1 - dev: true + dev: false /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} diff --git a/src/_utils.ts b/src/_utils.ts index abe6bc5..dae9e72 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -1,9 +1,6 @@ import { promises as fs } from "node:fs"; import { networkInterfaces } from "node:os"; -import { relative, resolve } from "node:path"; import { colors } from "consola/utils"; -import { fileURLToPath } from "mlly"; -import { isAbsolute } from "pathe"; import type { Certificate, HTTPSOptions } from "./types"; export async function resolveCert( @@ -69,33 +66,3 @@ export function formatURL(url: string) { ), ); } - -export async function createImporter(input: string, _cwd?: string) { - const cwd = resolve(_cwd ? fileURLToPath(_cwd) : "."); - - const jiti = await import("jiti").then((r) => r.default || r); - const _jitiRequire = jiti(cwd, { - esmResolve: true, - requireCache: false, - interopDefault: true, - }); - - if (!isAbsolute(input) && !input.startsWith(".")) { - input = `./${input}`; - } - - const entry = _jitiRequire.resolve(input); - - const _import = () => { - const r = _jitiRequire(input); - return Promise.resolve(r.default || r); - }; - - return { - cwd, - relative: (path: string) => relative(cwd, path), - formateRelative: (path: string) => `\`./${relative(cwd, path)}\``, - entry, - import: _import, - }; -} diff --git a/src/cli.ts b/src/cli.ts index 10f17c7..9ec15e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,11 @@ -import { resolve } from "node:path"; +import { WatchOptions } from "node:fs"; import { defineCommand, runMain as _runMain } from "citty"; +import { isAbsolute } from "pathe"; import { name, description, version } from "../package.json"; import { listen } from "./listen"; -import { listenAndWatch } from "./watch"; -import type { ListenOptions, WatchOptions } from "./types"; -import { createImporter } from "./_utils"; +import { listenAndWatch } from "./server"; +import type { ListenOptions } from "./types"; +import { DevServerOptions, createDevServer } from "./server/dev"; export const main = defineCommand({ meta: { @@ -63,12 +64,8 @@ export const main = defineCommand({ }, }, async run({ args }) { - const cwd = resolve(args.cwd || "."); - process.chdir(cwd); - - const opts: Partial = { + const opts: Partial = { ...args, - cwd, port: args.port, hostname: args.host, clipboard: args.clipboard, @@ -78,12 +75,17 @@ export const main = defineCommand({ https: args.https, // TODO: Support custom cert }; + const entry = + isAbsolute(args.entry) || args.entry.startsWith(".") + ? args.entry + : `./${args.entry}`; + if (args.watch) { - await listenAndWatch(args.entry, opts); + await listenAndWatch(entry, opts); } else { - const importer = await createImporter(args.entry); - const handler = await importer.import(); - await listen(handler, opts); + const devServer = await createDevServer(entry, opts); + await listen(devServer.nodeListener, opts); + await devServer.reload(true); } }, }); diff --git a/src/index.ts b/src/index.ts index fe23809..d1005cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export * from "./listen"; export * from "./types"; -export * from "./watch"; +export * from "./server"; diff --git a/src/server/_resolver.ts b/src/server/_resolver.ts new file mode 100644 index 0000000..7bf40d0 --- /dev/null +++ b/src/server/_resolver.ts @@ -0,0 +1,33 @@ +import { relative } from "node:path"; + +export async function createResolver() { + const jiti = await import("jiti").then((r) => r.default || r); + + const _jitiRequire = jiti(process.cwd(), { + cache: true, + esmResolve: true, + requireCache: false, + interopDefault: true, + }); + + const _import = (id: string) => { + const r = _jitiRequire(id); + return Promise.resolve(r.default || r); + }; + + const resolve = (id: string) => _jitiRequire.resolve(id); + + const tryResolve = (id: string) => { + try { + return resolve(id); + } catch {} + }; + + return { + relative: (path: string) => relative(process.cwd(), path), + formateRelative: (path: string) => `\`./${relative(process.cwd(), path)}\``, + import: _import, + resolve, + tryResolve, + }; +} diff --git a/src/server/dev.ts b/src/server/dev.ts new file mode 100644 index 0000000..56625ce --- /dev/null +++ b/src/server/dev.ts @@ -0,0 +1,230 @@ +import { existsSync, statSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { consola } from "consola"; +import { dirname, join, resolve } from "pathe"; +import type { ConsolaInstance } from "consola"; +import { createResolver } from "./_resolver"; + +export interface DevServerOptions { + cwd?: string; + staticDirs?: string[]; + logger?: ConsolaInstance; +} + +export async function createDevServer( + entry: string, + options: DevServerOptions, +) { + const logger = options.logger || consola.withTag("listhen"); + + const { + createApp, + fromNodeMiddleware, + serveStatic, + eventHandler, + dynamicEventHandler, + toNodeListener, + } = await import("h3"); + + // Initialize resolver + const resolver = await createResolver(); + const resolveEntry = () => { + for (const suffix of ["", "/server/src", "/server", "/src"]) { + const resolved = resolver.tryResolve(entry + suffix); + if (resolved) { + return resolved; + } + } + }; + + // Guess cwd + let cwd: string = options.cwd || ""; + if (!cwd) { + const resolvedEntry = resolveEntry() || resolve(process.cwd(), entry); + cwd = statSync(resolvedEntry, { throwIfNoEntry: false })?.isDirectory() + ? resolvedEntry + : dirname(resolvedEntry); + } + + // Create app instance + const app = createApp(); + + // Register static asset handlers + const staticDirs = (options.staticDirs || ["public"]) + .filter(Boolean) + .map((d) => resolve(cwd, d)) + .filter((d) => existsSync(d) && statSync(d).isDirectory()); + + for (const dir of staticDirs) { + app.use( + eventHandler(async (event) => { + await serveStatic(event, { + fallthrough: true, + getContents: (id) => readFile(join(dir, id)), + getMeta: async (id) => { + const stats = await stat(join(dir, id)).catch(() => {}); + if (!stats || !stats.isFile()) { + return; + } + return { + size: stats.size, + mtime: stats.mtimeMs, + }; + }, + }); + }), + ); + } + + // Error handler + let error: unknown; + app.use( + eventHandler(() => { + if (error) { + return errorTemplate(String(error), (error as Error).stack); + } + }), + ); + + // Main (dynamic) handler + const dynamicHandler = dynamicEventHandler(() => { + return `

Server is loading...

`; + }); + app.use(dynamicHandler); + + // Handler loader + let loadTime = 0; + const loadHandle = async (initial?: boolean) => { + if (initial) { + for (const dir of staticDirs) { + logger.log( + `📁 Serving static files from ${resolver.formateRelative(dir)}`, + ); + } + } + const start = Date.now(); + try { + const _entry = resolveEntry(); + if (!_entry) { + const message = `Cannot find a server entry in ${entry}`; + logger.warn(message); + error = new Error(message); + (error as Error).stack = ""; + return; + } + if (initial) { + logger.log( + `🚀 Loading server entry ${resolver.formateRelative(_entry)}`, + ); + } + let _handler = await resolver + .import(_entry) + .then((r) => r.handler || r.handle || r.app || r.default || r); + if (_handler.handler) { + _handler = _handler.handler; // h3 app + } + dynamicHandler.set(fromNodeMiddleware(_handler)); + error = undefined; + } catch (_error) { + error = normalizeErrorStack(_error as Error); + } + loadTime = Date.now() - start; + if (error) { + logger.error(error); + } else { + logger.success( + ` Server ${initial ? "initialized" : "reloaded"} in ${loadTime}ms`, + ); + } + }; + + return { + cwd, + resolver, + nodeListener: toNodeListener(app), + reload: (_initial?: boolean) => loadHandle(_initial), + }; +} + +const InternalStackRe = /jiti|node:internal|citty|listhen|listenAndWatch/; + +function normalizeErrorStack(error: Error) { + if (process.env.DEBUG) { + return error; + } + try { + const cwd = process.cwd(); + (error as Error).stack = (error as Error) + .stack!.split("\n") + .slice(1) + .map((l) => l.replace(cwd, ".")) + .filter((l) => !InternalStackRe.test(l)) + .join("\n"); + } catch {} + return error; +} + +function errorTemplate(message: string, stack = "") { + return ` + + + Server Error + + + + + +
+
+ +
Server Error
+
${message}
${stack}
+
+
+ + `; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..785cbd8 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,2 @@ +export { listenAndWatch, type WatchOptions } from "./watcher"; +export { createDevServer, type DevServerOptions } from "./dev"; diff --git a/src/server/watcher.ts b/src/server/watcher.ts new file mode 100644 index 0000000..7a8c7f4 --- /dev/null +++ b/src/server/watcher.ts @@ -0,0 +1,82 @@ +import { extname } from "node:path"; +import { consola } from "consola"; +import type { AsyncSubscription } from "@parcel/watcher"; +import type { ConsolaInstance } from "consola"; +import type { Listener, ListenOptions } from "../types"; +import { listen } from "../listen"; +import { createDevServer, DevServerOptions } from "./dev"; + +export interface WatchOptions extends DevServerOptions { + cwd?: string; + logger?: ConsolaInstance; + ignore?: string[]; + publicDirs?: string[]; +} + +export async function listenAndWatch( + entry: string, + options: Partial, +): Promise { + const logger = options.logger || consola.withTag("listhen"); + let watcher: AsyncSubscription; // eslint-disable-line prefer-const + + // Create dev server + const devServer = await createDevServer(entry, { + cwd: options.cwd, + logger, + }); + + // Initialize listener + const listenter = await listen(devServer.nodeListener, options); + + // Load dev server handler first time + await devServer.reload(true); + + // Hook close event to stop watcher too + const _close = listenter.close; + listenter.close = async () => { + if (watcher) { + await watcher.unsubscribe().catch((error) => { + logger.error(error); + }); + } + await _close(); + }; + + // Start watcher + // https://github.com/parcel-bundler/watcher + const { subscribe } = await import("@parcel/watcher").then( + (r) => r.default || r, + ); + + const jsExts = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]); + watcher = await subscribe( + devServer.cwd, + (_error, events) => { + const filteredEvents = events.filter((e) => jsExts.has(extname(e.path))); + if (filteredEvents.length === 0) { + return; + } + const eventsString = filteredEvents + .map((e) => `${devServer.resolver.formateRelative(e.path)} ${e.type}d`) + .join(", "); + logger.start(` Reloading server (${eventsString})`); + devServer.reload(); + }, + { + ignore: options.ignore || [ + "**/.git/**", + "**/node_modules/**", + "**/dist/**", + ], + }, + ); + + logger.log( + `👀 Watching ${devServer.resolver.formateRelative( + devServer.cwd, + )} for changes`, + ); + + return listenter; +} diff --git a/src/types.ts b/src/types.ts index 0d3d246..438895c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,6 @@ import type { Server } from "node:http"; import type { Server as HTTPServer } from "node:https"; import type { GetPortInput } from "get-port-please"; -import type { ConsolaInstance } from "consola"; export interface Certificate { key: string; @@ -30,13 +29,6 @@ export interface ListenOptions { autoCloseSignals: string[]; } -export interface WatchOptions { - cwd: string; - entry: string; - logger: ConsolaInstance; - ignore: string[]; -} - export interface ShowURLOptions { baseURL: string; name?: string; diff --git a/src/watch.ts b/src/watch.ts deleted file mode 100644 index c939687..0000000 --- a/src/watch.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { RequestListener } from "node:http"; -import { consola } from "consola"; -import { dirname } from "pathe"; -import type { AsyncSubscription } from "@parcel/watcher"; -import type { Listener, ListenOptions, WatchOptions } from "./types"; -import { listen } from "./listen"; -import { createImporter } from "./_utils"; - -export async function listenAndWatch( - input: string, - options: Partial = {}, -): Promise { - const logger = options.logger || consola.withTag("listhen"); - let watcher: AsyncSubscription; // eslint-disable-line prefer-const - let handle: RequestListener | undefined; - let error: undefined | unknown; - - // Initialize listener - const listenter = await listen((req, res) => { - if (error) { - res.setHeader("Content-Type", "text/html"); - return res.end(errorTemplate(error.toString(), (error as Error)?.stack)); - } else if (handle) { - return handle(req, res); - } else { - res.setHeader("Content-Type", "text/html"); - return res.end( - `

Server is loading...

`, - ); - } - }, options); - - // Hook close event to stop watcher too - const _close = listenter.close; - listenter.close = async () => { - if (watcher) { - await watcher.unsubscribe().catch((error) => { - logger.error(error); - }); - } - await _close(); - }; - - // Initialize resolver - let loadTime = 0; - const importer = await createImporter(input); - const resolveHandle = async () => { - const start = Date.now(); - try { - handle = await importer.import(); - error = undefined; - } catch (_error) { - try { - const cwd = process.cwd(); - const InternalStackRe = - /jiti|node:internal|citty|listhen|listenAndWatch/; - (_error as Error).stack = (_error as Error) - .stack!.split("\n") - .slice(1) - .map((l) => l.replace(cwd, ".")) - .filter((l) => !InternalStackRe.test(l)) - .join("\n"); - } catch {} - error = _error; - } - loadTime = Date.now() - start; - }; - - // Resolve handle once - logger.log( - `🚀 Loading server entry ${importer.formateRelative(importer.entry)}`, - ); - resolveHandle().then(() => { - if (error) { - logger.error(error); - } else { - logger.success(` Server initialized in ${loadTime}ms`); - } - }); - - // Start watcher - // https://github.com/parcel-bundler/watcher - const { subscribe } = await import("@parcel/watcher").then( - (r) => r.default || r, - ); - - const entryDir = dirname(importer.entry); - watcher = await subscribe( - entryDir, - (_error, events) => { - if (events.length === 0) { - return; - } - resolveHandle().then(() => { - const eventsString = events - .map((e) => `${importer.formateRelative(e.path)} ${e.type}d`) - .join(", "); - logger.start(` Reloading server (${eventsString})`); - if (error) { - logger.error(error); - } else { - logger.success(` Server reloaded in ${loadTime}ms`); - } - }); - }, - { - ignore: options.ignore || [ - "**/.git/**", - "**/node_modules/**", - "**/dist/**", - ], - }, - ); - - logger.log(`👀 Watching ${importer.formateRelative(entryDir)} for changes`); - - return listenter; -} - -function errorTemplate(message: string, stack = "") { - return ` - - - Server Error - - - - - -
-
- -
Server Error
-
${message}
${stack}
-
-
- - `; -}