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.
- package/README.md +2 -1
- package/dist/adapter-cloudflare.d.ts +1 -0
- package/dist/adapter-cloudflare.d.ts.map +1 -0
- package/dist/adapter-cloudflare.js +2 -0
- package/dist/adapter-node.d.ts +1 -0
- package/dist/adapter-node.d.ts.map +1 -0
- package/dist/adapter-node.js +2 -0
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/iso/action.d.ts +10 -14
- package/dist/iso/action.js +57 -21
- package/dist/iso/define-app.d.ts +7 -0
- package/dist/iso/define-app.js +3 -0
- package/dist/iso/define-loader.d.ts +19 -0
- package/dist/iso/define-loader.js +4 -0
- package/dist/iso/define-middleware.d.ts +43 -0
- package/dist/iso/define-middleware.js +6 -0
- package/dist/iso/define-page.d.ts +7 -2
- package/dist/iso/define-page.js +1 -1
- package/dist/iso/define-routes.d.ts +24 -1
- package/dist/iso/define-routes.js +34 -0
- package/dist/iso/define-stream-observer.d.ts +20 -0
- package/dist/iso/define-stream-observer.js +3 -0
- package/dist/iso/index.d.ts +10 -5
- package/dist/iso/index.js +5 -3
- package/dist/iso/internal/contexts.d.ts +0 -2
- package/dist/iso/internal/contexts.js +0 -1
- package/dist/iso/internal/loader-fetch.js +37 -7
- package/dist/iso/internal/loader-runner.js +105 -8
- package/dist/iso/internal/middleware-runner.d.ts +22 -0
- package/dist/iso/internal/middleware-runner.js +79 -0
- package/dist/iso/internal/page-middleware-host.d.ts +13 -0
- package/dist/iso/internal/page-middleware-host.js +119 -0
- package/dist/iso/internal/route-boundary.d.ts +1 -0
- package/dist/iso/internal/route-boundary.js +16 -0
- package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
- package/dist/iso/internal/stream-observer-runner.js +48 -0
- package/dist/iso/internal/use-partitioner.d.ts +9 -0
- package/dist/iso/internal/use-partitioner.js +11 -0
- package/dist/iso/internal/use-types.d.ts +7 -0
- package/dist/iso/internal/use-types.js +1 -0
- package/dist/iso/internal.d.ts +5 -4
- package/dist/iso/internal.js +8 -6
- package/dist/iso/outcomes.d.ts +38 -0
- package/dist/iso/outcomes.js +56 -0
- package/dist/iso/page-only.d.ts +5 -0
- package/dist/iso/page-only.js +20 -0
- package/dist/iso/page.d.ts +3 -3
- package/dist/iso/page.js +3 -3
- package/dist/page.d.ts +1 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +8 -0
- package/dist/server/actions-handler.d.ts +20 -6
- package/dist/server/actions-handler.js +83 -47
- package/dist/server/context.js +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/loaders-handler.d.ts +16 -0
- package/dist/server/loaders-handler.js +94 -17
- package/dist/server/render.d.ts +2 -0
- package/dist/server/render.js +104 -33
- package/dist/server/route-server-modules.d.ts +42 -1
- package/dist/server/route-server-modules.js +184 -0
- package/dist/server/sse.d.ts +24 -1
- package/dist/server/sse.js +56 -4
- package/dist/vite/adapter-cloudflare.d.ts +2 -0
- package/dist/vite/adapter-cloudflare.js +25 -0
- package/dist/vite/adapter-node.d.ts +2 -0
- package/dist/vite/adapter-node.js +49 -0
- package/dist/vite/adapter.d.ts +29 -0
- package/dist/vite/adapter.js +1 -0
- package/dist/vite/client-shim.js +5 -4
- package/dist/vite/guard-strip.js +52 -27
- package/dist/vite/hono-preact.d.ts +6 -6
- package/dist/vite/hono-preact.js +48 -77
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/dist/vite/node-dev-server.d.ts +4 -0
- package/dist/vite/node-dev-server.js +121 -0
- package/dist/vite/server-entry.d.ts +30 -7
- package/dist/vite/server-entry.js +161 -78
- package/dist/vite/server-exports-contract.d.ts +6 -0
- package/dist/vite/server-exports-contract.js +43 -0
- package/dist/vite/server-loader-validation.js +36 -9
- package/dist/vite/server-loaders-parser.d.ts +17 -1
- package/dist/vite/server-loaders-parser.js +41 -0
- package/dist/vite/server-only.js +20 -2
- package/package.json +32 -4
package/dist/server/render.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import { HonoRequestContext, runRequestScope, captureRequestScope, takeServerStreamingLoaders, } from '../iso/internal
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
61
|
-
return c
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
package/dist/server/sse.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/server/sse.js
CHANGED
|
@@ -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
|
-
//
|
|
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,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,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
|
+
}
|