hono-svelte 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - **Per-page data typing (opt-in, `pages({ dts: true })`)**: declare
6
+ `export type Data` (or `interface Data`) in a page's `<script module>` and
7
+ the generated `.d.ts` adds a typed overload per page —
8
+ `c.render("dashboard", { data })` is checked by `tsc` (a missing/mistyped
9
+ field fails the typecheck instead of shipping `undefined`). The declaration
10
+ is inlined verbatim, so it must be self-contained; types that reference
11
+ imports are skipped with a one-time warning and keep
12
+ `Record<string, unknown>` data. `RenderProps` is now generic
13
+ (`RenderProps<TData = Record<string, unknown>>`) and `pages().dataTypes()`
14
+ exposes the extractions. The dts also regenerates when a page's `Data`
15
+ type changes (dev watcher + `buildStart`).
16
+ - **Neutral `<body>`**: the shell no longer hardcodes DaisyUI classes
17
+ (`bg-base-200 min-h-screen text-base-content`) — it emits a plain,
18
+ CSS-framework-agnostic document. Style `body`/`html` from your global
19
+ stylesheet instead (`body { background: var(--color-base-200); ... }`, or
20
+ `@apply` with Tailwind — the playground does exactly that in
21
+ `styles.css`). **Breaking** for apps relying on the hardcoded classes.
22
+ - **`shell({ ssrFallback })`**: when SSR of a page throws at request time,
23
+ log the error and fall back to client rendering (entry script) instead of
24
+ a 500. Default `false` keeps the previous behavior.
25
+ - `prefetch: "all"` / `"hover"` now emit `rel="modulepreload"` (prefetch on a
26
+ module URL could double-fetch in some browsers; modulepreload also warms
27
+ the entry's import graph).
28
+ - `dataLimit` warning fires once per **(page, limit)** instead of once per
29
+ process — a second oversized page is reported too, with the entry name in
30
+ the message.
31
+ - **Removed dead stubs**: `versionsMatch()` (always returned `true`), the
32
+ unused `hasClient` export from the ssr-manifest stub/virtual manifest, and
33
+ the no-op `checkPagesDir`. **Breaking: `versionsMatch` is no longer
34
+ exported.**
35
+ - Fully typed handlers/middleware: `notFoundHandler()` / `errorHandler()`
36
+ match Hono's `NotFoundHandler`/`ErrorHandler` signatures and `shell()` no
37
+ longer uses `any` internally.
38
+ - **CLI: `hono-svelte add page <name>`** — scaffolds
39
+ `src/pages/<name>.svelte` (static by default, skip-if-exists, traversal
40
+ refused). `isValidPageName()` / `newPageFile()` exported for tests.
41
+ - Metadata: `engines` now declares `bun >= 1.4` and `node >= 26`, the
42
+ `./package.json` export subpath was added, and TypeScript 7 builds the
43
+ package.
44
+ - **Security floors in `peerDependencies`**: `hono ^4.13.8`, `svelte ^5.57.0`,
45
+ `vite ^8.3.0` — every published advisory sits outside these floors
46
+ (hono < 4.12.12 and 4.12.0–4.12.33, patched in 4.12.34; svelte ≤ 5.55.6,
47
+ patched in 5.55.7; vite ≤ 8.0.15, patched in 8.0.16; transitive `devalue`
48
+ resolves to the patched 5.9.2, which also closes CVE-2026-81176). The
49
+ versions installed in the repo and in the example are the latest published
50
+ and none is affected by a known advisory — the ranges just avoid
51
+ advertising vulnerable versions to consumers and scanners.
52
+ - CI: ubuntu + windows runners (Windows-only regressions like the old
53
+ `isSafeAppPath` bug are caught now), coverage via `@vitest/coverage-v8`
54
+ with thresholds (`npm test` runs it), all on the officially supported
55
+ runtimes (Node 26, TS 7, Vite 8, Bun 1.4+).
56
+ - `hono-svelte init` now scaffolds TypeScript 7 + `@types/node` 26 in the
57
+ generated app.
58
+
59
+ ## 0.4.2
60
+
61
+ - `hono-svelte init` now scaffolds a complete runnable app (works with
62
+ `npx`, `bunx` and `pnpm dlx` — writes text files only, no install/network).
63
+ Minimal flavor (default): `package.json`, `tsconfig.json`, `.gitignore`,
64
+ `vite.config.ts` (with `pages({ dts: true })`), two pages (`index` zero-JS,
65
+ `hello` with `data`), `src/routes/index.ts`, `src/styles.css`.
66
+ `init --full` adds auth (cookie session), dashboard with nested layout,
67
+ and typed RPC (`hc<AppType>`) — `/auth` → login → `/dashboard`.
68
+ Generated scripts (`dev`/`build`/`typecheck`/`start`) run under npm and bun.
69
+ `InitFlavor` type exported.
70
+
3
71
  ## 0.4.1
4
72
 
5
73
  - Fix `hono-svelte init` refusing every file on Windows: `isSafeAppPath()`
package/README.md CHANGED
@@ -15,10 +15,10 @@ Real sites and dashboards mix simple pages (landing, login, terms) with interact
15
15
 
16
16
  ## Installation
17
17
 
18
- Requires Node 22+, Hono 4, Svelte 5, and Vite 6/7/8.
18
+ Requires Bun 1.4+, Node 26+, Hono 4, Svelte 5, Vite 8, and TypeScript 7.
19
19
 
20
20
  ```sh
21
- npm install hono-svelte
21
+ bun add hono-svelte
22
22
  ```
23
23
 
24
24
  ## Quick start
@@ -126,7 +126,9 @@ Works for static (zero-JS) pages too: the shell passes `data` as SSR props **and
126
126
  app.get("/dashboard", (c) => c.render("dashboard", { data: { plan: "pro" } }));
127
127
  ```
128
128
 
129
- Large payloads trigger a one-time `console.warn` (default limit 100KB, `shell({ dataLimit })`).
129
+ Declare `export type Data` in the page's `<script module>` to type it per page — see [per-page data typing](#per-page-data-typing).
130
+
131
+ Large payloads trigger a `console.warn` — once per page (default limit 100KB, `shell({ dataLimit })`).
130
132
  Anything sensitive stays in the API (`hc<AppType>`, HttpOnly cookie). Never put secrets in `data`.
131
133
 
132
134
  ## Status, headers and per-page head
@@ -180,6 +182,8 @@ c.render("dashboard/page1", { data: { title: "Page 1", description: "..." } });
180
182
 
181
183
  Layouts never become entries (`pages().layouts()` / `layoutChain(entry)` inspect them; `layouts: false` restores legacy ignore).
182
184
 
185
+ > **Hydration wrapper:** each layout level wraps the page's SSR output in one `<div>` (a Svelte 5 raw-snippet requirement — an empty snippet render breaks hydration). The wrapper is hydration-safe, but it is part of the DOM: account for it in CSS selectors (e.g. `:global(> div)`).
186
+
183
187
 
184
188
  ## Error pages
185
189
 
@@ -193,11 +197,30 @@ app.onError(errorHandler()); // renders `500.svelte` with status 500
193
197
  ## CLI
194
198
 
195
199
  ```sh
196
- npx hono-svelte init # scaffold src/pages, vite.config.ts, env.d.ts
197
- npx hono-svelte doctor # check dist, link and vite config
198
- npx hono-svelte doctor --app=./my-app # check another app directory
200
+ # Start a new app (no install needed):
201
+ bunx hono-svelte init # minimal: 2 pages + routes + configs
202
+ bunx hono-svelte init --full # + auth, dashboard w/ layout, typed RPC
203
+
204
+ cd my-app && bun install && bun run dev
205
+ ```
206
+
207
+ `init` scaffolds a complete runnable app: `package.json`, `tsconfig.json`,
208
+ `vite.config.ts` (with `pages({ dts: true })`), `src/pages/` and
209
+ `src/routes/`. The `--full` flavor adds cookie-session auth, a dashboard with
210
+ nested layout, and a typed API (`hc<AppType>`) — open `/auth`, sign in, explore
211
+ `/dashboard`. (npm/pnpm work too: `npx hono-svelte init`, `npm install`.)
212
+
213
+ ```sh
214
+ bunx hono-svelte add page blog/post-1 # create src/pages/blog/post-1.svelte
215
+ bunx hono-svelte doctor # check dist, link and vite config
216
+ bunx hono-svelte doctor --app=./my-app # check another app directory
199
217
  ```
200
218
 
219
+ `add page <name>` writes a static page (no `<script>` → zero JS; add one to
220
+ hydrate on the client). Existing files are skipped, traversal names are
221
+ refused, and the default pagesDir is `src/pages` (a custom one set via
222
+ `pages({ pagesDir })` is not visible to the CLI — adjust the path manually).
223
+
201
224
  ## API
202
225
 
203
226
  ### `shell(options?)`
@@ -216,11 +239,12 @@ Hono middleware that provides `c.render(entry, { title?, data?, status?, headers
216
239
  | `head` | `""` | Extra HTML in `<head>` (fonts, meta tags) |
217
240
  | `nonce` | — | CSP nonce string or `(c) => string` (applied to shell `<script>`/`<link>`) |
218
241
  | `preload` | `true` in prod | `<link rel="modulepreload">` for the entry (+ manifest imports) |
219
- | `prefetch` | `false` | `"all"` prefetches other entries; `"hover"` injects a hover-prefetch script |
242
+ | `prefetch` | `false` | `"all"` emits `modulepreload` for other entries; `"hover"` injects a script that preloads them on link hover |
220
243
  | `dataLimit` | `102400` | Warn once above this many `data` bytes (`false` disables) |
221
244
  | `status` | `200` | Default response status |
222
245
  | `headers` | `{}` | Default response headers (per-render `headers` merge over them) |
223
246
  | `strict` | `true` | Unknown entries throw a dev-friendly error; `false` disables |
247
+ | `ssrFallback` | `false` | SSR failures log and fall back to client rendering instead of a 500 |
224
248
  | `knownEntries` | auto from manifest | Override the page list used by strict mode and prefetch |
225
249
 
226
250
  `shell()` also exposes `availableEntries()` for diagnostics.
@@ -237,7 +261,7 @@ Vite plugin (`hono-svelte/vite`) that discovers pages and generates client entri
237
261
  | `dts` | `false` | Typed `c.render` entries: `true` writes `src/hono-svelte-entries.d.ts`, a string sets a custom path |
238
262
  | `alwaysClient` | `[]` | Pages that always get JS, even without `<script>` |
239
263
 
240
- Handy methods: `input()` (client build entries), `entries()` (all pages), `staticEntries()` (static pages only), `hasClient(entry)`, `layouts()`, `layoutChain(entry)`, `types()` (entry union), `typeDeclarations()` (hono module snippet), `dtsPath()` / `writeDts()` (generated `.d.ts`), `validateConfig()` (fail fast on bad Vite setup).
264
+ Handy methods: `input()` (client build entries), `entries()` (all pages), `staticEntries()` (static pages only), `hasClient(entry)`, `layouts()`, `layoutChain(entry)`, `dataTypes()` (extracted `Data` declarations), `types()` (entry union), `typeDeclarations()` (hono module snippet), `dtsPath()` / `writeDts()` (generated `.d.ts`), `validateConfig()` (fail fast on bad Vite setup).
241
265
 
242
266
  ### Typing `c.render`
243
267
 
@@ -278,13 +302,56 @@ declare module "hono" {
278
302
  }
279
303
  ```
280
304
 
281
- `data` stays `Record<string, unknown>` — per-page payload types are a future feature. Programmatic access: `pages().types()` → `"admin" | "home"`, `pages().typeDeclarations()` for the snippet, `dtsPath()` / `writeDts()` for the file.
305
+ `data` stays `Record<string, unknown>` by default — declare a `Data` type in the page for per-page typing (below). Programmatic access: `pages().types()` → `"admin" | "home"`, `pages().typeDeclarations()` for the snippet, `dtsPath()` / `writeDts()` for the file.
306
+
307
+ ### Per-page data typing
308
+
309
+ Declare the page payload once — the `Data` type in a `<script module>` — and reuse it as the page props:
310
+
311
+ ```svelte
312
+ <!-- src/pages/dashboard.svelte -->
313
+ <script module lang="ts">
314
+ export type Data = { plan: string; seats: number };
315
+ </script>
316
+
317
+ <script lang="ts">
318
+ let { plan, seats }: Data = $props();
319
+ </script>
320
+
321
+ <h1>{plan} — {seats} seats</h1>
322
+ ```
323
+
324
+ With `pages({ dts: true })`, the generated `.d.ts` adds a typed overload per
325
+ page, so `c.render("dashboard", { data: { plan: "pro" } })` is checked by
326
+ `tsc` — a missing `plan` fails the typecheck instead of shipping `undefined`
327
+ to the client:
328
+
329
+ ```ts
330
+ // generated (excerpt)
331
+ type HonoSvelteData_dashboard = { plan: string; seats: number };
332
+
333
+ declare module "hono" {
334
+ interface ContextRenderer {
335
+ (entryName: "dashboard", props?: import("hono-svelte").RenderProps<HonoSvelteData_dashboard>): Response | Promise<Response>;
336
+ (entryName: HonoSvelteEntries, props?: import("hono-svelte").RenderProps): Response | Promise<Response>;
337
+ }
338
+ }
339
+ ```
340
+
341
+ The `Data` declaration is inlined verbatim into the dts, so it must be
342
+ **self-contained** — inline shapes or built-ins only. References to imported
343
+ types can't be resolved from the dts location; such pages keep
344
+ `Record<string, unknown>` data with a one-time warning. Both
345
+ `export type Data = ...` and `export interface Data { ... }` work (including
346
+ legacy `<script context="module">`). `pages().dataTypes()` exposes the
347
+ extracted declarations.
282
348
 
283
349
  ## Production tips
284
350
 
285
351
  - Pass the Vite manifest to `shell({ assets: manifest })` for hashed files; manifest CSS and shared chunks are emitted automatically (`modulepreload`).
286
352
  - Serve hashed files with long cache and `immutable` (`immutableHeaders()` / `CACHE_IMMUTABLE`); the HTML shell itself suits `CACHE_NO_STORE`.
287
353
  - CSP: pass `shell({ nonce })` (string or from `secureHeaders()` context) — shell tags carry the nonce. Without a nonce, static pages keep working with `script-src 'self'` (no executable inline script, except the opt-in `prefetch: "hover"` helper).
354
+ - The shell emits a neutral `<body>` (no framework classes) — style `body`/`html` from your global stylesheet (`stylesHref`), e.g. `body { @apply bg-base-200 min-h-screen text-base-content; }` with Tailwind/DaisyUI.
288
355
  - Run both client and server builds before serving; the example in `examples/playground/` shows the full setup.
289
356
 
290
357
  ## Example
@@ -292,7 +359,7 @@ declare module "hono" {
292
359
  `examples/playground/` is a real Hono app using the package: static landing, login, and a dashboard with typed RPC and cookie session. To run it:
293
360
 
294
361
  ```sh
295
- cd examples/playground && npm install && npm run build
362
+ cd examples/playground && bun install && bun run build
296
363
  ```
297
364
 
298
365
  ## License
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { doctorChecks, checkAppLink, initFiles, isSafeAppPath } from "./scaffold.js";
7
+ import { checkAppLink, doctorChecks, initFiles, isSafeAppPath, newPageFile, } from "./scaffold.js";
8
8
  const args = process.argv.slice(2);
9
9
  const command = args[0];
10
10
  function flagValue(name) {
@@ -12,9 +12,14 @@ function flagValue(name) {
12
12
  const hit = args.find((a) => a.startsWith(pref));
13
13
  return hit ? hit.slice(pref.length) : undefined;
14
14
  }
15
+ function hasFlag(name) {
16
+ return args.includes(`--${name}`);
17
+ }
15
18
  const appRoot = flagValue("app") ?? process.cwd();
19
+ const flavor = hasFlag("full") ? "full" : "minimal";
16
20
  function init() {
17
- for (const file of initFiles()) {
21
+ const files = initFiles(flavor);
22
+ for (const file of files) {
18
23
  const dest = join(appRoot, file.path);
19
24
  if (!isSafeAppPath(appRoot, dest)) {
20
25
  console.error(`[hono-svelte] refusing to write outside ${appRoot}: ${file.path}`);
@@ -29,10 +34,50 @@ function init() {
29
34
  writeFileSync(dest, file.content);
30
35
  console.log(`[hono-svelte] wrote ${file.path}`);
31
36
  }
37
+ console.log(`[hono-svelte] done (${flavor}). Next:`);
38
+ if (flavor === "minimal") {
39
+ console.log(` 1. bun install`);
40
+ console.log(` 2. bun run dev (or: bun --bun run dev)`);
41
+ console.log(` Tip: --full scaffolds auth + dashboard + layout + typed RPC.`);
42
+ }
43
+ else {
44
+ console.log(` 1. bun install`);
45
+ console.log(` 2. bun run dev (or: bun --bun run dev) → /auth → /dashboard`);
46
+ }
32
47
  }
33
48
  function packageRootForCli() {
34
49
  return join(dirname(fileURLToPath(import.meta.url)), "..");
35
50
  }
51
+ function addPage() {
52
+ const name = args[2];
53
+ if (!name) {
54
+ console.error("[hono-svelte] usage: hono-svelte add page <name> (e.g. blog/post-1)");
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ const file = newPageFile(name);
59
+ if (!file) {
60
+ console.error(`[hono-svelte] invalid page name: ${name} — use a relative entry name like "blog/post-1" (no ../, no leading /, not "layout")`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ const dest = join(appRoot, file.path);
65
+ if (!isSafeAppPath(appRoot, dest)) {
66
+ console.error(`[hono-svelte] refusing to write outside ${appRoot}: ${file.path}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ if (existsSync(dest)) {
71
+ console.log(`[hono-svelte] exists, skipping: ${file.path}`);
72
+ return;
73
+ }
74
+ mkdirSync(dirname(dest), { recursive: true });
75
+ writeFileSync(dest, file.content);
76
+ const entryName = file.path.replace(/^src\/pages\//, "").replace(/\.svelte$/, "");
77
+ console.log(`[hono-svelte] wrote ${file.path}`);
78
+ console.log(`[hono-svelte] next: render it from a route — c.render("${entryName}")`);
79
+ console.log(`[hono-svelte] (default pagesDir is src/pages; set a custom one via pages({ pagesDir }))`);
80
+ }
36
81
  function doctor() {
37
82
  const packageRoot = packageRootForCli();
38
83
  const issues = doctorChecks({ packageRoot, appRoot });
@@ -53,7 +98,12 @@ if (command === "init")
53
98
  init();
54
99
  else if (command === "doctor")
55
100
  doctor();
101
+ else if (command === "add" && args[1] === "page")
102
+ addPage();
56
103
  else {
57
- console.log("Usage: hono-svelte <init|doctor> [--app=<dir>]");
104
+ console.log("Usage: hono-svelte <init|doctor|add page <name>> [--app=<dir>] [--full]");
105
+ console.log(" init [--full] scaffold a runnable app (minimal by default)");
106
+ console.log(" add page <name> create src/pages/<name>.svelte (static, zero JS)");
107
+ console.log(" doctor [--app=<dir>] check dist, link and vite config");
58
108
  process.exitCode = 1;
59
109
  }
@@ -7,10 +7,10 @@ export type ErrorPageOptions = {
7
7
  head?: string | HeadProps;
8
8
  };
9
9
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
10
- export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => any;
10
+ export declare function notFoundHandler(opts?: ErrorPageOptions): (c: any) => Response | Promise<Response>;
11
11
  export type ServerErrorHandlerOptions = ErrorPageOptions & {
12
12
  /** Log the error. `true` = `console.error`. @default true */
13
13
  log?: boolean | ((err: unknown) => void);
14
14
  };
15
15
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
16
- export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: unknown, c: any) => any;
16
+ export declare function errorHandler(opts?: ServerErrorHandlerOptions): (err: Error, c: any) => Response | Promise<Response>;
package/dist/handlers.js CHANGED
@@ -1,15 +1,33 @@
1
1
  // Conventional error-page handlers: `404.svelte` / `500.svelte` pages
2
2
  // rendered with the matching HTTP status.
3
+ //
4
+ // The context parameter stays `any` ON PURPOSE — validated empirically:
5
+ // typing it with hono types works with a single hono copy (and even with
6
+ // duplicate copies of the same version), but breaks when the consumer's
7
+ // resolution picks a hono copy with a DIFFERENT version than the one this
8
+ // package's d.ts resolves to (file:/npm-link/monorepo setups — e.g.
9
+ // app hono 4.13.7 vs package types 4.13.8 fail on `[GET_MATCH_RESULT]`
10
+ // symbol members). hono's `Context<any, ...>` default is not a supertype of
11
+ // concrete contexts either. `any` keeps the handlers assignable in every
12
+ // copy/version layout; everything else (return type, the render call) is
13
+ // typed via the narrow cast below.
14
+ function entryRender(c) {
15
+ return c.render;
16
+ }
17
+ function pageProps(opts, status) {
18
+ return {
19
+ title: opts.title,
20
+ data: opts.data,
21
+ head: opts.head,
22
+ status,
23
+ };
24
+ }
3
25
  /** Hono `notFound` handler: `app.notFound(notFoundHandler())`. */
4
26
  export function notFoundHandler(opts = {}) {
5
27
  const entry = opts.entry ?? "404";
28
+ // See the file header for why the context param stays `any`.
6
29
  return (c) => {
7
- return c.render(entry, {
8
- title: opts.title,
9
- data: opts.data,
10
- head: opts.head,
11
- status: 404,
12
- });
30
+ return entryRender(c)(entry, pageProps(opts, 404));
13
31
  };
14
32
  }
15
33
  /** Hono `onError` handler: `app.onError(errorHandler())`. */
@@ -21,11 +39,6 @@ export function errorHandler(opts = {}) {
21
39
  console.error(err);
22
40
  else if (typeof log === "function")
23
41
  log(err);
24
- return c.render(entry, {
25
- title: opts.title,
26
- data: opts.data,
27
- head: opts.head,
28
- status: 500,
29
- });
42
+ return entryRender(c)(entry, pageProps(opts, 500));
30
43
  };
31
44
  }
package/dist/index.d.ts CHANGED
@@ -1,22 +1,22 @@
1
1
  import type { Next } from "hono";
2
2
  import { type AssetsResolver, type ViteManifest } from "./assets.js";
3
3
  import { type HeadProps } from "./head.js";
4
- export { getIds } from "./ids.js";
5
- export type { SharedIds } from "./ids.js";
6
- export { renderHead } from "./head.js";
7
- export type { HeadMetaItem, HeadProps } from "./head.js";
8
4
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
9
5
  export type { AssetsResolver, ManifestAssetsOptions, ViteManifest, ViteManifestChunk, } from "./assets.js";
10
- export { notFoundHandler, errorHandler } from "./handlers.js";
6
+ export { errorHandler, notFoundHandler } from "./handlers.js";
11
7
  export type { ErrorPageOptions, ServerErrorHandlerOptions } from "./handlers.js";
12
- export { initFiles, doctorChecks, checkAppLink, versionsMatch, isSafeAppPath } from "./scaffold.js";
13
- export type { DoctorIssue, DoctorOptions, InitFile } from "./scaffold.js";
8
+ export { renderHead } from "./head.js";
9
+ export type { HeadMetaItem, HeadProps } from "./head.js";
10
+ export { getIds } from "./ids.js";
11
+ export type { SharedIds } from "./ids.js";
12
+ export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
13
+ export type { DoctorIssue, DoctorOptions, InitFile, InitFlavor } from "./scaffold.js";
14
14
  export type SsrPageLoader = () => Promise<{
15
15
  default: unknown;
16
16
  }>;
17
- export type RenderProps = {
17
+ export type RenderProps<TData = Record<string, unknown>> = {
18
18
  title?: string;
19
- data?: Record<string, unknown>;
19
+ data?: TData;
20
20
  /** HTTP status of the shell response. @default 200 */
21
21
  status?: number;
22
22
  /** Extra response headers merged into the shell response. */
@@ -69,6 +69,11 @@ export type ShellOptions = {
69
69
  knownEntries?: string[];
70
70
  /** `false` disables unknown-entry validation. @default true */
71
71
  strict?: boolean;
72
+ /**
73
+ * When SSR of a page throws at request time: log the error and fall back
74
+ * to client rendering (entry script) instead of a 500. @default false
75
+ */
76
+ ssrFallback?: boolean;
72
77
  /** Default status when `c.render(entry)` omits it. @default 200 */
73
78
  status?: number;
74
79
  /** Default headers merged into every shell response. */
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@ import { renderHead } from "./head.js";
3
3
  import { getIds } from "./ids.js";
4
4
  import { allEntries as autoAllEntries, ssrLayouts as autoSsrLayouts, ssrPages as autoSsrPages, } from "./ssr-manifest.js";
5
5
  import { devEntryUrl } from "./virtual.js";
6
- export { getIds } from "./ids.js";
7
- export { renderHead } from "./head.js";
8
6
  export { CACHE_IMMUTABLE, CACHE_NO_STORE, createManifestAssets, immutableHeaders, manifestCssFor, manifestImportsFor, manifestKeysFor, } from "./assets.js";
9
- export { notFoundHandler, errorHandler } from "./handlers.js";
10
- export { initFiles, doctorChecks, checkAppLink, versionsMatch, isSafeAppPath } from "./scaffold.js";
7
+ export { errorHandler, notFoundHandler } from "./handlers.js";
8
+ export { renderHead } from "./head.js";
9
+ export { getIds } from "./ids.js";
10
+ export { checkAppLink, doctorChecks, initFiles, isSafeAppPath, isValidPageName, newPageFile } from "./scaffold.js";
11
11
  function escapeAttr(value) {
12
12
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
13
13
  }
@@ -24,7 +24,7 @@ function isValidEntryName(entryName) {
24
24
  function nonceAttr(nonce) {
25
25
  return nonce ? ` nonce="${escapeAttr(nonce)}"` : "";
26
26
  }
27
- function serializePageData(data, dataId, dataLimit) {
27
+ function serializePageData(entryName, data, dataId, dataLimit) {
28
28
  if (!data)
29
29
  return { json: "", html: "" };
30
30
  let json;
@@ -36,7 +36,7 @@ function serializePageData(data, dataId, dataLimit) {
36
36
  }
37
37
  if (json === undefined)
38
38
  return { json: "", html: "" };
39
- checkDataLimit(json, dataLimit);
39
+ checkDataLimit(entryName, json, dataLimit);
40
40
  const safe = json.replace(/</g, "\\u003c");
41
41
  const lt = String.fromCharCode(60);
42
42
  return {
@@ -45,19 +45,21 @@ function serializePageData(data, dataId, dataLimit) {
45
45
  };
46
46
  }
47
47
  const warnedDataLimit = new Set();
48
- function checkDataLimit(json, dataLimit) {
48
+ function checkDataLimit(entryName, json, dataLimit) {
49
49
  if (dataLimit === false)
50
50
  return;
51
51
  const bytes = new TextEncoder().encode(json).length;
52
52
  if (bytes <= dataLimit)
53
53
  return;
54
- const key = String(dataLimit);
54
+ // One warning per (page, limit): a second page over the limit must still
55
+ // be reported (the key used to be just the limit — one warn per process).
56
+ const key = entryName + ":" + String(dataLimit);
55
57
  if (warnedDataLimit.has(key))
56
58
  return;
57
59
  warnedDataLimit.add(key);
58
60
  const kb = (bytes / 1024).toFixed(1);
59
61
  const limitKb = (dataLimit / 1024).toFixed(0);
60
- console.warn(`[hono-svelte] page data is ${kb}KB (limit ${limitKb}KB). ` +
62
+ console.warn(`[hono-svelte] page "${entryName}" data is ${kb}KB (limit ${limitKb}KB). ` +
61
63
  `data is inlined in the HTML — move large/sensitive payloads to a typed API (hc<AppType>).`);
62
64
  }
63
65
  /** Visible for tests. */
@@ -84,7 +86,8 @@ function detectProd() {
84
86
  // plain node / vitest: no import.meta.env — bundled server is prod
85
87
  return true;
86
88
  }
87
- return globalThis.process?.env?.NODE_ENV === "production";
89
+ return (globalThis.process?.env?.NODE_ENV ===
90
+ "production");
88
91
  }
89
92
  function isDev() {
90
93
  try {
@@ -95,7 +98,8 @@ function isDev() {
95
98
  // plain node / vitest
96
99
  return false;
97
100
  }
98
- return globalThis.process?.env?.NODE_ENV !== "production";
101
+ return (globalThis.process?.env?.NODE_ENV !==
102
+ "production");
99
103
  }
100
104
  function defaultKnownEntries(ssrPages) {
101
105
  const keys = Object.keys(ssrPages);
@@ -137,7 +141,7 @@ function prefetchHoverScript(nonce, dataId, urls) {
137
141
  const map = JSON.stringify(Object.fromEntries(urls.map((u) => [u.entry, u.url])));
138
142
  const code = `(function(){var m=${map};` +
139
143
  `function pre(u){if(document.querySelector('link[rel=\"prefetch\"][href=\"'+u+'\"]'))return;` +
140
- `var l=document.createElement('link');l.rel='prefetch';l.href=u;document.head.appendChild(l);}` +
144
+ `var l=document.createElement('link');l.rel='modulepreload';l.href=u;document.head.appendChild(l);}` +
141
145
  `document.addEventListener('mouseover',function(e){var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;` +
142
146
  `if(!a)return;try{var p=new URL(a.href,location.origin).pathname.replace(/^\\//,'');` +
143
147
  `var u=m[p];if(u)pre(u);}catch(_){}},{passive:true});})();`;
@@ -153,6 +157,7 @@ export function shell(options = {}) {
153
157
  const defaultHeaders = options.headers ?? {};
154
158
  const dataLimit = options.dataLimit ?? 102400;
155
159
  const strict = options.strict ?? true;
160
+ const ssrFallback = options.ssrFallback ?? false;
156
161
  const extraStyles = options.styles ?? [];
157
162
  const resolveStyles = typeof options.stylesHref === "function"
158
163
  ? options.stylesHref
@@ -242,7 +247,7 @@ export function shell(options = {}) {
242
247
  const childSnippet = createRawSnippet(() => ({ render: () => `<div>${childHtml}</div>` }));
243
248
  const wrapped = render(layoutMod.default, {
244
249
  ...(pageData !== undefined ? { props: pageData } : {}),
245
- props: { ...(pageData ?? {}), children: childSnippet },
250
+ props: { ...pageData, children: childSnippet },
246
251
  ...(nonce ? { csp: { nonce } } : {}),
247
252
  });
248
253
  html = wrapped.html ?? wrapped.body ?? "";
@@ -252,7 +257,22 @@ export function shell(options = {}) {
252
257
  }
253
258
  return { html, head: headValue };
254
259
  }
255
- async function inner(c, next) {
260
+ // The context param stays `any` ON PURPOSE (see src/handlers.ts header for
261
+ // the full rationale: hono's unique symbols + `Context<any>` not being a
262
+ // supertype of concrete contexts). Everything the shell touches is typed
263
+ // through the casts below — no `any` leaks into the rendered response.
264
+ // The context param stays `any` ON PURPOSE — validated empirically:
265
+ // typing it (MiddlewareHandler / Context / generics) works with a single
266
+ // hono copy and even with duplicate copies of the SAME version, but breaks
267
+ // when the consumer's resolution picks a hono copy with a different version
268
+ // than the one this package's d.ts resolves to (file:/npm-link/monorepo
269
+ // topologies — e.g. app hono 4.13.7 vs package types 4.13.8 fail with
270
+ // `[GET_MATCH_RESULT]`-symbol mismatches). A d.ts free of hono type
271
+ // references is immune to any copy/version layout. The
272
+ // render/setRenderer boundary needs casts regardless (hono's default
273
+ // Renderer only accepts string content), so `any` here costs no real
274
+ // type safety — the casts carry the correctness burden.
275
+ const inner = async (c, next) => {
256
276
  c.setRenderer(async (entryName, props) => {
257
277
  if (!isValidEntryName(entryName)) {
258
278
  throw new Error(`hono-svelte: invalid entryName: ${JSON.stringify(entryName)}`);
@@ -265,7 +285,7 @@ export function shell(options = {}) {
265
285
  const chain = layoutChainFor(entryName)
266
286
  .map((l) => ssrLayouts[l])
267
287
  .filter((l) => l !== undefined);
268
- const isSsr = loader !== undefined && !hasClientEntry(entryName);
288
+ let isSsr = loader !== undefined && !hasClientEntry(entryName);
269
289
  if (strict && !isSsr) {
270
290
  const known = availableEntries();
271
291
  if (known && !known.includes(entryName)) {
@@ -278,14 +298,24 @@ export function shell(options = {}) {
278
298
  const nonce = resolveNonce(c);
279
299
  const nAttr = nonceAttr(nonce);
280
300
  const stylesHref = resolveStyles(isProd) ?? (isProd ? "/static/styles.css" : "/src/styles.css");
281
- const { html: dataHtml } = serializePageData(props?.data, ids.dataId, dataLimit);
301
+ const { html: dataHtml } = serializePageData(entryName, props?.data, ids.dataId, dataLimit);
282
302
  const lt = String.fromCharCode(60);
283
303
  let bodyHtml = "";
284
304
  let ssrHead = "";
285
305
  if (isSsr) {
286
- const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
287
- bodyHtml = rendered.html;
288
- ssrHead = rendered.head;
306
+ try {
307
+ const rendered = await renderSsrBody(loader, props?.data, nonce, chain);
308
+ bodyHtml = rendered.html;
309
+ ssrHead = rendered.head;
310
+ }
311
+ catch (err) {
312
+ if (!ssrFallback)
313
+ throw err;
314
+ console.error(`[hono-svelte] SSR failed for "${entryName}" — falling back to client rendering:`, err);
315
+ bodyHtml = "";
316
+ ssrHead = "";
317
+ isSsr = false;
318
+ }
289
319
  }
290
320
  const resolveUrl = (entry) => isProd ? resolveAssetUrl(assetsOpt, assetsBase, entry) : devEntryUrl(entry);
291
321
  let scriptHtml = "";
@@ -293,7 +323,8 @@ export function shell(options = {}) {
293
323
  let prefetchHtml = "";
294
324
  if (!isSsr) {
295
325
  const src = resolveUrl(entryName);
296
- scriptHtml = lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
326
+ scriptHtml =
327
+ lt + `script type="module" src="${escapeAttr(src)}"${nAttr}>` + lt + "/script>";
297
328
  const shouldPreload = options.preload ?? isProd;
298
329
  const manifest = isProd ? manifestFor(assetsOpt) : undefined;
299
330
  if (shouldPreload) {
@@ -312,7 +343,7 @@ export function shell(options = {}) {
312
343
  const known = availableEntries();
313
344
  if (options.prefetch === "all" && known) {
314
345
  for (const u of prefetchUrls("all", entryName, known, resolveUrl)) {
315
- prefetchHtml += lt + `link rel="prefetch" href="${escapeAttr(u)}"${nAttr} />`;
346
+ prefetchHtml += lt + `link rel="modulepreload" href="${escapeAttr(u)}"${nAttr} />`;
316
347
  }
317
348
  }
318
349
  else if (options.prefetch === "hover" && known) {
@@ -322,7 +353,7 @@ export function shell(options = {}) {
322
353
  }
323
354
  }
324
355
  const status = props?.status ?? defaultStatus;
325
- const headers = { ...defaultHeaders, ...(props?.headers ?? {}) };
356
+ const headers = { ...defaultHeaders, ...props?.headers };
326
357
  const pageHead = props?.head !== undefined ? renderHead(props.head) : "";
327
358
  const manifestNow = isProd ? manifestFor(assetsOpt) : undefined;
328
359
  const cssLinks = (manifestNow ? manifestCssFor(manifestNow, entryName) : [])
@@ -342,16 +373,13 @@ export function shell(options = {}) {
342
373
  prefetchHtml +
343
374
  scriptHtml +
344
375
  `</head>` +
345
- `<body class="bg-base-200 min-h-screen text-base-content"><div id="${ids.rootId}">${bodyHtml}</div>` +
376
+ `<body><div id="${ids.rootId}">${bodyHtml}</div>` +
346
377
  dataHtml +
347
378
  `</body></html>`, status, headers);
348
379
  });
349
380
  await next();
350
- }
351
- async function shellMiddleware(c, next) {
352
- return inner(c, next);
353
- }
354
- const mw = shellMiddleware;
381
+ };
382
+ const mw = inner;
355
383
  mw.availableEntries = availableEntries;
356
384
  return mw;
357
385
  }
@@ -0,0 +1,7 @@
1
+ export type PageDataExtraction = {
2
+ /** Declaration text (`type Data = ...` / `interface Data { ... }`, `export` stripped). Null when the page declares no `Data`. */
3
+ declaration: string | null;
4
+ /** Imported identifiers referenced by the declaration (unusable for the dts). */
5
+ importedRefs: string[];
6
+ };
7
+ export declare function extractPageDataType(source: string): PageDataExtraction;