hono-preact 0.1.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 +47 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +1 -0
- package/dist/iso/action.d.ts +78 -0
- package/dist/iso/action.js +189 -0
- package/dist/iso/cache.d.ts +17 -0
- package/dist/iso/cache.js +122 -0
- package/dist/iso/client-script.d.ts +2 -0
- package/dist/iso/client-script.js +13 -0
- package/dist/iso/define-loader.d.ts +47 -0
- package/dist/iso/define-loader.js +118 -0
- package/dist/iso/define-page.d.ts +10 -0
- package/dist/iso/define-page.js +7 -0
- package/dist/iso/define-routes.d.ts +34 -0
- package/dist/iso/define-routes.js +251 -0
- package/dist/iso/form.d.ts +7 -0
- package/dist/iso/form.js +40 -0
- package/dist/iso/guard.d.ts +33 -0
- package/dist/iso/guard.js +32 -0
- package/dist/iso/head.d.ts +6 -0
- package/dist/iso/head.js +4 -0
- package/dist/iso/index.d.ts +30 -0
- package/dist/iso/index.js +29 -0
- package/dist/iso/internal/cache-key.d.ts +2 -0
- package/dist/iso/internal/cache-key.js +8 -0
- package/dist/iso/internal/contexts.d.ts +12 -0
- package/dist/iso/internal/contexts.js +7 -0
- package/dist/iso/internal/envelope.d.ts +8 -0
- package/dist/iso/internal/envelope.js +21 -0
- package/dist/iso/internal/guard-noop.d.ts +7 -0
- package/dist/iso/internal/guard-noop.js +6 -0
- package/dist/iso/internal/guards.d.ts +14 -0
- package/dist/iso/internal/guards.js +54 -0
- package/dist/iso/internal/loader-fetch.d.ts +20 -0
- package/dist/iso/internal/loader-fetch.js +123 -0
- package/dist/iso/internal/loader-runner.d.ts +15 -0
- package/dist/iso/internal/loader-runner.js +59 -0
- package/dist/iso/internal/loader-stub.d.ts +8 -0
- package/dist/iso/internal/loader-stub.js +19 -0
- package/dist/iso/internal/loader.d.ts +13 -0
- package/dist/iso/internal/loader.js +31 -0
- package/dist/iso/internal/optimistic-overlay.d.ts +10 -0
- package/dist/iso/internal/optimistic-overlay.js +11 -0
- package/dist/iso/internal/preload.d.ts +15 -0
- package/dist/iso/internal/preload.js +36 -0
- package/dist/iso/internal/route-boundary.d.ts +25 -0
- package/dist/iso/internal/route-boundary.js +24 -0
- package/dist/iso/internal/route-change.d.ts +4 -0
- package/dist/iso/internal/route-change.js +18 -0
- package/dist/iso/internal/route-locations.d.ts +11 -0
- package/dist/iso/internal/route-locations.js +15 -0
- package/dist/iso/internal/sse-decoder.d.ts +5 -0
- package/dist/iso/internal/sse-decoder.js +43 -0
- package/dist/iso/internal/stream-registry.d.ts +60 -0
- package/dist/iso/internal/stream-registry.js +98 -0
- package/dist/iso/internal/streaming-ssr.d.ts +17 -0
- package/dist/iso/internal/streaming-ssr.js +32 -0
- package/dist/iso/internal/use-loader-runner.d.ts +12 -0
- package/dist/iso/internal/use-loader-runner.js +185 -0
- package/dist/iso/internal/wrap-promise.d.ts +4 -0
- package/dist/iso/internal/wrap-promise.js +24 -0
- package/dist/iso/internal.d.ts +19 -0
- package/dist/iso/internal.js +49 -0
- package/dist/iso/is-browser.d.ts +4 -0
- package/dist/iso/is-browser.js +6 -0
- package/dist/iso/optimistic-action.d.ts +19 -0
- package/dist/iso/optimistic-action.js +25 -0
- package/dist/iso/optimistic.d.ts +5 -0
- package/dist/iso/optimistic.js +31 -0
- package/dist/iso/page.d.ts +16 -0
- package/dist/iso/page.js +10 -0
- package/dist/iso/prefetch.d.ts +22 -0
- package/dist/iso/prefetch.js +78 -0
- package/dist/iso/reload-context.d.ts +6 -0
- package/dist/iso/reload-context.js +9 -0
- package/dist/iso/route-change.d.ts +2 -0
- package/dist/iso/route-change.js +10 -0
- package/dist/iso/view-transitions.d.ts +1 -0
- package/dist/iso/view-transitions.js +6 -0
- package/dist/server/actions-handler.d.ts +33 -0
- package/dist/server/actions-handler.js +159 -0
- package/dist/server/context.d.ts +6 -0
- package/dist/server/context.js +6 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +5 -0
- package/dist/server/loaders-handler.d.ts +30 -0
- package/dist/server/loaders-handler.js +117 -0
- package/dist/server/middleware/location.d.ts +1 -0
- package/dist/server/middleware/location.js +10 -0
- package/dist/server/render.d.ts +5 -0
- package/dist/server/render.js +203 -0
- package/dist/server/route-server-modules.d.ts +12 -0
- package/dist/server/route-server-modules.js +13 -0
- package/dist/server/sse.d.ts +22 -0
- package/dist/server/sse.js +83 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1 -0
- package/dist/vite/client-entry.d.ts +10 -0
- package/dist/vite/client-entry.js +47 -0
- package/dist/vite/client-shim.d.ts +12 -0
- package/dist/vite/client-shim.js +62 -0
- package/dist/vite/guard-strip.d.ts +2 -0
- package/dist/vite/guard-strip.js +96 -0
- package/dist/vite/hono-preact.d.ts +12 -0
- package/dist/vite/hono-preact.js +111 -0
- package/dist/vite/index.d.ts +7 -0
- package/dist/vite/index.js +7 -0
- package/dist/vite/module-key-plugin.d.ts +12 -0
- package/dist/vite/module-key-plugin.js +114 -0
- package/dist/vite/module-key.d.ts +11 -0
- package/dist/vite/module-key.js +20 -0
- package/dist/vite/parser-options.d.ts +16 -0
- package/dist/vite/parser-options.js +22 -0
- package/dist/vite/server-entry.d.ts +26 -0
- package/dist/vite/server-entry.js +201 -0
- package/dist/vite/server-loader-validation.d.ts +2 -0
- package/dist/vite/server-loader-validation.js +73 -0
- package/dist/vite/server-loaders-parser.d.ts +22 -0
- package/dist/vite/server-loaders-parser.js +64 -0
- package/dist/vite/server-only.d.ts +3 -0
- package/dist/vite/server-only.js +244 -0
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
import { type ActionGuardFn } from '../iso/index';
|
|
3
|
+
type GlobModule = {
|
|
4
|
+
__moduleKey?: unknown;
|
|
5
|
+
serverActions?: Record<string, unknown>;
|
|
6
|
+
actionGuards?: ActionGuardFn[];
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
type LazyGlob = Record<string, () => Promise<unknown>>;
|
|
10
|
+
type EagerGlob = Record<string, GlobModule>;
|
|
11
|
+
export interface ActionsHandlerOptions {
|
|
12
|
+
/**
|
|
13
|
+
* When true, rebuild the actions map on every request (so edits to
|
|
14
|
+
* `.server.ts` files take effect without a server restart). When false
|
|
15
|
+
* (default), the map is built once on first request and cached for the
|
|
16
|
+
* life of the process. The framework's generated server entry passes
|
|
17
|
+
* `{ dev: import.meta.env.DEV }`; custom wirings should set this
|
|
18
|
+
* explicitly rather than relying on a Vite-only build-time constant.
|
|
19
|
+
*/
|
|
20
|
+
dev?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Called for every error an action throws (other than `ActionGuardError`,
|
|
23
|
+
* which is treated as a structured response). Use it to hook into your
|
|
24
|
+
* observability stack (Sentry, console, etc.). The handler still
|
|
25
|
+
* responds with a sanitized 500; the hook is purely a side channel.
|
|
26
|
+
*/
|
|
27
|
+
onError?: (err: unknown, ctx: {
|
|
28
|
+
module: string;
|
|
29
|
+
action: string;
|
|
30
|
+
}) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function actionsHandler(glob: LazyGlob | EagerGlob, opts?: ActionsHandlerOptions): MiddlewareHandler;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ActionGuardError, GuardRedirect, } from '../iso/index.js';
|
|
2
|
+
import { runRequestScope } from '../iso/internal/index.js';
|
|
3
|
+
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
|
+
async function buildActionsMap(glob) {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const [, moduleOrLoader] of Object.entries(glob)) {
|
|
7
|
+
const mod = typeof moduleOrLoader === 'function'
|
|
8
|
+
? await moduleOrLoader()
|
|
9
|
+
: moduleOrLoader;
|
|
10
|
+
const key = mod.__moduleKey;
|
|
11
|
+
if (typeof key === 'string' && mod.serverActions) {
|
|
12
|
+
result[key] = {
|
|
13
|
+
actions: mod.serverActions,
|
|
14
|
+
guards: mod.actionGuards ?? [],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
async function runActionGuards(guards, ctx) {
|
|
21
|
+
const run = async (index) => {
|
|
22
|
+
if (index >= guards.length)
|
|
23
|
+
return;
|
|
24
|
+
// Track whether the guard explicitly opted to pass control on. Without
|
|
25
|
+
// this check a guard that forgets `return next()` (or just `next()`)
|
|
26
|
+
// would silently fall through to the action body — the OPPOSITE of every
|
|
27
|
+
// other middleware system users have seen (Express, Hono, Koa: blocked
|
|
28
|
+
// by default; opt in to pass). Make ambiguous returns loud instead of
|
|
29
|
+
// silently insecure. To block, throw ActionGuardError. To pass, await
|
|
30
|
+
// (or return) next().
|
|
31
|
+
let nextCalled = false;
|
|
32
|
+
await guards[index](ctx, () => {
|
|
33
|
+
nextCalled = true;
|
|
34
|
+
return run(index + 1);
|
|
35
|
+
});
|
|
36
|
+
if (!nextCalled) {
|
|
37
|
+
throw new Error(`ActionGuard for '${ctx.module}.${ctx.action}' returned without ` +
|
|
38
|
+
`calling next() or throwing. Guards must either: (a) await/return ` +
|
|
39
|
+
`next() to pass control on, or (b) throw ActionGuardError to block. ` +
|
|
40
|
+
`Returning silently is ambiguous and would let the action run.`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
await run(0);
|
|
44
|
+
}
|
|
45
|
+
export function actionsHandler(glob, opts = {}) {
|
|
46
|
+
const { dev = false, onError } = opts;
|
|
47
|
+
let cachedMapPromise = null;
|
|
48
|
+
return async (c) => {
|
|
49
|
+
const actionsMapPromise = dev
|
|
50
|
+
? buildActionsMap(glob)
|
|
51
|
+
: (cachedMapPromise ??= buildActionsMap(glob).catch((err) => {
|
|
52
|
+
cachedMapPromise = null;
|
|
53
|
+
return Promise.reject(err);
|
|
54
|
+
}));
|
|
55
|
+
let actionsMap;
|
|
56
|
+
try {
|
|
57
|
+
actionsMap = await actionsMapPromise;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
61
|
+
return c.json({ error: `Failed to load actions: ${message}` }, 503);
|
|
62
|
+
}
|
|
63
|
+
let module;
|
|
64
|
+
let action;
|
|
65
|
+
let payload;
|
|
66
|
+
const contentType = c.req.header('Content-Type') ?? '';
|
|
67
|
+
if (contentType.startsWith('multipart/form-data')) {
|
|
68
|
+
let formData;
|
|
69
|
+
try {
|
|
70
|
+
formData = await c.req.formData();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return c.json({ error: 'Invalid form data' }, 400);
|
|
74
|
+
}
|
|
75
|
+
const rawModule = formData.get('__module');
|
|
76
|
+
const rawAction = formData.get('__action');
|
|
77
|
+
if (typeof rawModule !== 'string' || typeof rawAction !== 'string') {
|
|
78
|
+
return c.json({ error: 'Form data must include __module and __action fields' }, 400);
|
|
79
|
+
}
|
|
80
|
+
module = rawModule;
|
|
81
|
+
action = rawAction;
|
|
82
|
+
const payloadObj = {};
|
|
83
|
+
for (const [key, value] of formData.entries()) {
|
|
84
|
+
if (key === '__module' || key === '__action')
|
|
85
|
+
continue;
|
|
86
|
+
const existing = payloadObj[key];
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
payloadObj[key] = Array.isArray(existing)
|
|
89
|
+
? [...existing, value]
|
|
90
|
+
: [existing, value];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
payloadObj[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
payload = payloadObj;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
let body;
|
|
100
|
+
try {
|
|
101
|
+
body = await c.req.json();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
105
|
+
}
|
|
106
|
+
const { module: m, action: a, payload: p } = body;
|
|
107
|
+
if (typeof m !== 'string' || typeof a !== 'string') {
|
|
108
|
+
return c.json({ error: 'Request body must include string fields: module, action' }, 400);
|
|
109
|
+
}
|
|
110
|
+
module = m;
|
|
111
|
+
action = a;
|
|
112
|
+
payload = p;
|
|
113
|
+
}
|
|
114
|
+
const entry = actionsMap[module];
|
|
115
|
+
if (!entry) {
|
|
116
|
+
return c.json({ error: `Module '${module}' not found` }, 404);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await runActionGuards(entry.guards, { c, module, action, payload });
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err instanceof ActionGuardError) {
|
|
123
|
+
return c.json({ error: err.message }, err.status);
|
|
124
|
+
}
|
|
125
|
+
if (err instanceof GuardRedirect) {
|
|
126
|
+
return c.json({ __redirect: err.location });
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const fn = entry.actions[action];
|
|
131
|
+
if (typeof fn !== 'function') {
|
|
132
|
+
return c.json({ error: `Action '${action}' not found in module '${module}'` }, 404);
|
|
133
|
+
}
|
|
134
|
+
const signal = c.req.raw.signal;
|
|
135
|
+
const actionCtx = { c, signal };
|
|
136
|
+
let result;
|
|
137
|
+
try {
|
|
138
|
+
result = await runRequestScope(() => fn(actionCtx, payload));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof ActionGuardError) {
|
|
142
|
+
return c.json({ error: err.message }, err.status);
|
|
143
|
+
}
|
|
144
|
+
if (err instanceof GuardRedirect) {
|
|
145
|
+
return c.json({ __redirect: err.location });
|
|
146
|
+
}
|
|
147
|
+
onError?.(err, { module, action });
|
|
148
|
+
const message = dev && err instanceof Error ? err.message : 'Action failed';
|
|
149
|
+
return c.json({ error: message }, 500);
|
|
150
|
+
}
|
|
151
|
+
if (isAsyncGenerator(result)) {
|
|
152
|
+
return sseGeneratorResponse(c, result, { emitResult: true });
|
|
153
|
+
}
|
|
154
|
+
if (result instanceof ReadableStream) {
|
|
155
|
+
return sseReadableStreamResponse(c, result);
|
|
156
|
+
}
|
|
157
|
+
return c.json(result);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { HonoContext, useHonoContext } from './context.js';
|
|
2
|
+
export { renderPage } from './render.js';
|
|
3
|
+
export { actionsHandler } from './actions-handler.js';
|
|
4
|
+
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
+
export { routeServerModules } from './route-server-modules.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { HonoContext, useHonoContext } from './context.js';
|
|
2
|
+
export { renderPage } from './render.js';
|
|
3
|
+
export { actionsHandler } from './actions-handler.js';
|
|
4
|
+
export { loadersHandler } from './loaders-handler.js';
|
|
5
|
+
export { routeServerModules } from './route-server-modules.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
type GlobModule = {
|
|
3
|
+
default?: unknown;
|
|
4
|
+
__moduleKey?: unknown;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
type LazyGlob = Record<string, () => Promise<unknown>>;
|
|
8
|
+
type EagerGlob = Record<string, GlobModule>;
|
|
9
|
+
export interface LoadersHandlerOptions {
|
|
10
|
+
/**
|
|
11
|
+
* When true, rebuild the loaders map on every request (so edits to
|
|
12
|
+
* `.server.ts` files take effect without a server restart). When false
|
|
13
|
+
* (default), the map is built once on first request and cached for the
|
|
14
|
+
* life of the process. The framework's generated server entry passes
|
|
15
|
+
* `{ dev: import.meta.env.DEV }`; custom wirings should set this
|
|
16
|
+
* explicitly rather than relying on a Vite-only build-time constant.
|
|
17
|
+
*/
|
|
18
|
+
dev?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Called for every error a loader throws. Use it to hook into your
|
|
21
|
+
* observability stack (Sentry, console, etc.). The handler still
|
|
22
|
+
* responds with a sanitized 500; the hook is purely a side channel.
|
|
23
|
+
*/
|
|
24
|
+
onError?: (err: unknown, ctx: {
|
|
25
|
+
module: string;
|
|
26
|
+
loader: string;
|
|
27
|
+
}) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare function loadersHandler(glob: LazyGlob | EagerGlob, opts?: LoadersHandlerOptions): MiddlewareHandler;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { GuardRedirect } from '../iso/index.js';
|
|
2
|
+
import { runRequestScope } from '../iso/internal/index.js';
|
|
3
|
+
import { sseGeneratorResponse, sseReadableStreamResponse, isAsyncGenerator, } from './sse.js';
|
|
4
|
+
async function buildLoadersMap(glob) {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const [, moduleOrLoader] of Object.entries(glob)) {
|
|
7
|
+
const mod = typeof moduleOrLoader === 'function'
|
|
8
|
+
? await moduleOrLoader()
|
|
9
|
+
: moduleOrLoader;
|
|
10
|
+
const moduleKey = mod.__moduleKey;
|
|
11
|
+
if (typeof moduleKey !== 'string')
|
|
12
|
+
continue;
|
|
13
|
+
const sl = mod.serverLoaders;
|
|
14
|
+
if (sl && typeof sl === 'object') {
|
|
15
|
+
for (const [name, val] of Object.entries(sl)) {
|
|
16
|
+
// Two accepted shapes:
|
|
17
|
+
// 1. a raw loader function `(ctx) => ...` (used by unit-test fixtures)
|
|
18
|
+
// 2. a `LoaderRef` returned by `defineLoader(fn)`, whose `.fn`
|
|
19
|
+
// property carries the original loader (used by user code)
|
|
20
|
+
if (typeof val === 'function') {
|
|
21
|
+
result[`${moduleKey}::${name}`] = val;
|
|
22
|
+
}
|
|
23
|
+
else if (val && typeof val.fn === 'function') {
|
|
24
|
+
result[`${moduleKey}::${name}`] = val.fn;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function validateLocation(loc) {
|
|
32
|
+
if (typeof loc !== 'object' || loc === null)
|
|
33
|
+
return null;
|
|
34
|
+
const o = loc;
|
|
35
|
+
if (typeof o.path !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof o.pathParams !== 'object' || o.pathParams === null)
|
|
38
|
+
return null;
|
|
39
|
+
if (typeof o.searchParams !== 'object' || o.searchParams === null)
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
path: o.path,
|
|
43
|
+
pathParams: o.pathParams,
|
|
44
|
+
searchParams: o.searchParams,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function loadersHandler(glob, opts = {}) {
|
|
48
|
+
const { dev = false, onError } = opts;
|
|
49
|
+
let cachedMapPromise = null;
|
|
50
|
+
return async (c) => {
|
|
51
|
+
const loadersMapPromise = dev
|
|
52
|
+
? buildLoadersMap(glob)
|
|
53
|
+
: (cachedMapPromise ??= buildLoadersMap(glob).catch((err) => {
|
|
54
|
+
cachedMapPromise = null;
|
|
55
|
+
return Promise.reject(err);
|
|
56
|
+
}));
|
|
57
|
+
let loadersMap;
|
|
58
|
+
try {
|
|
59
|
+
loadersMap = await loadersMapPromise;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
return c.json({ error: `Failed to load loaders: ${message}` }, 503);
|
|
64
|
+
}
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = await c.req.json();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return c.json({ error: 'Invalid JSON body' }, 400);
|
|
71
|
+
}
|
|
72
|
+
const { module, loader: loaderName, location } = body;
|
|
73
|
+
if (typeof module !== 'string') {
|
|
74
|
+
return c.json({ error: 'Request body must include string field: module' }, 400);
|
|
75
|
+
}
|
|
76
|
+
if (typeof loaderName !== 'string') {
|
|
77
|
+
return c.json({ error: 'Request body must include string field: loader' }, 400);
|
|
78
|
+
}
|
|
79
|
+
const validatedLocation = validateLocation(location);
|
|
80
|
+
if (!validatedLocation) {
|
|
81
|
+
return c.json({
|
|
82
|
+
error: 'Request body must include object field: location with shape { path: string, pathParams: object, searchParams: object }',
|
|
83
|
+
}, 400);
|
|
84
|
+
}
|
|
85
|
+
const loaderFn = loadersMap[`${module}::${loaderName}`];
|
|
86
|
+
if (!loaderFn) {
|
|
87
|
+
return c.json({ error: `Loader '${module}::${loaderName}' not found` }, 404);
|
|
88
|
+
}
|
|
89
|
+
const signal = c.req.raw.signal;
|
|
90
|
+
try {
|
|
91
|
+
const result = await runRequestScope(() => Promise.resolve(loaderFn({ c, location: validatedLocation, signal })), { honoContext: c });
|
|
92
|
+
if (isAsyncGenerator(result)) {
|
|
93
|
+
return sseGeneratorResponse(c, result, { emitResult: false });
|
|
94
|
+
}
|
|
95
|
+
if (result instanceof ReadableStream) {
|
|
96
|
+
return sseReadableStreamResponse(c, result);
|
|
97
|
+
}
|
|
98
|
+
return c.json(result);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// GuardRedirect thrown from a loader (or a guard that runs inside it)
|
|
102
|
+
// is a control-flow signal, not an error. The client RPC stub
|
|
103
|
+
// recognizes the `__redirect` envelope and navigates the browser
|
|
104
|
+
// rather than surfacing this as a thrown error in user code.
|
|
105
|
+
if (err instanceof GuardRedirect) {
|
|
106
|
+
return c.json({ __redirect: err.location });
|
|
107
|
+
}
|
|
108
|
+
onError?.(err, { module, loader: loaderName });
|
|
109
|
+
// In production we never leak the loader's error message: it may
|
|
110
|
+
// carry PII, internal stack hints, or details that help an attacker
|
|
111
|
+
// probe the system. Loader errors users want to surface should be
|
|
112
|
+
// returned as data, not thrown.
|
|
113
|
+
const message = dev && err instanceof Error ? err.message : 'Loader failed';
|
|
114
|
+
return c.json({ error: message }, 500);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const location: import("hono").MiddlewareHandler<any, string, {}, Response>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { locationStub } from "preact-iso/prerender";
|
|
3
|
+
export const location = createMiddleware(async (c, next) => {
|
|
4
|
+
const url = new URL(c.req.url);
|
|
5
|
+
// Pass pathname + search so preact-iso's SSR `globalThis.location` carries
|
|
6
|
+
// the query string. Streaming loaders read `ctx.location.searchParams` and
|
|
7
|
+
// would otherwise see empty params on initial render.
|
|
8
|
+
locationStub(url.pathname + url.search);
|
|
9
|
+
await next();
|
|
10
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
+
import { createDispatcher, HoofdProvider } from 'hoofd/preact';
|
|
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';
|
|
6
|
+
function escapeHtml(str) {
|
|
7
|
+
return str
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"');
|
|
12
|
+
}
|
|
13
|
+
// JSON.stringify produces a string that is JS-evaluable but NOT safe to embed
|
|
14
|
+
// inside <script>...</script>: any '</script>' substring in the payload closes
|
|
15
|
+
// the script tag and turns the rest into HTML. Escaping '<' as < keeps the
|
|
16
|
+
// output valid JSON (and so still parseable by the consumer) while preventing
|
|
17
|
+
// </script>, <!--, and <![CDATA[ from escaping the script context.
|
|
18
|
+
function jsonForScript(value) {
|
|
19
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
20
|
+
}
|
|
21
|
+
function toAttrs(obj) {
|
|
22
|
+
return Object.entries(obj)
|
|
23
|
+
.filter(([, v]) => v != null)
|
|
24
|
+
.map(([k, v]) => `${k}="${escapeHtml(String(v))}"`)
|
|
25
|
+
.join(' ');
|
|
26
|
+
}
|
|
27
|
+
export async function renderPage(c, node, options) {
|
|
28
|
+
const dispatcher = createDispatcher();
|
|
29
|
+
const previousEnv = env.current;
|
|
30
|
+
env.current = 'server';
|
|
31
|
+
let html;
|
|
32
|
+
let streamingLoaders;
|
|
33
|
+
// Binder that re-enters the per-request ALS store; populated inside the
|
|
34
|
+
// scope below. Streaming loaders that yield, then resume from outside
|
|
35
|
+
// `runRequestScope` (the ReadableStream.start callback runs after this
|
|
36
|
+
// frame returns) lose ALS propagation on V8. Wrapping the drain in this
|
|
37
|
+
// binder restores per-request isolation for `getRequestStore` /
|
|
38
|
+
// `getRequestHonoContext` reads from generator continuations.
|
|
39
|
+
let bindRequestScope = (fn) => fn();
|
|
40
|
+
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.
|
|
49
|
+
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 };
|
|
55
|
+
}, { honoContext: c });
|
|
56
|
+
html = result.html;
|
|
57
|
+
streamingLoaders = result.streamingLoaders;
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (e instanceof GuardRedirect)
|
|
61
|
+
return c.redirect(e.location);
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
env.current = previousEnv;
|
|
66
|
+
}
|
|
67
|
+
const { title, lang, metas = [], links = [] } = dispatcher.toStatic();
|
|
68
|
+
// Only inject a <title> when hoofd produced one or the caller provided a
|
|
69
|
+
// defaultTitle. Layouts that render their own static <title> (via <Head>)
|
|
70
|
+
// would otherwise be overridden by an empty injected one (browsers use
|
|
71
|
+
// the last <title> in <head>).
|
|
72
|
+
const titleSource = title ?? options?.defaultTitle;
|
|
73
|
+
const headTags = [
|
|
74
|
+
titleSource != null ? `<title>${escapeHtml(titleSource)}</title>` : '',
|
|
75
|
+
...metas.map((m) => `<meta ${toAttrs(m)} />`),
|
|
76
|
+
...links.map((l) => `<link ${toAttrs(l)} />`),
|
|
77
|
+
]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join('\n ');
|
|
80
|
+
const inner = html.replace('</head>', `${headTags}\n </head>`);
|
|
81
|
+
// If the rendered tree already starts with <html>, the user's Layout owns
|
|
82
|
+
// the document shell. Inject hoofd's lang into that <html> tag (if hoofd
|
|
83
|
+
// dispatched one) and emit only the doctype; do not double-wrap.
|
|
84
|
+
// Otherwise (custom server entry rendering a fragment) keep the framework's
|
|
85
|
+
// <html lang> wrapper for backward compatibility.
|
|
86
|
+
const startsWithHtml = /^\s*<html(\s|>)/i.test(inner);
|
|
87
|
+
const fullHtml = startsWithHtml
|
|
88
|
+
? lang != null
|
|
89
|
+
? inner.replace(/<html(\s|>)/i, `<html lang="${escapeHtml(lang)}"$1`)
|
|
90
|
+
: inner
|
|
91
|
+
: `<html lang="${escapeHtml(lang ?? 'en-US')}">\n${inner}\n</html>`;
|
|
92
|
+
// Non-streaming case: preserve existing single-shot behavior.
|
|
93
|
+
if (streamingLoaders.length === 0) {
|
|
94
|
+
return c.html(`<!doctype html>${fullHtml}`);
|
|
95
|
+
}
|
|
96
|
+
// Streaming case: split at </body> so we can interleave per-loader chunk
|
|
97
|
+
// script tags between the rendered body and the closing tags.
|
|
98
|
+
const bodyCloseIdx = fullHtml.lastIndexOf('</body>');
|
|
99
|
+
const beforeBody = bodyCloseIdx >= 0 ? fullHtml.slice(0, bodyCloseIdx) : fullHtml;
|
|
100
|
+
const afterBody = bodyCloseIdx >= 0 ? fullHtml.slice(bodyCloseIdx) : '';
|
|
101
|
+
// Inline bootstrap installs a queue on window.__HP_STREAM__ so that
|
|
102
|
+
// events flushed BEFORE the client bundle loads are buffered. The
|
|
103
|
+
// client entry calls installStreamRegistry() which drains the queue.
|
|
104
|
+
// Each emitted script self-removes after running so the DOM doesn't
|
|
105
|
+
// accumulate inert <script> nodes over the life of the page.
|
|
106
|
+
//
|
|
107
|
+
// The queue is capped at HP_STREAM_QUEUE_CAP events. If the client bundle
|
|
108
|
+
// never loads (slow network, blocked CDN, ad-blocker on the script URL),
|
|
109
|
+
// a long-running streaming page would otherwise grow this buffer without
|
|
110
|
+
// bound. When the cap is hit, additional events are silently dropped and
|
|
111
|
+
// `capped` is set so installStreamRegistry can see lossage occurred.
|
|
112
|
+
// Picked 1000 so realistic apps (50-100 chunks per page is high) never
|
|
113
|
+
// hit it; pathological cases trade data loss for bounded memory.
|
|
114
|
+
const HP_STREAM_QUEUE_CAP = 1000;
|
|
115
|
+
const bootstrap = `<script>window.__HP_STREAM__=window.__HP_STREAM__||{queue:[],capped:false,` +
|
|
116
|
+
`_p(e){if(this.queue.length>=${HP_STREAM_QUEUE_CAP}){this.capped=true;return}this.queue.push(e)},` +
|
|
117
|
+
`push(id,v){this._p({type:"push",loaderId:id,value:v})},` +
|
|
118
|
+
`end(id){this._p({type:"end",loaderId:id})},` +
|
|
119
|
+
`error(id,e){this._p({type:"error",loaderId:id,error:e})}};` +
|
|
120
|
+
`document.currentScript.remove()</script>`;
|
|
121
|
+
const encoder = new TextEncoder();
|
|
122
|
+
const requestSignal = c.req.raw.signal;
|
|
123
|
+
// Tracks whether the consumer (Hono/runtime) has cancelled or the request
|
|
124
|
+
// signal has aborted. Set by `cancel()` and by the abort listener below.
|
|
125
|
+
// Every controller op in the pump short-circuits when this is true so we
|
|
126
|
+
// do not enqueue or close on an already-terminated controller (which would
|
|
127
|
+
// throw and, for the per-loader catch, get logged as a synthetic error
|
|
128
|
+
// chunk that nobody can read anyway).
|
|
129
|
+
let aborted = false;
|
|
130
|
+
const responseStream = new ReadableStream({
|
|
131
|
+
start(controller) {
|
|
132
|
+
// Re-enter the captured request scope so generator continuations and
|
|
133
|
+
// anything they touch (e.g. `getRequestHonoContext`, per-request loader
|
|
134
|
+
// caches) see the same per-request store the initial prerender saw.
|
|
135
|
+
return bindRequestScope(async () => {
|
|
136
|
+
try {
|
|
137
|
+
if (aborted)
|
|
138
|
+
return;
|
|
139
|
+
controller.enqueue(encoder.encode(`<!doctype html>${beforeBody}\n${bootstrap}\n`));
|
|
140
|
+
// Drive each pending generator in parallel; emit script tags per chunk.
|
|
141
|
+
await Promise.all(streamingLoaders.map(async ({ loaderId, gen }) => {
|
|
142
|
+
try {
|
|
143
|
+
while (!aborted) {
|
|
144
|
+
const step = await gen.next();
|
|
145
|
+
if (aborted)
|
|
146
|
+
return;
|
|
147
|
+
if (step.done) {
|
|
148
|
+
controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.end(${jsonForScript(loaderId)});document.currentScript.remove()</script>\n`));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.push(${jsonForScript(loaderId)},${jsonForScript(step.value)});document.currentScript.remove()</script>\n`));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (aborted)
|
|
156
|
+
return;
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
const name = err instanceof Error ? err.name : 'Error';
|
|
159
|
+
controller.enqueue(encoder.encode(`<script>window.__HP_STREAM__.error(${jsonForScript(loaderId)},${jsonForScript({ message, name })});document.currentScript.remove()</script>\n`));
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
if (!aborted)
|
|
163
|
+
controller.enqueue(encoder.encode(afterBody));
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
if (!aborted)
|
|
167
|
+
controller.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
cancel() {
|
|
172
|
+
aborted = true;
|
|
173
|
+
for (const { gen } of streamingLoaders) {
|
|
174
|
+
gen.return(undefined).catch(() => {
|
|
175
|
+
/* swallow */
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
requestSignal.addEventListener('abort', () => {
|
|
181
|
+
aborted = true;
|
|
182
|
+
for (const { gen } of streamingLoaders) {
|
|
183
|
+
gen.return(undefined).catch(() => {
|
|
184
|
+
/* swallow */
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
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
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RoutesManifest } from '../iso/index';
|
|
2
|
+
/**
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
export declare function routeServerModules(manifest: RoutesManifest): ReadonlyArray<() => Promise<unknown>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
export function routeServerModules(manifest) {
|
|
12
|
+
return manifest.serverImports;
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
export type SseGeneratorOptions = {
|
|
3
|
+
/** When true, the generator's return value is emitted as `event: result`. */
|
|
4
|
+
emitResult?: boolean;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Wrap an async generator as an SSE response.
|
|
8
|
+
*
|
|
9
|
+
* Each yield is JSON-encoded and written as a `data:` event.
|
|
10
|
+
* If `emitResult` is true and the generator's return value is defined,
|
|
11
|
+
* it is written as `event: result\ndata: <json>` before the stream closes.
|
|
12
|
+
* If the generator throws, an `event: error\ndata: {"message","name"}` frame
|
|
13
|
+
* is written and the stream closes cleanly (Hono's default error handler is
|
|
14
|
+
* never invoked because we catch inside the callback).
|
|
15
|
+
*/
|
|
16
|
+
export declare function sseGeneratorResponse(c: Context, gen: AsyncGenerator<unknown, unknown, unknown>, options?: SseGeneratorOptions): Response;
|
|
17
|
+
/**
|
|
18
|
+
* Wrap a ReadableStream<T> (with T a JSON-encodable value) as an SSE response.
|
|
19
|
+
* Each enqueued chunk is JSON-encoded and written as a `data:` event.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sseReadableStreamResponse(c: Context, source: ReadableStream<unknown>): Response;
|
|
22
|
+
export declare function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown>;
|