diff --git a/README.md b/README.md index 269d724..ce008aa 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,7 @@ deno run -A page.tsx /** @jsxImportSource https://esm.sh/preact@10.27.2 */ // deno-lint-ignore-file no-import-prefix import { useEffect, useState } from "https://esm.sh/preact@10.27.2/hooks"; -import { - defineApi, - serve, -} from "https://esm.sh/jsr/@d2verb/pera?deps=preact@10.27.2"; +import { serve } from "https://esm.sh/jsr/@d2verb/pera?deps=preact@10.27.2"; type Props = { initial?: number }; @@ -77,11 +74,12 @@ await serve(App, { title: "Counter Sample", moduleUrl: import.meta.url, props: { initial: 4 }, - api: defineApi({ - "/users/:name": { - GET: (_, ctx) => new Response(`Hello, ${ctx.params.name}!`), - }, - }), + api: (app) => { + app.get( + "/users/:name", + (c) => new Response(`Hello, ${c.req.param("name")}!`), + ); + }, }); ``` diff --git a/deno.json b/deno.json index 8fa1f89..f9ea15b 100644 --- a/deno.json +++ b/deno.json @@ -17,6 +17,7 @@ }, "imports": { "@deno/emit": "jsr:@deno/emit@^0.46.0", + "@hono/hono": "jsr:@hono/hono@^4.9.10", "@std/assert": "jsr:@std/assert@1", "@std/async": "jsr:@std/async@^1.0.14", "@std/path": "jsr:@std/path@^1.1.2", diff --git a/deno.lock b/deno.lock index cba7ebc..9e46744 100644 --- a/deno.lock +++ b/deno.lock @@ -5,9 +5,11 @@ "jsr:@deno/emit@*": "0.46.0", "jsr:@deno/emit@0.46": "0.46.0", "jsr:@deno/graph@~0.73.1": "0.73.1", + "jsr:@hono/hono@^4.9.10": "4.9.10", "jsr:@std/assert@0.223": "0.223.0", "jsr:@std/assert@1": "1.0.14", "jsr:@std/assert@^1.0.13": "1.0.14", + "jsr:@std/async@^1.0.13": "1.0.14", "jsr:@std/async@^1.0.14": "1.0.14", "jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/data-structures@^1.0.9": "1.0.9", @@ -21,6 +23,7 @@ "jsr:@std/path@^1.1.1": "1.1.2", "jsr:@std/path@^1.1.2": "1.1.2", "jsr:@std/testing@^1.0.15": "1.0.15", + "npm:@types/node@*": "24.2.0", "npm:lefthook@1.13.6": "1.13.6", "npm:lefthook@^1.13.6": "1.13.6", "npm:preact-render-to-string@6.6.2": "6.6.2_preact@10.27.2", @@ -47,6 +50,9 @@ "@deno/graph@0.73.1": { "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" }, + "@hono/hono@4.9.10": { + "integrity": "b416cf3bf42e33353e37ea13df409b08bd9a67fabc5fca630f76924fbadb01e5" + }, "@std/assert@0.223.0": { "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" }, @@ -103,6 +109,7 @@ "integrity": "a490169f5ccb0f3ae9c94fbc69d2cd43603f2cffb41713a85f99bbb0e3087cbc", "dependencies": [ "jsr:@std/assert@^1.0.13", + "jsr:@std/async@^1.0.13", "jsr:@std/data-structures", "jsr:@std/fs@^1.0.19", "jsr:@std/internal", @@ -111,6 +118,12 @@ } }, "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, "lefthook-darwin-arm64@1.13.6": { "integrity": "sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A==", "os": ["darwin"], @@ -186,6 +199,9 @@ }, "preact@10.27.2": { "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==" + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" } }, "redirects": { @@ -430,6 +446,7 @@ "workspace": { "dependencies": [ "jsr:@deno/emit@0.46", + "jsr:@hono/hono@^4.9.10", "jsr:@std/assert@1", "jsr:@std/async@^1.0.14", "jsr:@std/path@^1.1.2", diff --git a/examples/counter.tsx b/examples/counter.tsx index 0c11460..af4e608 100644 --- a/examples/counter.tsx +++ b/examples/counter.tsx @@ -2,10 +2,7 @@ // deno-lint-ignore-file no-import-prefix // @ts-nocheck The old cached version of the library causes type errors sometimes. import { useEffect, useState } from "https://esm.sh/preact@10.27.2/hooks"; -import { - defineApi, - serve, -} from "https://esm.sh/jsr/@d2verb/pera?deps=preact@10.27.2"; +import { serve } from "https://esm.sh/jsr/@d2verb/pera?deps=preact@10.27.2"; type Props = { initial?: number }; @@ -51,9 +48,10 @@ await serve(App, { title: "Counter Sample", moduleUrl: import.meta.url, props: { initial: 4 }, - api: defineApi({ - "/users/:name": { - GET: (_, ctx) => new Response(`Hello, ${ctx.params.name}!`), - }, - }), + api: (app) => { + app.get( + "/users/:name", + (c) => new Response(`Hello, ${c.req.param("name")}!`), + ); + }, }); diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 2c08bf8..0000000 --- a/src/api.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * The HTTP methods that are supported by the API. - */ -export type ApiMethod = - | "GET" - | "POST" - | "PUT" - | "DELETE" - | "PATCH" - | "OPTIONS" - | "HEAD"; - -/** - * Prettify a type. - * - * @example - * ```ts - * Prettify<{ a: string } & { b: number }> = { a: string; b: number } - * ``` - */ -type Prettify = - & { - [K in keyof T]: T[K]; - } - // deno-lint-ignore ban-types - & {}; - -/** - * Extract path parameters from a URL pattern. - * - * @example - * ```ts - * PathParams<"/users/:id/posts/:postId"> = { id: string; postId: string } - * PathParams<"/users"> = Record - * PathParams<"/users/:name/:name"> = never - * PathParams<""> = never - * ``` - */ -export type PathParams = - Prettify< - Path extends "" ? never - : Path extends `${infer _Start}:${infer Param}/${infer Rest}` - ? Param extends "" ? never - : Param extends Seen ? never - : { [K in Param]: string } & PathParams<`/${Rest}`, Param | Seen> - : Path extends `${infer _Start}:${infer Param}` ? Param extends "" ? never - : Param extends Seen ? never - : { [K in Param]: string } - : Record - >; - -/** - * The context for the API function. - */ -export type ApiContext< - Params extends Record, -> = { - /** The path parameters parsed from the URL. */ - params: Params; -}; - -/** - * The function signature for the API function. - */ -export type ApiFn< - Params extends Record, -> = ( - req: Request, - ctx: ApiContext, -) => Response | Promise; - -/** - * The map of the API methods to the API functions. - */ -export type ApiMethodMap< - Params extends Record, -> = Partial>>; - -/** - * The map of the API endpoints to the API methods. - */ -export type ApiMap< - T extends Record = Record, -> = { - [K in keyof T & string]: ApiMethodMap>; -}; - -/** - * Type inference for the API map. - * - * TODO(d2verb): I want to trigger a type error if the path is invalid, but I don't know how to do it. - */ -export function defineApi>( - routes: ApiMap, -): ApiMap { - return routes; -} - -/** - * Find the API method map and the API parameters for the given path. - */ -export function findEndpoint< - T extends Record = Record, ->(path: string, api: ApiMap) { - for (const [endpoint, methodMap] of Object.entries(api)) { - const matched = new URLPattern({ pathname: endpoint }).exec({ - pathname: path, - }); - if (!matched) continue; - - return { - methodMap, - params: matched.pathname.groups, - }; - } - return null; -} diff --git a/src/mod.ts b/src/mod.ts index 047b788..a13c13f 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -13,17 +13,17 @@ import type { PeraApp, PeraOptions } from "./types.ts"; * ``` * * @example With API - * You should use defineApi() to define your API endpoints — it helps TypeScript infer the types of path parameters automatically. + * `app` is a Hono app instance. You can use it to define your API endpoints. * ```ts * await serve(App, { * port: 8080, * moduleUrl: import.meta.url, - * api: defineApi({ + * api: (app) => { * // The actual path is `/_pera/api/students/:name` - * "/students/:name": { - * GET: (_, ctx) => new Response(`Hello, ${ctx.params.name}!`), - * }, - * }), + * app.get("/students/:name", (c) => + * new Response(`Hello, ${c.req.param("name")}!`), + * ); + * }, * }); * ``` * @@ -42,13 +42,4 @@ export async function serve< return serveImpl(App, opts); } -export type { - ApiContext, - ApiFn, - ApiMap, - ApiMethod, - ApiMethodMap, - PathParams, -} from "./api.ts"; -export { defineApi } from "./api.ts"; export type { PeraOptions }; diff --git a/src/server.ts b/src/server.ts index ab4a3fe..4cd4ec4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,10 +1,10 @@ -import { type ApiMethod, findEndpoint } from "./api.ts"; import { escapeHtml, filePathFromModuleUrl } from "./utils.ts"; import type { PeraApp, PeraOptions } from "./types.ts"; import { bundle } from "@deno/emit"; import { dirname } from "@std/path"; import { renderToString } from "preact-render-to-string"; import { h } from "preact"; +import { Hono } from "@hono/hono"; /** * The implementation of the serve() function. @@ -12,7 +12,7 @@ import { h } from "preact"; * @param App The root component of the Pera app. * @param opts The options for the Pera app. */ -export function serveImpl< +export async function serveImpl< P extends Record = Record, >( App: PeraApp

, @@ -23,99 +23,78 @@ export function serveImpl< const rootId = opts.rootId ?? "root"; const appFile = filePathFromModuleUrl(opts.moduleUrl); const hmr = opts.hmr ?? true; - const api = opts.api ?? {}; - const handler = async (req: Request) => { - const url = new URL(req.url); + const app = new Hono(); - if (url.pathname === "/_pera/app.js") { - try { - const origCode = await Deno.readTextFile(appFile); - const autoExports = - '\n\nexport { h, render } from "https://esm.sh/preact@10.27.2";\n'; - const codeToBundle = origCode + autoExports; + app.get("/_pera/app.js", async () => { + try { + const origCode = await Deno.readTextFile(appFile); + const autoExports = + '\n\nexport { h, render } from "https://esm.sh/preact@10.27.2";\n'; + const codeToBundle = origCode + autoExports; - const tempFile = await Deno.makeTempFile({ - dir: dirname(appFile), - prefix: ".pera-temp-app-", - suffix: ".tsx", - }); + const tempFile = await Deno.makeTempFile({ + dir: dirname(appFile), + prefix: ".pera-temp-app-", + suffix: ".tsx", + }); - try { - await Deno.writeTextFile(tempFile, codeToBundle); - const url = new URL("file://" + tempFile); - const result = await bundle(url); - return new Response(result.code, { - headers: { - "content-type": "application/javascript; charset=utf-8", - }, - }); - } finally { - await Deno.remove(tempFile); - } - } catch (e) { - return new Response( - `// read error: ${e instanceof Error ? e.message : "unknown error"}`, - { - status: 500, - headers: { - "content-type": "application/javascript; charset=utf-8", - }, + try { + await Deno.writeTextFile(tempFile, codeToBundle); + const url = new URL("file://" + tempFile); + const result = await bundle(url); + return new Response(result.code, { + headers: { + "content-type": "application/javascript; charset=utf-8", }, - ); + }); + } finally { + await Deno.remove(tempFile); } - } - - if (url.pathname === "/_pera/hmr") { - let watcher: Deno.FsWatcher | undefined; - const stream = new ReadableStream({ - async start(controller) { - const enc = new TextEncoder(); - controller.enqueue(enc.encode("retry: 2000\n\n")); - watcher = Deno.watchFs(appFile); - for await (const ev of watcher) { - if (ev.kind === "modify") { - controller.enqueue( - enc.encode(`event: hot-reload\ndata: hot-reload\n\n`), - ); - } - } - }, - cancel() { - watcher?.close(); - }, - }); - return new Response(stream, { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - "connection": "keep-alive", + } catch (e) { + return new Response( + `// read error: ${e instanceof Error ? e.message : "unknown error"}`, + { + status: 500, + headers: { + "content-type": "application/javascript; charset=utf-8", + }, }, - }); + ); } + }); - if (url.pathname.startsWith("/_pera/api/")) { - const path = url.pathname.slice(10); - const result = findEndpoint(path, api); - if (!result) { - return new Response("Endpoint not found", { status: 404 }); - } - - const { methodMap, params } = result; - const method = req.method as ApiMethod; - const fn = methodMap[method]; - if (!fn) { - return new Response("Method not allowed", { status: 405 }); - } - - if (fn.length > 2) { - return new Response("Invalid function signature", { status: 500 }); - } + app.get("/_pera/hmr", () => { + let watcher: Deno.FsWatcher | undefined; + const stream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + controller.enqueue(enc.encode("retry: 2000\n\n")); + watcher = Deno.watchFs(appFile); + for await (const ev of watcher) { + if (ev.kind === "modify") { + controller.enqueue( + enc.encode(`event: hot-reload\ndata: hot-reload\n\n`), + ); + } + } + }, + cancel() { + watcher?.close(); + }, + }); - return await fn(req, { params }); - } + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "connection": "keep-alive", + }, + }); + }); - const ssr = await renderToString(h(App, opts.props ?? null)); + app.get("/", () => { + const ssr = renderToString(h(App, opts.props ?? null)); const propsStr = JSON.stringify(opts.props ?? {}); const html = ` @@ -149,9 +128,15 @@ export function serveImpl< return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" }, }); - }; + }); + + if (opts.api) { + const apiRouter = new Hono(); + await opts.api(apiRouter); + app.route("/_pera/api", apiRouter); + } - const server = Deno.serve({ port, signal: opts.signal }, handler); + const server = Deno.serve({ port, signal: opts.signal }, app.fetch); return server.finished; } diff --git a/src/types.ts b/src/types.ts index 4920c5c..f3b4512 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import type { JSX } from "preact"; -import type { ApiMap } from "./api.ts"; +import type { Hono } from "@hono/hono"; /** * The options for the Pera app. @@ -19,8 +19,8 @@ export type PeraOptions< rootId?: string; /** Whether to enable hot module replacement. (Default: true) */ hmr?: boolean; - /** The API endpoints to expose to the client. (Default: {}) */ - api?: ApiMap; + /** The function to register the API endpoints. (Default: undefined) */ + api?: (app: Hono) => void | Promise; /** The signal to abort the server. (Default: undefined) */ signal?: AbortSignal; }; diff --git a/tests/api_test.ts b/tests/api_test.ts deleted file mode 100644 index 2325bbc..0000000 --- a/tests/api_test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defineApi } from "../src/api.ts"; -import type { AssertTrue, IsExact } from "@std/testing/types"; - -Deno.test("defineApi() infers the correct type", () => { - defineApi({ - "/users/:name/items/:itemId": { - GET: (_, ctx) => { - type _ = AssertTrue< - IsExact - >; - return new Response("DUMMY"); - }, - }, - "/users": { - GET: (_, ctx) => { - type _ = AssertTrue>>; - return new Response("DUMMY"); - }, - }, - "/users/:name/:name": { - GET: (_, ctx) => { - type _ = AssertTrue>; - return new Response("DUMMY"); - }, - }, - "/users/:/test": { - GET: (_, ctx) => { - type _ = AssertTrue>; - return new Response("DUMMY"); - }, - }, - "": { - GET: (_, ctx) => { - type _ = AssertTrue>; - return new Response("DUMMY"); - }, - }, - }); -}); diff --git a/tests/sample-app.tsx b/tests/sample-app.tsx new file mode 100644 index 0000000..debfd87 --- /dev/null +++ b/tests/sample-app.tsx @@ -0,0 +1,3 @@ +export const App = () => { + return

Hello, World!
; +}; diff --git a/tests/serve_test.tsx b/tests/serve_test.tsx index 39a7a53..1533a2f 100644 --- a/tests/serve_test.tsx +++ b/tests/serve_test.tsx @@ -1,27 +1,25 @@ import { assertEquals, assertMatch } from "@std/assert"; import { delay } from "@std/async"; -import { defineApi, serve } from "../src/mod.ts"; - -const App = () => { - return
Hello, World!
; -}; +import { serve } from "../src/mod.ts"; +import { App } from "./sample-app.tsx"; const port: number = 9090; let finished: Promise; let controller: AbortController; Deno.test.beforeEach(async () => { + const sampleAppFile = new URL("sample-app.tsx", import.meta.url); controller = new AbortController(); finished = serve(App, { port, title: "Test Server", - moduleUrl: import.meta.url, - props: { initial: 7 }, - api: defineApi({ - "/users/:name": { - GET: (_, ctx) => new Response(`Hello, ${ctx.params.name}!`), - }, - }), + moduleUrl: sampleAppFile.href, + api: (app) => { + app.get( + "/users/:name", + (c) => new Response(`Hello, ${c.req.param("name")}!`), + ); + }, signal: controller.signal, }); await delay(100); @@ -49,14 +47,14 @@ Deno.test("serve() responds to GET /_pera/api/*", async () => { `http://localhost:${port}/_pera/api/not-found`, ); assertEquals(resApiNotFound.status, 404); - assertEquals(await resApiNotFound.text(), "Endpoint not found"); + await resApiNotFound.text(); const resApiMethodNotAllowed = await fetch( `http://localhost:${port}/_pera/api/users/deno`, { method: "POST" }, ); - assertEquals(resApiMethodNotAllowed.status, 405); - assertEquals(await resApiMethodNotAllowed.text(), "Method not allowed"); + assertEquals(resApiMethodNotAllowed.status, 404); + await resApiMethodNotAllowed.text(); }); Deno.test("serve() responds to GET /_pera/hmr", async () => { @@ -72,5 +70,11 @@ Deno.test("serve() responds to GET /_pera/hmr", async () => { }); Deno.test("serve() responds to GET /_pera/app.js", async () => { - // TODO(d2verb): Implement this test + const resAppJs = await fetch(`http://localhost:${port}/_pera/app.js`); + assertEquals(resAppJs.status, 200); + assertEquals( + resAppJs.headers.get("content-type"), + "application/javascript; charset=utf-8", + ); + await resAppJs.text(); });