hono-preact 0.1.0 → 0.3.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-result-context.d.ts +22 -0
- package/dist/iso/action-result-context.js +2 -0
- package/dist/iso/action.d.ts +60 -25
- package/dist/iso/action.js +210 -58
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +14 -0
- package/dist/iso/define-app.js +3 -0
- package/dist/iso/define-loader.d.ts +31 -0
- package/dist/iso/define-loader.js +30 -16
- 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/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +15 -7
- package/dist/iso/index.js +9 -4
- package/dist/iso/internal/action-envelope.d.ts +37 -0
- package/dist/iso/internal/action-envelope.js +47 -0
- package/dist/iso/internal/action-result-store.d.ts +28 -0
- package/dist/iso/internal/action-result-store.js +35 -0
- package/dist/iso/internal/contexts.d.ts +0 -2
- package/dist/iso/internal/contexts.js +0 -1
- package/dist/iso/internal/envelope.js +1 -2
- package/dist/iso/internal/form-submit-store.d.ts +9 -0
- package/dist/iso/internal/form-submit-store.js +32 -0
- package/dist/iso/internal/loader-fetch.js +102 -41
- package/dist/iso/internal/loader-runner.js +105 -8
- package/dist/iso/internal/loader.d.ts +3 -3
- 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 +5 -4
- package/dist/iso/internal/route-boundary.js +16 -0
- package/dist/iso/internal/safe-redirect.d.ts +7 -0
- package/dist/iso/internal/safe-redirect.js +27 -0
- package/dist/iso/internal/sse-decoder.d.ts +1 -1
- package/dist/iso/internal/sse-decoder.js +40 -26
- 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 +12 -5
- package/dist/iso/internal.js +16 -7
- package/dist/iso/optimistic-action.d.ts +10 -1
- package/dist/iso/optimistic-action.js +11 -3
- package/dist/iso/optimistic.d.ts +10 -1
- package/dist/iso/optimistic.js +45 -5
- package/dist/iso/outcomes.d.ts +50 -0
- package/dist/iso/outcomes.js +67 -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/iso/use-action-result.d.ts +25 -0
- package/dist/iso/use-action-result.js +39 -0
- package/dist/iso/use-form-status.d.ts +5 -0
- package/dist/iso/use-form-status.js +13 -0
- 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 +27 -6
- package/dist/server/actions-handler.js +121 -52
- package/dist/server/context.js +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/loaders-handler.d.ts +24 -0
- package/dist/server/loaders-handler.js +128 -18
- package/dist/server/page-action-handler.d.ts +63 -0
- package/dist/server/page-action-handler.js +274 -0
- package/dist/server/page-action-resolvers.d.ts +28 -0
- package/dist/server/page-action-resolvers.js +147 -0
- package/dist/server/render.d.ts +2 -0
- package/dist/server/render.js +142 -33
- package/dist/server/route-server-modules.d.ts +48 -8
- package/dist/server/route-server-modules.js +190 -7
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +50 -12
- package/dist/server/sse.js +130 -53
- 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 +170 -79
- 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 +33 -5
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
function extractActions(mod) {
|
|
2
|
+
const moduleKey = mod.__moduleKey;
|
|
3
|
+
if (typeof moduleKey !== 'string' || !mod.serverActions)
|
|
4
|
+
return [];
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const [name, val] of Object.entries(mod.serverActions)) {
|
|
7
|
+
if (typeof val !== 'function')
|
|
8
|
+
continue;
|
|
9
|
+
// `defineAction` attaches `use` and `timeoutMs` as non-enumerable
|
|
10
|
+
// properties on the function (see packages/iso/src/action.ts). Read
|
|
11
|
+
// them here as the single deserialization boundary; the handler reads
|
|
12
|
+
// `entry.fn`, `entry.use`, `entry.timeoutMs` through the typed
|
|
13
|
+
// ActionEntry shape from this point on.
|
|
14
|
+
const metadata = val;
|
|
15
|
+
out.push({
|
|
16
|
+
name,
|
|
17
|
+
entry: {
|
|
18
|
+
fn: val,
|
|
19
|
+
use: metadata.use ?? [],
|
|
20
|
+
timeoutMs: metadata.timeoutMs,
|
|
21
|
+
moduleKey,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function segmentsOf(p) {
|
|
28
|
+
return p.split('/').filter((s) => s !== '');
|
|
29
|
+
}
|
|
30
|
+
function urlPathMatchesPattern(urlPath, pattern) {
|
|
31
|
+
const ps = segmentsOf(pattern);
|
|
32
|
+
const us = segmentsOf(urlPath);
|
|
33
|
+
for (let i = 0; i < ps.length; i++) {
|
|
34
|
+
const p = ps[i];
|
|
35
|
+
if (p === '*')
|
|
36
|
+
return true;
|
|
37
|
+
if (i >= us.length)
|
|
38
|
+
return false;
|
|
39
|
+
if (p.startsWith(':'))
|
|
40
|
+
continue;
|
|
41
|
+
if (p !== us[i])
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return ps.length === us.length;
|
|
45
|
+
}
|
|
46
|
+
function patternScore(pattern) {
|
|
47
|
+
let score = 0;
|
|
48
|
+
for (const seg of segmentsOf(pattern)) {
|
|
49
|
+
if (seg === '*')
|
|
50
|
+
score += 0;
|
|
51
|
+
else if (seg.startsWith(':'))
|
|
52
|
+
score += 1;
|
|
53
|
+
else
|
|
54
|
+
score += 2;
|
|
55
|
+
}
|
|
56
|
+
return score;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build action resolvers keyed by route path and by module key. Each
|
|
60
|
+
* ServerRoute contributes its own serverActions and its ancestors' serverActions
|
|
61
|
+
* to the merged map for that path. Ancestor entries are written first so that
|
|
62
|
+
* a page-level action shadows a same-named layout action when names collide.
|
|
63
|
+
*
|
|
64
|
+
* Lazy semantics: the first call triggers loading all server modules. The result
|
|
65
|
+
* is cached for the process lifetime (unless dev=true, which rebuilds on every
|
|
66
|
+
* call so edits take effect without restarting the server).
|
|
67
|
+
*
|
|
68
|
+
* NOTE: framework-private. Intended consumer is the generated server entry and
|
|
69
|
+
* pageActionHandler.
|
|
70
|
+
*/
|
|
71
|
+
export function makePageActionResolvers(serverRoutes, options = {}) {
|
|
72
|
+
const dev = options.dev ?? false;
|
|
73
|
+
let buildPromise = null;
|
|
74
|
+
const build = async () => {
|
|
75
|
+
// Load each distinct server thunk once; a thunk may appear as `server`
|
|
76
|
+
// on one route and as an `ancestor` on its children.
|
|
77
|
+
const thunkCache = new Map();
|
|
78
|
+
const load = (thunk) => {
|
|
79
|
+
let p = thunkCache.get(thunk);
|
|
80
|
+
if (!p) {
|
|
81
|
+
p = thunk().then((m) => m);
|
|
82
|
+
thunkCache.set(thunk, p);
|
|
83
|
+
}
|
|
84
|
+
return p;
|
|
85
|
+
};
|
|
86
|
+
const byPathMap = new Map();
|
|
87
|
+
const byModuleKeyMap = new Map();
|
|
88
|
+
await Promise.all(serverRoutes.map(async (route) => {
|
|
89
|
+
const ancestorMods = await Promise.all(route.ancestors.map(load));
|
|
90
|
+
const selfMod = await load(route.server);
|
|
91
|
+
const merged = new Map();
|
|
92
|
+
// Write ancestors first (outer -> inner), then self. Later writes
|
|
93
|
+
// shadow earlier ones, so a page-level action wins over a layout
|
|
94
|
+
// action of the same name.
|
|
95
|
+
for (const mod of [...ancestorMods, selfMod]) {
|
|
96
|
+
for (const { name, entry } of extractActions(mod)) {
|
|
97
|
+
merged.set(name, entry);
|
|
98
|
+
let m = byModuleKeyMap.get(entry.moduleKey);
|
|
99
|
+
if (!m) {
|
|
100
|
+
m = new Map();
|
|
101
|
+
byModuleKeyMap.set(entry.moduleKey, m);
|
|
102
|
+
}
|
|
103
|
+
m.set(name, entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
byPathMap.set(route.path, merged);
|
|
107
|
+
}));
|
|
108
|
+
return { byPathMap, byModuleKeyMap };
|
|
109
|
+
};
|
|
110
|
+
const get = () => {
|
|
111
|
+
if (dev)
|
|
112
|
+
return build();
|
|
113
|
+
if (buildPromise)
|
|
114
|
+
return buildPromise;
|
|
115
|
+
buildPromise = build().catch((err) => {
|
|
116
|
+
buildPromise = null;
|
|
117
|
+
return Promise.reject(err);
|
|
118
|
+
});
|
|
119
|
+
return buildPromise;
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
async byPath(path) {
|
|
123
|
+
const { byPathMap } = await get();
|
|
124
|
+
let bestPattern = null;
|
|
125
|
+
let bestScore = -1;
|
|
126
|
+
let bestDepth = -1;
|
|
127
|
+
for (const pattern of byPathMap.keys()) {
|
|
128
|
+
if (!urlPathMatchesPattern(path, pattern))
|
|
129
|
+
continue;
|
|
130
|
+
const score = patternScore(pattern);
|
|
131
|
+
const depth = segmentsOf(pattern).length;
|
|
132
|
+
if (score > bestScore || (score === bestScore && depth > bestDepth)) {
|
|
133
|
+
bestPattern = pattern;
|
|
134
|
+
bestScore = score;
|
|
135
|
+
bestDepth = depth;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return bestPattern
|
|
139
|
+
? (byPathMap.get(bestPattern) ?? new Map())
|
|
140
|
+
: new Map();
|
|
141
|
+
},
|
|
142
|
+
async byModuleKey(moduleKey, actionName) {
|
|
143
|
+
const { byModuleKeyMap } = await get();
|
|
144
|
+
return byModuleKeyMap.get(moduleKey)?.get(actionName);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/server/render.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
2
|
import type { VNode } from 'preact';
|
|
3
|
+
import { type AppConfig } from '../iso/index';
|
|
3
4
|
export declare function renderPage(c: Context, node: VNode, options?: {
|
|
4
5
|
defaultTitle?: string;
|
|
6
|
+
appConfig?: AppConfig;
|
|
5
7
|
}): Promise<Response>;
|
package/dist/server/render.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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, ActionResultContext, } from '../iso/index.js';
|
|
5
|
+
import { HonoRequestContext, runRequestScope, captureRequestScope, takeServerStreamingLoaders, dispatchServer, partitionUse, getActionResultSlot, } from '../iso/internal.js';
|
|
6
|
+
import { speculationRulesTag } from './speculation-rules.js';
|
|
6
7
|
function escapeHtml(str) {
|
|
7
8
|
return str
|
|
8
9
|
.replace(/&/g, '&')
|
|
@@ -24,6 +25,63 @@ function toAttrs(obj) {
|
|
|
24
25
|
.map(([k, v]) => `${k}="${escapeHtml(String(v))}"`)
|
|
25
26
|
.join(' ');
|
|
26
27
|
}
|
|
28
|
+
// Outcome translation for the root chain dispatched around prerender. The
|
|
29
|
+
// root layer (appConfig.use) only legitimately produces `redirect` or
|
|
30
|
+
// `deny`; a `render` outcome is page-scope and must not flow through here.
|
|
31
|
+
// Defense-in-depth: surface programmer error as a 500 rather than crash.
|
|
32
|
+
function translateRootOutcome(c, outcome) {
|
|
33
|
+
if (outcome.__outcome === 'redirect') {
|
|
34
|
+
if (outcome.headers) {
|
|
35
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
36
|
+
c.header(k, v);
|
|
37
|
+
}
|
|
38
|
+
return c.redirect(outcome.to, outcome.status);
|
|
39
|
+
}
|
|
40
|
+
if (outcome.__outcome === 'deny') {
|
|
41
|
+
if (outcome.headers) {
|
|
42
|
+
for (const [k, v] of Object.entries(outcome.headers))
|
|
43
|
+
c.header(k, v);
|
|
44
|
+
}
|
|
45
|
+
return c.text(outcome.message ?? 'Forbidden', outcome.status);
|
|
46
|
+
}
|
|
47
|
+
return c.text('render outcome is page-scope only and cannot be issued by root middleware', 500);
|
|
48
|
+
}
|
|
49
|
+
function buildActionResultContext() {
|
|
50
|
+
const slot = getActionResultSlot();
|
|
51
|
+
if (!slot)
|
|
52
|
+
return null;
|
|
53
|
+
if (slot.resolution.kind === 'success') {
|
|
54
|
+
return {
|
|
55
|
+
module: slot.module,
|
|
56
|
+
action: slot.action,
|
|
57
|
+
kind: 'success',
|
|
58
|
+
data: slot.resolution.data,
|
|
59
|
+
submittedPayload: slot.submittedPayload,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (slot.resolution.kind === 'error') {
|
|
63
|
+
return {
|
|
64
|
+
module: slot.module,
|
|
65
|
+
action: slot.action,
|
|
66
|
+
kind: 'error',
|
|
67
|
+
message: slot.resolution.message,
|
|
68
|
+
submittedPayload: slot.submittedPayload,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const { outcome } = slot.resolution;
|
|
72
|
+
if (outcome.__outcome === 'deny') {
|
|
73
|
+
return {
|
|
74
|
+
module: slot.module,
|
|
75
|
+
action: slot.action,
|
|
76
|
+
kind: 'deny',
|
|
77
|
+
status: outcome.status,
|
|
78
|
+
message: outcome.message,
|
|
79
|
+
data: outcome.data,
|
|
80
|
+
submittedPayload: slot.submittedPayload,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
27
85
|
export async function renderPage(c, node, options) {
|
|
28
86
|
const dispatcher = createDispatcher();
|
|
29
87
|
const previousEnv = env.current;
|
|
@@ -37,33 +95,68 @@ export async function renderPage(c, node, options) {
|
|
|
37
95
|
// binder restores per-request isolation for `getRequestStore` /
|
|
38
96
|
// `getRequestHonoContext` reads from generator continuations.
|
|
39
97
|
let bindRequestScope = (fn) => fn();
|
|
98
|
+
let rootResult;
|
|
40
99
|
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.
|
|
100
|
+
rootResult = await runRequestScope(async () => {
|
|
49
101
|
const reqUrl = new URL(c.req.url);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
102
|
+
const location = {
|
|
103
|
+
path: reqUrl.pathname,
|
|
104
|
+
searchParams: Object.fromEntries(reqUrl.searchParams),
|
|
105
|
+
// Path params are route-match output; the root layer runs before
|
|
106
|
+
// route matching, so they're empty here. Page-layer middleware
|
|
107
|
+
// (added in a follow-up) will have them populated.
|
|
108
|
+
pathParams: {},
|
|
109
|
+
};
|
|
110
|
+
const rootUse = options?.appConfig?.use ?? [];
|
|
111
|
+
const serverMw = partitionUse(rootUse).middleware.filter((m) => m.runs === 'server');
|
|
112
|
+
const ctx = {
|
|
113
|
+
scope: 'page',
|
|
114
|
+
c,
|
|
115
|
+
signal: c.req.raw.signal,
|
|
116
|
+
location,
|
|
117
|
+
};
|
|
118
|
+
const dispatch = await dispatchServer({
|
|
119
|
+
middleware: serverMw,
|
|
120
|
+
ctx,
|
|
121
|
+
inner: async () => {
|
|
122
|
+
// preact-iso's `LocationProvider` reads `globalThis.location`
|
|
123
|
+
// once, synchronously, when it mounts. Set it on the same
|
|
124
|
+
// microtask as the `prerender` call so no other request can
|
|
125
|
+
// interleave and trample the global between us writing it and
|
|
126
|
+
// the provider reading it. Children resume from reducer state,
|
|
127
|
+
// never re-reading the global, so the rest of this render is
|
|
128
|
+
// safe even if another request resets `globalThis.location`
|
|
129
|
+
// while we await suspended children.
|
|
130
|
+
locationStub(reqUrl.pathname + reqUrl.search);
|
|
131
|
+
bindRequestScope = captureRequestScope();
|
|
132
|
+
const rendered = await prerender(_jsx(ActionResultContext.Provider, { value: buildActionResultContext(), children: _jsx(HonoRequestContext.Provider, { value: { context: c }, children: _jsx(HoofdProvider, { value: dispatcher, children: node }) }) }));
|
|
133
|
+
const loaders = takeServerStreamingLoaders();
|
|
134
|
+
return {
|
|
135
|
+
kind: 'value',
|
|
136
|
+
html: rendered.html,
|
|
137
|
+
streamingLoaders: loaders,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (dispatch.kind === 'outcome') {
|
|
142
|
+
return { kind: 'outcome', outcome: dispatch.outcome };
|
|
143
|
+
}
|
|
144
|
+
return dispatch.value;
|
|
55
145
|
}, { honoContext: c });
|
|
56
|
-
html = result.html;
|
|
57
|
-
streamingLoaders = result.streamingLoaders;
|
|
58
146
|
}
|
|
59
147
|
catch (e) {
|
|
60
|
-
if (e
|
|
61
|
-
return c
|
|
148
|
+
if (isOutcome(e))
|
|
149
|
+
return translateRootOutcome(c, e);
|
|
62
150
|
throw e;
|
|
63
151
|
}
|
|
64
152
|
finally {
|
|
65
153
|
env.current = previousEnv;
|
|
66
154
|
}
|
|
155
|
+
if (rootResult.kind === 'outcome') {
|
|
156
|
+
return translateRootOutcome(c, rootResult.outcome);
|
|
157
|
+
}
|
|
158
|
+
html = rootResult.html;
|
|
159
|
+
streamingLoaders = rootResult.streamingLoaders;
|
|
67
160
|
const { title, lang, metas = [], links = [] } = dispatcher.toStatic();
|
|
68
161
|
// Only inject a <title> when hoofd produced one or the caller provided a
|
|
69
162
|
// defaultTitle. Layouts that render their own static <title> (via <Head>)
|
|
@@ -74,6 +167,7 @@ export async function renderPage(c, node, options) {
|
|
|
74
167
|
titleSource != null ? `<title>${escapeHtml(titleSource)}</title>` : '',
|
|
75
168
|
...metas.map((m) => `<meta ${toAttrs(m)} />`),
|
|
76
169
|
...links.map((l) => `<link ${toAttrs(l)} />`),
|
|
170
|
+
speculationRulesTag(options?.appConfig ?? {}),
|
|
77
171
|
]
|
|
78
172
|
.filter(Boolean)
|
|
79
173
|
.join('\n ');
|
|
@@ -137,6 +231,16 @@ export async function renderPage(c, node, options) {
|
|
|
137
231
|
if (aborted)
|
|
138
232
|
return;
|
|
139
233
|
controller.enqueue(encoder.encode(`<!doctype html>${beforeBody}\n${bootstrap}\n`));
|
|
234
|
+
// Yield one microtask before advancing any loader generator past
|
|
235
|
+
// its first yield. `renderPage` is still on the synchronous frame
|
|
236
|
+
// that constructs this response (`new ReadableStream(...)` returns,
|
|
237
|
+
// then `c.body(...)` runs and commits the headers). Resuming a
|
|
238
|
+
// generator can call `setCookie(ctx.c, ...)`, which mutates Hono's
|
|
239
|
+
// prepared headers; deferring the pump guarantees the response is
|
|
240
|
+
// built first, so post-first-yield header writes are consistently
|
|
241
|
+
// excluded rather than racing construction. Cookies must be set
|
|
242
|
+
// before the loader's first yield to reach the streamed response.
|
|
243
|
+
await Promise.resolve();
|
|
140
244
|
// Drive each pending generator in parallel; emit script tags per chunk.
|
|
141
245
|
await Promise.all(streamingLoaders.map(async ({ loaderId, gen }) => {
|
|
142
246
|
try {
|
|
@@ -185,19 +289,24 @@ export async function renderPage(c, node, options) {
|
|
|
185
289
|
});
|
|
186
290
|
}
|
|
187
291
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
292
|
+
// Route through `c.body()` rather than `new Response(...)` so Hono merges
|
|
293
|
+
// its prepared headers into the streamed response. A streaming loader's
|
|
294
|
+
// body runs up to its first `yield` during prerender, so a `Set-Cookie`
|
|
295
|
+
// written via `ctx.c` before that yield is sitting in Hono's prepared
|
|
296
|
+
// headers by now; constructing the Response directly would drop it. The
|
|
297
|
+
// non-streaming branch above gets this for free via `c.html()`. Cookies
|
|
298
|
+
// written after a yield run in the pump below, once headers are already
|
|
299
|
+
// sent, and are unavoidably lost.
|
|
300
|
+
return c.body(responseStream, 200, {
|
|
301
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
302
|
+
'Transfer-Encoding': 'chunked',
|
|
303
|
+
// Prevent buffering / transformation by intermediate proxies. nginx
|
|
304
|
+
// honors `X-Accel-Buffering: no` to flush per chunk; `no-transform`
|
|
305
|
+
// stops middleboxes from rebuffering or gzipping the stream as a
|
|
306
|
+
// single response. We deliberately do NOT add `no-store`: streamed
|
|
307
|
+
// HTML can still be legitimately cacheable, and users can override
|
|
308
|
+
// via their own middleware.
|
|
309
|
+
'X-Accel-Buffering': 'no',
|
|
310
|
+
'Cache-Control': 'no-transform',
|
|
202
311
|
});
|
|
203
312
|
}
|
|
@@ -1,12 +1,52 @@
|
|
|
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
|
-
* that loadersHandler
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* entry.
|
|
4
|
+
* that loadersHandler accepts. Previously returned a record keyed by
|
|
5
|
+
* stringified integers; those keys were unused at the call site (handlers
|
|
6
|
+
* iterate values only), so the array form is just the same data without dead
|
|
7
|
+
* surface. Vite-style globs (`Record<string, ...>`) are still accepted by
|
|
8
|
+
* loadersHandler directly; this helper is for the routes-manifest-driven
|
|
9
|
+
* path used by the framework's generated server entry.
|
|
11
10
|
*/
|
|
12
11
|
export declare function routeServerModules(manifest: RoutesManifest): ReadonlyArray<() => Promise<unknown>>;
|
|
12
|
+
/**
|
|
13
|
+
* Build the two page-layer `use` resolvers wired into loadersHandler and
|
|
14
|
+
* pageActionHandler. The loader handler matches by the location's URL path;
|
|
15
|
+
* the action handler matches by the action's owning module key. Both
|
|
16
|
+
* lookups share one underlying composed map populated by loading every
|
|
17
|
+
* routed `.server.*` module exactly once (then caching the result).
|
|
18
|
+
*
|
|
19
|
+
* Ancestor composition: each ServerRoute carries an explicit list of
|
|
20
|
+
* ancestor server thunks captured during the route-tree walk. The
|
|
21
|
+
* resolver loads each ancestor's `pageUse` (if any) and concatenates them
|
|
22
|
+
* outer-first, with the route's own pageUse appended last. So a layout
|
|
23
|
+
* group's pageUse runs before each nested leaf's pageUse without the user
|
|
24
|
+
* having to repeat the import in every leaf .server.*. Order matches the
|
|
25
|
+
* middleware dispatcher's outer -> inner contract: app -> outermost
|
|
26
|
+
* layout -> ... -> leaf -> per-unit.
|
|
27
|
+
*
|
|
28
|
+
* Why route-tree ancestry (not URL-prefix ancestry): two routes can share
|
|
29
|
+
* a URL prefix without being parent/child in the tree. For example,
|
|
30
|
+
* `/demo/projects` and `/demo/projects/:projectId/issues/:issueId` are
|
|
31
|
+
* siblings of the `/demo` layout group; the latter is NOT a descendant of
|
|
32
|
+
* the former. URL-prefix matching incorrectly conflates them and runs the
|
|
33
|
+
* shared gate twice on every nested request.
|
|
34
|
+
*
|
|
35
|
+
* Lazy semantics: the first call to either resolver triggers the build of
|
|
36
|
+
* all server modules listed in `serverRoutes`. Subsequent calls return
|
|
37
|
+
* from the cached map. A failed build is not cached -- the next call
|
|
38
|
+
* retries -- so a transient import error doesn't permanently poison the
|
|
39
|
+
* resolver. Modules that don't export `pageUse` (the common case today)
|
|
40
|
+
* contribute nothing to the composed arrays. When `dev` is true the cache
|
|
41
|
+
* is bypassed on every call so editing a `.server.*` file's `pageUse`
|
|
42
|
+
* takes effect without restarting the server.
|
|
43
|
+
*
|
|
44
|
+
* NOTE: framework-private. The only intended consumer outside tests is
|
|
45
|
+
* the generated server entry. Reach for it at your own risk.
|
|
46
|
+
*/
|
|
47
|
+
export declare function makePageUseResolvers(serverRoutes: ReadonlyArray<ServerRoute>, options?: {
|
|
48
|
+
dev?: boolean;
|
|
49
|
+
}): {
|
|
50
|
+
byPath: (path: string) => Promise<ReadonlyArray<unknown>>;
|
|
51
|
+
byModuleKey: (key: string) => Promise<ReadonlyArray<unknown>>;
|
|
52
|
+
};
|
|
@@ -1,13 +1,196 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Convert a RoutesManifest into the array of lazy server-module loaders
|
|
3
|
-
* that loadersHandler
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* entry.
|
|
3
|
+
* that loadersHandler accepts. Previously returned a record keyed by
|
|
4
|
+
* stringified integers; those keys were unused at the call site (handlers
|
|
5
|
+
* iterate values only), so the array form is just the same data without dead
|
|
6
|
+
* surface. Vite-style globs (`Record<string, ...>`) are still accepted by
|
|
7
|
+
* loadersHandler directly; this helper is for the routes-manifest-driven
|
|
8
|
+
* path used by the framework's generated server entry.
|
|
10
9
|
*/
|
|
11
10
|
export function routeServerModules(manifest) {
|
|
12
11
|
return manifest.serverImports;
|
|
13
12
|
}
|
|
13
|
+
function segmentsOf(path) {
|
|
14
|
+
return path.split('/').filter((s) => s !== '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* True when `urlPath` (the concrete URL the user navigated to, with all
|
|
18
|
+
* params substituted) matches `pattern` exactly: same segment count, and
|
|
19
|
+
* each pattern segment either equals the URL segment, is a `:param`, or is
|
|
20
|
+
* a trailing `*`.
|
|
21
|
+
*
|
|
22
|
+
* Used at lookup time. `byPath` resolves the URL to the most specific
|
|
23
|
+
* pattern in the map and returns its already-composed pageUse.
|
|
24
|
+
*/
|
|
25
|
+
function urlPathMatchesPattern(urlPath, pattern) {
|
|
26
|
+
const ps = segmentsOf(pattern);
|
|
27
|
+
const us = segmentsOf(urlPath);
|
|
28
|
+
for (let i = 0; i < ps.length; i++) {
|
|
29
|
+
const p = ps[i];
|
|
30
|
+
if (p === '*')
|
|
31
|
+
return true;
|
|
32
|
+
if (i >= us.length)
|
|
33
|
+
return false;
|
|
34
|
+
if (p.startsWith(':'))
|
|
35
|
+
continue;
|
|
36
|
+
if (p !== us[i])
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return ps.length === us.length;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Score a route pattern for tiebreaker purposes when multiple patterns at
|
|
43
|
+
* the same segment depth match the URL. Mirrors preact-iso's runtime
|
|
44
|
+
* preference for literal segments: literal=2, param=1, wildcard=0. Within
|
|
45
|
+
* the same score, the caller falls back to depth, and within the same
|
|
46
|
+
* depth, to the loaded order in `serverRoutes`. Pre-merged literal wins
|
|
47
|
+
* over `/admin/users/:id` when the URL is `/admin/users/me`.
|
|
48
|
+
*/
|
|
49
|
+
function patternScore(pattern) {
|
|
50
|
+
let score = 0;
|
|
51
|
+
for (const seg of segmentsOf(pattern)) {
|
|
52
|
+
if (seg === '*')
|
|
53
|
+
score += 0;
|
|
54
|
+
else if (seg.startsWith(':'))
|
|
55
|
+
score += 1;
|
|
56
|
+
else
|
|
57
|
+
score += 2;
|
|
58
|
+
}
|
|
59
|
+
return score;
|
|
60
|
+
}
|
|
61
|
+
function pageUseFromMod(mod, patternPath) {
|
|
62
|
+
if (mod.pageUse === undefined || mod.pageUse === null)
|
|
63
|
+
return [];
|
|
64
|
+
if (Array.isArray(mod.pageUse))
|
|
65
|
+
return mod.pageUse;
|
|
66
|
+
// Runtime guard for non-array pageUse: surface a descriptive error so
|
|
67
|
+
// the user finds the typo (`pageUse = mySingleMw` instead of `[mySingleMw]`)
|
|
68
|
+
// immediately rather than experiencing a silent gate failure. The
|
|
69
|
+
// build-time plugin should catch this first; this is the runtime backstop.
|
|
70
|
+
throw new Error(`Route '${patternPath}' exports a non-array \`pageUse\`. ` +
|
|
71
|
+
`pageUse must be an array (typically a reference to a const declared as \`[mw1, mw2]\`). ` +
|
|
72
|
+
`Wrap a single middleware in brackets: pageUse = [myMiddleware].`);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build the two page-layer `use` resolvers wired into loadersHandler and
|
|
76
|
+
* pageActionHandler. The loader handler matches by the location's URL path;
|
|
77
|
+
* the action handler matches by the action's owning module key. Both
|
|
78
|
+
* lookups share one underlying composed map populated by loading every
|
|
79
|
+
* routed `.server.*` module exactly once (then caching the result).
|
|
80
|
+
*
|
|
81
|
+
* Ancestor composition: each ServerRoute carries an explicit list of
|
|
82
|
+
* ancestor server thunks captured during the route-tree walk. The
|
|
83
|
+
* resolver loads each ancestor's `pageUse` (if any) and concatenates them
|
|
84
|
+
* outer-first, with the route's own pageUse appended last. So a layout
|
|
85
|
+
* group's pageUse runs before each nested leaf's pageUse without the user
|
|
86
|
+
* having to repeat the import in every leaf .server.*. Order matches the
|
|
87
|
+
* middleware dispatcher's outer -> inner contract: app -> outermost
|
|
88
|
+
* layout -> ... -> leaf -> per-unit.
|
|
89
|
+
*
|
|
90
|
+
* Why route-tree ancestry (not URL-prefix ancestry): two routes can share
|
|
91
|
+
* a URL prefix without being parent/child in the tree. For example,
|
|
92
|
+
* `/demo/projects` and `/demo/projects/:projectId/issues/:issueId` are
|
|
93
|
+
* siblings of the `/demo` layout group; the latter is NOT a descendant of
|
|
94
|
+
* the former. URL-prefix matching incorrectly conflates them and runs the
|
|
95
|
+
* shared gate twice on every nested request.
|
|
96
|
+
*
|
|
97
|
+
* Lazy semantics: the first call to either resolver triggers the build of
|
|
98
|
+
* all server modules listed in `serverRoutes`. Subsequent calls return
|
|
99
|
+
* from the cached map. A failed build is not cached -- the next call
|
|
100
|
+
* retries -- so a transient import error doesn't permanently poison the
|
|
101
|
+
* resolver. Modules that don't export `pageUse` (the common case today)
|
|
102
|
+
* contribute nothing to the composed arrays. When `dev` is true the cache
|
|
103
|
+
* is bypassed on every call so editing a `.server.*` file's `pageUse`
|
|
104
|
+
* takes effect without restarting the server.
|
|
105
|
+
*
|
|
106
|
+
* NOTE: framework-private. The only intended consumer outside tests is
|
|
107
|
+
* the generated server entry. Reach for it at your own risk.
|
|
108
|
+
*/
|
|
109
|
+
export function makePageUseResolvers(serverRoutes, options = {}) {
|
|
110
|
+
const dev = options.dev ?? false;
|
|
111
|
+
let buildPromise = null;
|
|
112
|
+
const build = async () => {
|
|
113
|
+
// Load every distinct server thunk exactly once. A given thunk may
|
|
114
|
+
// appear as `server` on one ServerRoute and as an `ancestor` on
|
|
115
|
+
// descendants; calling it just once keeps module-init side effects
|
|
116
|
+
// (e.g. logging, registry insertion) idempotent.
|
|
117
|
+
const thunkCache = new Map();
|
|
118
|
+
const load = (thunk) => {
|
|
119
|
+
let p = thunkCache.get(thunk);
|
|
120
|
+
if (!p) {
|
|
121
|
+
p = thunk().then((mod) => mod);
|
|
122
|
+
thunkCache.set(thunk, p);
|
|
123
|
+
}
|
|
124
|
+
return p;
|
|
125
|
+
};
|
|
126
|
+
const composedByPath = new Map();
|
|
127
|
+
const patternByModuleKey = new Map();
|
|
128
|
+
await Promise.all(serverRoutes.map(async (route) => {
|
|
129
|
+
const ancestorMods = await Promise.all(route.ancestors.map((t) => load(t)));
|
|
130
|
+
const selfMod = await load(route.server);
|
|
131
|
+
const composed = [];
|
|
132
|
+
for (let i = 0; i < ancestorMods.length; i++) {
|
|
133
|
+
composed.push(...pageUseFromMod(ancestorMods[i], route.path));
|
|
134
|
+
}
|
|
135
|
+
composed.push(...pageUseFromMod(selfMod, route.path));
|
|
136
|
+
// Two ServerRoutes sharing the same path mean two `.server.*` files
|
|
137
|
+
// claim the same route -- a route-table error. The route validator
|
|
138
|
+
// is the right place to surface that; here we simply preserve the
|
|
139
|
+
// load order (last write wins for the composed map, which matches
|
|
140
|
+
// the previous behavior of `composedByPath.set(path, ...)`).
|
|
141
|
+
composedByPath.set(route.path, composed);
|
|
142
|
+
if (typeof selfMod.__moduleKey === 'string') {
|
|
143
|
+
patternByModuleKey.set(selfMod.__moduleKey, route.path);
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
return { composedByPath, patternByModuleKey };
|
|
147
|
+
};
|
|
148
|
+
const get = () => {
|
|
149
|
+
if (dev) {
|
|
150
|
+
// In dev, always rebuild so edits to `pageUse` in any .server.* file
|
|
151
|
+
// take effect on the next request without restarting the process.
|
|
152
|
+
return build();
|
|
153
|
+
}
|
|
154
|
+
if (buildPromise)
|
|
155
|
+
return buildPromise;
|
|
156
|
+
buildPromise = build().catch((err) => {
|
|
157
|
+
buildPromise = null;
|
|
158
|
+
return Promise.reject(err);
|
|
159
|
+
});
|
|
160
|
+
return buildPromise;
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
async byPath(path) {
|
|
164
|
+
const { composedByPath } = await get();
|
|
165
|
+
// The handler receives the matched route's URL path (params
|
|
166
|
+
// substituted to literal values). Walk the composed map and pick the
|
|
167
|
+
// best-matching pattern. Tiebreaker: (1) higher specificity score
|
|
168
|
+
// (literal=2, param=1, wildcard=0); (2) within same score, longer
|
|
169
|
+
// path; (3) within same length, first inserted. Mirrors preact-iso's
|
|
170
|
+
// runtime preference for literal matches over parameterized siblings.
|
|
171
|
+
//
|
|
172
|
+
// NOTE: O(routes) linear scan. Fine for small apps; a precomputed
|
|
173
|
+
// trie or a request-keyed memo would help at scale.
|
|
174
|
+
let bestPattern = null;
|
|
175
|
+
let bestScore = -1;
|
|
176
|
+
let bestDepth = -1;
|
|
177
|
+
for (const pattern of composedByPath.keys()) {
|
|
178
|
+
if (!urlPathMatchesPattern(path, pattern))
|
|
179
|
+
continue;
|
|
180
|
+
const score = patternScore(pattern);
|
|
181
|
+
const depth = segmentsOf(pattern).length;
|
|
182
|
+
if (score > bestScore || (score === bestScore && depth > bestDepth)) {
|
|
183
|
+
bestPattern = pattern;
|
|
184
|
+
bestScore = score;
|
|
185
|
+
bestDepth = depth;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return bestPattern ? (composedByPath.get(bestPattern) ?? []) : [];
|
|
189
|
+
},
|
|
190
|
+
async byModuleKey(key) {
|
|
191
|
+
const { composedByPath, patternByModuleKey } = await get();
|
|
192
|
+
const pattern = patternByModuleKey.get(key);
|
|
193
|
+
return pattern ? (composedByPath.get(pattern) ?? []) : [];
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AppConfig } from '../iso/index';
|
|
2
|
+
export declare const SPECULATION_RULES_TAG = "<script type=\"speculationrules\">{\"prefetch\":[{\"where\":{\"and\":[{\"href_matches\":\"/*\"},{\"not\":{\"selector_matches\":\"[data-no-prefetch]\"}}]},\"eagerness\":\"moderate\"}]}</script>";
|
|
3
|
+
export declare function speculationRulesTag(config: AppConfig): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const SPECULATION_RULES_JSON = '{"prefetch":[{"where":{"and":[' +
|
|
2
|
+
'{"href_matches":"/*"},' +
|
|
3
|
+
'{"not":{"selector_matches":"[data-no-prefetch]"}}' +
|
|
4
|
+
']},"eagerness":"moderate"}]}';
|
|
5
|
+
export const SPECULATION_RULES_TAG = `<script type="speculationrules">${SPECULATION_RULES_JSON}</script>`;
|
|
6
|
+
export function speculationRulesTag(config) {
|
|
7
|
+
return config.speculation === true ? SPECULATION_RULES_TAG : '';
|
|
8
|
+
}
|