{"_id":"@bioleyl/hxpress","_rev":"2-72bc57cdd96172a4bc01c993c4c64437","name":"@bioleyl/hxpress","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@bioleyl/hxpress","version":"0.1.0","license":"MIT","_id":"@bioleyl/hxpress@0.1.0","maintainers":[{"name":"bioleyl","email":"lb@syware.ch"}],"homepage":"https://github.com/bioleyl/hxpress#readme","bugs":{"url":"https://github.com/bioleyl/hxpress/issues"},"dist":{"shasum":"e91e6848fac826e79de32f5424f09906acea4c05","tarball":"https://registry.npmjs.org/@bioleyl/hxpress/-/hxpress-0.1.0.tgz","fileCount":36,"integrity":"sha512-eMOdcdBJdbiyxJiUh+2ZVw/2cyp4ttPfi45VgbNvGj6wvfrnnoPtCkMOhwGDEOCXri4jRDvDjrgvKEnxarKNfQ==","signatures":[{"sig":"MEUCIAWtSNgRXGd7ZC7fef0zEZRsh9SdK/gImPTnqrOttKtrAiEAp2HjwD5IkxBKoONnUhtQot1i4LkzhEjmhA9AzmjqLeM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":257455},"main":"./dist/cjs/index.cjs","type":"module","types":"./dist/types/index.d.ts","exports":{".":{"import":{"types":"./dist/types/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/types/index.d.ts","default":"./dist/cjs/index.cjs"}},"./jsx-runtime":{"import":{"types":"./dist/types/jsx/jsx-runtime.d.ts","default":"./dist/esm/jsx-runtime.js"},"require":{"types":"./dist/types/jsx/jsx-runtime.d.ts","default":"./dist/cjs/jsx-runtime.cjs"}},"./jsx-dev-runtime":{"import":{"types":"./dist/types/jsx/jsx-dev-runtime.d.ts","default":"./dist/esm/jsx-dev-runtime.js"},"require":{"types":"./dist/types/jsx/jsx-dev-runtime.d.ts","default":"./dist/cjs/jsx-dev-runtime.cjs"}}},"gitHead":"a77d31d9ebaf830a9cbacb86a95368376efe55bc","scripts":{"dev":"rollup -c rollup.config.mjs -w","test":"vitest run","build":"npm run clean && rollup -c rollup.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationDir dist/types --outDir dist/types","clean":"rm -rf dist","typecheck":"tsc --noEmit","test:watch":"vitest","publish:guard":"node -e \"if (process.env.GITHUB_ACTIONS !== 'true') { console.error('Publishing is restricted to GitHub Actions workflow.'); process.exit(1); }\"","release:check":"npm run test && npm run build && npm pack --dry-run","prepublishOnly":"npm run publish:guard && npm run build"},"_npmUser":{"name":"bioleyl","email":"lb@syware.ch"},"repository":{"url":"git+https://github.com/bioleyl/hxpress.git","type":"git"},"_npmVersion":"11.6.1","description":"A type-safe Express + HTMX framework with TSX rendering","directories":{},"_nodeVersion":"24.11.0","publishConfig":{"access":"public","provenance":true},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.1.0","tslib":"^2.8.0","rollup":"^4.40.0","vitest":"^3.0.0","express":"^4.21.0","@swc/core":"^1.15.47","supertest":"^7.2.2","typescript":"^7.0.2","@types/node":"^24.0.0","@types/express":"^5.0.3","@types/supertest":"^7.2.1","@rollup/plugin-swc":"^0.4.1"},"peerDependencies":{"zod":"^3.22.0 || ^4.0.0","express":"^4.18.0 || ^5.0.0"},"_npmOperationalInternal":{"tmp":"tmp/hxpress_0.1.0_1786095402769_0.4742946410479061","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@bioleyl/hxpress","version":"0.2.0","description":"A type-safe Express + HTMX framework with TSX rendering","type":"module","main":"./dist/cjs/index.cjs","types":"./dist/types/index.d.ts","exports":{".":{"import":{"types":"./dist/types/index.d.ts","default":"./dist/esm/index.js"},"require":{"types":"./dist/types/index.d.ts","default":"./dist/cjs/index.cjs"}},"./jsx-runtime":{"import":{"types":"./dist/types/jsx/jsx-runtime.d.ts","default":"./dist/esm/jsx-runtime.js"},"require":{"types":"./dist/types/jsx/jsx-runtime.d.ts","default":"./dist/cjs/jsx-runtime.cjs"}},"./jsx-dev-runtime":{"import":{"types":"./dist/types/jsx/jsx-dev-runtime.d.ts","default":"./dist/esm/jsx-dev-runtime.js"},"require":{"types":"./dist/types/jsx/jsx-dev-runtime.d.ts","default":"./dist/cjs/jsx-dev-runtime.cjs"}}},"scripts":{"clean":"rm -rf dist","build":"npm run clean && rollup -c rollup.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationDir dist/types --outDir dist/types","dev":"rollup -c rollup.config.mjs -w","test":"vitest run","test:watch":"vitest","typecheck":"tsc --noEmit","release:check":"npm run test && npm run build && npm pack --dry-run","publish:guard":"node -e \"if (process.env.GITHUB_ACTIONS !== 'true') { console.error('Publishing is restricted to the manual GitHub Actions workflow (workflow_dispatch).'); process.exit(1); }\"","prepublishOnly":"npm run publish:guard && npm run build"},"publishConfig":{"access":"public","provenance":true},"peerDependencies":{"express":"^4.18.0 || ^5.0.0","zod":"^3.22.0 || ^4.0.0"},"devDependencies":{"@rollup/plugin-swc":"^0.4.1","@types/express":"^5.0.3","@types/node":"^24.0.0","@types/supertest":"^7.2.1","express":"^4.21.0","rollup":"^4.40.0","supertest":"^7.2.2","@swc/core":"^1.15.47","tslib":"^2.8.0","typescript":"^7.0.2","vitest":"^3.0.0","zod":"^4.1.0"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/bioleyl/hxpress.git"},"gitHead":"18273c4d8ae1247deb4d301fa6bf13a30ad3d09c","_id":"@bioleyl/hxpress@0.2.0","bugs":{"url":"https://github.com/bioleyl/hxpress/issues"},"homepage":"https://github.com/bioleyl/hxpress#readme","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-O60UKURuSNTKmWgAjmSrjpMNKBNA8jlxfRYOHk4W0qZXUXGCaFb4D+ovLR+VwWQml7jbGSJN4CS39zpTrr2rLQ==","shasum":"d13798810acca1a0bd9295b6fb9772874aff300b","tarball":"https://registry.npmjs.org/@bioleyl/hxpress/-/hxpress-0.2.0.tgz","fileCount":36,"unpackedSize":259315,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@bioleyl%2fhxpress@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIHePhIky0CbAUZ1h72w2N6upabtDVJah2PmRcU+1Zx+fAiBQRV9IVxeHTIaMYCT4anOBDDCsjwARomxEYFIvc4CfKw=="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:d5a6fd83-8ec6-4dab-9f3a-9d0698d39eea"}},"directories":{},"maintainers":[{"name":"bioleyl","email":"lb@syware.ch"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/hxpress_0.2.0_1786111678966_0.37275907103864614"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-07T09:36:42.596Z","modified":"2026-08-07T14:07:59.491Z","0.1.0":"2026-08-07T09:36:42.925Z","0.2.0":"2026-08-07T14:07:59.147Z"},"bugs":{"url":"https://github.com/bioleyl/hxpress/issues"},"license":"MIT","homepage":"https://github.com/bioleyl/hxpress#readme","repository":{"type":"git","url":"git+https://github.com/bioleyl/hxpress.git"},"description":"A type-safe Express + HTMX framework with TSX rendering","maintainers":[{"name":"bioleyl","email":"lb@syware.ch"}],"readme":"# HxPress — Type-safe Express + HTMX Framework with TSX Rendering\n\n**HxPress** is a lightweight, type-safe framework layer on top of [Express.js](https://expressjs.com/) for building server-rendered [HTMX](https://htmx.org/)-powered applications. It provides a **TSX developer experience** — write JSX components that render to HTML on the server, with zero client-side JavaScript and no hydration overhead.\n\n## Philosophy\n\n- **Type safety first**: Strong TypeScript inference via Zod schemas for validated request bodies.\n- **Simplicity**: No virtual DOM, no build step for the framework itself, minimal abstractions.\n- **HTMX-first**: Automatic detection of `HX-Request` headers to serve full pages or partial fragments.\n\n## Table of Contents\n\n- [Installation](#installation)\n- [Quick Start](#quick-start)\n- [TSX Setup](#tsx-setup)\n- [TSX Rendering](#tsx-rendering)\n  - [JSX Elements](#jsx-elements)\n  - [Components](#components)\n  - [Fragments & Arrays](#fragments--arrays)\n  - [Async Components](#async-components)\n  - [HTML Escaping](#html-escaping)\n- [Routing](#routing)\n  - [GET Routes](#get-routes)\n  - [POST Routes (Traditional)](#post-routes-traditional)\n- [HTMX Rendering Behavior](#htmx-rendering-behavior)\n  - [`onPage` vs `onPartial`](#onpage-vs-onpartial)\n  - [Automatic HTMX Detection](#automatic-hmx-detection)\n- [Typed Actions & Validation](#typed-actions--validation)\n  - [Zod Schema Inference](#zod-schema-inference)\n  - [Action Handlers](#action-handlers)\n  - [Error Handling with `onError`](#error-handling-with-onerror)\n  - [HTMX Response Headers](#htmx-response-headers)\n- [Request Context](#request-context)\n- [Plugin System](#plugin-system)\n  - [Context Extension](#context-extension)\n  - [Lifecycle Hooks](#lifecycle-hooks)\n  - [Middleware Registration](#middleware-registration)\n  - [Route Registration](#route-registration)\n- [API Reference](#api-reference)\n- [Example Application](#example-application)\n\n---\n\n## Installation\n\n```bash\nnpm install @bioleyl/hxpress express zod\n```\n\n**Peer dependencies**: `express` (v4 or v5), `zod` (v3.22+ or v4).\n\nHxPress publishes both **ESM** and **CommonJS** builds with full TypeScript type declarations.\n\n---\n\n## Quick Start\n\nA minimal HxPress application:\n\n```tsx\nimport express from \"express\";\nimport { createHxpress } from \"@bioleyl/hxpress\";\n\nconst app = express();\nconst hx = createHxpress(app);\n\n// GET route — renders a full page for normal requests, partial for HTMX.\nhx.get(\"/invoices\", {\n  onPage: () => (\n    <html>\n      <body>\n        <h1>Invoices</h1>\n        <table id=\"invoice-list\"></table>\n      </body>\n    </html>\n  ),\n  onPartial: () => <table id=\"invoice-list\">\n    <tr><td>Invoice A — $250.00</td></tr>\n  </table>,\n});\n\nhx.apply(); // Wire routes onto Express\napp.listen(3000);\n```\n\n---\n\n## TSX Setup\n\nTo use TSX with HxPress, set these TypeScript compiler options:\n\n```json\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"@bioleyl/hxpress\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true\n  }\n}\n```\n\nNotes:\n\n- Use `.tsx` for files that contain JSX.\n- HxPress exposes `@bioleyl/hxpress/jsx-runtime` and `@bioleyl/hxpress/jsx-dev-runtime`, so no React dependency is required.\n\n---\n\n## TSX Rendering\n\nHxPress provides a lightweight JSX runtime that converts JSX trees to HTML strings on the server. **No React dependency, no virtual DOM, no hydration.**\n\n### JSX Elements\n\nPrefer regular TSX syntax:\n\n```tsx\nconst element = (\n  <div class=\"card\">\n    <h1>Hello World</h1>\n  </div>\n);\n```\n\nLow-level factory form (rarely needed, mostly for runtime internals):\n\n```ts\nimport { jsx } from \"@bioleyl/hxpress\";\n\nconst element = jsx(\"div\", { class: \"card\" }, \"key\", \"Hello World\");\n```\n\n### Components\n\nComponents are plain functions that return JSX nodes:\n\n```tsx\nimport type { PropsWithChildren } from \"@bioleyl/hxpress\";\n\nfunction Card(props: PropsWithChildren<{ title: string }>) {\n  return (\n    <article class=\"card\">\n      <h2>{props.title}</h2>\n      {props.children}\n    </article>\n  );\n}\n\n// Usage:\n<Card title=\"My Card\">\n  <p>Card content goes here.</p>\n</Card>\n```\n\nYou can also use `HxFC<Props>` / `AsyncHxFC<Props>` as shorthand component aliases when you want `children` included automatically.\n\n### Fragments & Arrays\n\nUse fragments to group elements without adding a wrapper DOM node:\n\n```tsx\n<>\n  <span />\n  <em />\n</>\n```\n\nArrays render their items sequentially. Nested arrays are flattened automatically:\n\n```tsx\n[\"a\", [\"b\", \"c\"]] // → abc\n```\n\n### Async Components\n\nComponents can be async — HxPress detects `Promise` returns and awaits them:\n\n```tsx\nasync function UserProfile(props: { userId: number }) {\n  const user = await fetchUserById(props.userId);\n  return (\n    <div>\n      <h1>{user.name}</h1>\n    </div>\n  );\n}\n```\n\n### HTML Escaping\n\nAll text content and attribute values are automatically escaped to prevent XSS. The `escapeHtml` and `escapeAttr` functions are also exported for manual escaping when needed:\n\n```tsx\nimport { escapeHtml } from \"@bioleyl/hxpress\";\n\n<p>{escapeHtml(userInput)}</p>; // Safe against <script> injection\n```\n\n---\n\n## Routing\n\n### GET Routes\n\nRegister a GET route with `onPage` (full page) and optionally `onPartial` (HTMX fragment):\n\n```tsx\nhx.get(\"/invoices\", {\n  onPage: () => (\n    <html>\n      <body>\n        <table></table>\n      </body>\n    </html>\n  ),\n  onPartial: () => <table></table>,\n});\n```\n\n### POST Routes (Traditional)\n\nPOST routes without validation use the same `onPage`/`onPartial` pattern:\n\n```tsx\nhx.post(\"/submit\", {\n  onPage: (_ctx) => <form></form>,\n  onPartial: (_ctx) => <p>Submitted!</p>,\n});\n```\n\n---\n\n## HTMX Rendering Behavior\n\n### `onPage` vs `onPartial`\n\nHxPress automatically detects whether a request is an HTMX request by checking the `HX-Request: true` header. When it is, HxPress calls `onPartial`; otherwise, it calls `onPage`.\n\n```ts\nhx.get(\"/items/:id\", {\n  // Called for normal browser navigation → full page with layout wrappers\n  onPage: (ctx) => <html><body>Item {ctx.params.id}</body></html>,\n\n  // Called when HX-Request header is present → only the partial content\n  onPartial: (ctx) => <div class=\"item-detail\">Details for item {ctx.params.id}</div>,\n});\n```\n\n### Automatic HTMX Detection\n\nThe detection checks `req.headers[\"hx-request\"]` and matches it case-insensitively against `\"true\"`. This works with both GET and POST routes.\n\n---\n\n## Typed Actions & Validation\n\nHxPress supports **action-style POST routes** that validate request bodies using Zod schemas, then pass the validated data to an action handler with full TypeScript inference.\n\n### Zod Schema Inference\n\n```ts\nimport { z } from \"zod\";\nimport { createSchema } from \"@bioleyl/hxpress\";\n\nconst CreateInvoiceSchema = z.object({\n  title: z.string().min(1),\n  amount: z.number(),\n});\n\n// The schema is wrapped to provide explicit _input/_output type markers.\n```\n\n### Action Handlers\n\nThe `action` handler receives a context where `ctx.body` is **typed from the Zod schema**:\n\n```ts\nhx.post(\"/invoices\", {\n  schema: CreateInvoiceSchema,\n\n  action(ctx) {\n    // ctx.body has type { title: string; amount: number } — inferred automatically!\n    const invoice = createInvoice({\n      title: ctx.body.title,   // ← TypeScript knows this is a string.\n      amount: ctx.body.amount, // ← TypeScript knows this is a number.\n    });\n\n    return { html: <tr><td>{invoice.id}</td></tr> };\n  },\n});\n```\n\n### Error Handling with `onError`\n\nWhen validation fails, the optional `onError` callback receives the raw (invalid) body and structured errors:\n\n```ts\nhx.post(\"/invoices\", {\n  schema: CreateInvoiceSchema,\n  action(ctx) { /* ... */ },\n  onError(_ctx, errors) {\n    // errors is ValidationError[] with message + path for each issue.\n    return { html: <ul>{errors.map(e => <li>{e.message}</li>)}</ul> };\n  },\n});\n```\n\n### HTMX Response Headers\n\nAction handlers can set HTMX response headers via the `ActionResponse` object:\n\n| Field | Purpose | Header Set |\n|-------|---------|------------|\n| `html` | Rendered HTML body (JSX node or string) | — |\n| `trigger` | Client-side event trigger name | `HX-Trigger` |\n| `redirect` | Redirect URL | `HX-Retarget`, Express redirect |\n\n```ts\naction(ctx) {\n  return { html: <tr>...</tr>, trigger: \"invoiceCreated\" };\n}\n// Sets HX-Trigger: invoiceCreated header.\n```\n\n---\n\n## Request Context\n\nEvery handler receives a typed context object with the following properties:\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `ctx.req` | `express.Request` | The raw Express request object |\n| `ctx.res` | `express.Response` | The raw Express response object |\n| `ctx.params` | `Record<string, string>` | Route parameters (e.g., `{ id: \"42\" }`) |\n| `ctx.query` | `Record<string, string \\| string[]>` | Query string key-value pairs |\n| `ctx.body` | `T` (from Zod schema) or `undefined` | Validated body in action routes |\n| `ctx.validationErrors` | `ValidationError[] \\| undefined` | Validation errors for form re-rendering |\n| `ctx.isHxRequest()` | `boolean` | Whether the request has `HX-Request: true` |\n| `ctx.render(node)` | `(node) => Promise<void>` | Render a JSX node and send it as response |\n\n```ts\nhx.get(\"/users/:id\", {\n  onPage(ctx) {\n    console.log(ctx.params.id);       // \"42\"\n    console.log(ctx.query.q ?? \"\");   // query string value\n    return <div>User {ctx.params.id}</div>;\n  },\n});\n```\n\n---\n\n## Plugin System\n\nHxPress provides a powerful plugin architecture for extending the framework. Plugins can register middleware, routes, extend context per-request, and hook into the rendering pipeline.\n\n### Context Extension\n\nAdd custom properties to every request's context:\n\n```tsx\nconst userPlugin = createPlugin({\n  name: \"user-plugin\",\n  extendContext() {\n    return { user: { id: \"42\", name: \"Alice\" } };\n  },\n});\n\n// Now ctx.user is available in all handlers.\nhx.get(\"/profile\", { onPage: (ctx) => <div>{ctx.user.name}</div> });\n```\n\n### Lifecycle Hooks\n\nTransform rendered HTML or inspect the rendering pipeline:\n\n```tsx\nconst transformPlugin = createPlugin({\n  name: \"transform-plugin\",\n  hooks: {\n    // Called before each JSX tree is rendered.\n    beforeRender(ctx, node) { console.log(\"Rendering:\", typeof node); },\n\n    // Called after HTML is generated — return a modified string to replace it.\n    afterRender(_ctx, html) { return `<html><body>${html}</body></html>`; },\n  },\n});\n```\n\n### Middleware Registration\n\nRegister Express middleware that runs before route handlers:\n\n```ts\nconst authPlugin = createPlugin({\n  name: \"auth-plugin\",\n  registerMiddleware() {\n    return [(req, res, next) => {\n      if (!isAuthenticated(req)) { res.status(401).send(\"Unauthorized\"); }\n      else { next(); }\n    }];\n  },\n});\n```\n\n### Route Registration\n\nPlugins can add fallback routes:\n\n```tsx\nconst healthPlugin = createPlugin({\n  name: \"health-plugin\",\n  registerRoutes() {\n    return [{ path: \"/healthz\", method: \"get\" as const, onPage: () => <p>OK</p> }];\n  },\n});\n```\n\n### Registering Plugins\n\nPass plugins at app creation time:\n\n```ts\nconst hx = createHxpress(app, {\n  plugins: [userPlugin, authPlugin, healthPlugin],\n});\n```\n\n---\n\n## API Reference\n\n| Export | Type | Description |\n|--------|------|-------------|\n| `createHxpress(app, options?)` | `(app) => Hxpress` | Create a framework instance attached to an Express app. |\n| `jsx(tag, props?, maybeKey?, ...children)` | `JsxElement` | Low-level JSX factory used by the TSX transform. |\n| `Fragment({ children })` | `JsxFragment` | Group multiple nodes without adding a wrapper DOM element. |\n| `render(node)` | `(node) => Promise<string>` | Convert a JSX tree to an HTML string. |\n| `escapeHtml(str)` | `(str) => string` | Escape special characters for safe text content inclusion. |\n| `escapeAttr(str)` | `(str) => string` | Escape special characters for safe attribute value inclusion. |\n| `PropsWithChildren<Props>` | `Props & { children?: HxChildren }` | Add optional `children` to a component props shape. |\n| `HxFC<Props>` | `(props) => JsxNode` | Synchronous component alias with `children` included in props. |\n| `AsyncHxFC<Props>` | `(props) => Promise<JsxNode>` | Async component alias with `children` included in props. |\n| `createPlugin(def)` | `(def) => HxPlugin` | Create a typed plugin from a definition object. |\n| `validateRequest(schema)` | `(schema) => RequestHandler` | Express middleware that validates body against a Zod schema. |\n| `createSchema(zodSchema)` | `(zodSchema) => ValidatedSchema<T>` | Wrap a Zod schema with explicit `_input`/`_output` type markers. |\n| `createHxContext(req, res)` | `(req, res) => HxContext` | Create a typed request context from Express req/res objects. |\n\n---\n\n## Example Application\n\nA complete working example is included in the repository:\n\n```bash\ncd examples/basic-app\nnpm install\nnpm run dev\n```\n\nThe demo implements an invoice management app with:\n\n- **TSX components**: `InvoiceRow`, `InvoiceTable`, `CreateInvoiceForm`, `CardSection` (typed with `PropsWithChildren`)\n- **HTMX behavior**: Full pages for navigation, partials for HTMX swaps\n- **Typed actions**: Zod-validated POST routes with typed `ctx.body`\n- **Plugins**: Analytics plugin (context extension + HTML transformation) and logger middleware\n\n---\n\n## License\n\nMIT © [bioleyl](https://github.com/bioleyl/hxpress)\n","readmeFilename":"README.md"}