diff --git a/build/run.go b/build/run.go index 19670f660..633e1b03a 100644 --- a/build/run.go +++ b/build/run.go @@ -148,6 +148,8 @@ func writeOutConsts(version string) error { "examples/php/hello-world/public/hello-world.html": helloWorldExamplePHP, "examples/zig/httpz/hello-world/src/hello-world.html": helloWorldExample, "examples/zig/tokamak/hello-world/hello-world.html": helloWorldExample, + "examples/typescript/deno/public/hello-world.html": helloWorldExample, + "examples/typescript/node/public/hello-world.html": helloWorldExample, "examples/rust/axum/hello-world/hello-world.html": helloWorldExample, "examples/rust/rocket/hello-world/hello-world.html": helloWorldExample, } diff --git a/examples/typescript/deno-sw/README.md b/examples/typescript/deno-sw/README.md new file mode 100644 index 000000000..b609c8a18 --- /dev/null +++ b/examples/typescript/deno-sw/README.md @@ -0,0 +1,8 @@ +This is a sample app that can serve the same responses from both the Deno backend and the Service Worker while offline. + +To test it, start the server with `deno run start` and load the site at `http://localhost:8000`. + +The service worker will install in the browser and static assets will be cached. You can then either turn on offline mode in your browser's dev tools Network tab, or turn off the Deno server (there will be a slight delay in the response as it tries to fetch from the network first). Reload the page while offline and it will load from the service worker! And the SSE response when you click Start will be rendered from the SW as well. + +If you want to expand upon it, it would be best to modify the `shared-router.ts` file, which is what both versions ultimately use (other than for loading static assets, which require different mechanisms in each environment). You can use `deno run dev` to rebuild the service worker (if your OS is capable of using esbuild) and start the server. + diff --git a/examples/typescript/deno-sw/build.ts b/examples/typescript/deno-sw/build.ts new file mode 100644 index 000000000..ff3590ab8 --- /dev/null +++ b/examples/typescript/deno-sw/build.ts @@ -0,0 +1,21 @@ +import * as esbuild from "https://deno.land/x/esbuild/mod.js"; +import { denoPlugins } from "jsr:@luca/esbuild-deno-loader"; + +await esbuild.build({ + plugins: [...denoPlugins()], + write: true, + entryPoints: ["./src/service-worker.ts"], + outfile: "./src/static/service-worker.js", + bundle: true, + minify: false, + format: "esm", + legalComments: "none", + platform: "browser", + conditions: ["worker", "browser"], + resolveExtensions: [".ts", ".js", ".mjs"], + loader: { + ".ts": "ts", + }, +}); + +esbuild.stop(); diff --git a/examples/typescript/deno-sw/deno.json b/examples/typescript/deno-sw/deno.json new file mode 100644 index 000000000..60ab9f76b --- /dev/null +++ b/examples/typescript/deno-sw/deno.json @@ -0,0 +1,12 @@ +{ + "imports": { + "@hono/hono": "jsr:@hono/hono@^4.6.19", + "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.9.0" + }, + "tasks": { + "start": "deno run -A ./src/deno.ts", + "build": "deno run -A build.ts", + "dev": "deno task build && deno run start" + }, + "nodeModulesDir": "auto" +} diff --git a/examples/typescript/deno-sw/deno.lock b/examples/typescript/deno-sw/deno.lock new file mode 100644 index 000000000..6e2cfbabb --- /dev/null +++ b/examples/typescript/deno-sw/deno.lock @@ -0,0 +1,172 @@ +{ + "version": "4", + "specifiers": { + "jsr:@hono/hono@*": "4.6.19", + "jsr:@hono/hono@^4.6.19": "4.6.19", + "jsr:@luca/esbuild-deno-loader@*": "0.9.0", + "jsr:@luca/esbuild-deno-loader@0.9": "0.9.0", + "jsr:@std/assert@~0.213.1": "0.213.1", + "jsr:@std/encoding@0.213": "0.213.1", + "jsr:@std/json@~0.213.1": "0.213.1", + "jsr:@std/jsonc@0.213": "0.213.1", + "jsr:@std/path@0.213": "0.213.1", + "npm:esbuild@0.20": "0.20.2", + "npm:type-fest@*": "4.33.0" + }, + "jsr": { + "@hono/hono@4.6.19": { + "integrity": "5ba1bd0ef74449c0a647f029e29896776c30fb64aefbcef8724af4ce4846791b" + }, + "@luca/esbuild-deno-loader@0.9.0": { + "integrity": "288bbcede5c8a6f97e635f8fa4df779b13440ee0c0506d9e478fb6537789dc93", + "dependencies": [ + "jsr:@std/encoding", + "jsr:@std/jsonc", + "jsr:@std/path", + "npm:esbuild" + ] + }, + "@std/assert@0.213.1": { + "integrity": "24c28178b30c8e0782c18e8e94ea72b16282207569cdd10ffb9d1d26f2edebfe" + }, + "@std/encoding@0.213.1": { + "integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62" + }, + "@std/json@0.213.1": { + "integrity": "f572b1de605d07c4a5602445dac54bfc51b1fb87a3710a17aed2608bfca54e68" + }, + "@std/jsonc@0.213.1": { + "integrity": "5578f21aa583b7eb7317eed077ffcde47b294f1056bdbb9aacec407758637bfe", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/json" + ] + }, + "@std/path@0.213.1": { + "integrity": "f187bf278a172752e02fcbacf6bd78a335ed320d080a7ed3a5a59c3e88abc673", + "dependencies": [ + "jsr:@std/assert" + ] + } + }, + "npm": { + "@esbuild/aix-ppc64@0.20.2": { + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==" + }, + "@esbuild/android-arm64@0.20.2": { + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==" + }, + "@esbuild/android-arm@0.20.2": { + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==" + }, + "@esbuild/android-x64@0.20.2": { + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==" + }, + "@esbuild/darwin-arm64@0.20.2": { + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==" + }, + "@esbuild/darwin-x64@0.20.2": { + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==" + }, + "@esbuild/freebsd-arm64@0.20.2": { + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==" + }, + "@esbuild/freebsd-x64@0.20.2": { + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==" + }, + "@esbuild/linux-arm64@0.20.2": { + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==" + }, + "@esbuild/linux-arm@0.20.2": { + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==" + }, + "@esbuild/linux-ia32@0.20.2": { + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==" + }, + "@esbuild/linux-loong64@0.20.2": { + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==" + }, + "@esbuild/linux-mips64el@0.20.2": { + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==" + }, + "@esbuild/linux-ppc64@0.20.2": { + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==" + }, + "@esbuild/linux-riscv64@0.20.2": { + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==" + }, + "@esbuild/linux-s390x@0.20.2": { + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==" + }, + "@esbuild/linux-x64@0.20.2": { + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==" + }, + "@esbuild/netbsd-x64@0.20.2": { + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==" + }, + "@esbuild/openbsd-x64@0.20.2": { + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==" + }, + "@esbuild/sunos-x64@0.20.2": { + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==" + }, + "@esbuild/win32-arm64@0.20.2": { + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==" + }, + "@esbuild/win32-ia32@0.20.2": { + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==" + }, + "@esbuild/win32-x64@0.20.2": { + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==" + }, + "esbuild@0.20.2": { + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-x64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ] + }, + "type-fest@4.33.0": { + "integrity": "sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g==" + } + }, + "redirects": { + "https://deno.land/std/fs/ensure_dir.ts": "https://deno.land/std@0.224.0/fs/ensure_dir.ts", + "https://deno.land/x/esbuild/mod.js": "https://deno.land/x/esbuild@v0.24.2/mod.js" + }, + "remote": { + "https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", + "https://deno.land/std@0.224.0/fs/ensure_dir.ts": "51a6279016c65d2985f8803c848e2888e206d1b510686a509fa7cc34ce59d29f", + "https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6", + "https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4", + "https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d", + "https://deno.land/x/esbuild@v0.24.2/mod.js": "8d1e46a6494585235b0514d37743ee48a4f6f0b8e00fca9d0a2e371914b1df0e" + }, + "workspace": { + "dependencies": [ + "jsr:@hono/hono@^4.6.19", + "jsr:@luca/esbuild-deno-loader@0.9" + ] + } +} diff --git a/examples/typescript/deno-sw/src/deno.ts b/examples/typescript/deno-sw/src/deno.ts new file mode 100644 index 000000000..99ee87335 --- /dev/null +++ b/examples/typescript/deno-sw/src/deno.ts @@ -0,0 +1,19 @@ +import { Hono } from "jsr:@hono/hono/tiny"; +import { serveStatic } from "jsr:@hono/hono/deno"; +import { createRouter } from "./shared-router.ts"; + +const app = new Hono(); + +// Serve static files +app.use("/static/*", serveStatic({ root: "./src" })); + +// Serve the service worker from the root path - this is need so that it has scope for the entire application rather than just /static +app.use("/service-worker.js", serveStatic({ path: "./src/static/service-worker.js" })); + +// Mount the shared router at the root path +app.route("/", createRouter()); + +Deno.serve({ + port: 8000, + hostname: "127.0.0.1", +}, app.fetch); diff --git a/examples/typescript/deno-sw/src/hello-world.js b/examples/typescript/deno-sw/src/hello-world.js new file mode 100644 index 000000000..b8285d8e6 --- /dev/null +++ b/examples/typescript/deno-sw/src/hello-world.js @@ -0,0 +1,39 @@ +export function getHelloWorldHtml() { + return ` + + + Datastar SDK Demo + + + + + +
+
+

Datastar SDK Demo

+ Rocket +
+

SSE events will be streamed from the backend to the frontend.

+
+ + +
+ +
+
+
Hello, world!
+
+ +`; +} \ No newline at end of file diff --git a/examples/typescript/deno-sw/src/service-worker.ts b/examples/typescript/deno-sw/src/service-worker.ts new file mode 100644 index 000000000..ce0354da5 --- /dev/null +++ b/examples/typescript/deno-sw/src/service-worker.ts @@ -0,0 +1,88 @@ +import { createRouter } from "./shared-router.ts"; + +declare const self: ServiceWorkerGlobalScope; + +const CACHE_NAME = 'datastar-cache'; +const CORE_ASSETS = [ + '/', + 'https://unpkg.com/@tailwindcss/browser@4', + 'https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.2/bundles/datastar.js', + 'https://data-star.dev/static/images/rocket.png' +]; + +const router = createRouter(); + +self.addEventListener('install', (event) => { + console.log('SW Install'); + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); + for (const url of CORE_ASSETS) { + try { + const fetchOptions = url.includes('rocket') ? { mode: 'no-cors' as RequestMode } : undefined; + const response = await fetch(url, fetchOptions); + await cache.put(url, response); + console.log('Cached:', url); + } catch (error) { + console.error('Failed to cache:', url, error); + } + } + await self.skipWaiting(); + })() + ); +}); + +self.addEventListener('activate', (event) => { + console.log('SW Activate'); + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + (async () => { + const cache = await caches.open(CACHE_NAME); + const url = new URL(event.request.url); + + // Try cache first for core assets + if (CORE_ASSETS.includes(url.toString()) || CORE_ASSETS.includes(url.pathname)) { + const cachedResponse = await cache.match(event.request); + if (cachedResponse) { + console.log('SW Cache Hit:', event.request.url); + return cachedResponse; + } + } + + // If browser is set to offline, use router for dynamic routes + if (!self.navigator.onLine) { + console.log('Browser is offline, using router fallback'); + return await router.fetch(event.request); + } + + // Try network + try { + console.log('SW Fetch:', event.request.url); + + const fetchOptions = event.request.url.includes('rocket') ? { mode: 'no-cors' as RequestMode } : undefined; + + const networkResponse = await fetch(event.request, fetchOptions); + + // Cache successful GET requests for core assets + if (networkResponse.ok && + event.request.method === 'GET' + && (CORE_ASSETS.includes(url.toString()) || CORE_ASSETS.includes(url.pathname)) + ) + { + await cache.put(event.request, networkResponse.clone()); + console.log('SW Cache Put:', event.request.url); + } + + return networkResponse; + } catch (error) { + // Fall back to router for dynamic routes + console.log('Network request failed, using router fallback'); + return await router.fetch(event.request); + } + })() + ); + +}); diff --git a/examples/typescript/deno-sw/src/shared-router.ts b/examples/typescript/deno-sw/src/shared-router.ts new file mode 100644 index 000000000..99ccaf9c3 --- /dev/null +++ b/examples/typescript/deno-sw/src/shared-router.ts @@ -0,0 +1,47 @@ +import { Hono } from "jsr:@hono/hono/tiny"; +import { ServerSentEventGenerator } from "../../../../sdk/typescript/src/web/serverSentEventGenerator.ts"; +import { getHelloWorldHtml } from "./hello-world.js"; + +// Make Store properties explicitly typed +interface Store { + delay: number | null; +} + +export function createRouter() { + const app = new Hono(); + + // Homepage route + app.get("/", async (c) => { + return c.html(getHelloWorldHtml()); + }); + + // Hello, world! route + app.get("/hello-world", async (c) => { + const reader = await ServerSentEventGenerator.readSignals(c.req.raw); + + if (!reader.success) { + return c.text(`Error while reading signals: ${reader.error}`, 400); + } + + return ServerSentEventGenerator.stream(async (stream) => { + const message = "Hello, world!"; + const delay = typeof reader.signals.delay === 'number' ? reader.signals.delay : 400; // Default delay + + for (let i = 0; i < message.length; i++) { + stream.mergeFragments( + `
${message.substring(0, i + 1)}
`, + ); + await new Promise(resolve => setTimeout(resolve, delay)); + } + }); + }); + + // Catch-all route - just return 404 for unmatched routes + app.all("*", (c) => { + console.log(`Route not found: ${c.req.url}`); + return c.text(`Path not found: ${c.req.url}`, 404); + }); + + return app; +} + diff --git a/examples/typescript/deno-sw/src/static/service-worker.js b/examples/typescript/deno-sw/src/static/service-worker.js new file mode 100644 index 000000000..1b4f00b63 --- /dev/null +++ b/examples/typescript/deno-sw/src/static/service-worker.js @@ -0,0 +1,1781 @@ +// https://jsr.io/@hono/hono/4.6.19/src/utils/body.ts +var parseBody = async (request, options = /* @__PURE__ */ Object.create(null)) => { + const { all = false, dot = false } = options; + const headers = request instanceof HonoRequest ? request.raw.headers : request.headers; + const contentType = headers.get("Content-Type"); + if (contentType?.startsWith("multipart/form-data") || contentType?.startsWith("application/x-www-form-urlencoded")) { + return parseFormData(request, { all, dot }); + } + return {}; +}; +async function parseFormData(request, options) { + const formData = await request.formData(); + if (formData) { + return convertFormDataToBodyData(formData, options); + } + return {}; +} +function convertFormDataToBodyData(formData, options) { + const form = /* @__PURE__ */ Object.create(null); + formData.forEach((value, key) => { + const shouldParseAllValues = options.all || key.endsWith("[]"); + if (!shouldParseAllValues) { + form[key] = value; + } else { + handleParsingAllValues(form, key, value); + } + }); + if (options.dot) { + Object.entries(form).forEach(([key, value]) => { + const shouldParseDotValues = key.includes("."); + if (shouldParseDotValues) { + handleParsingNestedValues(form, key, value); + delete form[key]; + } + }); + } + return form; +} +var handleParsingAllValues = (form, key, value) => { + if (form[key] !== void 0) { + if (Array.isArray(form[key])) { + ; + form[key].push(value); + } else { + form[key] = [form[key], value]; + } + } else { + form[key] = value; + } +}; +var handleParsingNestedValues = (form, key, value) => { + let nestedForm = form; + const keys = key.split("."); + keys.forEach((key2, index) => { + if (index === keys.length - 1) { + nestedForm[key2] = value; + } else { + if (!nestedForm[key2] || typeof nestedForm[key2] !== "object" || Array.isArray(nestedForm[key2]) || nestedForm[key2] instanceof File) { + nestedForm[key2] = /* @__PURE__ */ Object.create(null); + } + nestedForm = nestedForm[key2]; + } + }); +}; + +// https://jsr.io/@hono/hono/4.6.19/src/utils/url.ts +var tryDecode = (str, decoder) => { + try { + return decoder(str); + } catch { + return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => { + try { + return decoder(match); + } catch { + return match; + } + }); + } +}; +var tryDecodeURI = (str) => tryDecode(str, decodeURI); +var getPath = (request) => { + const url = request.url; + const start = url.indexOf("/", 8); + let i = start; + for (; i < url.length; i++) { + const charCode = url.charCodeAt(i); + if (charCode === 37) { + const queryIndex = url.indexOf("?", i); + const path = url.slice(start, queryIndex === -1 ? void 0 : queryIndex); + return tryDecodeURI(path.includes("%25") ? path.replace(/%25/g, "%2525") : path); + } else if (charCode === 63) { + break; + } + } + return url.slice(start, i); +}; +var getPathNoStrict = (request) => { + const result = getPath(request); + return result.length > 1 && result.at(-1) === "/" ? result.slice(0, -1) : result; +}; +var mergePath = (...paths) => { + let p = ""; + let endsWithSlash = false; + for (let path of paths) { + if (p.at(-1) === "/") { + p = p.slice(0, -1); + endsWithSlash = true; + } + if (path[0] !== "/") { + path = `/${path}`; + } + if (path === "/" && endsWithSlash) { + p = `${p}/`; + } else if (path !== "/") { + p = `${p}${path}`; + } + if (path === "/" && p === "") { + p = "/"; + } + } + return p; +}; +var _decodeURI = (value) => { + if (!/[%+]/.test(value)) { + return value; + } + if (value.indexOf("+") !== -1) { + value = value.replace(/\+/g, " "); + } + return value.indexOf("%") !== -1 ? decodeURIComponent_(value) : value; +}; +var _getQueryParam = (url, key, multiple) => { + let encoded; + if (!multiple && key && !/[%+]/.test(key)) { + let keyIndex2 = url.indexOf(`?${key}`, 8); + if (keyIndex2 === -1) { + keyIndex2 = url.indexOf(`&${key}`, 8); + } + while (keyIndex2 !== -1) { + const trailingKeyCode = url.charCodeAt(keyIndex2 + key.length + 1); + if (trailingKeyCode === 61) { + const valueIndex = keyIndex2 + key.length + 2; + const endIndex = url.indexOf("&", valueIndex); + return _decodeURI(url.slice(valueIndex, endIndex === -1 ? void 0 : endIndex)); + } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { + return ""; + } + keyIndex2 = url.indexOf(`&${key}`, keyIndex2 + 1); + } + encoded = /[%+]/.test(url); + if (!encoded) { + return void 0; + } + } + const results = {}; + encoded ??= /[%+]/.test(url); + let keyIndex = url.indexOf("?", 8); + while (keyIndex !== -1) { + const nextKeyIndex = url.indexOf("&", keyIndex + 1); + let valueIndex = url.indexOf("=", keyIndex); + if (valueIndex > nextKeyIndex && nextKeyIndex !== -1) { + valueIndex = -1; + } + let name = url.slice( + keyIndex + 1, + valueIndex === -1 ? nextKeyIndex === -1 ? void 0 : nextKeyIndex : valueIndex + ); + if (encoded) { + name = _decodeURI(name); + } + keyIndex = nextKeyIndex; + if (name === "") { + continue; + } + let value; + if (valueIndex === -1) { + value = ""; + } else { + value = url.slice(valueIndex + 1, nextKeyIndex === -1 ? void 0 : nextKeyIndex); + if (encoded) { + value = _decodeURI(value); + } + } + if (multiple) { + if (!(results[name] && Array.isArray(results[name]))) { + results[name] = []; + } + ; + results[name].push(value); + } else { + results[name] ??= value; + } + } + return key ? results[key] : results; +}; +var getQueryParam = _getQueryParam; +var getQueryParams = (url, key) => { + return _getQueryParam(url, key, true); +}; +var decodeURIComponent_ = decodeURIComponent; + +// https://jsr.io/@hono/hono/4.6.19/src/request.ts +var tryDecodeURIComponent = (str) => tryDecode(str, decodeURIComponent_); +var HonoRequest = class { + /** + * `.raw` can get the raw Request object. + * + * @see {@link https://hono.dev/docs/api/request#raw} + * + * @example + * ```ts + * // For Cloudflare Workers + * app.post('/', async (c) => { + * const metadata = c.req.raw.cf?.hostMetadata? + * ... + * }) + * ``` + */ + raw; + #validatedData; + // Short name of validatedData + #matchResult; + routeIndex = 0; + /** + * `.path` can get the pathname of the request. + * + * @see {@link https://hono.dev/docs/api/request#path} + * + * @example + * ```ts + * app.get('/about/me', (c) => { + * const pathname = c.req.path // `/about/me` + * }) + * ``` + */ + path; + bodyCache = {}; + constructor(request, path = "/", matchResult = [[]]) { + this.raw = request; + this.path = path; + this.#matchResult = matchResult; + this.#validatedData = {}; + } + param(key) { + return key ? this.#getDecodedParam(key) : this.#getAllDecodedParams(); + } + #getDecodedParam(key) { + const paramKey = this.#matchResult[0][this.routeIndex][1][key]; + const param = this.#getParamValue(paramKey); + return param ? /\%/.test(param) ? tryDecodeURIComponent(param) : param : void 0; + } + #getAllDecodedParams() { + const decoded = {}; + const keys = Object.keys(this.#matchResult[0][this.routeIndex][1]); + for (const key of keys) { + const value = this.#getParamValue(this.#matchResult[0][this.routeIndex][1][key]); + if (value && typeof value === "string") { + decoded[key] = /\%/.test(value) ? tryDecodeURIComponent(value) : value; + } + } + return decoded; + } + #getParamValue(paramKey) { + return this.#matchResult[1] ? this.#matchResult[1][paramKey] : paramKey; + } + query(key) { + return getQueryParam(this.url, key); + } + queries(key) { + return getQueryParams(this.url, key); + } + header(name) { + if (name) { + return this.raw.headers.get(name.toLowerCase()) ?? void 0; + } + const headerData = {}; + this.raw.headers.forEach((value, key) => { + headerData[key] = value; + }); + return headerData; + } + async parseBody(options) { + return this.bodyCache.parsedBody ??= await parseBody(this, options); + } + #cachedBody = (key) => { + const { bodyCache, raw: raw2 } = this; + const cachedBody = bodyCache[key]; + if (cachedBody) { + return cachedBody; + } + const anyCachedKey = Object.keys(bodyCache)[0]; + if (anyCachedKey) { + return bodyCache[anyCachedKey].then((body) => { + if (anyCachedKey === "json") { + body = JSON.stringify(body); + } + return new Response(body)[key](); + }); + } + return bodyCache[key] = raw2[key](); + }; + /** + * `.json()` can parse Request body of type `application/json` + * + * @see {@link https://hono.dev/docs/api/request#json} + * + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.json() + * }) + * ``` + */ + json() { + return this.#cachedBody("json"); + } + /** + * `.text()` can parse Request body of type `text/plain` + * + * @see {@link https://hono.dev/docs/api/request#text} + * + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.text() + * }) + * ``` + */ + text() { + return this.#cachedBody("text"); + } + /** + * `.arrayBuffer()` parse Request body as an `ArrayBuffer` + * + * @see {@link https://hono.dev/docs/api/request#arraybuffer} + * + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.arrayBuffer() + * }) + * ``` + */ + arrayBuffer() { + return this.#cachedBody("arrayBuffer"); + } + /** + * Parses the request body as a `Blob`. + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.blob(); + * }); + * ``` + * @see https://hono.dev/docs/api/request#blob + */ + blob() { + return this.#cachedBody("blob"); + } + /** + * Parses the request body as `FormData`. + * @example + * ```ts + * app.post('/entry', async (c) => { + * const body = await c.req.formData(); + * }); + * ``` + * @see https://hono.dev/docs/api/request#formdata + */ + formData() { + return this.#cachedBody("formData"); + } + /** + * Adds validated data to the request. + * + * @param target - The target of the validation. + * @param data - The validated data to add. + */ + addValidatedData(target, data) { + this.#validatedData[target] = data; + } + valid(target) { + return this.#validatedData[target]; + } + /** + * `.url()` can get the request url strings. + * + * @see {@link https://hono.dev/docs/api/request#url} + * + * @example + * ```ts + * app.get('/about/me', (c) => { + * const url = c.req.url // `http://localhost:8787/about/me` + * ... + * }) + * ``` + */ + get url() { + return this.raw.url; + } + /** + * `.method()` can get the method name of the request. + * + * @see {@link https://hono.dev/docs/api/request#method} + * + * @example + * ```ts + * app.get('/about/me', (c) => { + * const method = c.req.method // `GET` + * }) + * ``` + */ + get method() { + return this.raw.method; + } + /** + * `.matchedRoutes()` can return a matched route in the handler + * + * @see {@link https://hono.dev/docs/api/request#matchedroutes} + * + * @example + * ```ts + * app.use('*', async function logger(c, next) { + * await next() + * c.req.matchedRoutes.forEach(({ handler, method, path }, i) => { + * const name = handler.name || (handler.length < 2 ? '[handler]' : '[middleware]') + * console.log( + * method, + * ' ', + * path, + * ' '.repeat(Math.max(10 - path.length, 0)), + * name, + * i === c.req.routeIndex ? '<- respond from here' : '' + * ) + * }) + * }) + * ``` + */ + get matchedRoutes() { + return this.#matchResult[0].map(([[, route]]) => route); + } + /** + * `routePath()` can retrieve the path registered within the handler + * + * @see {@link https://hono.dev/docs/api/request#routepath} + * + * @example + * ```ts + * app.get('/posts/:id', (c) => { + * return c.json({ path: c.req.routePath }) + * }) + * ``` + */ + get routePath() { + return this.#matchResult[0].map(([[, route]]) => route)[this.routeIndex].path; + } +}; + +// https://jsr.io/@hono/hono/4.6.19/src/utils/html.ts +var HtmlEscapedCallbackPhase = { + Stringify: 1, + BeforeStream: 2, + Stream: 3 +}; +var raw = (value, callbacks) => { + const escapedString = new String(value); + escapedString.isEscaped = true; + escapedString.callbacks = callbacks; + return escapedString; +}; +var resolveCallback = async (str, phase, preserveCallbacks, context, buffer) => { + if (typeof str === "object" && !(str instanceof String)) { + if (!(str instanceof Promise)) { + str = str.toString(); + } + if (str instanceof Promise) { + str = await str; + } + } + const callbacks = str.callbacks; + if (!callbacks?.length) { + return Promise.resolve(str); + } + if (buffer) { + buffer[0] += str; + } else { + buffer = [str]; + } + const resStr = Promise.all(callbacks.map((c) => c({ phase, buffer, context }))).then( + (res) => Promise.all( + res.filter(Boolean).map((str2) => resolveCallback(str2, phase, false, context, buffer)) + ).then(() => buffer[0]) + ); + if (preserveCallbacks) { + return raw(await resStr, callbacks); + } else { + return resStr; + } +}; + +// https://jsr.io/@hono/hono/4.6.19/src/context.ts +var TEXT_PLAIN = "text/plain; charset=UTF-8"; +var setHeaders = (headers, map = {}) => { + for (const key of Object.keys(map)) { + headers.set(key, map[key]); + } + return headers; +}; +var Context = class { + #rawRequest; + #req; + /** + * `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers. + * + * @see {@link https://hono.dev/docs/api/context#env} + * + * @example + * ```ts + * // Environment object for Cloudflare Workers + * app.get('*', async c => { + * const counter = c.env.COUNTER + * }) + * ``` + */ + env = {}; + #var; + finalized = false; + /** + * `.error` can get the error object from the middleware if the Handler throws an error. + * + * @see {@link https://hono.dev/docs/api/context#error} + * + * @example + * ```ts + * app.use('*', async (c, next) => { + * await next() + * if (c.error) { + * // do something... + * } + * }) + * ``` + */ + error; + #status = 200; + #executionCtx; + #headers; + #preparedHeaders; + #res; + #isFresh = true; + #layout; + #renderer; + #notFoundHandler; + #matchResult; + #path; + /** + * Creates an instance of the Context class. + * + * @param req - The Request object. + * @param options - Optional configuration options for the context. + */ + constructor(req, options) { + this.#rawRequest = req; + if (options) { + this.#executionCtx = options.executionCtx; + this.env = options.env; + this.#notFoundHandler = options.notFoundHandler; + this.#path = options.path; + this.#matchResult = options.matchResult; + } + } + /** + * `.req` is the instance of {@link HonoRequest}. + */ + get req() { + this.#req ??= new HonoRequest(this.#rawRequest, this.#path, this.#matchResult); + return this.#req; + } + /** + * @see {@link https://hono.dev/docs/api/context#event} + * The FetchEvent associated with the current request. + * + * @throws Will throw an error if the context does not have a FetchEvent. + */ + get event() { + if (this.#executionCtx && "respondWith" in this.#executionCtx) { + return this.#executionCtx; + } else { + throw Error("This context has no FetchEvent"); + } + } + /** + * @see {@link https://hono.dev/docs/api/context#executionctx} + * The ExecutionContext associated with the current request. + * + * @throws Will throw an error if the context does not have an ExecutionContext. + */ + get executionCtx() { + if (this.#executionCtx) { + return this.#executionCtx; + } else { + throw Error("This context has no ExecutionContext"); + } + } + /** + * @see {@link https://hono.dev/docs/api/context#res} + * The Response object for the current request. + */ + get res() { + this.#isFresh = false; + return this.#res ||= new Response("404 Not Found", { status: 404 }); + } + /** + * Sets the Response object for the current request. + * + * @param _res - The Response object to set. + */ + set res(_res) { + this.#isFresh = false; + if (this.#res && _res) { + try { + for (const [k, v] of this.#res.headers.entries()) { + if (k === "content-type") { + continue; + } + if (k === "set-cookie") { + const cookies = this.#res.headers.getSetCookie(); + _res.headers.delete("set-cookie"); + for (const cookie of cookies) { + _res.headers.append("set-cookie", cookie); + } + } else { + _res.headers.set(k, v); + } + } + } catch (e) { + if (e instanceof TypeError && e.message.includes("immutable")) { + this.res = new Response(_res.body, { + headers: _res.headers, + status: _res.status + }); + return; + } else { + throw e; + } + } + } + this.#res = _res; + this.finalized = true; + } + /** + * `.render()` can create a response within a layout. + * + * @see {@link https://hono.dev/docs/api/context#render-setrenderer} + * + * @example + * ```ts + * app.get('/', (c) => { + * return c.render('Hello!') + * }) + * ``` + */ + render = (...args) => { + this.#renderer ??= (content) => this.html(content); + return this.#renderer(...args); + }; + /** + * Sets the layout for the response. + * + * @param layout - The layout to set. + * @returns The layout function. + */ + setLayout = (layout) => this.#layout = layout; + /** + * Gets the current layout for the response. + * + * @returns The current layout function. + */ + getLayout = () => this.#layout; + /** + * `.setRenderer()` can set the layout in the custom middleware. + * + * @see {@link https://hono.dev/docs/api/context#render-setrenderer} + * + * @example + * ```tsx + * app.use('*', async (c, next) => { + * c.setRenderer((content) => { + * return c.html( + * + * + *

{content}

+ * + * + * ) + * }) + * await next() + * }) + * ``` + */ + setRenderer = (renderer) => { + this.#renderer = renderer; + }; + /** + * `.header()` can set headers. + * + * @see {@link https://hono.dev/docs/api/context#body} + * + * @example + * ```ts + * app.get('/welcome', (c) => { + * // Set headers + * c.header('X-Message', 'Hello!') + * c.header('Content-Type', 'text/plain') + * + * return c.body('Thank you for coming') + * }) + * ``` + */ + header = (name, value, options) => { + if (value === void 0) { + if (this.#headers) { + this.#headers.delete(name); + } else if (this.#preparedHeaders) { + delete this.#preparedHeaders[name.toLocaleLowerCase()]; + } + if (this.finalized) { + this.res.headers.delete(name); + } + return; + } + if (options?.append) { + if (!this.#headers) { + this.#isFresh = false; + this.#headers = new Headers(this.#preparedHeaders); + this.#preparedHeaders = {}; + } + this.#headers.append(name, value); + } else { + if (this.#headers) { + this.#headers.set(name, value); + } else { + this.#preparedHeaders ??= {}; + this.#preparedHeaders[name.toLowerCase()] = value; + } + } + if (this.finalized) { + if (options?.append) { + this.res.headers.append(name, value); + } else { + this.res.headers.set(name, value); + } + } + }; + status = (status) => { + this.#isFresh = false; + this.#status = status; + }; + /** + * `.set()` can set the value specified by the key. + * + * @see {@link https://hono.dev/docs/api/context#set-get} + * + * @example + * ```ts + * app.use('*', async (c, next) => { + * c.set('message', 'Hono is cool!!') + * await next() + * }) + * ``` + */ + set = (key, value) => { + this.#var ??= /* @__PURE__ */ new Map(); + this.#var.set(key, value); + }; + /** + * `.get()` can use the value specified by the key. + * + * @see {@link https://hono.dev/docs/api/context#set-get} + * + * @example + * ```ts + * app.get('/', (c) => { + * const message = c.get('message') + * return c.text(`The message is "${message}"`) + * }) + * ``` + */ + get = (key) => { + return this.#var ? this.#var.get(key) : void 0; + }; + /** + * `.var` can access the value of a variable. + * + * @see {@link https://hono.dev/docs/api/context#var} + * + * @example + * ```ts + * const result = c.var.client.oneMethod() + * ``` + */ + // c.var.propName is a read-only + get var() { + if (!this.#var) { + return {}; + } + return Object.fromEntries(this.#var); + } + #newResponse(data, arg, headers) { + if (this.#isFresh && !headers && !arg && this.#status === 200) { + return new Response(data, { + headers: this.#preparedHeaders + }); + } + if (arg && typeof arg !== "number") { + const header = new Headers(arg.headers); + if (this.#headers) { + this.#headers.forEach((v, k) => { + if (k === "set-cookie") { + header.append(k, v); + } else { + header.set(k, v); + } + }); + } + const headers2 = setHeaders(header, this.#preparedHeaders); + return new Response(data, { + headers: headers2, + status: arg.status ?? this.#status + }); + } + const status = typeof arg === "number" ? arg : this.#status; + this.#preparedHeaders ??= {}; + this.#headers ??= new Headers(); + setHeaders(this.#headers, this.#preparedHeaders); + if (this.#res) { + this.#res.headers.forEach((v, k) => { + if (k === "set-cookie") { + this.#headers?.append(k, v); + } else { + this.#headers?.set(k, v); + } + }); + setHeaders(this.#headers, this.#preparedHeaders); + } + headers ??= {}; + for (const [k, v] of Object.entries(headers)) { + if (typeof v === "string") { + this.#headers.set(k, v); + } else { + this.#headers.delete(k); + for (const v2 of v) { + this.#headers.append(k, v2); + } + } + } + return new Response(data, { + status, + headers: this.#headers + }); + } + newResponse = (...args) => this.#newResponse(...args); + /** + * `.body()` can return the HTTP response. + * You can set headers with `.header()` and set HTTP status code with `.status`. + * This can also be set in `.text()`, `.json()` and so on. + * + * @see {@link https://hono.dev/docs/api/context#body} + * + * @example + * ```ts + * app.get('/welcome', (c) => { + * // Set headers + * c.header('X-Message', 'Hello!') + * c.header('Content-Type', 'text/plain') + * // Set HTTP status code + * c.status(201) + * + * // Return the response body + * return c.body('Thank you for coming') + * }) + * ``` + */ + body = (data, arg, headers) => { + return typeof arg === "number" ? this.#newResponse(data, arg, headers) : this.#newResponse(data, arg); + }; + /** + * `.text()` can render text as `Content-Type:text/plain`. + * + * @see {@link https://hono.dev/docs/api/context#text} + * + * @example + * ```ts + * app.get('/say', (c) => { + * return c.text('Hello!') + * }) + * ``` + */ + text = (text, arg, headers) => { + if (!this.#preparedHeaders) { + if (this.#isFresh && !headers && !arg) { + return new Response(text); + } + this.#preparedHeaders = {}; + } + this.#preparedHeaders["content-type"] = TEXT_PLAIN; + if (typeof arg === "number") { + return this.#newResponse(text, arg, headers); + } + return this.#newResponse(text, arg); + }; + /** + * `.json()` can render JSON as `Content-Type:application/json`. + * + * @see {@link https://hono.dev/docs/api/context#json} + * + * @example + * ```ts + * app.get('/api', (c) => { + * return c.json({ message: 'Hello!' }) + * }) + * ``` + */ + json = (object, arg, headers) => { + const body = JSON.stringify(object); + this.#preparedHeaders ??= {}; + this.#preparedHeaders["content-type"] = "application/json"; + return typeof arg === "number" ? this.#newResponse(body, arg, headers) : this.#newResponse(body, arg); + }; + html = (html, arg, headers) => { + this.#preparedHeaders ??= {}; + this.#preparedHeaders["content-type"] = "text/html; charset=UTF-8"; + if (typeof html === "object") { + return resolveCallback(html, HtmlEscapedCallbackPhase.Stringify, false, {}).then((html2) => { + return typeof arg === "number" ? this.#newResponse(html2, arg, headers) : this.#newResponse(html2, arg); + }); + } + return typeof arg === "number" ? this.#newResponse(html, arg, headers) : this.#newResponse(html, arg); + }; + /** + * `.redirect()` can Redirect, default status code is 302. + * + * @see {@link https://hono.dev/docs/api/context#redirect} + * + * @example + * ```ts + * app.get('/redirect', (c) => { + * return c.redirect('/') + * }) + * app.get('/redirect-permanently', (c) => { + * return c.redirect('/', 301) + * }) + * ``` + */ + redirect = (location, status) => { + this.#headers ??= new Headers(); + this.#headers.set("Location", String(location)); + return this.newResponse(null, status ?? 302); + }; + /** + * `.notFound()` can return the Not Found Response. + * + * @see {@link https://hono.dev/docs/api/context#notfound} + * + * @example + * ```ts + * app.get('/notfound', (c) => { + * return c.notFound() + * }) + * ``` + */ + notFound = () => { + this.#notFoundHandler ??= () => new Response(); + return this.#notFoundHandler(this); + }; +}; + +// https://jsr.io/@hono/hono/4.6.19/src/compose.ts +var compose = (middleware, onError, onNotFound) => { + return (context, next) => { + let index = -1; + const isContext = context instanceof Context; + return dispatch(0); + async function dispatch(i) { + if (i <= index) { + throw new Error("next() called multiple times"); + } + index = i; + let res; + let isError = false; + let handler; + if (middleware[i]) { + handler = middleware[i][0][0]; + if (isContext) { + context.req.routeIndex = i; + } + } else { + handler = i === middleware.length && next || void 0; + } + if (!handler) { + if (isContext && context.finalized === false && onNotFound) { + res = await onNotFound(context); + } + } else { + try { + res = await handler(context, () => { + return dispatch(i + 1); + }); + } catch (err) { + if (err instanceof Error && isContext && onError) { + context.error = err; + res = await onError(err, context); + isError = true; + } else { + throw err; + } + } + } + if (res && (context.finalized === false || isError)) { + context.res = res; + } + return context; + } + }; +}; + +// https://jsr.io/@hono/hono/4.6.19/src/router.ts +var METHOD_NAME_ALL = "ALL"; +var METHOD_NAME_ALL_LOWERCASE = "all"; +var METHODS = ["get", "post", "put", "delete", "options", "patch"]; +var UnsupportedPathError = class extends Error { +}; + +// https://jsr.io/@hono/hono/4.6.19/src/utils/constants.ts +var COMPOSED_HANDLER = "__COMPOSED_HANDLER"; + +// https://jsr.io/@hono/hono/4.6.19/src/hono-base.ts +var notFoundHandler = (c) => { + return c.text("404 Not Found", 404); +}; +var errorHandler = (err, c) => { + if ("getResponse" in err) { + return err.getResponse(); + } + console.error(err); + return c.text("Internal Server Error", 500); +}; +var Hono = class _Hono { + get; + post; + put; + delete; + options; + patch; + all; + on; + use; + /* + This class is like an abstract class and does not have a router. + To use it, inherit the class and implement router in the constructor. + */ + router; + getPath; + // Cannot use `#` because it requires visibility at JavaScript runtime. + _basePath = "/"; + #path = "/"; + routes = []; + constructor(options = {}) { + const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE]; + allMethods.forEach((method) => { + this[method] = (args1, ...args) => { + if (typeof args1 === "string") { + this.#path = args1; + } else { + this.#addRoute(method, this.#path, args1); + } + args.forEach((handler) => { + this.#addRoute(method, this.#path, handler); + }); + return this; + }; + }); + this.on = (method, path, ...handlers) => { + for (const p of [path].flat()) { + this.#path = p; + for (const m of [method].flat()) { + handlers.map((handler) => { + this.#addRoute(m.toUpperCase(), this.#path, handler); + }); + } + } + return this; + }; + this.use = (arg1, ...handlers) => { + if (typeof arg1 === "string") { + this.#path = arg1; + } else { + this.#path = "*"; + handlers.unshift(arg1); + } + handlers.forEach((handler) => { + this.#addRoute(METHOD_NAME_ALL, this.#path, handler); + }); + return this; + }; + const strict = options.strict ?? true; + delete options.strict; + Object.assign(this, options); + this.getPath = strict ? options.getPath ?? getPath : getPathNoStrict; + } + #clone() { + const clone = new _Hono({ + router: this.router, + getPath: this.getPath + }); + clone.routes = this.routes; + return clone; + } + #notFoundHandler = notFoundHandler; + // Cannot use `#` because it requires visibility at JavaScript runtime. + errorHandler = errorHandler; + /** + * `.route()` allows grouping other Hono instance in routes. + * + * @see {@link https://hono.dev/docs/api/routing#grouping} + * + * @param {string} path - base Path + * @param {Hono} app - other Hono instance + * @returns {Hono} routed Hono instance + * + * @example + * ```ts + * const app = new Hono() + * const app2 = new Hono() + * + * app2.get("/user", (c) => c.text("user")) + * app.route("/api", app2) // GET /api/user + * ``` + */ + route(path, app) { + const subApp = this.basePath(path); + app.routes.map((r) => { + let handler; + if (app.errorHandler === errorHandler) { + handler = r.handler; + } else { + handler = async (c, next) => (await compose([], app.errorHandler)(c, () => r.handler(c, next))).res; + handler[COMPOSED_HANDLER] = r.handler; + } + subApp.#addRoute(r.method, r.path, handler); + }); + return this; + } + /** + * `.basePath()` allows base paths to be specified. + * + * @see {@link https://hono.dev/docs/api/routing#base-path} + * + * @param {string} path - base Path + * @returns {Hono} changed Hono instance + * + * @example + * ```ts + * const api = new Hono().basePath('/api') + * ``` + */ + basePath(path) { + const subApp = this.#clone(); + subApp._basePath = mergePath(this._basePath, path); + return subApp; + } + /** + * `.onError()` handles an error and returns a customized Response. + * + * @see {@link https://hono.dev/docs/api/hono#error-handling} + * + * @param {ErrorHandler} handler - request Handler for error + * @returns {Hono} changed Hono instance + * + * @example + * ```ts + * app.onError((err, c) => { + * console.error(`${err}`) + * return c.text('Custom Error Message', 500) + * }) + * ``` + */ + onError = (handler) => { + this.errorHandler = handler; + return this; + }; + /** + * `.notFound()` allows you to customize a Not Found Response. + * + * @see {@link https://hono.dev/docs/api/hono#not-found} + * + * @param {NotFoundHandler} handler - request handler for not-found + * @returns {Hono} changed Hono instance + * + * @example + * ```ts + * app.notFound((c) => { + * return c.text('Custom 404 Message', 404) + * }) + * ``` + */ + notFound = (handler) => { + this.#notFoundHandler = handler; + return this; + }; + /** + * `.mount()` allows you to mount applications built with other frameworks into your Hono application. + * + * @see {@link https://hono.dev/docs/api/hono#mount} + * + * @param {string} path - base Path + * @param {Function} applicationHandler - other Request Handler + * @param {MountOptions} [options] - options of `.mount()` + * @returns {Hono} mounted Hono instance + * + * @example + * ```ts + * import { Router as IttyRouter } from 'itty-router' + * import { Hono } from 'hono' + * // Create itty-router application + * const ittyRouter = IttyRouter() + * // GET /itty-router/hello + * ittyRouter.get('/hello', () => new Response('Hello from itty-router')) + * + * const app = new Hono() + * app.mount('/itty-router', ittyRouter.handle) + * ``` + * + * @example + * ```ts + * const app = new Hono() + * // Send the request to another application without modification. + * app.mount('/app', anotherApp, { + * replaceRequest: (req) => req, + * }) + * ``` + */ + mount(path, applicationHandler, options) { + let replaceRequest; + let optionHandler; + if (options) { + if (typeof options === "function") { + optionHandler = options; + } else { + optionHandler = options.optionHandler; + replaceRequest = options.replaceRequest; + } + } + const getOptions = optionHandler ? (c) => { + const options2 = optionHandler(c); + return Array.isArray(options2) ? options2 : [options2]; + } : (c) => { + let executionContext = void 0; + try { + executionContext = c.executionCtx; + } catch { + } + return [c.env, executionContext]; + }; + replaceRequest ||= (() => { + const mergedPath = mergePath(this._basePath, path); + const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length; + return (request) => { + const url = new URL(request.url); + url.pathname = url.pathname.slice(pathPrefixLength) || "/"; + return new Request(url, request); + }; + })(); + const handler = async (c, next) => { + const res = await applicationHandler(replaceRequest(c.req.raw), ...getOptions(c)); + if (res) { + return res; + } + await next(); + }; + this.#addRoute(METHOD_NAME_ALL, mergePath(path, "*"), handler); + return this; + } + #addRoute(method, path, handler) { + method = method.toUpperCase(); + path = mergePath(this._basePath, path); + const r = { path, method, handler }; + this.router.add(method, path, [handler, r]); + this.routes.push(r); + } + #handleError(err, c) { + if (err instanceof Error) { + return this.errorHandler(err, c); + } + throw err; + } + #dispatch(request, executionCtx, env, method) { + if (method === "HEAD") { + return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))(); + } + const path = this.getPath(request, { env }); + const matchResult = this.router.match(method, path); + const c = new Context(request, { + path, + matchResult, + env, + executionCtx, + notFoundHandler: this.#notFoundHandler + }); + if (matchResult[0].length === 1) { + let res; + try { + res = matchResult[0][0][0][0](c, async () => { + c.res = await this.#notFoundHandler(c); + }); + } catch (err) { + return this.#handleError(err, c); + } + return res instanceof Promise ? res.then( + (resolved) => resolved || (c.finalized ? c.res : this.#notFoundHandler(c)) + ).catch((err) => this.#handleError(err, c)) : res ?? this.#notFoundHandler(c); + } + const composed = compose(matchResult[0], this.errorHandler, this.#notFoundHandler); + return (async () => { + try { + const context = await composed(c); + if (!context.finalized) { + throw new Error( + "Context is not finalized. Did you forget to return a Response object or `await next()`?" + ); + } + return context.res; + } catch (err) { + return this.#handleError(err, c); + } + })(); + } + /** + * `.fetch()` will be entry point of your app. + * + * @see {@link https://hono.dev/docs/api/hono#fetch} + * + * @param {Request} request - request Object of request + * @param {Env} Env - env Object + * @param {ExecutionContext} - context of execution + * @returns {Response | Promise} response of request + * + */ + fetch = (request, ...rest) => { + return this.#dispatch(request, rest[1], rest[0], request.method); + }; + /** + * `.request()` is a useful method for testing. + * You can pass a URL or pathname to send a GET request. + * app will return a Response object. + * ```ts + * test('GET /hello is ok', async () => { + * const res = await app.request('/hello') + * expect(res.status).toBe(200) + * }) + * ``` + * @see https://hono.dev/docs/api/hono#request + */ + request = (input, requestInit, Env, executionCtx) => { + if (input instanceof Request) { + return this.fetch(requestInit ? new Request(input, requestInit) : input, Env, executionCtx); + } + input = input.toString(); + return this.fetch( + new Request( + /^https?:\/\//.test(input) ? input : `http://localhost${mergePath("/", input)}`, + requestInit + ), + Env, + executionCtx + ); + }; + /** + * `.fire()` automatically adds a global fetch event listener. + * This can be useful for environments that adhere to the Service Worker API, such as non-ES module Cloudflare Workers. + * @see https://hono.dev/docs/api/hono#fire + * @see https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API + * @see https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/ + */ + fire = () => { + addEventListener("fetch", (event) => { + event.respondWith(this.#dispatch(event.request, event, void 0, event.request.method)); + }); + }; +}; + +// https://jsr.io/@hono/hono/4.6.19/src/router/pattern-router/router.ts +var emptyParams = /* @__PURE__ */ Object.create(null); +var PatternRouter = class { + name = "PatternRouter"; + #routes = []; + add(method, path, handler) { + const endsWithWildcard = path.at(-1) === "*"; + if (endsWithWildcard) { + path = path.slice(0, -2); + } + if (path.at(-1) === "?") { + path = path.slice(0, -1); + this.add(method, path.replace(/\/[^/]+$/, ""), handler); + } + const parts = (path.match(/\/?(:\w+(?:{(?:(?:{[\d,]+})|[^}])+})?)|\/?[^\/\?]+/g) || []).map( + (part) => { + const match = part.match(/^\/:([^{]+)(?:{(.*)})?/); + return match ? `/(?<${match[1]}>${match[2] || "[^/]+"})` : part === "/*" ? "/[^/]+" : part.replace(/[.\\+*[^\]$()]/g, "\\$&"); + } + ); + let re; + try { + re = new RegExp(`^${parts.join("")}${endsWithWildcard ? "" : "/?$"}`); + } catch { + throw new UnsupportedPathError(); + } + this.#routes.push([re, method, handler]); + } + match(method, path) { + const handlers = []; + for (let i = 0, len = this.#routes.length; i < len; i++) { + const [pattern, routeMethod, handler] = this.#routes[i]; + if (routeMethod === method || routeMethod === METHOD_NAME_ALL) { + const match = pattern.exec(path); + if (match) { + handlers.push([handler, match.groups || emptyParams]); + } + } + } + return [handlers]; + } +}; + +// https://jsr.io/@hono/hono/4.6.19/src/preset/tiny.ts +var Hono2 = class extends Hono { + constructor(options = {}) { + super(options); + this.router = new PatternRouter(); + } +}; + +// ../../../sdk/typescript/src/types.ts +var sseHeaders = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "text/event-stream" +}; + +// ../../../sdk/typescript/src/consts.ts +var DefaultSseRetryDurationMs = 1e3; + +// ../../../sdk/typescript/src/abstractServerSentEventGenerator.ts +var ServerSentEventGenerator = class { + constructor() { + } + /** + * Sends a server-sent event (SSE) to the client. + * + * Runtimes should override this method by calling the parent function + * with `super.send(event, dataLines, options)`. That will return all the + * datalines as an array of strings that should be streamed to the client. + * + * @param eventType - The type of the event. + * @param dataLines - Lines of data to send. + * @param [sendOptions] - Additional options for sending events. + */ + send(event, dataLines, options) { + const { eventId, retryDuration } = options || {}; + const typeLine = [`event: ${event} +`]; + const idLine = eventId ? [`id: ${eventId} +`] : []; + const retryLine = [ + `retry: ${retryDuration ?? DefaultSseRetryDurationMs} +` + ]; + return typeLine.concat( + idLine, + retryLine, + dataLines.map((data) => { + return `data: ${data} +`; + }), + ["\n\n"] + ); + } + eachNewlineIsADataLine(prefix, data) { + return data.split("\n").map((line) => { + return `${prefix} ${line}`; + }); + } + eachOptionIsADataLine(options) { + return Object.keys(options).flatMap((key) => { + return this.eachNewlineIsADataLine( + key, + options[key].toString() + ); + }); + } + /** + * Sends a merge fragments event. + * + * @param fragments - HTML fragments that will be merged. + * @param [options] - Additional options for merging. + */ + mergeFragments(data, options) { + const { eventId, retryDuration, ...renderOptions } = options || {}; + const dataLines = this.eachOptionIsADataLine(renderOptions).concat(this.eachNewlineIsADataLine("fragments", data)); + return this.send("datastar-merge-fragments", dataLines, { + eventId, + retryDuration + }); + } + /** + * Sends a remove fragments event. + * + * @param selector - CSS selector of fragments to remove. + * @param [options] - Additional options for removing. + */ + removeFragments(selector, options) { + const { eventId, retryDuration, ...eventOptions } = options || {}; + const dataLines = this.eachOptionIsADataLine(eventOptions).concat(this.eachNewlineIsADataLine("selector", selector)); + return this.send("datastar-remove-fragments", dataLines, { + eventId, + retryDuration + }); + } + /** + * Sends a merge signals event. + * + * @param data - Data object that will be merged into the client's signals. + * @param options - Additional options for merging. + */ + mergeSignals(data, options) { + const { eventId, retryDuration, ...eventOptions } = options || {}; + const dataLines = this.eachOptionIsADataLine(eventOptions).concat(this.eachNewlineIsADataLine("signals", JSON.stringify(data))); + return this.send("datastar-merge-signals", dataLines, { + eventId, + retryDuration + }); + } + /** + * Sends a remove signals event. + * + * @param paths - Array of paths to remove from the client's signals + * @param options - Additional options for removing signals. + */ + removeSignals(paths, options) { + const eventOptions = options || {}; + const dataLines = paths.flatMap((path) => path.split(" ")).map( + (path) => `paths ${path}` + ); + return this.send("datastar-remove-signals", dataLines, eventOptions); + } + /** + * Executes a script on the client-side. + * + * @param script - Script code to execute. + * @param options - Additional options for execution. + */ + executeScript(script, options) { + const { + eventId, + retryDuration, + attributes, + ...eventOptions + } = options || {}; + const attributesDataLines = this.eachOptionIsADataLine(attributes ?? {}).map((line) => `attributes ${line}`); + const dataLines = attributesDataLines.concat( + this.eachOptionIsADataLine(eventOptions), + this.eachNewlineIsADataLine("script", script) + ); + return this.send("datastar-execute-script", dataLines, { + eventId, + retryDuration + }); + } +}; + +// ../../../sdk/typescript/src/web/serverSentEventGenerator.ts +function isRecord(obj) { + return typeof obj === "object" && obj !== null; +} +var ServerSentEventGenerator2 = class _ServerSentEventGenerator extends ServerSentEventGenerator { + controller; + constructor(controller) { + super(); + this.controller = controller; + } + /** + * Initializes the server-sent event generator and executes the streamFunc function. + * + * @param streamFunc - A function that will be passed the initialized ServerSentEventGenerator class as it's first parameter. + * @returns an HTTP Response + */ + static stream(streamFunc) { + const stream = new ReadableStream({ + async start(controller) { + const stream2 = streamFunc(new _ServerSentEventGenerator(controller)); + if (stream2 instanceof Promise) await stream2; + controller.close(); + } + }); + return new Response(stream, { + headers: sseHeaders + }); + } + send(event, dataLines, options) { + const eventLines = super.send(event, dataLines, options); + eventLines.forEach((line) => { + this.controller?.enqueue(new TextEncoder().encode(line)); + }); + return eventLines; + } + /** + * Reads client sent signals based on HTTP methods + * + * @params request - The HTTP Request object. + * + * @returns An object containing a success boolean and either the client's signals or an error message. + */ + static async readSignals(request) { + try { + if (request.method === "GET") { + const url = new URL(request.url); + const params = url.searchParams; + if (params.has("datastar")) { + const signals2 = JSON.parse(params.get("datastar")); + if (isRecord(signals2)) { + return { success: true, signals: signals2 }; + } else throw new Error("Datastar param is not a record"); + } else throw new Error("No datastar object in request"); + } + const signals = await request.json(); + if (isRecord(signals)) { + return { success: true, signals }; + } + throw new Error("Parsed JSON body is not of type record"); + } catch (e) { + if (isRecord(e) && "message" in e && typeof e.message === "string") { + return { success: false, error: e.message }; + } + return { success: false, error: "unknown error when parsing request" }; + } + } +}; + +// src/hello-world.js +function getHelloWorldHtml() { + return ` + + + Datastar SDK Demo + + + + + +
+
+

+ Datastar SDK Demo +

+ Rocket +
+

+ SSE events will be streamed from the backend to the frontend. +

+
+ + +
+ +
+
+
Hello, world!
+
+ + + \ No newline at end of file diff --git a/sdk/typescript/examples/node.js b/examples/typescript/node/node.js similarity index 92% rename from sdk/typescript/examples/node.js rename to examples/typescript/node/node.js index 6dc10105a..fe5f7f879 100644 --- a/sdk/typescript/examples/node.js +++ b/examples/typescript/node/node.js @@ -1,6 +1,6 @@ import { createServer } from "node:http"; // for this to work the esm build needs to be generated, see ../README.md -import { ServerSentEventGenerator } from "../npm/esm/node/serverSentEventGenerator.js"; +import { ServerSentEventGenerator } from "../../../sdk/typescript/npm/esm/node/serverSentEventGenerator.js"; const hostname = "127.0.0.1"; const port = 3000; diff --git a/examples/typescript/node/public/hello-world.html b/examples/typescript/node/public/hello-world.html new file mode 100644 index 000000000..6f403b8dd --- /dev/null +++ b/examples/typescript/node/public/hello-world.html @@ -0,0 +1,43 @@ + + + + + + + Datastar SDK Demo + + + + + +
+
+

+ Datastar SDK Demo +

+ Rocket +
+

+ SSE events will be streamed from the backend to the frontend. +

+
+ + +
+ +
+
+
Hello, world!
+
+ + + \ No newline at end of file diff --git a/sdk/typescript/examples/deno.ts b/sdk/typescript/examples/deno.ts deleted file mode 100644 index 82f0ddd2d..000000000 --- a/sdk/typescript/examples/deno.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { serve } from "https://deno.land/std@0.140.0/http/server.ts"; -import { ServerSentEventGenerator } from "../src/web/serverSentEventGenerator.ts"; - -serve(async (req: Request) => { - const url = new URL(req.url); - - if (url.pathname === "/") { - return new Response( - `
Hello
`, - { - headers: { "Content-Type": "text/html" }, - }, - ); - } else if (url.pathname.includes("/merge")) { - const reader = await ServerSentEventGenerator.readSignals(req); - - if (!reader.success) { - console.error("Error while reading signals", reader.error); - - return new Response(`Error while reading signals`, { - headers: { "Content-Type": "text/html" }, - }); - } - - if (!("foo" in reader.signals)) { - console.error("The foo signal is not present"); - - return new Response("The foo signal is not present", { - headers: { "Content-Type": "text/html" }, - }); - } - - return ServerSentEventGenerator.stream((stream) => { - stream.mergeFragments( - `
Hello ${reader.signals.foo}
`, - ); - stream.close(); - }); - } - - return new Response(`Path not found: ${req.url}`, { - headers: { "Content-Type": "text/html" }, - }); -});