diff --git a/.changeset/small-trees-sparkle.md b/.changeset/small-trees-sparkle.md new file mode 100644 index 0000000..ac3e3e0 --- /dev/null +++ b/.changeset/small-trees-sparkle.md @@ -0,0 +1,9 @@ +--- +"domco": minor +"create-domco": patch +--- + +Adds request listener in place of `@hono/node-server`. + +- This change removes the last remaining dependency for the project other than `vite` and `hono`. +- Removes `serveStatic` option for `createApp`, use `middleware` instead, see [example](https://domco.robino.dev/deploy#example). diff --git a/apps/docs/src/content/deploy.md b/apps/docs/src/content/deploy.md index 5a8fb7b..7a92036 100644 --- a/apps/docs/src/content/deploy.md +++ b/apps/docs/src/content/deploy.md @@ -15,20 +15,20 @@ Run `vite build` to build your application into `dist/`. By default **domco** will generate a `app.js` module and static assets for your application. If you are not using an [adapter](#adapters), you can import `createApp` from the `app.js` module and configure your app to use in one of [Hono's supported environments](https://hono.dev/docs/getting-started/basic). -The `client/` directory holds client assets. JS and CSS assets with hashed file names will automatically be served with immutable cache headers from `dist/client/_immutable/`. Other assets are processed and included in `dist/client/` directly. +The `client/` directory holds client assets. JS and CSS assets with hashed file names will be output to `dist/client/_immutable/`, you can serve this path with immutable cache headers. Other assets are processed and included in `dist/client/` directly. ## Example -Here's an example of how to serve your app using the result of your build with `@hono/node-server`. +Here's an example of how to serve your app using the result of your build with [`@hono/node-server`](https://github.com/honojs/node-server). ```ts -// main.js +// server.js // import from build output import { createApp } from "./dist/server/app.js"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; -const app = createApp({ serveStatic }); +const app = createApp({ middleware: [serveStatic({ root: "./dist/client" })] }); serve(app); ``` @@ -36,7 +36,7 @@ serve(app); Run this module to start your server. ```bash -node main.js +node server.js ``` ## Adapters diff --git a/package-lock.json b/package-lock.json index 885bea3..70e08f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1148,6 +1148,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1525,18 +1526,6 @@ "node": ">=12" } }, - "node_modules/@hono/node-server": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.12.2.tgz", - "integrity": "sha512-xjzhqhSWUE/OhN0g3KCNVzNsQMlFUAL+/8GgPUr3TKcU7cvgZVBGswFofJ8WwGEHTqobzze1lDpGJl9ZNckDhA==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -6137,7 +6126,7 @@ } }, "packages/create-domco": { - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", @@ -6151,11 +6140,8 @@ } }, "packages/domco": { - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.12.2" - }, "devDependencies": { "typedoc": "^0.26.6", "typedoc-plugin-markdown": "^4.2.6", diff --git a/packages/create-domco/src/template/index.ts b/packages/create-domco/src/template/index.ts index c94fe92..18d1c12 100644 --- a/packages/create-domco/src/template/index.ts +++ b/packages/create-domco/src/template/index.ts @@ -1,5 +1,5 @@ const versions = { - domco: "0.9.1", + domco: "0.10.0", hono: "4.5.11", autoprefixer: "10.4.20", prettier: "3.3.3", diff --git a/packages/domco/package.json b/packages/domco/package.json index d52940c..0072711 100644 --- a/packages/domco/package.json +++ b/packages/domco/package.json @@ -49,6 +49,10 @@ "types": "./dist/injector/index.d.ts", "default": "./dist/injector/index.js" }, + "./node/request-listener": { + "types": "./dist/node/request-listener/index.d.ts", + "default": "./dist/node/request-listener/index.js" + }, "./version": { "types": "./dist/version/index.d.ts", "default": "./dist/version/index.js" @@ -76,8 +80,5 @@ "peerDependencies": { "hono": "^4.5.0", "vite": "^5.4.0" - }, - "dependencies": { - "@hono/node-server": "^1.12.2" } } diff --git a/packages/domco/src/adapter/vercel/index.ts b/packages/domco/src/adapter/vercel/index.ts index 0ced7ab..f4962b1 100644 --- a/packages/domco/src/adapter/vercel/index.ts +++ b/packages/domco/src/adapter/vercel/index.ts @@ -7,7 +7,7 @@ import type { OutputConfig, RequiredOptions, VercelAdapterOptions, -} from "./types/index.js"; +} from "./types.js"; import { createMiddleware } from "hono/factory"; import type { HonoOptions } from "hono/hono-base"; import fs from "node:fs/promises"; @@ -37,12 +37,12 @@ const nodeEntry: AdapterEntry = ({ appId }) => { id: entryId, code: ` import { createApp } from "${appId}"; -import { handle } from "@hono/node-server/vercel"; +import { createRequestListener } from "domco/node/request-listener"; import { getPath } from "domco/adapter/vercel"; const app = createApp({ honoOptions: { getPath } }); -export default handle(app); +export default createRequestListener(app.fetch); `, }; }; @@ -118,24 +118,25 @@ export const adapter: AdapterBuilder = ( resolvedOptions.isr = options?.isr; resolvedOptions.images = options?.images; + /** + * This is applied in `dev` and `preview` so users can see the src images. + */ const imageMiddleware = createMiddleware(async (c, next) => { if (resolvedOptions.images) { if (c.req.path.startsWith("/_vercel/image")) { const { url, w, q } = c.req.query(); - if (!url) throw Error(`Add a \`url\` query param to ${c.req.url}`); - if (!w) throw Error(`Add a \`w\` query param to ${c.req.url}`); - if (!q) throw Error(`Add a \`q\` query param to ${c.req.url}`); + if (!url) throw new Error(`Add a \`url\` query param to ${c.req.url}`); + if (!w) throw new Error(`Add a \`w\` query param to ${c.req.url}`); + if (!q) throw new Error(`Add a \`q\` query param to ${c.req.url}`); if (!resolvedOptions.images.sizes.includes(parseInt(w))) { - throw Error( + throw new Error( `\`${w}\` is not an included image size. Add \`${w}\` to \`sizes\` in your adapter config to support this width.`, ); } - if (url) { - return c.redirect(url); - } + return c.redirect(url); } } await next(); diff --git a/packages/domco/src/adapter/vercel/types/index.ts b/packages/domco/src/adapter/vercel/types.ts similarity index 100% rename from packages/domco/src/adapter/vercel/types/index.ts rename to packages/domco/src/adapter/vercel/types.ts diff --git a/packages/domco/src/app/dev/index.ts b/packages/domco/src/app/dev/index.ts index 4ec7d65..1387d69 100644 --- a/packages/domco/src/app/dev/index.ts +++ b/packages/domco/src/app/dev/index.ts @@ -14,12 +14,14 @@ import type { ViteDevServer } from "vite"; * @param options * @returns Hono app instance. */ -export const createAppDev = (options?: { - devServer?: ViteDevServer; - honoOptions?: HonoOptions; - middleware?: MiddlewareHandler[]; -}) => { - const { devServer, honoOptions, middleware } = options ?? {}; +export const createAppDev = ( + options: { + devServer?: ViteDevServer; + honoOptions?: HonoOptions; + middleware?: MiddlewareHandler[]; + } = {}, +) => { + const { devServer, honoOptions, middleware } = options; const rootApp = new Hono(honoOptions); diff --git a/packages/domco/src/app/index.ts b/packages/domco/src/app/index.ts index 2c807be..1706ffd 100644 --- a/packages/domco/src/app/index.ts +++ b/packages/domco/src/app/index.ts @@ -1,10 +1,8 @@ -import { dirNames, headers } from "../constants/index.js"; import { addRoutes, applySetup, setServer } from "./util/index.js"; import { manifest } from "domco:manifest"; import { routes } from "domco:routes"; import { Hono, type MiddlewareHandler } from "hono"; import type { HonoOptions } from "hono/hono-base"; -import type { ServeStaticOptions } from "hono/serve-static"; /** * Creates your production Hono app instance. You can import `createApp` from @@ -22,50 +20,32 @@ import type { ServeStaticOptions } from "hono/serve-static"; * @example * * ```js - * // example of the NodeJS build that is output to `./dist/server/node.js` + * // example using Node.js and `@hono/node-server` * import { serve } from "@hono/node-server"; * import { serveStatic } from "@hono/node-server/serve-static"; * import { createApp } from "./dist/server/app.js"; * - * const app = createApp({ serveStatic }); + * const app = createApp({ middleware: [serveStatic({ root: "./dist/client" })] }); * * serve(app); * ``` */ -export const createApp = (options?: { - honoOptions?: HonoOptions; - middleware?: MiddlewareHandler[]; - serveStatic?: (options?: ServeStaticOptions) => MiddlewareHandler; -}) => { - const { honoOptions, serveStatic, middleware } = options ?? {}; - - const app = new Hono(honoOptions); +export const createApp = ( + options: { + honoOptions?: HonoOptions; + middleware?: MiddlewareHandler[]; + } = {}, +) => { + const app = new Hono(options.honoOptions); app.use(setServer); - if (middleware) { - for (const mw of middleware) { - app.use(mw); - } - } - applySetup(app, routes); - // handlers need to be added after static so handleStatic will run first - if (serveStatic) { - app.use(`/${dirNames.out.client.immutable}/*`, async (c, next) => { - await next(); - c.header("cache-control", headers.cacheControl.immutable); - }); - - app.use(async (c, next) => { - if (c.req.method === "GET") { - return serveStatic({ - root: `./${dirNames.out.base}/${dirNames.out.client.base}`, - })(c, next); - } - await next(); - }); + if (options.middleware) { + for (const mw of options.middleware) { + app.use(mw); + } } addRoutes({ app, routes, manifest }); diff --git a/packages/domco/src/node/request-listener/index.ts b/packages/domco/src/node/request-listener/index.ts new file mode 100644 index 0000000..ac9ae43 --- /dev/null +++ b/packages/domco/src/node/request-listener/index.ts @@ -0,0 +1,219 @@ +/** + * copied from https://github.com/mjackson/remix-the-web/blob/main/packages/node-fetch-server/src/lib/request-listener.ts + * + * created this issue https://github.com/mjackson/remix-the-web/issues/13 + */ +import type { MaybePromise } from "../../types/helper/index.js"; +import type { ReadStream } from "node:fs"; +import type { + IncomingHttpHeaders, + IncomingMessage, + RequestListener, +} from "node:http"; + +type ClientAddress = { + /** + * The IP address of the client that sent the request. + * + * [Node.js Reference](https://nodejs.org/api/net.html#socketremoteaddress) + */ + address: string; + /** + * The family of the client IP address. + * + * [Node.js Reference](https://nodejs.org/api/net.html#socketremotefamily) + */ + family: "IPv4" | "IPv6"; + /** + * The remote port of the client that sent the request. + * + * [Node.js Reference](https://nodejs.org/api/net.html#socketremoteport) + */ + port: number; +}; + +/** + * A function that handles an error that occurred during request handling. May return a response to + * send to the client, or `void` which creates an early return. + * + * [MDN `Response` Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response) + */ +type ErrorHandler = (error: unknown) => MaybePromise; + +/** + * A function that handles an incoming request and returns a response. + * + * [MDN `Request` Reference](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * + * [MDN `Response` Reference](https://developer.mozilla.org/en-US/docs/Web/API/Response) + */ +type FetchHandler = ( + request: Request, + client: ClientAddress, +) => MaybePromise; + +type RequestListenerOptions = { + /** + * Overrides the host portion of the incoming request URL. By default the request URL host is + * derived from the HTTP `Host` header. + * + * For example, if you have a `$HOST` environment variable that contains the hostname of your + * server, you can use it to set the host of all incoming request URLs like so: + * + * ```ts + * createRequestListener(handler, { host: process.env.HOST }) + * ``` + */ + host?: string; + /** + * An error handler that determines the response when the request handler throws an error. By + * default a 500 Internal Server Error response will be sent. + */ + onError?: ErrorHandler; + /** + * Overrides the protocol of the incoming request URL. By default the request URL protocol is + * derived from the connection protocol. So e.g. when serving over HTTPS (using + * `https.createServer()`), the request URL will begin with `https:`. + */ + protocol?: string; +}; + +/** + * Wraps a fetch handler in a Node.js `http.RequestListener` that can be used with + * `http.createServer()` or `https.createServer()`. + */ +export const createRequestListener = ( + handler: FetchHandler, + options?: RequestListenerOptions, +): RequestListener => { + const onError = options?.onError ?? defaultErrorHandler; + + return async (req, res) => { + const protocol = + options?.protocol ?? + ("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:"); + const host = options?.host ?? req.headers.host ?? "localhost"; + const url = new URL(req.url!, `${protocol}//${host}`); + + const controller = new AbortController(); + res.on("close", () => { + controller.abort(); + }); + + const request = createRequest(req, url, controller.signal); + const client = { + address: req.socket.remoteAddress!, + family: req.socket.remoteFamily! as ClientAddress["family"], + port: req.socket.remotePort!, + }; + + let response: Response; + try { + response = await handler(request, client); + } catch (error) { + try { + const errorResponse = await onError(error); + // handled in vite middleware via `next(error)` + if (!errorResponse) return; + response = errorResponse; + } catch (error) { + console.error(`There was an error in the error handler: ${error}`); + response = internalServerError(); + } + } + + // Use the rawHeaders API and iterate over response.headers so we are sure to send multiple + // Set-Cookie headers correctly. These would incorrectly be merged into a single header if we + // tried to use `Object.fromEntries(response.headers.entries())`. + const rawHeaders: string[] = []; + for (let [key, value] of response.headers) { + rawHeaders.push(key, value); + } + + res.writeHead(response.status, rawHeaders); + + if (response.body != null && req.method !== "HEAD") { + //@ts-expect-error + for await (let chunk of response.body) { + res.write(chunk); + } + } + + res.end(); + }; +}; + +const defaultErrorHandler = (error: unknown) => { + console.error(error); + return internalServerError(); +}; + +const internalServerError = () => { + return new Response( + // "Internal Server Error" + new Uint8Array([ + 73, 110, 116, 101, 114, 110, 97, 108, 32, 83, 101, 114, 118, 101, 114, 32, + 69, 114, 114, 111, 114, + ]), + { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }, + ); +}; + +const createRequest = (req: IncomingMessage, url: URL, signal: AbortSignal) => { + const init: RequestInit = { + method: req.method, + headers: createHeaders(req.headers), + signal, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = createStreamBody(req); + + // init.duplex = 'half' must be set when body is a ReadableStream, and Node follows the spec. + // However, this property is not defined in the TypeScript types for RequestInit, so we have + // to cast it here in order to set it without a type error. + // See https://fetch.spec.whatwg.org/#dom-requestinit-duplex + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url, init); +}; + +const createHeaders = (incoming: IncomingHttpHeaders) => { + const headers = new Headers(); + + for (const key in incoming) { + const value = incoming[key]; + + if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + } else if (value != null) { + headers.append(key, value); + } + } + + return headers; +}; + +export const createStreamBody = (req: IncomingMessage | ReadStream) => { + return new ReadableStream({ + start(controller) { + req.on("data", (chunk) => { + controller.enqueue( + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength), + ); + }); + + req.on("end", () => { + controller.close(); + }); + }, + }); +}; diff --git a/packages/domco/src/node/serve-static/index.ts b/packages/domco/src/node/serve-static/index.ts new file mode 100644 index 0000000..81c2a1b --- /dev/null +++ b/packages/domco/src/node/serve-static/index.ts @@ -0,0 +1,90 @@ +/** + * Used during `preview` only. + * + * Adapted from https://github.com/honojs/node-server/blob/main/src/serve-static.ts + */ +import { dirNames } from "../../constants/index.js"; +import { createStreamBody } from "../request-listener/index.js"; +import { createMiddleware } from "hono/factory"; +import { + getFilePath, + getFilePathWithoutDefaultDocument, +} from "hono/utils/filepath"; +import { getMimeType } from "hono/utils/mime"; +import { createReadStream } from "node:fs"; +import { lstat } from "node:fs/promises"; + +export const serveStatic = createMiddleware(async (c, next) => { + if (c.finalized) { + return next(); + } + + const root = `./${dirNames.out.base}/${dirNames.out.client.base}`; + const filename = decodeURIComponent(c.req.path); + + let path = getFilePathWithoutDefaultDocument({ filename, root }); + + if (!path) return next(); + + path = `./${path}`; + + let stats = await getStats(path); + + if (stats?.isDirectory()) { + path = getFilePath({ filename, root }); + + if (!path) return next(); + + path = `./${path}`; + + stats = await getStats(path); + } + + if (!stats) return next(); + + const mime = getMimeType(path); + + if (mime) { + c.header("Content-Type", mime); + } + + if (c.req.method == "HEAD" || c.req.method == "OPTIONS") { + c.header("Content-Length", stats.size.toString()); + return c.body(null, 200); + } + + const range = c.req.header("range") || ""; + + if (!range) { + c.header("Content-Length", stats.size.toString()); + return c.body(createStreamBody(createReadStream(path)), 200); + } + + // handle range + c.header("Accept-Ranges", "bytes"); + c.header("Date", stats.birthtime.toUTCString()); + + const parts = range.replace(/bytes=/, "").split("-", 2); + const start = parts[0] ? parseInt(parts[0], 10) : 0; + let end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + if (stats.size < end - start + 1) { + end = stats.size - 1; + } + + const chunkSize = end - start + 1; + const stream = createReadStream(path, { start, end }); + + c.header("Content-Length", chunkSize.toString()); + c.header("Content-Range", `bytes ${start}-${end}/${stats.size}`); + + return c.body(createStreamBody(stream), 206); +}); + +const getStats = async (path: string) => { + try { + const stats = await lstat(path); + return stats; + } catch { + return null; + } +}; diff --git a/packages/domco/src/plugin/configure-server/index.ts b/packages/domco/src/plugin/configure-server/index.ts index df6ea21..145f12b 100644 --- a/packages/domco/src/plugin/configure-server/index.ts +++ b/packages/domco/src/plugin/configure-server/index.ts @@ -1,8 +1,10 @@ import { createAppDev } from "../../app/dev/index.js"; +import type { createApp as createAppType } from "../../app/index.js"; import { dirNames, fileNames } from "../../constants/index.js"; +import { createRequestListener } from "../../node/request-listener/index.js"; +import { serveStatic } from "../../node/serve-static/index.js"; import type { Adapter } from "../../types/public/index.js"; -import { getRequestListener } from "@hono/node-server"; -import { serveStatic } from "@hono/node-server/serve-static"; +import type { MiddlewareHandler } from "hono"; import path from "node:path"; import process from "node:process"; import url from "node:url"; @@ -13,7 +15,6 @@ export const configureServerPlugin = (adapter?: Adapter): Plugin => { name: "domco:configure-server", apply: "serve", - // only apply in dev transform(code, id) { // inject vite client to client entries, in case the script is added // without adding an html file. This is a easier than injecting a tag @@ -47,9 +48,8 @@ export const configureServerPlugin = (adapter?: Adapter): Plugin => { }); devServer.middlewares.use(async (req, res, next) => { - getRequestListener( - // This code is copied from - // https://github.com/honojs/vite-plugins/blob/main/packages/dev-server/src/dev-server.ts + createRequestListener( + // Copied from https://github.com/honojs/vite-plugins/blob/main/packages/dev-server/src/dev-server.ts async (request) => { const response = await app.fetch(request); @@ -58,8 +58,7 @@ export const configureServerPlugin = (adapter?: Adapter): Plugin => { return response; }, { - overrideGlobalObjects: false, - errorHandler: (e) => { + onError: (e) => { let error: Error; if (e instanceof Error) { @@ -94,23 +93,17 @@ export const configureServerPlugin = (adapter?: Adapter): Plugin => { ), ).href ) - ).createApp; + ).createApp as typeof createAppType; - // use node serve static since the preview server is a node server - const app = createApp({ - serveStatic, - middleware: adapter?.previewMiddleware, - }); + const middleware: MiddlewareHandler[] = [serveStatic]; - previewServer.middlewares.use(async (req, res) => { - getRequestListener(async (request) => { - const response = await app.fetch(request); + if (adapter?.previewMiddleware) { + middleware.push(...adapter.previewMiddleware); + } - if (!(response instanceof Response)) throw response; + const app = createApp({ middleware }); - return response; - })(req, res); - }); + previewServer.middlewares.use(createRequestListener(app.fetch)); }, }; }; diff --git a/packages/domco/src/version/index.ts b/packages/domco/src/version/index.ts index 742c0f0..d080148 100644 --- a/packages/domco/src/version/index.ts +++ b/packages/domco/src/version/index.ts @@ -1 +1 @@ -export const version = "0.8.1"; +export const version = "0.9.1";