h3leaf 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shamilkotta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # h3leaf
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Convention-based H3 route compiler, CLI, and optional Vite plugin (`h3leaf/vite`).
4
+
5
+ ## Route conventions
6
+
7
+ Files under the routes directory are URL endpoints. `index.ts` resolves to its directory; extensions may be `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, or `.mjs`. Files beginning with `_`, test/spec files, and declaration files are ignored.
8
+
9
+ | File | Route |
10
+ | ---------------------- | --------------- |
11
+ | `health.ts` | `/health` |
12
+ | `users/index.ts` | `/users` |
13
+ | `users/[id].ts` | `/users/:id` |
14
+ | `docs/[...path].ts` | `/docs/**:path` |
15
+ | `docs/[[...path]].ts` | `/docs/**` |
16
+ | `blog/[[slug]].ts` | `/blog/:slug?` |
17
+ | `(internal)/health.ts` | `/health` |
18
+
19
+ Routes export uppercase HTTP method handlers (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`), plus an optional `handler` or default export for all methods. Specific methods win over `handler`; two route files resolving to the same path are rejected at build time.
20
+
21
+ Generated handlers receive H3's `H3Event`, so H3's response conversion, params, middleware-compatible context, errors, and `event.waitUntil()` remain available. The compilation stage is represented as an `Effect` program, keeping filesystem failures typed and composable for embedding tools.
22
+
23
+ ## Optional app entry
24
+
25
+ Place `_leaf.ts` (or another supported JS/TS extension) at the root of the routes directory to own the H3 app instance. Export a named `app` (recommended) or default `H3` instance; h3leaf attaches every discovered route to it. This is where application-wide middleware, H3 plugins, error hooks, and custom app configuration live.
26
+
27
+ ```ts
28
+ // api/_leaf.ts
29
+ import { H3 } from "h3";
30
+
31
+ export const app = new H3().use((event) => {
32
+ event.res.headers.set("x-powered-by", "my-api");
33
+ });
34
+ ```
35
+
36
+ The CLI supports `--entry custom-app` for another module name. The compiler and Vite plugin accept `entryFile: "custom-app"`; set it to `false` to disable entry-file discovery.
37
+
38
+ ## Vite plugin
39
+
40
+ Build the frontend and its H3 API Worker in one `vite build` command:
41
+
42
+ ```ts
43
+ // vite.config.ts
44
+ import { defineConfig } from "vite";
45
+ import { h3leaf } from "h3leaf/vite";
46
+
47
+ export default defineConfig({
48
+ plugins: [h3leaf({ routesDir: "api", base: "/api", outFile: "worker.mjs" })],
49
+ });
50
+ ```
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { n as generateEntry, r as resolveEntryFile, t as discoverRoutes } from "./src-DQ-jHPmt.mjs";
3
+ import { Effect } from "effect";
4
+ import path from "node:path";
5
+ import { watch } from "node:fs";
6
+ import { build, context } from "esbuild";
7
+ import { pathToFileURL } from "node:url";
8
+ import { tmpdir } from "node:os";
9
+ import { H3, serve } from "h3";
10
+ //#region src/cli.ts
11
+ const args = process.argv.slice(2);
12
+ const option = (name) => {
13
+ const index = args.indexOf(name);
14
+ return index === -1 ? void 0 : args[index + 1];
15
+ };
16
+ const usage = [
17
+ "Usage:",
18
+ " h3leaf build [--routes api] [--base /api] [--entry _leaf] [--out dist/worker.mjs]",
19
+ " h3leaf dev [--routes api] [--base /api] [--entry _leaf] [--port 3000]"
20
+ ].join("\n");
21
+ const command = args[0];
22
+ if (!command || args.includes("--help") || !["build", "dev"].includes(command)) {
23
+ console.log(usage);
24
+ process.exit(args.includes("--help") ? 0 : 1);
25
+ }
26
+ const routesDirectory = option("--routes") ?? "api";
27
+ const base = option("--base");
28
+ const entryFile = option("--entry");
29
+ const virtualEntry = "virtual:h3leaf/entry";
30
+ const prepareEntry = () => {
31
+ const resolvedRoutes = path.resolve(routesDirectory);
32
+ return Effect.all({
33
+ routes: discoverRoutes(resolvedRoutes),
34
+ resolvedEntry: resolveEntryFile(resolvedRoutes, entryFile)
35
+ }).pipe(Effect.map(({ routes, resolvedEntry }) => ({
36
+ routes,
37
+ contents: generateEntry(routes, {
38
+ routesDirectory: resolvedRoutes,
39
+ entryFile: resolvedEntry,
40
+ base,
41
+ importFile: (file) => file.replace(/\\/g, "/")
42
+ })
43
+ })));
44
+ };
45
+ const virtualEntryPlugin = (getContents) => ({
46
+ name: "h3leaf-virtual-entry",
47
+ setup(buildApi) {
48
+ buildApi.onResolve({ filter: /^virtual:h3leaf\/entry$/ }, () => ({
49
+ path: virtualEntry,
50
+ namespace: "h3leaf"
51
+ }));
52
+ buildApi.onLoad({
53
+ filter: /.*/,
54
+ namespace: "h3leaf"
55
+ }, () => ({
56
+ contents: getContents(),
57
+ loader: "ts",
58
+ resolveDir: path.resolve(routesDirectory)
59
+ }));
60
+ }
61
+ });
62
+ const sharedBundleOptions = {
63
+ entryPoints: [virtualEntry],
64
+ bundle: true,
65
+ format: "esm",
66
+ platform: "neutral",
67
+ target: "es2022",
68
+ legalComments: "none"
69
+ };
70
+ if (command === "build") {
71
+ const outFile = path.resolve(option("--out") ?? "dist/api.mjs");
72
+ const buildWorker = prepareEntry().pipe(Effect.flatMap(({ routes, contents }) => Effect.tryPromise({
73
+ try: async () => {
74
+ await build({
75
+ ...sharedBundleOptions,
76
+ outfile: outFile,
77
+ sourcemap: false,
78
+ plugins: [virtualEntryPlugin(() => contents)]
79
+ });
80
+ return {
81
+ routes,
82
+ outFile
83
+ };
84
+ },
85
+ catch: (cause) => new Error(`Unable to bundle worker: ${cause instanceof Error ? cause.message : String(cause)}`, { cause })
86
+ })));
87
+ Effect.runPromise(buildWorker).then(({ routes, outFile: bundledFile }) => console.log(`h3leaf: bundled ${routes.length} route${routes.length === 1 ? "" : "s"} → ${bundledFile}`)).catch((error) => {
88
+ console.error(`h3leaf: ${error.message}`);
89
+ process.exitCode = 1;
90
+ });
91
+ } else {
92
+ const port = Number(option("--port") ?? "3000");
93
+ const bundledWorker = path.join(tmpdir(), `h3leaf-dev-${process.pid}.mjs`);
94
+ let entryContents = "";
95
+ const bundler = await context({
96
+ ...sharedBundleOptions,
97
+ sourcemap: true,
98
+ outfile: bundledWorker,
99
+ plugins: [virtualEntryPlugin(() => entryContents)]
100
+ });
101
+ let activeApp;
102
+ let timer;
103
+ const reload = async () => {
104
+ try {
105
+ const { routes, contents } = await Effect.runPromise(prepareEntry());
106
+ entryContents = contents;
107
+ await bundler.rebuild();
108
+ activeApp = (await import(`${pathToFileURL(bundledWorker).href}?v=${Date.now()}`)).app;
109
+ console.log(`h3leaf: reloaded ${routes.length} route${routes.length === 1 ? "" : "s"}`);
110
+ } catch (error) {
111
+ console.error(`h3leaf: reload failed; keeping the last working app\n${error}`);
112
+ }
113
+ };
114
+ await reload();
115
+ if (!activeApp) process.exit(1);
116
+ const devApp = new H3().all("/**", (event) => activeApp ? activeApp.fetch(event.req) : new Response("h3leaf is reloading", { status: 503 }));
117
+ const listener = serve(devApp, { port });
118
+ const watcher = watch(path.resolve(routesDirectory), { recursive: true }, () => {
119
+ if (timer) clearTimeout(timer);
120
+ timer = setTimeout(reload, 60);
121
+ });
122
+ const shutdown = () => {
123
+ watcher.close();
124
+ listener.close();
125
+ bundler.dispose();
126
+ };
127
+ process.once("SIGINT", shutdown);
128
+ process.once("SIGTERM", shutdown);
129
+ }
130
+ //#endregion
131
+ export {};
@@ -0,0 +1,96 @@
1
+ import { Data, Effect } from "effect";
2
+ import path from "node:path";
3
+ import { promises } from "node:fs";
4
+ //#region src/index.ts
5
+ const HTTP_METHODS = [
6
+ "GET",
7
+ "POST",
8
+ "PUT",
9
+ "PATCH",
10
+ "DELETE",
11
+ "HEAD",
12
+ "OPTIONS",
13
+ "CONNECT",
14
+ "TRACE"
15
+ ];
16
+ var CompileError = class extends Data.TaggedError("CompileError") {};
17
+ const extensions = /* @__PURE__ */ new Set([
18
+ ".ts",
19
+ ".tsx",
20
+ ".js",
21
+ ".jsx",
22
+ ".mts",
23
+ ".mjs"
24
+ ]);
25
+ const ignored = (name) => name.startsWith("_") || name.endsWith(".d.ts") || /\.(test|spec)\.[cm]?[jt]sx?$/.test(name);
26
+ const walk = (directory) => Effect.tryPromise({
27
+ try: async () => {
28
+ const entries = await promises.readdir(directory, { withFileTypes: true });
29
+ return (await Promise.all(entries.map(async (entry) => {
30
+ const candidate = path.join(directory, entry.name);
31
+ if (entry.isDirectory()) return ignored(entry.name) ? [] : await Effect.runPromise(walk(candidate));
32
+ return entry.isFile() && extensions.has(path.extname(entry.name)) && !ignored(entry.name) ? [candidate] : [];
33
+ }))).flat();
34
+ },
35
+ catch: (cause) => new CompileError({
36
+ message: `Unable to read routes directory: ${directory}`,
37
+ cause
38
+ })
39
+ });
40
+ const fileToRoute = (routesDirectory, file) => {
41
+ return `/${path.relative(routesDirectory, file).replace(/\.[^.]+$/, "").split(path.sep).filter((segment) => segment !== "index" && !/^\(.+\)$/.test(segment)).map((segment) => {
42
+ if (/^\[\[\.\.\.(.+)\]\]$/.test(segment)) return "**";
43
+ if (/^\[\.\.\.(.+)\]$/.test(segment)) return `**:${segment.slice(4, -1)}`;
44
+ if (/^\[\[(.+)\]\]$/.test(segment)) return `:${segment.slice(2, -2)}?`;
45
+ if (/^\[(.+)\]$/.test(segment)) return `:${segment.slice(1, -1)}`;
46
+ return segment;
47
+ }).join("/")}`.replace(/\/$/, "") || "/";
48
+ };
49
+ const discoverRoutes = (routesDirectory) => walk(routesDirectory).pipe(Effect.map((files) => files.map((file) => ({
50
+ file,
51
+ route: fileToRoute(routesDirectory, file)
52
+ })).toSorted((a, b) => a.route.localeCompare(b.route) || a.file.localeCompare(b.file))), Effect.flatMap((routes) => {
53
+ const duplicate = routes.find((route, index) => index > 0 && routes[index - 1]?.route === route.route);
54
+ return duplicate ? Effect.fail(new CompileError({ message: `Two route files resolve to ${duplicate.route}: ${routes.filter((r) => r.route === duplicate.route).map((r) => r.file).join(", ")}` })) : Effect.succeed(routes);
55
+ }));
56
+ /** Resolves an optional root `_leaf` module (or a configured replacement). */
57
+ const resolveEntryFile = (routesDirectory, entryFile = void 0) => {
58
+ if (entryFile === false) return Effect.succeed(void 0);
59
+ const requested = entryFile ?? "_leaf";
60
+ const resolved = path.resolve(routesDirectory, requested);
61
+ const directory = path.dirname(resolved);
62
+ const fileName = path.basename(resolved);
63
+ const candidates = extensions.has(path.extname(fileName)) ? [fileName] : [...extensions].map((extension) => `${fileName}${extension}`);
64
+ return Effect.tryPromise({
65
+ try: () => promises.readdir(directory, { withFileTypes: true }),
66
+ catch: (cause) => new CompileError({
67
+ message: `Unable to read entry directory: ${directory}`,
68
+ cause
69
+ })
70
+ }).pipe(Effect.flatMap((entries) => {
71
+ const matches = entries.filter((entry) => entry.isFile() && candidates.includes(entry.name)).map((entry) => path.join(directory, entry.name));
72
+ if (matches.length === 0 && entryFile === void 0) return Effect.succeed(void 0);
73
+ if (matches.length === 1) return Effect.succeed(matches[0]);
74
+ return Effect.fail(new CompileError({ message: matches.length === 0 ? `Configured entry file was not found: ${resolved}` : `More than one entry file matches ${requested}: ${matches.join(", ")}` }));
75
+ }));
76
+ };
77
+ const generateEntry = (routes, options) => {
78
+ const base = options.base && options.base !== "/" ? `/${options.base.replace(/^\/+|\/+$/g, "")}` : "";
79
+ const routePath = (route) => `${base}${route === "/" ? "" : route}` || "/";
80
+ const importFile = (file) => options.importFile?.(file) ?? path.relative(path.dirname(options.sourceFile ?? "entry.ts"), file).replace(/\\/g, "/").replace(/^(?!\.)/, "./");
81
+ const entryImport = options.entryFile ? `import * as leafEntry from ${JSON.stringify(importFile(options.entryFile))};\n` : "";
82
+ const imports = routes.map((route, index) => {
83
+ return `import * as route${index} from ${JSON.stringify(importFile(route.file))};`;
84
+ }).join("\n");
85
+ const registrations = routes.map((route, index) => `{
86
+ const module = route${index};
87
+ for (const [method, handler] of Object.entries(module)) {
88
+ if (${JSON.stringify(HTTP_METHODS)}.includes(method) && typeof handler === "function") app.on(method, ${JSON.stringify(routePath(route.route))}, handler);
89
+ }
90
+ const fallback = Object.entries(module).find(([name]) => name === "handler")?.[1] ?? Object.entries(module).find(([name]) => name === "default")?.[1];
91
+ if (typeof fallback === "function") app.all(${JSON.stringify(routePath(route.route))}, fallback);
92
+ }`).join("\n");
93
+ return `// Generated by h3leaf. Do not edit.\nimport { H3 } from "h3";\n${entryImport}${imports}\n\nexport const app = ${options.entryFile ? "Object.entries(leafEntry).find(([name]) => name === \"app\")?.[1] ?? Object.entries(leafEntry).find(([name]) => name === \"default\")?.[1]" : "new H3()"};\nif (!app || typeof app.on !== "function" || typeof app.all !== "function") throw new TypeError("h3leaf entry must export an H3 app as named app or default export.");\n${registrations}\n\nexport const fetch = app.fetch;\nexport default { fetch };\n`;
94
+ };
95
+ //#endregion
96
+ export { generateEntry as n, resolveEntryFile as r, discoverRoutes as t };
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "vite";
2
+ //#region src/vite.d.ts
3
+ export type H3LeafPluginOptions = Readonly<{
4
+ routesDir?: string;
5
+ base?: string;
6
+ entryFile?: string | false;
7
+ outFile?: string;
8
+ sourcemap?: boolean;
9
+ }>;
10
+ export declare const h3leaf: (options?: H3LeafPluginOptions) => Plugin;
11
+ //#endregion
package/dist/vite.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import { n as generateEntry, r as resolveEntryFile, t as discoverRoutes } from "./src-DQ-jHPmt.mjs";
2
+ import { Effect } from "effect";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ //#region src/vite.ts
6
+ const environmentName = "h3leaf";
7
+ const virtualEntryId = "virtual:h3leaf/worker-entry";
8
+ const resolvedVirtualEntryId = `\0${virtualEntryId}`;
9
+ const normalizedBase = (base) => base && base !== "/" ? `/${base.replace(/^\/+|\/+$/g, "")}` : void 0;
10
+ const h3leaf = (options = {}) => {
11
+ return {
12
+ name: "h3leaf",
13
+ config(config) {
14
+ const outDir = config.build?.outDir ?? "dist";
15
+ return {
16
+ builder: {},
17
+ environments: { [environmentName]: {
18
+ consumer: "server",
19
+ resolve: { noExternal: true },
20
+ build: {
21
+ outDir,
22
+ emptyOutDir: false,
23
+ sourcemap: options.sourcemap ?? config.build?.sourcemap,
24
+ target: "es2022",
25
+ rollupOptions: {
26
+ input: virtualEntryId,
27
+ output: {
28
+ entryFileNames: options.outFile ?? (options.base ? `${options.base.replace(/^\/+|\/+$/g, "")}.mjs` : "api.mjs"),
29
+ format: "es"
30
+ }
31
+ }
32
+ }
33
+ } }
34
+ };
35
+ },
36
+ async buildApp(builder) {
37
+ for (const environment of Object.values(builder.environments)) if (!environment.isBuilt) await builder.build(environment);
38
+ },
39
+ resolveId(id) {
40
+ if (id === virtualEntryId && [environmentName, "ssr"].includes(this.environment.name)) return resolvedVirtualEntryId;
41
+ },
42
+ async load(id) {
43
+ if (id !== resolvedVirtualEntryId || ![environmentName, "ssr"].includes(this.environment.name)) return;
44
+ const routesDirectory = path.resolve(this.environment.config.root, options.routesDir ?? "api");
45
+ const { routes, entryFile } = await Effect.runPromise(Effect.all({
46
+ routes: discoverRoutes(routesDirectory),
47
+ entryFile: resolveEntryFile(routesDirectory, options.entryFile)
48
+ }));
49
+ return generateEntry(routes, {
50
+ routesDirectory,
51
+ sourceFile: path.join(this.environment.config.root, ".h3leaf", "entry.ts"),
52
+ entryFile,
53
+ base: options.base,
54
+ importFile: (file) => file.replace(/\\/g, "/")
55
+ });
56
+ },
57
+ configureServer(server) {
58
+ const base = normalizedBase(options.base);
59
+ if (!base) return;
60
+ server.middlewares.use((request, response, next) => {
61
+ if (request.url !== base && !request.url?.startsWith(`${base}/`)) return next();
62
+ (async () => {
63
+ try {
64
+ const worker = await server.ssrLoadModule(virtualEntryId);
65
+ const method = request.method ?? "GET";
66
+ const headers = new Headers();
67
+ Object.entries(request.headers).forEach(([name, value]) => {
68
+ if (value !== void 0) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
69
+ });
70
+ const body = method === "GET" || method === "HEAD" ? void 0 : Readable.toWeb(request);
71
+ const webRequest = new Request(`http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`, {
72
+ method,
73
+ headers,
74
+ body,
75
+ duplex: "half"
76
+ });
77
+ const webResponse = await worker.fetch(webRequest);
78
+ response.statusCode = webResponse.status;
79
+ webResponse.headers.forEach((value, name) => response.setHeader(name, value));
80
+ if (!webResponse.body) return response.end();
81
+ Readable.fromWeb(webResponse.body).pipe(response);
82
+ } catch (error) {
83
+ next(error);
84
+ }
85
+ })();
86
+ });
87
+ }
88
+ };
89
+ };
90
+ //#endregion
91
+ export { h3leaf };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "h3leaf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Convention-based H3 route compiler, CLI, and optional Vite plugin for Workers and Web-standard runtimes",
5
+ "license": "MIT",
6
+ "author": "shamilkotta",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/shamilkotta/h3leaf.git",
10
+ "directory": "packages/h3leaf"
11
+ },
12
+ "homepage": "https://github.com/shamilkotta/h3leaf/tree/main/packages/h3leaf#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/shamilkotta/h3leaf/issues"
15
+ },
16
+ "keywords": [
17
+ "h3",
18
+ "vite",
19
+ "cloudflare",
20
+ "workers",
21
+ "router",
22
+ "file-based-routing",
23
+ "api",
24
+ "effect"
25
+ ],
26
+ "type": "module",
27
+ "bin": {
28
+ "h3leaf": "./dist/cli.mjs"
29
+ },
30
+ "exports": {
31
+ "./vite": {
32
+ "types": "./dist/vite.d.mts",
33
+ "import": "./dist/vite.mjs",
34
+ "default": "./dist/vite.mjs"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "LICENSE",
41
+ "README.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "sideEffects": false,
47
+ "dependencies": {
48
+ "effect": "^3.17.8",
49
+ "esbuild": "^0.25.10"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^24.19.2",
53
+ "tsdown": "^0.23.0",
54
+ "vite": "^7.1.7"
55
+ },
56
+ "peerDependencies": {
57
+ "h3": "^2.0.1-rc.9",
58
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "vite": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "scripts": {
69
+ "build": "tsdown",
70
+ "typecheck": "tsc -p tsconfig.json --noEmit",
71
+ "test": "vitest run"
72
+ }
6
73
  }