hono-preact 0.1.0 → 0.2.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 (88) hide show
  1. package/README.md +2 -1
  2. package/dist/adapter-cloudflare.d.ts +1 -0
  3. package/dist/adapter-cloudflare.d.ts.map +1 -0
  4. package/dist/adapter-cloudflare.js +2 -0
  5. package/dist/adapter-node.d.ts +1 -0
  6. package/dist/adapter-node.d.ts.map +1 -0
  7. package/dist/adapter-node.js +2 -0
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/internal.js +1 -1
  10. package/dist/iso/action.d.ts +10 -14
  11. package/dist/iso/action.js +57 -21
  12. package/dist/iso/define-app.d.ts +7 -0
  13. package/dist/iso/define-app.js +3 -0
  14. package/dist/iso/define-loader.d.ts +19 -0
  15. package/dist/iso/define-loader.js +4 -0
  16. package/dist/iso/define-middleware.d.ts +43 -0
  17. package/dist/iso/define-middleware.js +6 -0
  18. package/dist/iso/define-page.d.ts +7 -2
  19. package/dist/iso/define-page.js +1 -1
  20. package/dist/iso/define-routes.d.ts +24 -1
  21. package/dist/iso/define-routes.js +34 -0
  22. package/dist/iso/define-stream-observer.d.ts +20 -0
  23. package/dist/iso/define-stream-observer.js +3 -0
  24. package/dist/iso/index.d.ts +10 -5
  25. package/dist/iso/index.js +5 -3
  26. package/dist/iso/internal/contexts.d.ts +0 -2
  27. package/dist/iso/internal/contexts.js +0 -1
  28. package/dist/iso/internal/loader-fetch.js +37 -7
  29. package/dist/iso/internal/loader-runner.js +105 -8
  30. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  31. package/dist/iso/internal/middleware-runner.js +79 -0
  32. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  33. package/dist/iso/internal/page-middleware-host.js +119 -0
  34. package/dist/iso/internal/route-boundary.d.ts +1 -0
  35. package/dist/iso/internal/route-boundary.js +16 -0
  36. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  37. package/dist/iso/internal/stream-observer-runner.js +48 -0
  38. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  39. package/dist/iso/internal/use-partitioner.js +11 -0
  40. package/dist/iso/internal/use-types.d.ts +7 -0
  41. package/dist/iso/internal/use-types.js +1 -0
  42. package/dist/iso/internal.d.ts +5 -4
  43. package/dist/iso/internal.js +8 -6
  44. package/dist/iso/outcomes.d.ts +38 -0
  45. package/dist/iso/outcomes.js +56 -0
  46. package/dist/iso/page-only.d.ts +5 -0
  47. package/dist/iso/page-only.js +20 -0
  48. package/dist/iso/page.d.ts +3 -3
  49. package/dist/iso/page.js +3 -3
  50. package/dist/page.d.ts +1 -0
  51. package/dist/page.d.ts.map +1 -0
  52. package/dist/page.js +8 -0
  53. package/dist/server/actions-handler.d.ts +20 -6
  54. package/dist/server/actions-handler.js +83 -47
  55. package/dist/server/context.js +1 -1
  56. package/dist/server/index.d.ts +1 -1
  57. package/dist/server/index.js +1 -1
  58. package/dist/server/loaders-handler.d.ts +16 -0
  59. package/dist/server/loaders-handler.js +94 -17
  60. package/dist/server/render.d.ts +2 -0
  61. package/dist/server/render.js +104 -33
  62. package/dist/server/route-server-modules.d.ts +42 -1
  63. package/dist/server/route-server-modules.js +184 -0
  64. package/dist/server/sse.d.ts +24 -1
  65. package/dist/server/sse.js +56 -4
  66. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  67. package/dist/vite/adapter-cloudflare.js +25 -0
  68. package/dist/vite/adapter-node.d.ts +2 -0
  69. package/dist/vite/adapter-node.js +49 -0
  70. package/dist/vite/adapter.d.ts +29 -0
  71. package/dist/vite/adapter.js +1 -0
  72. package/dist/vite/client-shim.js +5 -4
  73. package/dist/vite/guard-strip.js +52 -27
  74. package/dist/vite/hono-preact.d.ts +6 -6
  75. package/dist/vite/hono-preact.js +48 -77
  76. package/dist/vite/index.d.ts +2 -1
  77. package/dist/vite/index.js +1 -1
  78. package/dist/vite/node-dev-server.d.ts +4 -0
  79. package/dist/vite/node-dev-server.js +121 -0
  80. package/dist/vite/server-entry.d.ts +30 -7
  81. package/dist/vite/server-entry.js +161 -78
  82. package/dist/vite/server-exports-contract.d.ts +6 -0
  83. package/dist/vite/server-exports-contract.js +43 -0
  84. package/dist/vite/server-loader-validation.js +36 -9
  85. package/dist/vite/server-loaders-parser.d.ts +17 -1
  86. package/dist/vite/server-loaders-parser.js +41 -0
  87. package/dist/vite/server-only.js +20 -2
  88. package/package.json +32 -4
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "preact/jsx-runtime";
2
2
  import { createDispatcher, HoofdProvider } from 'hoofd/preact';
3
3
  import { prerender, locationStub } from 'preact-iso/prerender';
4
- import { GuardRedirect, env } from '../iso/index.js';
5
- import { HonoRequestContext, runRequestScope, captureRequestScope, takeServerStreamingLoaders, } from '../iso/internal/index.js';
4
+ import { env, isOutcome, } from '../iso/index.js';
5
+ import { HonoRequestContext, runRequestScope, captureRequestScope, takeServerStreamingLoaders, dispatchServer, partitionUse, } from '../iso/internal.js';
6
6
  function escapeHtml(str) {
7
7
  return str
8
8
  .replace(/&/g, '&')
@@ -24,6 +24,27 @@ function toAttrs(obj) {
24
24
  .map(([k, v]) => `${k}="${escapeHtml(String(v))}"`)
25
25
  .join(' ');
26
26
  }
27
+ // Outcome translation for the root chain dispatched around prerender. The
28
+ // root layer (appConfig.use) only legitimately produces `redirect` or
29
+ // `deny`; a `render` outcome is page-scope and must not flow through here.
30
+ // Defense-in-depth: surface programmer error as a 500 rather than crash.
31
+ function translateRootOutcome(c, outcome) {
32
+ if (outcome.__outcome === 'redirect') {
33
+ if (outcome.headers) {
34
+ for (const [k, v] of Object.entries(outcome.headers))
35
+ c.header(k, v);
36
+ }
37
+ return c.redirect(outcome.to, outcome.status);
38
+ }
39
+ if (outcome.__outcome === 'deny') {
40
+ if (outcome.headers) {
41
+ for (const [k, v] of Object.entries(outcome.headers))
42
+ c.header(k, v);
43
+ }
44
+ return c.text(outcome.message ?? 'Forbidden', outcome.status);
45
+ }
46
+ return c.text('render outcome is page-scope only and cannot be issued by root middleware', 500);
47
+ }
27
48
  export async function renderPage(c, node, options) {
28
49
  const dispatcher = createDispatcher();
29
50
  const previousEnv = env.current;
@@ -37,33 +58,68 @@ export async function renderPage(c, node, options) {
37
58
  // binder restores per-request isolation for `getRequestStore` /
38
59
  // `getRequestHonoContext` reads from generator continuations.
39
60
  let bindRequestScope = (fn) => fn();
61
+ let rootResult;
40
62
  try {
41
- const result = await runRequestScope(async () => {
42
- // preact-iso's `LocationProvider` reads `globalThis.location` once,
43
- // synchronously, when it mounts. Set it on the same microtask as the
44
- // `prerender` call so no other request can interleave and trample
45
- // the global between us writing it and the provider reading it.
46
- // Children resume from reducer state, never re-reading the global,
47
- // so the rest of this render is safe even if another request resets
48
- // `globalThis.location` while we await suspended children.
63
+ rootResult = await runRequestScope(async () => {
49
64
  const reqUrl = new URL(c.req.url);
50
- locationStub(reqUrl.pathname + reqUrl.search);
51
- bindRequestScope = captureRequestScope();
52
- const rendered = await prerender(_jsx(HonoRequestContext.Provider, { value: { context: c }, children: _jsx(HoofdProvider, { value: dispatcher, children: node }) }));
53
- const loaders = takeServerStreamingLoaders();
54
- return { html: rendered.html, streamingLoaders: loaders };
65
+ const location = {
66
+ path: reqUrl.pathname,
67
+ searchParams: Object.fromEntries(reqUrl.searchParams),
68
+ // Path params are route-match output; the root layer runs before
69
+ // route matching, so they're empty here. Page-layer middleware
70
+ // (added in a follow-up) will have them populated.
71
+ pathParams: {},
72
+ };
73
+ const rootUse = options?.appConfig?.use ?? [];
74
+ const serverMw = partitionUse(rootUse).middleware.filter((m) => m.runs === 'server');
75
+ const ctx = {
76
+ scope: 'page',
77
+ c,
78
+ signal: c.req.raw.signal,
79
+ location,
80
+ };
81
+ const dispatch = await dispatchServer({
82
+ middleware: serverMw,
83
+ ctx,
84
+ inner: async () => {
85
+ // preact-iso's `LocationProvider` reads `globalThis.location`
86
+ // once, synchronously, when it mounts. Set it on the same
87
+ // microtask as the `prerender` call so no other request can
88
+ // interleave and trample the global between us writing it and
89
+ // the provider reading it. Children resume from reducer state,
90
+ // never re-reading the global, so the rest of this render is
91
+ // safe even if another request resets `globalThis.location`
92
+ // while we await suspended children.
93
+ locationStub(reqUrl.pathname + reqUrl.search);
94
+ bindRequestScope = captureRequestScope();
95
+ const rendered = await prerender(_jsx(HonoRequestContext.Provider, { value: { context: c }, children: _jsx(HoofdProvider, { value: dispatcher, children: node }) }));
96
+ const loaders = takeServerStreamingLoaders();
97
+ return {
98
+ kind: 'value',
99
+ html: rendered.html,
100
+ streamingLoaders: loaders,
101
+ };
102
+ },
103
+ });
104
+ if (dispatch.kind === 'outcome') {
105
+ return { kind: 'outcome', outcome: dispatch.outcome };
106
+ }
107
+ return dispatch.value;
55
108
  }, { honoContext: c });
56
- html = result.html;
57
- streamingLoaders = result.streamingLoaders;
58
109
  }
59
110
  catch (e) {
60
- if (e instanceof GuardRedirect)
61
- return c.redirect(e.location);
111
+ if (isOutcome(e))
112
+ return translateRootOutcome(c, e);
62
113
  throw e;
63
114
  }
64
115
  finally {
65
116
  env.current = previousEnv;
66
117
  }
118
+ if (rootResult.kind === 'outcome') {
119
+ return translateRootOutcome(c, rootResult.outcome);
120
+ }
121
+ html = rootResult.html;
122
+ streamingLoaders = rootResult.streamingLoaders;
67
123
  const { title, lang, metas = [], links = [] } = dispatcher.toStatic();
68
124
  // Only inject a <title> when hoofd produced one or the caller provided a
69
125
  // defaultTitle. Layouts that render their own static <title> (via <Head>)
@@ -137,6 +193,16 @@ export async function renderPage(c, node, options) {
137
193
  if (aborted)
138
194
  return;
139
195
  controller.enqueue(encoder.encode(`<!doctype html>${beforeBody}\n${bootstrap}\n`));
196
+ // Yield one microtask before advancing any loader generator past
197
+ // its first yield. `renderPage` is still on the synchronous frame
198
+ // that constructs this response (`new ReadableStream(...)` returns,
199
+ // then `c.body(...)` runs and commits the headers). Resuming a
200
+ // generator can call `setCookie(ctx.c, ...)`, which mutates Hono's
201
+ // prepared headers; deferring the pump guarantees the response is
202
+ // built first, so post-first-yield header writes are consistently
203
+ // excluded rather than racing construction. Cookies must be set
204
+ // before the loader's first yield to reach the streamed response.
205
+ await Promise.resolve();
140
206
  // Drive each pending generator in parallel; emit script tags per chunk.
141
207
  await Promise.all(streamingLoaders.map(async ({ loaderId, gen }) => {
142
208
  try {
@@ -185,19 +251,24 @@ export async function renderPage(c, node, options) {
185
251
  });
186
252
  }
187
253
  });
188
- return new Response(responseStream, {
189
- status: 200,
190
- headers: {
191
- 'Content-Type': 'text/html; charset=utf-8',
192
- 'Transfer-Encoding': 'chunked',
193
- // Prevent buffering / transformation by intermediate proxies. nginx
194
- // honors `X-Accel-Buffering: no` to flush per chunk; `no-transform`
195
- // stops middleboxes from rebuffering or gzipping the stream as a
196
- // single response. We deliberately do NOT add `no-store`: streamed
197
- // HTML can still be legitimately cacheable, and users can override
198
- // via their own middleware.
199
- 'X-Accel-Buffering': 'no',
200
- 'Cache-Control': 'no-transform',
201
- },
254
+ // Route through `c.body()` rather than `new Response(...)` so Hono merges
255
+ // its prepared headers into the streamed response. A streaming loader's
256
+ // body runs up to its first `yield` during prerender, so a `Set-Cookie`
257
+ // written via `ctx.c` before that yield is sitting in Hono's prepared
258
+ // headers by now; constructing the Response directly would drop it. The
259
+ // non-streaming branch above gets this for free via `c.html()`. Cookies
260
+ // written after a yield run in the pump below, once headers are already
261
+ // sent, and are unavoidably lost.
262
+ return c.body(responseStream, 200, {
263
+ 'Content-Type': 'text/html; charset=utf-8',
264
+ 'Transfer-Encoding': 'chunked',
265
+ // Prevent buffering / transformation by intermediate proxies. nginx
266
+ // honors `X-Accel-Buffering: no` to flush per chunk; `no-transform`
267
+ // stops middleboxes from rebuffering or gzipping the stream as a
268
+ // single response. We deliberately do NOT add `no-store`: streamed
269
+ // HTML can still be legitimately cacheable, and users can override
270
+ // via their own middleware.
271
+ 'X-Accel-Buffering': 'no',
272
+ 'Cache-Control': 'no-transform',
202
273
  });
203
274
  }
@@ -1,4 +1,4 @@
1
- import type { RoutesManifest } from '../iso/index';
1
+ import type { RoutesManifest, ServerRoute } from '../iso/index';
2
2
  /**
3
3
  * Convert a RoutesManifest into the array of lazy server-module loaders
4
4
  * that loadersHandler / actionsHandler accept. Previously returned a record
@@ -10,3 +10,44 @@ import type { RoutesManifest } from '../iso/index';
10
10
  * entry.
11
11
  */
12
12
  export declare function routeServerModules(manifest: RoutesManifest): ReadonlyArray<() => Promise<unknown>>;
13
+ /**
14
+ * Build the two page-layer `use` resolvers wired into loadersHandler and
15
+ * actionsHandler. The loader handler matches by the location's URL path;
16
+ * the action handler matches by the action's owning module key. Both
17
+ * lookups share one underlying composed map populated by loading every
18
+ * routed `.server.*` module exactly once (then caching the result).
19
+ *
20
+ * Ancestor composition: each ServerRoute carries an explicit list of
21
+ * ancestor server thunks captured during the route-tree walk. The
22
+ * resolver loads each ancestor's `pageUse` (if any) and concatenates them
23
+ * outer-first, with the route's own pageUse appended last. So a layout
24
+ * group's pageUse runs before each nested leaf's pageUse without the user
25
+ * having to repeat the import in every leaf .server.*. Order matches the
26
+ * middleware dispatcher's outer -> inner contract: app -> outermost
27
+ * layout -> ... -> leaf -> per-unit.
28
+ *
29
+ * Why route-tree ancestry (not URL-prefix ancestry): two routes can share
30
+ * a URL prefix without being parent/child in the tree. For example,
31
+ * `/demo/projects` and `/demo/projects/:projectId/issues/:issueId` are
32
+ * siblings of the `/demo` layout group; the latter is NOT a descendant of
33
+ * the former. URL-prefix matching incorrectly conflates them and runs the
34
+ * shared gate twice on every nested request.
35
+ *
36
+ * Lazy semantics: the first call to either resolver triggers the build of
37
+ * all server modules listed in `serverRoutes`. Subsequent calls return
38
+ * from the cached map. A failed build is not cached -- the next call
39
+ * retries -- so a transient import error doesn't permanently poison the
40
+ * resolver. Modules that don't export `pageUse` (the common case today)
41
+ * contribute nothing to the composed arrays. When `dev` is true the cache
42
+ * is bypassed on every call so editing a `.server.*` file's `pageUse`
43
+ * takes effect without restarting the server.
44
+ *
45
+ * NOTE: framework-private. The only intended consumer outside tests is
46
+ * the generated server entry. Reach for it at your own risk.
47
+ */
48
+ export declare function makePageUseResolvers(serverRoutes: ReadonlyArray<ServerRoute>, options?: {
49
+ dev?: boolean;
50
+ }): {
51
+ byPath: (path: string) => Promise<ReadonlyArray<unknown>>;
52
+ byModuleKey: (key: string) => Promise<ReadonlyArray<unknown>>;
53
+ };
@@ -11,3 +11,187 @@
11
11
  export function routeServerModules(manifest) {
12
12
  return manifest.serverImports;
13
13
  }
14
+ function segmentsOf(path) {
15
+ return path.split('/').filter((s) => s !== '');
16
+ }
17
+ /**
18
+ * True when `urlPath` (the concrete URL the user navigated to, with all
19
+ * params substituted) matches `pattern` exactly: same segment count, and
20
+ * each pattern segment either equals the URL segment, is a `:param`, or is
21
+ * a trailing `*`.
22
+ *
23
+ * Used at lookup time. `byPath` resolves the URL to the most specific
24
+ * pattern in the map and returns its already-composed pageUse.
25
+ */
26
+ function urlPathMatchesPattern(urlPath, pattern) {
27
+ const ps = segmentsOf(pattern);
28
+ const us = segmentsOf(urlPath);
29
+ for (let i = 0; i < ps.length; i++) {
30
+ const p = ps[i];
31
+ if (p === '*')
32
+ return true;
33
+ if (i >= us.length)
34
+ return false;
35
+ if (p.startsWith(':'))
36
+ continue;
37
+ if (p !== us[i])
38
+ return false;
39
+ }
40
+ return ps.length === us.length;
41
+ }
42
+ /**
43
+ * Score a route pattern for tiebreaker purposes when multiple patterns at
44
+ * the same segment depth match the URL. Mirrors preact-iso's runtime
45
+ * preference for literal segments: literal=2, param=1, wildcard=0. Within
46
+ * the same score, the caller falls back to depth, and within the same
47
+ * depth, to the loaded order in `serverRoutes`. Pre-merged literal wins
48
+ * over `/admin/users/:id` when the URL is `/admin/users/me`.
49
+ */
50
+ function patternScore(pattern) {
51
+ let score = 0;
52
+ for (const seg of segmentsOf(pattern)) {
53
+ if (seg === '*')
54
+ score += 0;
55
+ else if (seg.startsWith(':'))
56
+ score += 1;
57
+ else
58
+ score += 2;
59
+ }
60
+ return score;
61
+ }
62
+ function pageUseFromMod(mod, patternPath) {
63
+ if (mod.pageUse === undefined || mod.pageUse === null)
64
+ return [];
65
+ if (Array.isArray(mod.pageUse))
66
+ return mod.pageUse;
67
+ // Runtime guard for non-array pageUse: surface a descriptive error so
68
+ // the user finds the typo (`pageUse = mySingleMw` instead of `[mySingleMw]`)
69
+ // immediately rather than experiencing a silent gate failure. The
70
+ // build-time plugin should catch this first; this is the runtime backstop.
71
+ throw new Error(`Route '${patternPath}' exports a non-array \`pageUse\`. ` +
72
+ `pageUse must be an array (typically a reference to a const declared as \`[mw1, mw2]\`). ` +
73
+ `Wrap a single middleware in brackets: pageUse = [myMiddleware].`);
74
+ }
75
+ /**
76
+ * Build the two page-layer `use` resolvers wired into loadersHandler and
77
+ * actionsHandler. The loader handler matches by the location's URL path;
78
+ * the action handler matches by the action's owning module key. Both
79
+ * lookups share one underlying composed map populated by loading every
80
+ * routed `.server.*` module exactly once (then caching the result).
81
+ *
82
+ * Ancestor composition: each ServerRoute carries an explicit list of
83
+ * ancestor server thunks captured during the route-tree walk. The
84
+ * resolver loads each ancestor's `pageUse` (if any) and concatenates them
85
+ * outer-first, with the route's own pageUse appended last. So a layout
86
+ * group's pageUse runs before each nested leaf's pageUse without the user
87
+ * having to repeat the import in every leaf .server.*. Order matches the
88
+ * middleware dispatcher's outer -> inner contract: app -> outermost
89
+ * layout -> ... -> leaf -> per-unit.
90
+ *
91
+ * Why route-tree ancestry (not URL-prefix ancestry): two routes can share
92
+ * a URL prefix without being parent/child in the tree. For example,
93
+ * `/demo/projects` and `/demo/projects/:projectId/issues/:issueId` are
94
+ * siblings of the `/demo` layout group; the latter is NOT a descendant of
95
+ * the former. URL-prefix matching incorrectly conflates them and runs the
96
+ * shared gate twice on every nested request.
97
+ *
98
+ * Lazy semantics: the first call to either resolver triggers the build of
99
+ * all server modules listed in `serverRoutes`. Subsequent calls return
100
+ * from the cached map. A failed build is not cached -- the next call
101
+ * retries -- so a transient import error doesn't permanently poison the
102
+ * resolver. Modules that don't export `pageUse` (the common case today)
103
+ * contribute nothing to the composed arrays. When `dev` is true the cache
104
+ * is bypassed on every call so editing a `.server.*` file's `pageUse`
105
+ * takes effect without restarting the server.
106
+ *
107
+ * NOTE: framework-private. The only intended consumer outside tests is
108
+ * the generated server entry. Reach for it at your own risk.
109
+ */
110
+ export function makePageUseResolvers(serverRoutes, options = {}) {
111
+ const dev = options.dev ?? false;
112
+ let buildPromise = null;
113
+ const build = async () => {
114
+ // Load every distinct server thunk exactly once. A given thunk may
115
+ // appear as `server` on one ServerRoute and as an `ancestor` on
116
+ // descendants; calling it just once keeps module-init side effects
117
+ // (e.g. logging, registry insertion) idempotent.
118
+ const thunkCache = new Map();
119
+ const load = (thunk) => {
120
+ let p = thunkCache.get(thunk);
121
+ if (!p) {
122
+ p = thunk().then((mod) => mod);
123
+ thunkCache.set(thunk, p);
124
+ }
125
+ return p;
126
+ };
127
+ const composedByPath = new Map();
128
+ const patternByModuleKey = new Map();
129
+ await Promise.all(serverRoutes.map(async (route) => {
130
+ const ancestorMods = await Promise.all(route.ancestors.map((t) => load(t)));
131
+ const selfMod = await load(route.server);
132
+ const composed = [];
133
+ for (let i = 0; i < ancestorMods.length; i++) {
134
+ composed.push(...pageUseFromMod(ancestorMods[i], route.path));
135
+ }
136
+ composed.push(...pageUseFromMod(selfMod, route.path));
137
+ // Two ServerRoutes sharing the same path mean two `.server.*` files
138
+ // claim the same route -- a route-table error. The route validator
139
+ // is the right place to surface that; here we simply preserve the
140
+ // load order (last write wins for the composed map, which matches
141
+ // the previous behavior of `composedByPath.set(path, ...)`).
142
+ composedByPath.set(route.path, composed);
143
+ if (typeof selfMod.__moduleKey === 'string') {
144
+ patternByModuleKey.set(selfMod.__moduleKey, route.path);
145
+ }
146
+ }));
147
+ return { composedByPath, patternByModuleKey };
148
+ };
149
+ const get = () => {
150
+ if (dev) {
151
+ // In dev, always rebuild so edits to `pageUse` in any .server.* file
152
+ // take effect on the next request without restarting the process.
153
+ return build();
154
+ }
155
+ if (buildPromise)
156
+ return buildPromise;
157
+ buildPromise = build().catch((err) => {
158
+ buildPromise = null;
159
+ return Promise.reject(err);
160
+ });
161
+ return buildPromise;
162
+ };
163
+ return {
164
+ async byPath(path) {
165
+ const { composedByPath } = await get();
166
+ // The handler receives the matched route's URL path (params
167
+ // substituted to literal values). Walk the composed map and pick the
168
+ // best-matching pattern. Tiebreaker: (1) higher specificity score
169
+ // (literal=2, param=1, wildcard=0); (2) within same score, longer
170
+ // path; (3) within same length, first inserted. Mirrors preact-iso's
171
+ // runtime preference for literal matches over parameterized siblings.
172
+ //
173
+ // NOTE: O(routes) linear scan. Fine for small apps; a precomputed
174
+ // trie or a request-keyed memo would help at scale.
175
+ let bestPattern = null;
176
+ let bestScore = -1;
177
+ let bestDepth = -1;
178
+ for (const pattern of composedByPath.keys()) {
179
+ if (!urlPathMatchesPattern(path, pattern))
180
+ continue;
181
+ const score = patternScore(pattern);
182
+ const depth = segmentsOf(pattern).length;
183
+ if (score > bestScore || (score === bestScore && depth > bestDepth)) {
184
+ bestPattern = pattern;
185
+ bestScore = score;
186
+ bestDepth = depth;
187
+ }
188
+ }
189
+ return bestPattern ? (composedByPath.get(bestPattern) ?? []) : [];
190
+ },
191
+ async byModuleKey(key) {
192
+ const { composedByPath, patternByModuleKey } = await get();
193
+ const pattern = patternByModuleKey.get(key);
194
+ return pattern ? (composedByPath.get(pattern) ?? []) : [];
195
+ },
196
+ };
197
+ }
@@ -1,7 +1,19 @@
1
1
  import type { Context } from 'hono';
2
+ import type { StreamObserver, ServerStreamCtx } from '../iso/index';
2
3
  export type SseGeneratorOptions = {
3
4
  /** When true, the generator's return value is emitted as `event: result`. */
4
5
  emitResult?: boolean;
6
+ /**
7
+ * Stream observers harvested from the loader/action's `use` array (the
8
+ * non-middleware partition). The SSE pump fires `onStart` before the
9
+ * first chunk, `onChunk` per yielded value, `onEnd` on clean completion,
10
+ * `onError` on throw, and `onAbort` when the response stream is aborted
11
+ * (typically because the client disconnected). Hooks are isolated: a
12
+ * throwing observer never corrupts the stream.
13
+ */
14
+ observers?: ReadonlyArray<StreamObserver<unknown, never>>;
15
+ /** Server-stream ctx threaded to each observer hook. */
16
+ observerCtx?: ServerStreamCtx;
5
17
  };
6
18
  /**
7
19
  * Wrap an async generator as an SSE response.
@@ -12,11 +24,22 @@ export type SseGeneratorOptions = {
12
24
  * If the generator throws, an `event: error\ndata: {"message","name"}` frame
13
25
  * is written and the stream closes cleanly (Hono's default error handler is
14
26
  * never invoked because we catch inside the callback).
27
+ *
28
+ * When `observers` is provided, the pump fires the corresponding lifecycle
29
+ * hooks (`onStart` / `onChunk` / `onEnd` / `onError` / `onAbort`) so
30
+ * users can attach instrumentation via `defineStreamObserver(...)`.
15
31
  */
16
32
  export declare function sseGeneratorResponse(c: Context, gen: AsyncGenerator<unknown, unknown, unknown>, options?: SseGeneratorOptions): Response;
17
33
  /**
18
34
  * Wrap a ReadableStream<T> (with T a JSON-encodable value) as an SSE response.
19
35
  * Each enqueued chunk is JSON-encoded and written as a `data:` event.
36
+ *
37
+ * Observer fanout mirrors `sseGeneratorResponse`: `onStart` fires before the
38
+ * first read, `onChunk` per chunk, `onEnd` on normal completion, `onError` on
39
+ * throw, `onAbort` when the response stream is aborted.
20
40
  */
21
- export declare function sseReadableStreamResponse(c: Context, source: ReadableStream<unknown>): Response;
41
+ export declare function sseReadableStreamResponse(c: Context, source: ReadableStream<unknown>, options?: {
42
+ observers?: ReadonlyArray<StreamObserver<unknown, never>>;
43
+ observerCtx?: ServerStreamCtx;
44
+ }): Response;
22
45
  export declare function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown>;
@@ -1,4 +1,5 @@
1
1
  import { streamSSE } from 'hono/streaming';
2
+ import { fanStart, fanChunk, fanEnd, fanError, fanAbort, } from '../iso/internal.js';
2
3
  function encodeErrorPayload(err) {
3
4
  const message = err instanceof Error ? err.message : String(err);
4
5
  const name = err instanceof Error ? err.name : 'Error';
@@ -13,10 +14,21 @@ function encodeErrorPayload(err) {
13
14
  * If the generator throws, an `event: error\ndata: {"message","name"}` frame
14
15
  * is written and the stream closes cleanly (Hono's default error handler is
15
16
  * never invoked because we catch inside the callback).
17
+ *
18
+ * When `observers` is provided, the pump fires the corresponding lifecycle
19
+ * hooks (`onStart` / `onChunk` / `onEnd` / `onError` / `onAbort`) so
20
+ * users can attach instrumentation via `defineStreamObserver(...)`.
16
21
  */
17
22
  export function sseGeneratorResponse(c, gen, options = {}) {
18
- const { emitResult = false } = options;
23
+ const { emitResult = false, observers, observerCtx } = options;
24
+ const obs = observers ?? [];
19
25
  return streamSSE(c, async (stream) => {
26
+ let chunks = 0;
27
+ let started = false;
28
+ if (obs.length > 0 && observerCtx) {
29
+ fanStart(obs, observerCtx);
30
+ started = true;
31
+ }
20
32
  try {
21
33
  while (!stream.aborted) {
22
34
  const step = await gen.next();
@@ -27,19 +39,33 @@ export function sseGeneratorResponse(c, gen, options = {}) {
27
39
  data: JSON.stringify(step.value),
28
40
  });
29
41
  }
42
+ if (started && observerCtx) {
43
+ fanEnd(obs, observerCtx, { chunks, result: step.value });
44
+ }
30
45
  return;
31
46
  }
32
47
  await stream.writeSSE({ data: JSON.stringify(step.value) });
48
+ if (started && observerCtx) {
49
+ fanChunk(obs, observerCtx, step.value, chunks);
50
+ }
51
+ chunks += 1;
33
52
  }
34
- // Aborted; release the generator cleanly.
53
+ // Loop exited because the response stream was aborted (typically a
54
+ // client disconnect). Release the generator and notify observers.
35
55
  await gen.return(undefined).catch(() => {
36
56
  /* swallow */
37
57
  });
58
+ if (started && observerCtx) {
59
+ fanAbort(obs, observerCtx, { chunks });
60
+ }
38
61
  }
39
62
  catch (err) {
40
63
  await gen.return(undefined).catch(() => {
41
64
  /* swallow */
42
65
  });
66
+ if (started && observerCtx) {
67
+ fanError(obs, observerCtx, err, { chunks });
68
+ }
43
69
  await stream.writeSSE({
44
70
  event: 'error',
45
71
  data: encodeErrorPayload(err),
@@ -50,19 +76,45 @@ export function sseGeneratorResponse(c, gen, options = {}) {
50
76
  /**
51
77
  * Wrap a ReadableStream<T> (with T a JSON-encodable value) as an SSE response.
52
78
  * Each enqueued chunk is JSON-encoded and written as a `data:` event.
79
+ *
80
+ * Observer fanout mirrors `sseGeneratorResponse`: `onStart` fires before the
81
+ * first read, `onChunk` per chunk, `onEnd` on normal completion, `onError` on
82
+ * throw, `onAbort` when the response stream is aborted.
53
83
  */
54
- export function sseReadableStreamResponse(c, source) {
84
+ export function sseReadableStreamResponse(c, source, options = {}) {
85
+ const { observers, observerCtx } = options;
86
+ const obs = observers ?? [];
55
87
  return streamSSE(c, async (stream) => {
56
88
  const reader = source.getReader();
89
+ let chunks = 0;
90
+ let started = false;
91
+ if (obs.length > 0 && observerCtx) {
92
+ fanStart(obs, observerCtx);
93
+ started = true;
94
+ }
57
95
  try {
58
96
  while (!stream.aborted) {
59
97
  const { done, value } = await reader.read();
60
- if (done)
98
+ if (done) {
99
+ if (started && observerCtx) {
100
+ fanEnd(obs, observerCtx, { chunks, result: undefined });
101
+ }
61
102
  return;
103
+ }
62
104
  await stream.writeSSE({ data: JSON.stringify(value) });
105
+ if (started && observerCtx) {
106
+ fanChunk(obs, observerCtx, value, chunks);
107
+ }
108
+ chunks += 1;
109
+ }
110
+ if (started && observerCtx) {
111
+ fanAbort(obs, observerCtx, { chunks });
63
112
  }
64
113
  }
65
114
  catch (err) {
115
+ if (started && observerCtx) {
116
+ fanError(obs, observerCtx, err, { chunks });
117
+ }
66
118
  await stream.writeSSE({
67
119
  event: 'error',
68
120
  data: encodeErrorPayload(err),
@@ -0,0 +1,2 @@
1
+ import type { HonoPreactAdapter } from './adapter.js';
2
+ export declare function cloudflareAdapter(): HonoPreactAdapter;
@@ -0,0 +1,25 @@
1
+ // packages/vite/src/adapter-cloudflare.ts
2
+ //
3
+ // Standalone module. NOT re-exported by index.ts: importing `hono-preact/vite`
4
+ // must never pull in `@cloudflare/vite-plugin`. Only importing
5
+ // `hono-preact/adapter-cloudflare` loads this file.
6
+ import { cloudflare } from '@cloudflare/vite-plugin';
7
+ export function cloudflareAdapter() {
8
+ return {
9
+ name: 'cloudflare',
10
+ vitePlugins() {
11
+ // `@cloudflare/vite-plugin` drives both workerd dev and the build via
12
+ // the Environment API, and reads the worker entry from wrangler.jsonc
13
+ // `main`. It needs no entry argument from the framework.
14
+ // `cloudflare()` may return a single plugin or an array; normalize so
15
+ // the HonoPreactAdapter contract (a flat Plugin[]) holds either way.
16
+ const produced = cloudflare();
17
+ return Array.isArray(produced) ? produced : [produced];
18
+ },
19
+ wrapEntry(ctx) {
20
+ // A Hono app's default export is already a valid Workers fetch handler,
21
+ // so the platform tail is a bare re-export of the core app module.
22
+ return `export { default } from ${JSON.stringify(ctx.coreAppModuleId)};\n`;
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { HonoPreactAdapter } from './adapter.js';
2
+ export declare function nodeAdapter(): HonoPreactAdapter;
@@ -0,0 +1,49 @@
1
+ import { nodeBuildPlugin, nodeDevServerPlugin } from './node-dev-server.js';
2
+ export function nodeAdapter() {
3
+ return {
4
+ name: 'node',
5
+ vitePlugins(ctx) {
6
+ return [nodeBuildPlugin(ctx), nodeDevServerPlugin(ctx)];
7
+ },
8
+ wrapEntry(ctx) {
9
+ const hasApi = ctx.apiModuleId != null;
10
+ const apiImport = hasApi
11
+ ? `import * as __api from ${JSON.stringify(ctx.apiModuleId)};\n`
12
+ : '';
13
+ const injectExport = hasApi
14
+ ? `export const injectWebSocket = __api.injectWebSocket;\n`
15
+ : '';
16
+ const injectBoot = hasApi
17
+ ? ` if (__api.injectWebSocket) __api.injectWebSocket(server);\n`
18
+ : '';
19
+ // The outer app serves built client assets under /static/* and mounts
20
+ // the framework's core Hono app at the root.
21
+ //
22
+ // The serve() boot is guarded by `import.meta.env.PROD`. In `vite dev`
23
+ // the Node dev plugin loads this wrapper through the SSR module runner
24
+ // purely to obtain `app` (and `injectWebSocket`); PROD is false there so
25
+ // no rogue HTTP server starts. In the production build it constant-folds
26
+ // to true and the bundle boots a real server.
27
+ return (`import { serve } from '@hono/node-server';\n` +
28
+ `import { serveStatic } from '@hono/node-server/serve-static';\n` +
29
+ `import { Hono } from 'hono';\n` +
30
+ `import coreApp from ${JSON.stringify(ctx.coreAppModuleId)};\n` +
31
+ apiImport +
32
+ `\n` +
33
+ `const app = new Hono()\n` +
34
+ ` .use('/static/*', serveStatic({ root: './dist/client' }))\n` +
35
+ ` .route('/', coreApp);\n` +
36
+ `\n` +
37
+ `export { app };\n` +
38
+ `export default app;\n` +
39
+ injectExport +
40
+ `\n` +
41
+ `if (import.meta.env.PROD) {\n` +
42
+ ` const port = Number(process.env.PORT) || 3000;\n` +
43
+ ` const server = serve({ fetch: app.fetch, port });\n` +
44
+ ` console.log(\`hono-preact: listening on http://localhost:\${port}\`);\n` +
45
+ injectBoot +
46
+ `}\n`);
47
+ },
48
+ };
49
+ }