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.
Files changed (120) 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-result-context.d.ts +22 -0
  11. package/dist/iso/action-result-context.js +2 -0
  12. package/dist/iso/action.d.ts +60 -25
  13. package/dist/iso/action.js +210 -58
  14. package/dist/iso/cache.d.ts +9 -0
  15. package/dist/iso/cache.js +26 -0
  16. package/dist/iso/define-app.d.ts +14 -0
  17. package/dist/iso/define-app.js +3 -0
  18. package/dist/iso/define-loader.d.ts +31 -0
  19. package/dist/iso/define-loader.js +30 -16
  20. package/dist/iso/define-middleware.d.ts +43 -0
  21. package/dist/iso/define-middleware.js +6 -0
  22. package/dist/iso/define-page.d.ts +7 -2
  23. package/dist/iso/define-page.js +1 -1
  24. package/dist/iso/define-routes.d.ts +24 -1
  25. package/dist/iso/define-routes.js +34 -0
  26. package/dist/iso/define-stream-observer.d.ts +20 -0
  27. package/dist/iso/define-stream-observer.js +3 -0
  28. package/dist/iso/form.d.ts +13 -4
  29. package/dist/iso/form.js +115 -33
  30. package/dist/iso/index.d.ts +15 -7
  31. package/dist/iso/index.js +9 -4
  32. package/dist/iso/internal/action-envelope.d.ts +37 -0
  33. package/dist/iso/internal/action-envelope.js +47 -0
  34. package/dist/iso/internal/action-result-store.d.ts +28 -0
  35. package/dist/iso/internal/action-result-store.js +35 -0
  36. package/dist/iso/internal/contexts.d.ts +0 -2
  37. package/dist/iso/internal/contexts.js +0 -1
  38. package/dist/iso/internal/envelope.js +1 -2
  39. package/dist/iso/internal/form-submit-store.d.ts +9 -0
  40. package/dist/iso/internal/form-submit-store.js +32 -0
  41. package/dist/iso/internal/loader-fetch.js +102 -41
  42. package/dist/iso/internal/loader-runner.js +105 -8
  43. package/dist/iso/internal/loader.d.ts +3 -3
  44. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  45. package/dist/iso/internal/middleware-runner.js +79 -0
  46. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  47. package/dist/iso/internal/page-middleware-host.js +119 -0
  48. package/dist/iso/internal/route-boundary.d.ts +5 -4
  49. package/dist/iso/internal/route-boundary.js +16 -0
  50. package/dist/iso/internal/safe-redirect.d.ts +7 -0
  51. package/dist/iso/internal/safe-redirect.js +27 -0
  52. package/dist/iso/internal/sse-decoder.d.ts +1 -1
  53. package/dist/iso/internal/sse-decoder.js +40 -26
  54. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  55. package/dist/iso/internal/stream-observer-runner.js +48 -0
  56. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  57. package/dist/iso/internal/use-partitioner.js +11 -0
  58. package/dist/iso/internal/use-types.d.ts +7 -0
  59. package/dist/iso/internal/use-types.js +1 -0
  60. package/dist/iso/internal.d.ts +12 -5
  61. package/dist/iso/internal.js +16 -7
  62. package/dist/iso/optimistic-action.d.ts +10 -1
  63. package/dist/iso/optimistic-action.js +11 -3
  64. package/dist/iso/optimistic.d.ts +10 -1
  65. package/dist/iso/optimistic.js +45 -5
  66. package/dist/iso/outcomes.d.ts +50 -0
  67. package/dist/iso/outcomes.js +67 -0
  68. package/dist/iso/page-only.d.ts +5 -0
  69. package/dist/iso/page-only.js +20 -0
  70. package/dist/iso/page.d.ts +3 -3
  71. package/dist/iso/page.js +3 -3
  72. package/dist/iso/use-action-result.d.ts +25 -0
  73. package/dist/iso/use-action-result.js +39 -0
  74. package/dist/iso/use-form-status.d.ts +5 -0
  75. package/dist/iso/use-form-status.js +13 -0
  76. package/dist/page.d.ts +1 -0
  77. package/dist/page.d.ts.map +1 -0
  78. package/dist/page.js +8 -0
  79. package/dist/server/actions-handler.d.ts +27 -6
  80. package/dist/server/actions-handler.js +121 -52
  81. package/dist/server/context.js +1 -1
  82. package/dist/server/index.d.ts +3 -2
  83. package/dist/server/index.js +3 -2
  84. package/dist/server/loaders-handler.d.ts +24 -0
  85. package/dist/server/loaders-handler.js +128 -18
  86. package/dist/server/page-action-handler.d.ts +63 -0
  87. package/dist/server/page-action-handler.js +274 -0
  88. package/dist/server/page-action-resolvers.d.ts +28 -0
  89. package/dist/server/page-action-resolvers.js +147 -0
  90. package/dist/server/render.d.ts +2 -0
  91. package/dist/server/render.js +142 -33
  92. package/dist/server/route-server-modules.d.ts +48 -8
  93. package/dist/server/route-server-modules.js +190 -7
  94. package/dist/server/speculation-rules.d.ts +3 -0
  95. package/dist/server/speculation-rules.js +8 -0
  96. package/dist/server/sse.d.ts +50 -12
  97. package/dist/server/sse.js +130 -53
  98. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  99. package/dist/vite/adapter-cloudflare.js +25 -0
  100. package/dist/vite/adapter-node.d.ts +2 -0
  101. package/dist/vite/adapter-node.js +49 -0
  102. package/dist/vite/adapter.d.ts +29 -0
  103. package/dist/vite/adapter.js +1 -0
  104. package/dist/vite/client-shim.js +5 -4
  105. package/dist/vite/guard-strip.js +52 -27
  106. package/dist/vite/hono-preact.d.ts +6 -6
  107. package/dist/vite/hono-preact.js +48 -77
  108. package/dist/vite/index.d.ts +2 -1
  109. package/dist/vite/index.js +1 -1
  110. package/dist/vite/node-dev-server.d.ts +4 -0
  111. package/dist/vite/node-dev-server.js +121 -0
  112. package/dist/vite/server-entry.d.ts +30 -7
  113. package/dist/vite/server-entry.js +170 -79
  114. package/dist/vite/server-exports-contract.d.ts +6 -0
  115. package/dist/vite/server-exports-contract.js +43 -0
  116. package/dist/vite/server-loader-validation.js +36 -9
  117. package/dist/vite/server-loaders-parser.d.ts +17 -1
  118. package/dist/vite/server-loaders-parser.js +41 -0
  119. package/dist/vite/server-only.js +20 -2
  120. 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
+ }
@@ -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>;
@@ -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 { GuardRedirect, env } from '../iso/index.js';
5
- import { HonoRequestContext, runRequestScope, captureRequestScope, takeServerStreamingLoaders, } from '../iso/internal/index.js';
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, '&amp;')
@@ -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
- 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.
100
+ rootResult = await runRequestScope(async () => {
49
101
  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 };
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 instanceof GuardRedirect)
61
- return c.redirect(e.location);
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
- 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
- },
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 / actionsHandler accept. Previously returned a record
5
- * keyed by stringified integers; those keys were unused at the call site
6
- * (handlers iterate values only), so the array form is just the same data
7
- * without dead surface. Vite-style globs (`Record<string, ...>`) are still
8
- * accepted by the handlers directly; this helper is for the
9
- * routes-manifest-driven path used by the framework's generated server
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 / actionsHandler accept. Previously returned a record
4
- * keyed by stringified integers; those keys were unused at the call site
5
- * (handlers iterate values only), so the array form is just the same data
6
- * without dead surface. Vite-style globs (`Record<string, ...>`) are still
7
- * accepted by the handlers directly; this helper is for the
8
- * routes-manifest-driven path used by the framework's generated server
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
+ }