tachyon-dom 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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +339 -0
  3. package/dist/adapters/lambda.d.ts +65 -0
  4. package/dist/adapters/lambda.js +211 -0
  5. package/dist/adapters/node.d.ts +25 -0
  6. package/dist/adapters/node.js +360 -0
  7. package/dist/adapters/workers.d.ts +149 -0
  8. package/dist/adapters/workers.js +272 -0
  9. package/dist/adapters.d.ts +3 -0
  10. package/dist/adapters.js +3 -0
  11. package/dist/app.d.ts +98 -0
  12. package/dist/app.js +306 -0
  13. package/dist/cli.d.ts +55 -0
  14. package/dist/cli.js +711 -0
  15. package/dist/compiler/expression.d.ts +55 -0
  16. package/dist/compiler/expression.js +858 -0
  17. package/dist/compiler/index.d.ts +8 -0
  18. package/dist/compiler/index.js +50 -0
  19. package/dist/compiler/ir.d.ts +4 -0
  20. package/dist/compiler/ir.js +255 -0
  21. package/dist/compiler/parser.d.ts +3 -0
  22. package/dist/compiler/parser.js +236 -0
  23. package/dist/compiler/sfc.d.ts +33 -0
  24. package/dist/compiler/sfc.js +439 -0
  25. package/dist/compiler/targets/client.d.ts +3 -0
  26. package/dist/compiler/targets/client.js +568 -0
  27. package/dist/compiler/targets/server.d.ts +7 -0
  28. package/dist/compiler/targets/server.js +396 -0
  29. package/dist/compiler/targets/stream.d.ts +2 -0
  30. package/dist/compiler/targets/stream.js +204 -0
  31. package/dist/compiler/types.d.ts +185 -0
  32. package/dist/compiler/types.js +1 -0
  33. package/dist/compiler/utils.d.ts +40 -0
  34. package/dist/compiler/utils.js +185 -0
  35. package/dist/compiler/whitespace.d.ts +2 -0
  36. package/dist/compiler/whitespace.js +66 -0
  37. package/dist/compiler.d.ts +1 -0
  38. package/dist/compiler.js +1 -0
  39. package/dist/constant-time.d.ts +1 -0
  40. package/dist/constant-time.js +26 -0
  41. package/dist/cookies.d.ts +44 -0
  42. package/dist/cookies.js +199 -0
  43. package/dist/diagnostics.d.ts +22 -0
  44. package/dist/diagnostics.js +45 -0
  45. package/dist/env.d.ts +27 -0
  46. package/dist/env.js +37 -0
  47. package/dist/html-escape.d.ts +2 -0
  48. package/dist/html-escape.js +20 -0
  49. package/dist/html-whitespace.d.ts +6 -0
  50. package/dist/html-whitespace.js +83 -0
  51. package/dist/i18n.d.ts +19 -0
  52. package/dist/i18n.js +93 -0
  53. package/dist/index.d.ts +20 -0
  54. package/dist/index.js +18 -0
  55. package/dist/language-server.d.ts +17 -0
  56. package/dist/language-server.js +79 -0
  57. package/dist/package-integrity.d.ts +9 -0
  58. package/dist/package-integrity.js +64 -0
  59. package/dist/result.d.ts +13 -0
  60. package/dist/result.js +4 -0
  61. package/dist/router-node.d.ts +2 -0
  62. package/dist/router-node.js +12 -0
  63. package/dist/router.d.ts +324 -0
  64. package/dist/router.js +1024 -0
  65. package/dist/runtime/attr.d.ts +3 -0
  66. package/dist/runtime/attr.js +66 -0
  67. package/dist/runtime/class.d.ts +2 -0
  68. package/dist/runtime/class.js +10 -0
  69. package/dist/runtime/conditional.d.ts +78 -0
  70. package/dist/runtime/conditional.js +140 -0
  71. package/dist/runtime/enhancement.d.ts +11 -0
  72. package/dist/runtime/enhancement.js +70 -0
  73. package/dist/runtime/error-boundary.d.ts +8 -0
  74. package/dist/runtime/error-boundary.js +27 -0
  75. package/dist/runtime/event.d.ts +1 -0
  76. package/dist/runtime/event.js +11 -0
  77. package/dist/runtime/form.d.ts +41 -0
  78. package/dist/runtime/form.js +167 -0
  79. package/dist/runtime/fragment.d.ts +6 -0
  80. package/dist/runtime/fragment.js +18 -0
  81. package/dist/runtime/html.d.ts +8 -0
  82. package/dist/runtime/html.js +6 -0
  83. package/dist/runtime/hydrate.d.ts +68 -0
  84. package/dist/runtime/hydrate.js +205 -0
  85. package/dist/runtime/keyed-rows.d.ts +61 -0
  86. package/dist/runtime/keyed-rows.js +192 -0
  87. package/dist/runtime/list.d.ts +84 -0
  88. package/dist/runtime/list.js +424 -0
  89. package/dist/runtime/portal.d.ts +6 -0
  90. package/dist/runtime/portal.js +12 -0
  91. package/dist/runtime/router.d.ts +102 -0
  92. package/dist/runtime/router.js +649 -0
  93. package/dist/runtime/signal.d.ts +30 -0
  94. package/dist/runtime/signal.js +275 -0
  95. package/dist/runtime/store.d.ts +1 -0
  96. package/dist/runtime/store.js +1 -0
  97. package/dist/runtime/stream-client.d.ts +8 -0
  98. package/dist/runtime/stream-client.js +33 -0
  99. package/dist/runtime/text.d.ts +2 -0
  100. package/dist/runtime/text.js +10 -0
  101. package/dist/runtime/virtual-list.d.ts +15 -0
  102. package/dist/runtime/virtual-list.js +89 -0
  103. package/dist/security.d.ts +27 -0
  104. package/dist/security.js +173 -0
  105. package/dist/server/form-action.d.ts +52 -0
  106. package/dist/server/form-action.js +102 -0
  107. package/dist/server/html.d.ts +19 -0
  108. package/dist/server/html.js +101 -0
  109. package/dist/server/stream.d.ts +4 -0
  110. package/dist/server/stream.js +72 -0
  111. package/dist/source-map.d.ts +17 -0
  112. package/dist/source-map.js +18 -0
  113. package/dist/tachyon-html.d.ts +110 -0
  114. package/dist/testing.d.ts +14 -0
  115. package/dist/testing.js +36 -0
  116. package/dist/typed.d.ts +9 -0
  117. package/dist/typed.js +4 -0
  118. package/dist/vite.d.ts +84 -0
  119. package/dist/vite.js +426 -0
  120. package/package.json +604 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tatsuo Kaniwa
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
13
+ all 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
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,339 @@
1
+ # Tachyon DOM
2
+
3
+ Tachyon DOM is a small TypeScript UI runtime started from the hot paths in `js-framework-benchmark`. The first implementation focuses on keyed row workloads: bulk creation, replacement, partial text updates, row selection, swap, removal, append, and clear.
4
+
5
+ The core idea is to keep a static DOM template and bind dynamic fields directly into reusable template chunks before cloning. That gives the benchmark path the same mechanical advantages as the fastest vanilla implementations while keeping the API reusable outside a single hand-written table.
6
+
7
+ ## Current Target
8
+
9
+ The initial runtime optimizes:
10
+
11
+ - chunked row creation with detached `tbody` replacement
12
+ - direct `Text.nodeValue` writes for dynamic fields
13
+ - local selected-row class changes
14
+ - `insertBefore` swaps for keyed row movement
15
+ - `textContent = ""` clears
16
+
17
+ Byte weight is intentionally secondary for now. The runtime is split so benchmark-specialized code can remain separate from future general-purpose modules.
18
+
19
+ ## Compiler Direction
20
+
21
+ The framework direction is HTML-first syntax with Solid-style fine-grained reactivity and Marko-style separated server/client compiler targets. The first compiler slice lives in `tachyon-dom/compiler` and can:
22
+
23
+ - parse a single-root HTML-like template
24
+ - extract `{expr}` text bindings
25
+ - extract `class:name={expr}` class bindings
26
+ - extract `on:event={handler}` event bindings
27
+ - extract `<for each={items} key={item.id}>` keyed list boundaries
28
+ - extract `<store count={initialCount}/>` store tags without adding DOM nodes
29
+ - record `hydrate:id={islandId}` hydration boundaries for server/client handoff
30
+ - record `<component name="Panel" prop={value}>` transparent component boundaries with props and local stores
31
+ - generate `<await value={promise} then="name">` streaming fragments in the server stream target
32
+ - render an escaped server string for the same template
33
+ - generate a chunk-yielding streaming server target as a separate compiler output
34
+ - generate client code that imports only the runtime helper modules it needs
35
+ - optionally generate fine-grained client bindings through `tachyon-dom/runtime/signal`
36
+
37
+ The fixed syntax surface is documented in [`docs/syntax-spec.md`](docs/syntax-spec.md).
38
+
39
+ Routing is documented in [`docs/routing.md`](docs/routing.md), and runtime modules are summarized in [`docs/runtime.md`](docs/runtime.md).
40
+
41
+ The compiler output is intentionally close to the current direct DOM runtime: static markup stays static, dynamic text/class/event slots are recorded as explicit paths, and generated client code can import subpath helpers such as `tachyon-dom/runtime/text`.
42
+
43
+ The first server streaming adapter, `tachyon-dom/server/stream`, accepts sync or async HTML chunks and adapts them to `ReadableStream<Uint8Array>` or `Response` without building a single full HTML string first.
44
+
45
+ The first list runtime path, `tachyon-dom/runtime/list`, preserves keyed row elements across updates, moves reused elements into order, patches text/class bindings, removes stale rows, and keeps event handlers current through a mutable row scope. Row events are delegated through the list container so reused rows do not need one listener per row.
46
+
47
+ ## App Layer
48
+
49
+ `tachyon-dom/app` provides a small app definition layer for examples and applications that should not need duplicated `main.ts`, `ssr.ts`, and HTML entry files. Define pages once, render SSR documents from the same definition, and let the Vite preset serve generated HTML in development and emit normalized HTML in production. With no option, development preserves tag formatting while production normalizes it. An explicit `htmlWhitespace` policy applies identically in both modes; legacy JavaScript values use the same runtime mapping, and invalid values fail during plugin creation before a server starts. The deprecated `minifyHtml` boolean also applies to both modes when explicitly set, while `htmlWhitespace` takes precedence:
50
+
51
+ Create a starter app with:
52
+
53
+ ```sh
54
+ npm create tachyon-dom@latest my-app
55
+ pnpm create tachyon-dom my-app
56
+ ```
57
+
58
+ ```ts
59
+ import { defineApp } from "tachyon-dom/app";
60
+ import { tachyonApp, tachyonDom } from "tachyon-dom/vite";
61
+
62
+ export const app = defineApp({
63
+ pages: [
64
+ {
65
+ path: "/",
66
+ fileName: "index.html",
67
+ template: "<section><h1>{title}</h1></section>",
68
+ scope: { title: "Home" },
69
+ },
70
+ ],
71
+ });
72
+
73
+ export default {
74
+ plugins: [tachyonDom({ reactive: true }), tachyonApp(app)],
75
+ };
76
+ ```
77
+
78
+ Route-local template conventions are available through `pagesFromRouteFiles()`: `src/routes/index/page.td` maps to `/`, `src/routes/counter/page.td` maps to `/counter/`, `[id]` maps to `:id`, and `[...slug]` maps to a named wildcard. Generated starters keep these pages in `src/routes.generated.ts`; `tachyon-dom add page settings/profile` updates that registry, so the new URL is available to the app, Vite, tests, and SSR without a manual import.
79
+
80
+ `defineApp()` rejects every duplicate normalized route and output filename before compilation or serving. Path aliases such as `/`, `/index.html`, `/guide`, and `/guide/` share their normalized route keys. `renderAppDocument()` throws for an unknown path instead of falling back to home. SSR adapters should use `renderAppResponse()`, which returns `{ status, html }`; missing routes retain status 404 and render either the configured `notFound` page or the built-in not-found document.
81
+
82
+ `examples/full-app` is a multi-page browser example with SSR initial HTML for every page, a persistent layout, client-side routing, counters, keyed lists, forms, settings, and compiler/stream diagnostics. Run it with `pnpm example:full-app`, then open the Vite dev server root. Source pages are route-local `.td` templates, and the example Vite config generates dev/build HTML entries instead of keeping `index.html` files in source. Production output is available with `pnpm example:full-app:build`; the example config builds every generated page entry and safely condenses HTML tag syntax during build.
83
+
84
+ Use `tachyonDom({ templateWhitespace: "condense" })` when formatting newlines and indentation in directly imported `.td` templates should be reduced. Standard applications that compile raw route source must pass the same `templateWhitespace` value to `defineApp()` or `loadRouteApp()`. The compiler applies this opt-in policy to one shared template tree before client, buffered server, and streaming server generation. It preserves same-line spaces, non-ASCII whitespace, hydration markers, text-binding separators, RCDATA and raw-text-like elements including `title`, and inherited `xml:space="preserve"` content in SVG and MathML. A static `xml:space="default"` resets foreign-content preservation, and `foreignObject` returns to HTML whitespace rules. Formatting runs that contain line breaks become a single ASCII space, so applications whose CSS makes arbitrary whitespace significant should retain the default `"preserve"` policy.
85
+
86
+ `normalizeHtmlTagWhitespace()` is a separate, parse5-validated helper that changes whitespace inside tag syntax only and copies every text node and comment byte-for-byte. The older `condenseHtmlWhitespace()`, `minifyHtml()`, `minify`, and `minifyHtml` names remain deprecated compatibility aliases. App `whitespace: "normalize-tags"`, Vite app `htmlWhitespace: "normalize-tags"`, and router `htmlWhitespace: "normalize-tags"` perform tag normalization only; they do not reduce inter-element indentation. `HtmlWhitespacePolicy` is deliberately distinct from compiler `TemplateWhitespacePolicy`, and tag-normalization options accept only `"preserve-tags" | "normalize-tags"`. Migrate the removed legacy option literal `"preserve"` to `"preserve-tags"` and `"condense"` to `"normalize-tags"`; deprecated boolean and helper aliases remain available separately. A route may provide a separate `stream` async iterable. Loaders and authoritative status and headers resolve before body iteration starts, and chunks are forwarded without collecting the completed body. Iteration failures terminate the body without injecting error HTML, and consumer cancellation closes the source iterator even if the body has not been pulled.
87
+
88
+ ### HTML whitespace policy migration
89
+
90
+ TypeScript consumers must replace the removed `LegacyHtmlWhitespacePolicy`, `HtmlWhitespacePolicyInput`, and `CompatibleHtmlWhitespacePolicy<T>` exports with `HtmlWhitespacePolicy`, remove the obsolete policy type parameter from app, Vite, router, Workers, Node, and Lambda option types, and use `"preserve-tags"` or `"normalize-tags"`. For example, migrate `WorkersHandlerOptions<Env, LegacyPolicy>` to `WorkersHandlerOptions<Env>` and `RouteRenderOptions<LegacyPolicy>` to `RouteRenderOptions`. Runtime compatibility remains for already-built JavaScript and JSON configuration: `"preserve"` maps to `"preserve-tags"`, and `"condense"` maps to `"normalize-tags"`. Unknown values never silently select preserve behavior, but the observable error depends on the public boundary:
91
+
92
+ | Public boundary | Unknown runtime policy outcome |
93
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
94
+ | App document rendering | Throws the migration error directly. |
95
+ | Vite app plugin creation | Throws the migration error directly before a development server or build starts. |
96
+ | Buffered router | Resolves to the generic internal-error document with status 500 because route rendering owns an error boundary. |
97
+ | Streaming router | Rejects with the migration error before returning stream metadata or chunks. |
98
+ | Workers and Node buffered adapters | Return the buffered router's generic 500 response. |
99
+ | Lambda proxy buffered adapter | Returns the generic Lambda proxy response with status 500. |
100
+ | Workers, Node, and Lambda streaming adapters | Reject before committing a response when `streaming: true` selects the streaming router. |
101
+
102
+ ## Recommended App Shape
103
+
104
+ For applications, keep route markup in route-local `.td` files and let Vite run the Tachyon DOM plugins on the main development path:
105
+
106
+ ```text
107
+ src/
108
+ routes/
109
+ index/
110
+ page.td
111
+ page.td.d.ts
112
+ routes.generated.ts
113
+ client/
114
+ main.ts
115
+ app.ts
116
+ vite.config.ts
117
+ ```
118
+
119
+ Use `src/routes/**/page.td` as the source of truth for page markup. Use `src/client/main.ts` for client-side runtime code that should be bundled by Vite. Do not put application code in `public/client/main.js`; reserve `public/` for static assets such as images, icons, manifests, and service workers.
120
+
121
+ Add Tachyon DOM template module types when TypeScript imports `.td` files directly:
122
+
123
+ ```ts
124
+ /// <reference types="tachyon-dom/td-modules" />
125
+ ```
126
+
127
+ For project-wide setup, add `"tachyon-dom/td-modules"` to `compilerOptions.types` alongside `"vite/client"`. The type entry covers `.td`, `.td?client`, `.td?server`, `.td?stream`, and `.td?raw` imports. During normal Vite development and builds, `tachyonDom()` writes an adjacent `.td.d.ts` for each transformed template. These declarations preserve exported SFC `scope()` types, require every referenced template field, and type event handlers. The generated starter and `add page` command create the initial declarations so a clean project typechecks before its first Vite run. `tachyon-dom typegen` remains available for non-Vite tooling but is not required by the standard workflow.
128
+
129
+ ## Typed Templates and Environment Validation
130
+
131
+ `tachyon-dom/typed` exposes the typed-template helpers used by generated template declarations:
132
+
133
+ ```ts
134
+ import { defineTemplate, templateScope, type TypedTemplate } from "tachyon-dom/typed";
135
+
136
+ type PageScope = {
137
+ title: string;
138
+ count: number;
139
+ };
140
+
141
+ const page = defineTemplate<PageScope, "<h1>{title}</h1>">("<h1>{title}</h1>");
142
+ const scoped = templateScope<PageScope>().define("<p>{count}</p>");
143
+
144
+ const typed: TypedTemplate<PageScope> = page;
145
+ void typed;
146
+ void scoped;
147
+ ```
148
+
149
+ `tachyon-dom/env` provides a small runtime environment validator with a public/private split:
150
+
151
+ ```ts
152
+ import { defineEnvSchema, readEnv } from "tachyon-dom/env";
153
+
154
+ const schema = defineEnvSchema({
155
+ PUBLIC_APP_NAME: { default: "Tachyon App", public: true },
156
+ SESSION_SECRET: { required: true },
157
+ });
158
+
159
+ const result = readEnv(process.env, schema);
160
+ if (!result.ok) {
161
+ throw new Error(result.error.map((error) => error.message).join("\n"));
162
+ }
163
+
164
+ result.value.publicEnv.PUBLIC_APP_NAME;
165
+ result.value.env.SESSION_SECRET;
166
+ ```
167
+
168
+ Only keys marked `public: true` are exposed under `result.value.publicEnv`. By default public keys must use the `PUBLIC_` prefix; pass `publicPrefix` to `readEnv()` when an app uses a different convention.
169
+
170
+ ## Runtime APIs
171
+
172
+ The root entry exports the small reactive runtime: `createSignal()`, `createMemo()`, `effect()`, `batch()`, `untrack()`, `createResource()`, and `catchError()`. Use `createResource(source, loader)` for signal-driven async data with `data`, `error`, `loading`, and `refetch` accessors. Source changes abort superseded loads; the loader receives an `AbortSignal`, and `resource.dispose()` detaches tracking and aborts in-flight work. Use `untrack(fn)` to read signals without subscribing the active effect, and use `catchError(fn, onError)` when an effect should recover and keep tracking after a thrown error.
173
+
174
+ `createErrorBoundary()` is available from the root entry and `tachyon-dom/runtime/error-boundary` for DOM-mounted fallback UI around client enhancements. `createI18n()` and `localeMiddleware()` are available from the root entry and `tachyon-dom/i18n` for dictionary lookup, interpolation, and request locale selection.
175
+
176
+ Adapters are lower-level deployment APIs for Node, Workers, and Lambda composition. They are useful when composing Tachyon DOM with an existing Request-to-Response handler, but an adapter-only app with TypeScript string templates is not the standard framework shape. If you are migrating an existing SSR app, start by replacing hand-written enhancement registries with `tachyon-dom/runtime/enhancement`, then move one screen at a time into `.td` templates, and finally wire those screens through the app or route layer.
177
+
178
+ ## Routing
179
+
180
+ Routing is provided as a separate layer instead of being baked into the template compiler. The server router in `tachyon-dom/router` supports static routes, `:param` routes, wildcard routes, nested layouts through `outlet`, route loaders, form actions, auth guards, 404/error boundaries, head descriptor rendering, route hydration state scripts, trusted HTML responses, backend HTML sanitization helpers and adapters, CSRF guards, signed cookie sessions, middleware, observability hooks, deferred data helpers, CSP nonce propagation, resource hints, streaming finalization, build manifests, typed href builders, route preload plans, and route type generation. The client router in `tachyon-dom/runtime/router` supports same-origin link interception, History API navigation, `popstate`, abortable route loaders/actions, action-driven revalidation, loader cache/prefetch/invalidation, Vite route HMR revalidation, scroll-to-top hooks, focus restoration, history-entry `restoreScroll`, navigation announcements, title/head updates, nested layout outlets, optional `viewTransition`, and 404/error rendering.
181
+
182
+ The Vite integration also exposes `tachyonDomRoutes()` for a `virtual:tachyon-dom/routes` module. It emits a manifest plus lazy dynamic imports, which keeps route modules split into separate chunks.
183
+
184
+ During Vite dev server runs, `tachyonDom()` logs simple request lines such as `GET / 200 4ms` through Vite's logger. Query strings are omitted by default to avoid leaking tokens or other sensitive parameters. Disable request logs with `tachyonDom({ requestLog: false })`, pass `requestLog: { logger }` to route messages to a custom sink, or set `requestLog: { includeQuery: true }` when query strings are explicitly useful.
185
+
186
+ For request-scoped dynamic SSR, use `tachyonSsr()` in the Vite plugin list. It mounts a fetch-style `Request -> Response` handler in Vite dev, passes Vite module URLs such as `/@vite/client`, `/src/`, and `/node_modules/` through to Vite, and can serve public assets before the dynamic handler through the Node adapter static asset semantics:
187
+
188
+ ```ts
189
+ import { defineConfig } from "vite";
190
+ import { tachyonDom, tachyonSsr } from "tachyon-dom/vite";
191
+
192
+ const clientScript = (request: Request) =>
193
+ new URL(request.url).searchParams.has("prod") ? "/client/main.js" : "/src/client/main.ts";
194
+
195
+ export default defineConfig({
196
+ plugins: [
197
+ tachyonDom({ reactive: true }),
198
+ tachyonSsr({
199
+ clientScript,
200
+ fetch: async (request, { clientScript }) => {
201
+ const user = new URL(request.url).searchParams.get("user") ?? "Guest";
202
+ return new Response(`<main>Hello ${user}</main><script type="module" src="${clientScript ?? ""}"></script>`, {
203
+ headers: { "content-type": "text/html; charset=utf-8" },
204
+ });
205
+ },
206
+ }),
207
+ ],
208
+ });
209
+ ```
210
+
211
+ Server adapters live in `tachyon-dom/adapters` as compatibility exports, with runtime-specific entries at `tachyon-dom/adapters/node`, `tachyon-dom/adapters/workers`, and `tachyon-dom/adapters/lambda`. The Workers entry avoids Node built-ins and can serve Cloudflare Assets bindings before dynamic routes; the Node entry keeps file-system static asset serving; the Lambda entry supports Function URL and API Gateway HTTP API v2 style events, including AWS Lambda response streaming. When a Node app serves public files from a root `staticAssets.basePath`, set `staticAssets.fallthroughOnNotFound: true` to let missing files such as `/healthz`, `/`, or non-GET/HEAD app routes like `POST /login` continue to the app handler while still serving files that exist. `tachyon-dom/runtime/form` includes progressive form enhancement, `tachyon-dom/runtime/enhancement` adds small opt-in client behavior for SSR markup via `data-td-enhance`, and `tachyon-dom/runtime/hydrate` includes boundary mismatch diagnostics for SSR tests and development builds.
212
+
213
+ For Cloudflare Pages, `tachyon-dom/vite` exposes `packageCloudflarePages()` to copy static assets and generate a Pages `_worker.js` that tries `env.ASSETS` before delegating to a Tachyon DOM SSR renderer.
214
+
215
+ Server-rendered string helpers live under `tachyon-dom/server/html` and `tachyon-dom/server/form-action`. The HTML helper escapes interpolation by default, provides explicit `attr()`, `booleanAttr()`, `classList()`, `join()`, and `rawHtml()` helpers, and does not require DOM globals. The form action helper keeps validation in userland while standardizing `FormData` parsing, safe value preservation, accessible field error attributes, and path-relative redirect responses for classic SSR forms.
216
+
217
+ ## Testing Templates
218
+
219
+ `tachyon-dom/testing` includes helpers for fast server-side assertions without starting Vite. Use `renderTdForTest(filePath, scope, { locale })` in Vitest when you want to assert the SSR HTML for a `.td` template directly:
220
+
221
+ ```ts
222
+ import { expect, it } from "vitest";
223
+ import { renderTdForTest } from "tachyon-dom/testing";
224
+
225
+ it("escapes user content in the account view", async () => {
226
+ await expect(
227
+ renderTdForTest("src/account-view.td", {
228
+ userName: `<img src=x onerror=alert(1)>`,
229
+ }),
230
+ ).resolves.toContain("&lt;img src=x onerror=alert(1)&gt;");
231
+ });
232
+ ```
233
+
234
+ The helper reads and compiles the template with Tachyon DOM diagnostics, then renders it through the server target. Compiler errors include the template file path, line, column, source line, and pointer so failures are suitable for normal unit-test output.
235
+
236
+ ## Security Notes
237
+
238
+ `sanitizeHtml(markup)` has a small built-in allowlist sanitizer for constrained, already-simple backend HTML. Do not rely on the default sanitizer for arbitrary untrusted HTML. For user-generated or third-party markup, pass a vetted adapter through `createHtmlSanitizer()`/`sanitizeHtml(..., { adapter })`, such as a DOMPurify-backed sanitizer in the target runtime.
239
+
240
+ Progressive route `stream()` strings are trusted raw HTML. Node, Workers, and Lambda adapters never escape or sanitize chunks. Escape untrusted text explicitly:
241
+
242
+ ```ts
243
+ import { escapeToHtml, trustedHtmlChunk, type RouteDefinition } from "tachyon-dom/router";
244
+
245
+ const route: RouteDefinition = {
246
+ path: "/search",
247
+ loader: ({ url }) => url.searchParams.get("q") ?? "",
248
+ render: () => "",
249
+ stream: async function* ({ data }) {
250
+ yield `<p>${trustedHtmlChunk(escapeToHtml(data))}</p>`;
251
+ },
252
+ };
253
+ ```
254
+
255
+ `escapeToHtml()` is for HTML text content, not unquoted attributes, script/style source, URLs, or other parser contexts. For intentionally accepted markup, use a vetted sanitizer adapter and pass its factory-created `TrustedHtml` through `trustedHtmlChunk()`; forged structural objects are rejected.
256
+
257
+ `tachyon-dom/server/html` is an escaping helper, not a sanitizer. Use `rawHtml()` only for trusted framework or application output. Sanitize user-generated HTML before it reaches `rawHtml()`.
258
+
259
+ The built-in sanitizer rejects protocol-relative URLs and removes absolute `http:`/`https:` URLs unless their origin is explicitly listed in `allowedUrlOrigins`. `redirect()` similarly accepts path-relative targets by default; external redirects require `allowExternal: true` plus an `allowedOrigins` entry for the target origin.
260
+
261
+ The Node adapter derives request URLs from `Host` only when you pass `trustedHosts`, or from a fixed `origin` when configured. Without either option it falls back to `localhost` instead of trusting the incoming `Host` header. Only enable `trustProxy` behind a trusted proxy or edge that normalizes forwarded headers.
262
+
263
+ The Lambda adapter derives request URLs from `event.requestContext.domainName` by default and falls back to the `Host` header for minimal local events. Pass `origin` when the public origin differs from the Lambda event domain, for example behind CloudFront or a custom domain.
264
+
265
+ ## Commands
266
+
267
+ ```sh
268
+ pnpm install
269
+ pnpm test
270
+ pnpm build
271
+ pnpm lint
272
+ pnpm check:exports
273
+ pnpm check:size
274
+ pnpm bench:local
275
+ pnpm bench:local:dev
276
+ pnpm bench:local:full
277
+ pnpm bench:local:gate
278
+ pnpm bench:local:smoke
279
+ ```
280
+
281
+ The package CLI also exposes:
282
+
283
+ ```sh
284
+ tachyon-dom compile view.td --target client --out view.js
285
+ tachyon-dom routes src/routes --out route-manifest.json
286
+ tachyon-dom typegen src/routes/index/page.td --out src/routes/index/page.td.ts
287
+ tachyon-dom add page settings/profile --routes-dir src/routes
288
+ npm create tachyon-dom@latest my-app
289
+ pnpm create tachyon-dom my-app
290
+ tachyon-dom init --template basic --out my-app
291
+ tachyon-dom dev --host 127.0.0.1 --port 5173
292
+ tachyon-dom build
293
+ tachyon-dom preview --host 127.0.0.1 --port 4173
294
+ tachyon-dom language-server --stdio
295
+ ```
296
+
297
+ `npm create tachyon-dom@latest my-app` and `pnpm create tachyon-dom my-app` create a route-local starter with `src/routes/index/page.td`, `src/client/main.ts`, `src/app.ts`, `vite.config.ts`, `tsconfig.json`, `.gitignore`, a smoke test, CI workflow, `README.md`, and `package.json`. `tachyon-dom init --template basic --out my-app` is the equivalent installed-package command. Use `tachyon-dom init --template ssr --out my-app` when you also want a small `src/server.ts` SSR composition entry.
298
+
299
+ `tachyon-dom init` and `tachyon-dom add page` preserve existing files by default. A starter conflict aborts before any file is written. Pass `--force` only when you deliberately want to overwrite the listed managed files; the command reports every overwritten path.
300
+
301
+ `tachyon-dom add page` also reports the normalized route URL, adjacent declaration file, and generated registry it changed. For example, `users/[id]` reports `/users/:id/`, while `blog/[...slug]` reports `/blog/*slug/`.
302
+
303
+ Template files use the short `.td` extension. The Vite plugin and file router also accept `.tachyon` and `.tachyon.html` for compatibility.
304
+
305
+ `tachyon-dom language-server --stdio` starts a minimal Language Server Protocol server for editor integrations. The first version reports Tachyon template diagnostics for `.td`, `.tachyon`, and `.tachyon.html` documents; completion, hover, and semantic tokens are intentionally left to later editor-support slices.
306
+
307
+ `pnpm bench:local` builds every implementation in production mode (bundled and minified, matching what applications ship), serves them from a temporary Vite preview server, measures Tachyon DOM against local copies of the keyed vanilla benchmark implementations in Playwright Chromium, prints ratio tables, and writes JSON results under `benchmark/local-compare/results/`. Production is the canonical mode because dev-server module loading adds overhead that does not exist in a shipped app.
308
+
309
+ `pnpm bench:local:dev` runs the same comparison against the unbundled dev server for fast local iteration (no production build step).
310
+
311
+ `pnpm bench:local:full` runs the canonical production comparison with extra warmup and measured iterations for noisier performance investigations.
312
+
313
+ `pnpm bench:local:smoke` runs the same local comparison with one warmup and one measured iteration for manual benchmark smoke checks. The CI workflow exposes it through `workflow_dispatch`; normal push and pull request runs skip the benchmark.
314
+
315
+ `pnpm bench:local:gate` runs the local comparison with operation and memory regression thresholds.
316
+
317
+ The local benchmark prints row-operation timings plus auxiliary metrics for startup, JS heap usage, DOM node counts, and local source size. JSON output also keeps per-run values with mean, median, min, max, and p95.
318
+
319
+ `pnpm check:exports` runs `publint --strict` and `attw --pack --no-emoji` against the package exports and declaration files. `pnpm check:size` runs per-subpath size budgets for the root entry, selected runtime helpers including `runtime/signal`, `runtime/list`, `runtime/error-boundary`, the client router, `i18n`, and the server HTML helper. CI runs both gates after `pnpm build` and `pnpm verify:package`.
320
+
321
+ Version tags matching `v*` publish through GitHub Actions with `npm publish --access public` after the same build, package, exports, and size checks pass. npm provenance is intentionally omitted because this source repository is private.
322
+
323
+ ## Example
324
+
325
+ ```sh
326
+ pnpm example:web
327
+ ```
328
+
329
+ Open `http://127.0.0.1:5173/examples/web/` to try the browser example. It shows the compiled SSR preview, hydrate boundary markers, store updates, keyed list updates, stream chunks, and generated client module shape in one page.
330
+
331
+ Open `http://127.0.0.1:5173/examples/auth-todo/` for the authenticated todo example. Browser example HTML entries are generated by the Vite dev plugin, so these examples do not need hand-written `index.html` files.
332
+
333
+ ```sh
334
+ pnpm example:stream
335
+ ```
336
+
337
+ The CLI example in `examples/store-hydrate-stream.ts` prints the streamed SSR HTML for the same store/hydrate/streaming slice.
338
+
339
+ `examples/router.ts` shows nested route rendering with a loader, route head tags, and route hydration state.
@@ -0,0 +1,65 @@
1
+ import { type AdapterFetchHandler, type WorkersFetchHandlerOptions, type RouteAdapterHandlerOptions } from "./workers.js";
2
+ import { type Writable } from "node:stream";
3
+ export type LambdaHttpEventV2 = {
4
+ version?: string;
5
+ routeKey?: string;
6
+ rawPath?: string;
7
+ rawQueryString?: string;
8
+ cookies?: readonly string[];
9
+ headers?: Record<string, string | undefined>;
10
+ requestContext?: {
11
+ domainName?: string;
12
+ http?: {
13
+ method?: string;
14
+ path?: string;
15
+ protocol?: string;
16
+ sourceIp?: string;
17
+ userAgent?: string;
18
+ };
19
+ };
20
+ body?: string;
21
+ isBase64Encoded?: boolean;
22
+ };
23
+ export type LambdaProxyResponseV2 = {
24
+ statusCode: number;
25
+ headers: Record<string, string>;
26
+ body: string;
27
+ isBase64Encoded: boolean;
28
+ cookies?: string[];
29
+ };
30
+ export type LambdaHandlerOptions = RouteAdapterHandlerOptions & {
31
+ origin?: string | ((event: LambdaHttpEventV2) => string);
32
+ trustedHosts?: readonly string[];
33
+ };
34
+ export type LambdaFetchHandlerOptions = Omit<WorkersFetchHandlerOptions, "fetch"> & {
35
+ fetch: AdapterFetchHandler;
36
+ origin?: string | ((event: LambdaHttpEventV2) => string);
37
+ trustedHosts?: readonly string[];
38
+ };
39
+ export type LambdaResponseStream = Writable;
40
+ export type LambdaHttpResponseMetadata = {
41
+ statusCode: number;
42
+ headers: Record<string, string>;
43
+ multiValueHeaders?: Record<string, string[]>;
44
+ };
45
+ export type LambdaStreamingRuntime = {
46
+ streamifyResponse: <Event = LambdaHttpEventV2, Context = unknown>(handler: (event: Event, responseStream: LambdaResponseStream, context: Context) => void | Promise<void>) => unknown;
47
+ HttpResponseStream: {
48
+ from: (responseStream: LambdaResponseStream, metadata: LambdaHttpResponseMetadata) => LambdaResponseStream;
49
+ };
50
+ };
51
+ export type LambdaContextMetadata = {
52
+ awsRequestId?: string;
53
+ functionName?: string;
54
+ functionVersion?: string;
55
+ invokedFunctionArn?: string;
56
+ memoryLimitInMB?: string;
57
+ logGroupName?: string;
58
+ logStreamName?: string;
59
+ };
60
+ export declare const requestFromLambdaEvent: (event: LambdaHttpEventV2, options?: Pick<LambdaHandlerOptions, "origin" | "trustedHosts">) => Request;
61
+ export declare const lambdaResponseFromWebResponse: (response: Response) => Promise<LambdaProxyResponseV2>;
62
+ export declare const writeWebResponseToLambdaStream: (response: Response, responseStream: LambdaResponseStream, runtime: Pick<LambdaStreamingRuntime, "HttpResponseStream">) => Promise<void>;
63
+ export declare const createLambdaHandler: (options: LambdaHandlerOptions) => (event: LambdaHttpEventV2, context?: unknown) => Promise<LambdaProxyResponseV2>;
64
+ export declare const createLambdaFetchHandler: (options: LambdaFetchHandlerOptions) => (event: LambdaHttpEventV2, context?: unknown) => Promise<LambdaProxyResponseV2>;
65
+ export declare const createLambdaStreamingHandler: (options: LambdaHandlerOptions, runtime?: LambdaStreamingRuntime) => unknown;
@@ -0,0 +1,211 @@
1
+ import { createWorkersFetchHandler, createWorkersHandler, } from "./workers.js";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ const textDecoder = new TextDecoder();
5
+ const headerValue = (headers, name) => {
6
+ const lowerName = name.toLowerCase();
7
+ const entry = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === lowerName);
8
+ return entry?.[1];
9
+ };
10
+ const normalizePath = (path) => {
11
+ if (!path) {
12
+ return "/";
13
+ }
14
+ return path.startsWith("/") ? path : `/${path}`;
15
+ };
16
+ const normalizeOrigin = (origin) => {
17
+ const candidate = origin.includes("://") ? origin : `https://${origin}`;
18
+ return new URL(candidate).origin;
19
+ };
20
+ const hostName = (host) => host.toLowerCase().replace(/:\d+$/, "");
21
+ const isTrustedHost = (host, trustedHosts) => {
22
+ if (!trustedHosts || trustedHosts.length === 0) {
23
+ return false;
24
+ }
25
+ const normalized = host.toLowerCase();
26
+ const normalizedName = hostName(normalized);
27
+ return trustedHosts.some((trusted) => {
28
+ const candidate = trusted.toLowerCase();
29
+ return normalized === candidate || normalizedName === candidate;
30
+ });
31
+ };
32
+ const originForEvent = (event, options) => {
33
+ if (typeof options?.origin === "function") {
34
+ return normalizeOrigin(options.origin(event));
35
+ }
36
+ if (options?.origin) {
37
+ return normalizeOrigin(options.origin);
38
+ }
39
+ const domainName = event.requestContext?.domainName;
40
+ if (domainName) {
41
+ return normalizeOrigin(domainName);
42
+ }
43
+ const host = headerValue(event.headers, "host") ?? "localhost";
44
+ const hasTrustedHosts = options?.trustedHosts !== undefined && options.trustedHosts.length > 0;
45
+ if (hasTrustedHosts && !isTrustedHost(host, options.trustedHosts)) {
46
+ throw new Error("Untrusted Host header");
47
+ }
48
+ return normalizeOrigin(isTrustedHost(host, options?.trustedHosts) ? host : "localhost");
49
+ };
50
+ const methodForEvent = (event) => event.requestContext?.http?.method ?? "GET";
51
+ const headersForEvent = (event) => {
52
+ const headers = new Headers();
53
+ for (const [key, value] of Object.entries(event.headers ?? {})) {
54
+ if (value !== undefined) {
55
+ headers.set(key, value);
56
+ }
57
+ }
58
+ if (event.cookies?.length) {
59
+ headers.set("cookie", event.cookies.join("; "));
60
+ }
61
+ return headers;
62
+ };
63
+ const bodyForEvent = (event) => {
64
+ const method = methodForEvent(event);
65
+ if (method === "GET" || method === "HEAD" || event.body === undefined) {
66
+ return undefined;
67
+ }
68
+ return event.isBase64Encoded ? Buffer.from(event.body, "base64") : event.body;
69
+ };
70
+ export const requestFromLambdaEvent = (event, options) => {
71
+ const method = methodForEvent(event);
72
+ const path = normalizePath(event.rawPath ?? event.requestContext?.http?.path);
73
+ const query = event.rawQueryString ? `?${event.rawQueryString}` : "";
74
+ const body = bodyForEvent(event);
75
+ const init = {
76
+ method,
77
+ headers: headersForEvent(event),
78
+ ...(body ? { body, duplex: "half" } : {}),
79
+ };
80
+ return new Request(`${originForEvent(event, options)}${path}${query}`, init);
81
+ };
82
+ const getSetCookies = (headers) => {
83
+ const getSetCookie = headers.getSetCookie;
84
+ if (getSetCookie) {
85
+ const cookies = getSetCookie.call(headers);
86
+ if (cookies.length > 0) {
87
+ return cookies;
88
+ }
89
+ }
90
+ const cookie = headers.get("set-cookie");
91
+ return cookie ? [cookie] : [];
92
+ };
93
+ const responseHeaders = (headers) => {
94
+ const output = {};
95
+ headers.forEach((value, key) => {
96
+ if (key.toLowerCase() !== "set-cookie") {
97
+ output[key] = value;
98
+ }
99
+ });
100
+ return output;
101
+ };
102
+ const isTextResponse = (headers) => {
103
+ if (headers.has("content-encoding")) {
104
+ return false;
105
+ }
106
+ const contentType = headers.get("content-type")?.toLowerCase();
107
+ if (!contentType) {
108
+ return true;
109
+ }
110
+ return (contentType.startsWith("text/") ||
111
+ contentType.includes("json") ||
112
+ contentType.includes("javascript") ||
113
+ contentType.includes("xml") ||
114
+ contentType === "image/svg+xml");
115
+ };
116
+ export const lambdaResponseFromWebResponse = async (response) => {
117
+ const buffer = Buffer.from(await response.arrayBuffer());
118
+ const textBody = isTextResponse(response.headers);
119
+ const cookies = getSetCookies(response.headers);
120
+ return {
121
+ statusCode: response.status,
122
+ headers: responseHeaders(response.headers),
123
+ body: textBody ? textDecoder.decode(buffer) : buffer.toString("base64"),
124
+ isBase64Encoded: !textBody,
125
+ ...(cookies.length > 0 ? { cookies } : {}),
126
+ };
127
+ };
128
+ const metadataFromWebResponse = (response) => {
129
+ const cookies = getSetCookies(response.headers);
130
+ return {
131
+ statusCode: response.status,
132
+ headers: responseHeaders(response.headers),
133
+ ...(cookies.length > 0 ? { multiValueHeaders: { "Set-Cookie": cookies } } : {}),
134
+ };
135
+ };
136
+ const resolveStreamingRuntime = (runtime) => {
137
+ if (runtime) {
138
+ return runtime;
139
+ }
140
+ const candidate = globalThis.awslambda;
141
+ if (!candidate?.streamifyResponse || !candidate.HttpResponseStream?.from) {
142
+ throw new Error("AWS Lambda response streaming requires the Lambda Node.js awslambda runtime or an injected LambdaStreamingRuntime.");
143
+ }
144
+ return candidate;
145
+ };
146
+ const metadataForContext = (context) => {
147
+ if (!context || typeof context !== "object") {
148
+ return undefined;
149
+ }
150
+ const candidate = context;
151
+ const metadata = {};
152
+ for (const key of [
153
+ "awsRequestId",
154
+ "functionName",
155
+ "functionVersion",
156
+ "invokedFunctionArn",
157
+ "memoryLimitInMB",
158
+ "logGroupName",
159
+ "logStreamName",
160
+ ]) {
161
+ if (candidate[key] !== undefined) {
162
+ metadata[key] = candidate[key];
163
+ }
164
+ }
165
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
166
+ };
167
+ const lambdaObservability = (observability, context) => {
168
+ const metadata = metadataForContext(context);
169
+ if (!observability && !metadata) {
170
+ return undefined;
171
+ }
172
+ return {
173
+ ...observability,
174
+ adapter: "lambda",
175
+ runtime: "aws-lambda",
176
+ ...(metadata || observability?.metadata ? { metadata: { ...observability?.metadata, ...metadata } } : {}),
177
+ };
178
+ };
179
+ export const writeWebResponseToLambdaStream = async (response, responseStream, runtime) => {
180
+ const stream = runtime.HttpResponseStream.from(responseStream, metadataFromWebResponse(response));
181
+ const source = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
182
+ await pipeline(source, stream);
183
+ };
184
+ export const createLambdaHandler = (options) => async (event, context) => {
185
+ const request = requestFromLambdaEvent(event, options);
186
+ const response = await createWorkersHandler({
187
+ ...options,
188
+ observability: lambdaObservability(options.observability, context),
189
+ }).fetch(request);
190
+ return lambdaResponseFromWebResponse(response);
191
+ };
192
+ export const createLambdaFetchHandler = (options) => async (event, context) => {
193
+ const request = requestFromLambdaEvent(event, options);
194
+ const response = await createWorkersFetchHandler({
195
+ ...options,
196
+ observability: lambdaObservability(options.observability, context),
197
+ }).fetch(request);
198
+ return lambdaResponseFromWebResponse(response);
199
+ };
200
+ export const createLambdaStreamingHandler = (options, runtime) => {
201
+ const resolvedRuntime = resolveStreamingRuntime(runtime);
202
+ return resolvedRuntime.streamifyResponse(async (event, responseStream, context) => {
203
+ const request = requestFromLambdaEvent(event, options);
204
+ const webHandler = createWorkersHandler({
205
+ ...options,
206
+ observability: lambdaObservability(options.observability, context),
207
+ });
208
+ const response = await webHandler.fetch(request);
209
+ await writeWebResponseToLambdaStream(response, responseStream, resolvedRuntime);
210
+ });
211
+ };