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
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# hono-preact
|
|
2
|
+
|
|
3
|
+
A small full-stack framework. Hono on the edge, Preact in the browser, manifest driven routes, typed RPC, streaming everywhere.
|
|
4
|
+
|
|
5
|
+
- **Docs:** https://framework.sbesh.com/docs
|
|
6
|
+
- **Demo:** https://framework.sbesh.com/demo
|
|
7
|
+
- **Repo:** https://github.com/sbesh91/hono-preact
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add hono-preact hono preact preact-iso preact-render-to-string hoofd
|
|
13
|
+
pnpm add -D vite
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// vite.config.ts
|
|
20
|
+
import { defineConfig } from 'vite';
|
|
21
|
+
import { honoPreact } from 'hono-preact/vite';
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
plugins: [honoPreact()],
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// src/routes.ts
|
|
30
|
+
import { defineRoutes } from 'hono-preact';
|
|
31
|
+
export default defineRoutes([
|
|
32
|
+
{ path: '/', view: () => import('./pages/home.js') },
|
|
33
|
+
]);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Full walkthrough: https://framework.sbesh.com/docs/quick-start
|
|
37
|
+
|
|
38
|
+
## Subpaths
|
|
39
|
+
|
|
40
|
+
- `hono-preact`: iso runtime exports (routes, pages, loaders, actions, forms, guards).
|
|
41
|
+
- `hono-preact/server`: server entry, `renderPage`, SSR streaming helpers.
|
|
42
|
+
- `hono-preact/vite`: `honoPreact()` plugin for Vite.
|
|
43
|
+
- `hono-preact/internal`: advanced exports for tooling authors. No stability guarantee.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './iso/index';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './iso/index.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './iso/internal/index';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC"}
|
package/dist/internal.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './iso/internal/index.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
import type { LoaderRef } from './define-loader.js';
|
|
4
|
+
export type ActionStub<TPayload, TResult, TChunk = never> = {
|
|
5
|
+
readonly __module: string;
|
|
6
|
+
readonly __action: string;
|
|
7
|
+
readonly __phantom?: readonly [TPayload, TResult, TChunk];
|
|
8
|
+
useAction<TSnapshot = unknown>(options?: UseActionOptions<TPayload, TResult, TChunk, TSnapshot>): UseActionResult<TPayload, TResult>;
|
|
9
|
+
};
|
|
10
|
+
export type ActionCtx = {
|
|
11
|
+
c: Context;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
export type ActionFn<TPayload, TResult, TChunk = never> = ((ctx: ActionCtx, payload: TPayload) => Promise<TResult>) | ((ctx: ActionCtx, payload: TPayload) => Promise<ReadableStream<TChunk>>) | ((ctx: ActionCtx, payload: TPayload) => AsyncGenerator<TChunk, TResult, unknown>);
|
|
15
|
+
export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>): ActionStub<TPayload, TResult, TChunk>;
|
|
16
|
+
export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = {
|
|
17
|
+
/**
|
|
18
|
+
* How to update loader caches after the action commits. Three modes:
|
|
19
|
+
*
|
|
20
|
+
* - `'auto'`: re-RUN the active page's loader (the one wrapping this
|
|
21
|
+
* `useAction` call). Triggers a real fetch through `/__loaders` — it
|
|
22
|
+
* is NOT a no-op even when nothing observable changed. Equivalent to
|
|
23
|
+
* calling `useReload().reload()` from `onSuccess`.
|
|
24
|
+
* - `false` (default): do nothing.
|
|
25
|
+
* - An array of `LoaderRef`s: call `.invalidate()` on each (clear cache
|
|
26
|
+
* only; no immediate refetch). If the active page's loader is in the
|
|
27
|
+
* array, ALSO re-run it.
|
|
28
|
+
*
|
|
29
|
+
* See `/docs/reloading` for the full mental model.
|
|
30
|
+
*/
|
|
31
|
+
invalidate?: 'auto' | false | ReadonlyArray<LoaderRef<unknown>>;
|
|
32
|
+
onMutate?: (payload: TPayload) => TSnapshot;
|
|
33
|
+
onChunk?: (chunk: TChunk) => void;
|
|
34
|
+
onError?: (err: Error, snapshot: TSnapshot) => void;
|
|
35
|
+
onSuccess?: (data: TResult, snapshot: TSnapshot) => void;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The value `mutate` resolves to. A discriminated union so callers can
|
|
39
|
+
* chain on success without awaiting then probing the hook's `data`/`error`
|
|
40
|
+
* state, and without leaking unhandled rejections in fire-and-forget callers.
|
|
41
|
+
*
|
|
42
|
+
* - Success: `{ ok: true, data }`. For streaming actions that emit no
|
|
43
|
+
* `result` SSE event, `data` is `undefined`; declare `TResult = void` (or
|
|
44
|
+
* include `undefined` in its union) if your action doesn't emit a result.
|
|
45
|
+
* - Failure: `{ ok: false, error }`. The same `Error` instance is also
|
|
46
|
+
* written to the hook's `error` state and passed to `onError`.
|
|
47
|
+
*
|
|
48
|
+
* Returning a union (rather than throwing) keeps `mutate(...)` ergonomic
|
|
49
|
+
* for non-awaiting call sites — the existing `error` state field is the
|
|
50
|
+
* idiomatic way to render an error UI — while still letting awaiting
|
|
51
|
+
* callers do `if (result.ok) navigate(...)`.
|
|
52
|
+
*/
|
|
53
|
+
export type MutateResult<TResult> = {
|
|
54
|
+
ok: true;
|
|
55
|
+
data: TResult;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
error: Error;
|
|
59
|
+
};
|
|
60
|
+
export type UseActionResult<TPayload, TResult> = {
|
|
61
|
+
mutate: (payload: TPayload) => Promise<MutateResult<TResult>>;
|
|
62
|
+
pending: boolean;
|
|
63
|
+
error: Error | null;
|
|
64
|
+
data: TResult | null;
|
|
65
|
+
};
|
|
66
|
+
export declare function useAction<TPayload, TResult, TChunk = never, TSnapshot = unknown>(stub: ActionStub<TPayload, TResult, TChunk>, options?: UseActionOptions<TPayload, TResult, TChunk, TSnapshot>): UseActionResult<TPayload, TResult>;
|
|
67
|
+
export type ActionGuardContext = {
|
|
68
|
+
c: Context;
|
|
69
|
+
module: string;
|
|
70
|
+
action: string;
|
|
71
|
+
payload: unknown;
|
|
72
|
+
};
|
|
73
|
+
export type ActionGuardFn = (ctx: ActionGuardContext, next: () => Promise<void>) => Promise<void>;
|
|
74
|
+
export declare class ActionGuardError extends Error {
|
|
75
|
+
readonly status: ContentfulStatusCode;
|
|
76
|
+
constructor(message: string, status?: ContentfulStatusCode);
|
|
77
|
+
}
|
|
78
|
+
export declare const defineActionGuard: (fn: ActionGuardFn) => ActionGuardFn;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useCallback, useContext, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { ReloadContext } from './reload-context.js';
|
|
3
|
+
import { ActiveLoaderIdContext } from './internal/contexts.js';
|
|
4
|
+
export function defineAction(fn) {
|
|
5
|
+
// Runtime no-op: returns fn as-is. actionsHandler casts it back to a function.
|
|
6
|
+
// The ActionStub type is enforced only by TypeScript and the Vite plugin.
|
|
7
|
+
return fn;
|
|
8
|
+
}
|
|
9
|
+
function hasFileValues(payload) {
|
|
10
|
+
if (typeof File === 'undefined')
|
|
11
|
+
return false;
|
|
12
|
+
if (typeof payload !== 'object' || payload === null)
|
|
13
|
+
return false;
|
|
14
|
+
return Object.values(payload).some((v) => v instanceof File);
|
|
15
|
+
}
|
|
16
|
+
export function useAction(stub, options) {
|
|
17
|
+
const [pending, setPending] = useState(false);
|
|
18
|
+
const [error, setError] = useState(null);
|
|
19
|
+
const [data, setData] = useState(null);
|
|
20
|
+
const reloadCtx = useContext(ReloadContext);
|
|
21
|
+
const activeLoaderId = useContext(ActiveLoaderIdContext);
|
|
22
|
+
const stubRef = useRef(stub);
|
|
23
|
+
stubRef.current = stub;
|
|
24
|
+
const optionsRef = useRef(options);
|
|
25
|
+
optionsRef.current = options;
|
|
26
|
+
const mutate = useCallback(async (payload) => {
|
|
27
|
+
setPending(true);
|
|
28
|
+
setError(null);
|
|
29
|
+
const currentStub = stubRef.current;
|
|
30
|
+
const currentOptions = optionsRef.current;
|
|
31
|
+
let snapshot;
|
|
32
|
+
if (currentOptions?.onMutate) {
|
|
33
|
+
snapshot = currentOptions.onMutate(payload);
|
|
34
|
+
}
|
|
35
|
+
let finalResult;
|
|
36
|
+
try {
|
|
37
|
+
const stub = currentStub;
|
|
38
|
+
let response;
|
|
39
|
+
if (hasFileValues(payload)) {
|
|
40
|
+
const fd = new FormData();
|
|
41
|
+
fd.append('__module', stub.__module);
|
|
42
|
+
fd.append('__action', stub.__action);
|
|
43
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
44
|
+
if (key === '__module' || key === '__action')
|
|
45
|
+
continue;
|
|
46
|
+
if (value instanceof File) {
|
|
47
|
+
fd.append(key, value);
|
|
48
|
+
}
|
|
49
|
+
else if (typeof value === 'string') {
|
|
50
|
+
fd.append(key, value);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
fd.append(key, JSON.stringify(value));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
response = await fetch('/__actions', { method: 'POST', body: fd });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
response = await fetch('/__actions', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
module: stub.__module,
|
|
64
|
+
action: stub.__action,
|
|
65
|
+
payload,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const body = (await response.json());
|
|
71
|
+
throw new Error(body.error ?? `Action failed with status ${response.status}`);
|
|
72
|
+
}
|
|
73
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
74
|
+
// Server-side `GuardRedirect` thrown from an action (or its guards) comes
|
|
75
|
+
// back as `{ __redirect }`. Hand off to the browser; the rest of this
|
|
76
|
+
// promise will never settle, but the page is navigating away anyway.
|
|
77
|
+
if (!contentType.includes('text/event-stream')) {
|
|
78
|
+
const peek = (await response
|
|
79
|
+
.clone()
|
|
80
|
+
.json()
|
|
81
|
+
.catch(() => undefined));
|
|
82
|
+
if (peek !== null &&
|
|
83
|
+
typeof peek === 'object' &&
|
|
84
|
+
peek !== undefined &&
|
|
85
|
+
'__redirect' in peek &&
|
|
86
|
+
typeof peek.__redirect === 'string') {
|
|
87
|
+
if (typeof window !== 'undefined') {
|
|
88
|
+
window.location.assign(peek.__redirect);
|
|
89
|
+
}
|
|
90
|
+
// Cast through `as` because TS can't see this promise never settles.
|
|
91
|
+
return await new Promise(() => { });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (contentType.includes('text/event-stream') && response.body) {
|
|
95
|
+
const { readSSE } = await import('./internal/sse-decoder.js');
|
|
96
|
+
let resultValue;
|
|
97
|
+
let streamError = null;
|
|
98
|
+
for await (const ev of readSSE(response.body)) {
|
|
99
|
+
if (ev.event === 'message') {
|
|
100
|
+
try {
|
|
101
|
+
currentOptions?.onChunk?.(JSON.parse(ev.data));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// malformed JSON in stream: skip
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (ev.event === 'result') {
|
|
108
|
+
try {
|
|
109
|
+
resultValue = JSON.parse(ev.data);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
streamError = new Error(`Malformed result event in stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (ev.event === 'error') {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(ev.data);
|
|
118
|
+
streamError = new Error(parsed.message ?? 'Streamed error');
|
|
119
|
+
if (parsed.name)
|
|
120
|
+
streamError.name = parsed.name;
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
streamError = new Error(`Malformed error event in stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (streamError) {
|
|
128
|
+
throw streamError;
|
|
129
|
+
}
|
|
130
|
+
if (resultValue !== undefined) {
|
|
131
|
+
setData(resultValue);
|
|
132
|
+
currentOptions?.onSuccess?.(resultValue, snapshot);
|
|
133
|
+
finalResult = resultValue;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
currentOptions?.onSuccess?.(undefined, snapshot);
|
|
137
|
+
// Streaming actions with no `result` event resolve with undefined.
|
|
138
|
+
// Consumers should type `TResult = void` (or include `undefined`)
|
|
139
|
+
// when their action doesn't emit a result.
|
|
140
|
+
finalResult = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const result = (await response.json());
|
|
145
|
+
setData(result);
|
|
146
|
+
currentOptions?.onSuccess?.(result, snapshot);
|
|
147
|
+
finalResult = result;
|
|
148
|
+
}
|
|
149
|
+
if (currentOptions?.invalidate === 'auto') {
|
|
150
|
+
reloadCtx?.reload();
|
|
151
|
+
}
|
|
152
|
+
else if (Array.isArray(currentOptions?.invalidate)) {
|
|
153
|
+
let invalidatedActive = false;
|
|
154
|
+
for (const ref of currentOptions.invalidate) {
|
|
155
|
+
ref.invalidate();
|
|
156
|
+
if (activeLoaderId && ref.__id === activeLoaderId) {
|
|
157
|
+
invalidatedActive = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// If the user's invalidate list includes the active page's loader,
|
|
161
|
+
// also re-run that loader so the visible <Loader> picks up fresh
|
|
162
|
+
// data. Other refs (sibling pages) just clear their caches; those
|
|
163
|
+
// pages will refetch on their next mount.
|
|
164
|
+
if (invalidatedActive) {
|
|
165
|
+
reloadCtx?.reload();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
171
|
+
setError(e);
|
|
172
|
+
currentOptions?.onError?.(e, snapshot);
|
|
173
|
+
setPending(false);
|
|
174
|
+
return { ok: false, error: e };
|
|
175
|
+
}
|
|
176
|
+
setPending(false);
|
|
177
|
+
return { ok: true, data: finalResult };
|
|
178
|
+
}, []);
|
|
179
|
+
return { mutate, pending, error, data };
|
|
180
|
+
}
|
|
181
|
+
export class ActionGuardError extends Error {
|
|
182
|
+
status;
|
|
183
|
+
constructor(message, status = 403) {
|
|
184
|
+
super(message);
|
|
185
|
+
this.status = status;
|
|
186
|
+
this.name = 'ActionGuardError';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export const defineActionGuard = (fn) => fn;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Loader } from './define-loader.js';
|
|
2
|
+
export interface LoaderCache<T> {
|
|
3
|
+
get(locKey?: string): T | null;
|
|
4
|
+
set(value: T, locKey?: string): void;
|
|
5
|
+
has(locKey?: string): boolean;
|
|
6
|
+
wrap(loader: Loader<T>): Loader<T>;
|
|
7
|
+
invalidate(): void;
|
|
8
|
+
}
|
|
9
|
+
type RequestStore = Map<symbol, unknown>;
|
|
10
|
+
export declare function getRequestStore(): RequestStore | undefined;
|
|
11
|
+
export declare function getRequestHonoContext<T = unknown>(): T | undefined;
|
|
12
|
+
export declare function runRequestScope<R>(fn: () => R | Promise<R>, initial?: {
|
|
13
|
+
honoContext?: unknown;
|
|
14
|
+
}): R | Promise<R>;
|
|
15
|
+
export declare function captureRequestScope(): <R>(fn: () => R | Promise<R>) => R | Promise<R>;
|
|
16
|
+
export declare function createCache<T>(): LoaderCache<T>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isBrowser } from './is-browser.js';
|
|
2
|
+
// AsyncLocalStorage powers per-request isolation on the server. Available on
|
|
3
|
+
// Node and on Cloudflare Workers with `nodejs_compat`. We skip the import in
|
|
4
|
+
// a browser-like environment so client bundles don't try to resolve
|
|
5
|
+
// `node:async_hooks`.
|
|
6
|
+
let alsInstance = null;
|
|
7
|
+
const looksLikeBrowser = typeof globalThis !== 'undefined' &&
|
|
8
|
+
typeof globalThis.window !== 'undefined' &&
|
|
9
|
+
typeof globalThis.document !== 'undefined';
|
|
10
|
+
if (!looksLikeBrowser) {
|
|
11
|
+
try {
|
|
12
|
+
const moduleName = 'node:async_hooks';
|
|
13
|
+
const mod = (await import(/* @vite-ignore */ moduleName));
|
|
14
|
+
alsInstance = new mod.AsyncLocalStorage();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
alsInstance = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const HONO_CONTEXT_KEY = Symbol('@hono-preact/iso/honoContext');
|
|
21
|
+
export function getRequestStore() {
|
|
22
|
+
return alsInstance?.getStore();
|
|
23
|
+
}
|
|
24
|
+
// Returns the seeded value from the active runRequestScope, or undefined when no scope
|
|
25
|
+
// is active (browser / happy-dom: node:async_hooks is unavailable). Throws when a scope
|
|
26
|
+
// IS active but was never seeded with { honoContext } (framework bug, surfaces loud).
|
|
27
|
+
// The `as T` is a typed Map-read, not a value cast.
|
|
28
|
+
export function getRequestHonoContext() {
|
|
29
|
+
const store = getRequestStore();
|
|
30
|
+
if (!store)
|
|
31
|
+
return undefined;
|
|
32
|
+
const ctx = store.get(HONO_CONTEXT_KEY);
|
|
33
|
+
if (ctx === undefined) {
|
|
34
|
+
throw new Error('runRequestScope is active but was not seeded with { honoContext }. ' +
|
|
35
|
+
'The framework must pass { honoContext: c } when entering the scope.');
|
|
36
|
+
}
|
|
37
|
+
return ctx;
|
|
38
|
+
}
|
|
39
|
+
export function runRequestScope(fn, initial) {
|
|
40
|
+
if (!alsInstance)
|
|
41
|
+
return fn();
|
|
42
|
+
const store = new Map();
|
|
43
|
+
if (initial?.honoContext !== undefined) {
|
|
44
|
+
store.set(HONO_CONTEXT_KEY, initial.honoContext);
|
|
45
|
+
}
|
|
46
|
+
return alsInstance.run(store, fn);
|
|
47
|
+
}
|
|
48
|
+
// Capture the active request scope so work scheduled later (e.g. a
|
|
49
|
+
// `ReadableStream.start` callback that fires after the outer `runRequestScope`
|
|
50
|
+
// frame has already returned) can re-enter the same per-request store.
|
|
51
|
+
// Returns a binder; in a non-ALS environment, the binder runs `fn` directly.
|
|
52
|
+
// Generators that yield and then resume from outside the scope lose ALS
|
|
53
|
+
// propagation on V8; binding their drain restores it.
|
|
54
|
+
export function captureRequestScope() {
|
|
55
|
+
if (!alsInstance)
|
|
56
|
+
return (fn) => fn();
|
|
57
|
+
const store = alsInstance.getStore();
|
|
58
|
+
if (!store)
|
|
59
|
+
return (fn) => fn();
|
|
60
|
+
const als = alsInstance;
|
|
61
|
+
return (fn) => als.run(store, fn);
|
|
62
|
+
}
|
|
63
|
+
export function createCache() {
|
|
64
|
+
const key = Symbol('cache');
|
|
65
|
+
let fallbackStore = null;
|
|
66
|
+
function readEntry() {
|
|
67
|
+
if (!isBrowser()) {
|
|
68
|
+
const reqStore = getRequestStore();
|
|
69
|
+
if (reqStore) {
|
|
70
|
+
return reqStore.get(key) ?? null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return fallbackStore;
|
|
74
|
+
}
|
|
75
|
+
function writeEntry(entry) {
|
|
76
|
+
if (!isBrowser()) {
|
|
77
|
+
const reqStore = getRequestStore();
|
|
78
|
+
if (reqStore) {
|
|
79
|
+
if (entry === null)
|
|
80
|
+
reqStore.delete(key);
|
|
81
|
+
else
|
|
82
|
+
reqStore.set(key, entry);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
fallbackStore = entry;
|
|
87
|
+
}
|
|
88
|
+
function entryMatches(entry, locKey) {
|
|
89
|
+
// A null locKey on the entry means "matches any caller locKey" (back-compat).
|
|
90
|
+
return entry.locKey === null || entry.locKey === locKey;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
get(locKey) {
|
|
94
|
+
const entry = readEntry();
|
|
95
|
+
if (entry === null || !entryMatches(entry, locKey))
|
|
96
|
+
return null;
|
|
97
|
+
return entry.value;
|
|
98
|
+
},
|
|
99
|
+
set(value, locKey) {
|
|
100
|
+
writeEntry({ value, locKey: locKey ?? null });
|
|
101
|
+
},
|
|
102
|
+
has(locKey) {
|
|
103
|
+
const entry = readEntry();
|
|
104
|
+
return entry !== null && entryMatches(entry, locKey);
|
|
105
|
+
},
|
|
106
|
+
wrap(loader) {
|
|
107
|
+
// Cast to Promise<T>: Task 11 will add a runtime adapter for generators/streams.
|
|
108
|
+
// wrap() writes without a locKey so existing callers remain back-compat.
|
|
109
|
+
return async (props) => {
|
|
110
|
+
const entry = readEntry();
|
|
111
|
+
if (entry !== null)
|
|
112
|
+
return entry.value;
|
|
113
|
+
const result = await loader(props);
|
|
114
|
+
writeEntry({ value: result, locKey: null });
|
|
115
|
+
return result;
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
invalidate() {
|
|
119
|
+
writeEntry(null);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
+
export function ClientScript() {
|
|
3
|
+
const src = import.meta.env.PROD
|
|
4
|
+
? '/static/client.js'
|
|
5
|
+
: '/@id/__x00__virtual:hono-preact/client';
|
|
6
|
+
// `async` on a module script: download in parallel with parsing AND execute
|
|
7
|
+
// as soon as available, rather than waiting for the document to finish
|
|
8
|
+
// parsing. Critical for streaming SSR: without it, the client entry waits
|
|
9
|
+
// for the entire streaming response to close before hydrating, by which
|
|
10
|
+
// time every chunk has queued, and the post-hydration drain collapses
|
|
11
|
+
// them all into a single render at the final value.
|
|
12
|
+
return _jsx("script", { type: "module", src: src, async: true });
|
|
13
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ComponentChildren, ComponentType, FunctionComponent } from 'preact';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import type { RouteHook } from 'preact-iso';
|
|
4
|
+
import { type LoaderCache } from './cache.js';
|
|
5
|
+
export type LoaderCtx = {
|
|
6
|
+
c: Context;
|
|
7
|
+
location: RouteHook;
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
};
|
|
10
|
+
export type Loader<T> = ((ctx: LoaderCtx) => Promise<T>) | ((ctx: LoaderCtx) => Promise<ReadableStream<T>>) | ((ctx: LoaderCtx) => AsyncGenerator<T, void, unknown>);
|
|
11
|
+
export interface LoaderRef<T> {
|
|
12
|
+
readonly __id: symbol;
|
|
13
|
+
readonly __moduleKey?: string;
|
|
14
|
+
readonly __loaderName?: string;
|
|
15
|
+
readonly fn: Loader<T>;
|
|
16
|
+
readonly cache: LoaderCache<T>;
|
|
17
|
+
readonly params: string[] | '*';
|
|
18
|
+
useData(): T;
|
|
19
|
+
useError(): Error | null;
|
|
20
|
+
invalidate(): void;
|
|
21
|
+
Boundary: ComponentType<{
|
|
22
|
+
fallback?: ComponentChildren;
|
|
23
|
+
errorFallback?: ComponentChildren | ((err: Error, reset: () => void) => ComponentChildren);
|
|
24
|
+
children: ComponentChildren;
|
|
25
|
+
}>;
|
|
26
|
+
View<P extends Record<string, unknown> = {}>(render: (args: P & {
|
|
27
|
+
data: T;
|
|
28
|
+
error: Error | null;
|
|
29
|
+
reload: () => void;
|
|
30
|
+
}) => ComponentChildren, opts?: {
|
|
31
|
+
fallback?: ComponentChildren;
|
|
32
|
+
errorFallback?: ComponentChildren | ((err: Error, reset: () => void) => ComponentChildren);
|
|
33
|
+
}): FunctionComponent<P>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Plugin-emitted opts for `defineLoader`. The `__moduleKey` field is threaded
|
|
37
|
+
* in by the `moduleKeyPlugin` Vite transform; user code does not set it.
|
|
38
|
+
* `cache` is an opt-in for sharing a cache instance across multiple loaders;
|
|
39
|
+
* when omitted, `defineLoader` creates a fresh one.
|
|
40
|
+
*/
|
|
41
|
+
export type DefineLoaderOpts<T> = {
|
|
42
|
+
__moduleKey?: string;
|
|
43
|
+
__loaderName?: string;
|
|
44
|
+
cache?: LoaderCache<T>;
|
|
45
|
+
params?: string[] | '*';
|
|
46
|
+
};
|
|
47
|
+
export declare function defineLoader<T>(fn: Loader<T>, opts?: DefineLoaderOpts<T>): LoaderRef<T>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { h } from 'preact';
|
|
2
|
+
import { useContext } from 'preact/hooks';
|
|
3
|
+
import { createCache } from './cache.js';
|
|
4
|
+
import { LoaderDataContext, LoaderErrorContext } from './internal/contexts.js';
|
|
5
|
+
import { Loader as LoaderHost } from './internal/loader.js';
|
|
6
|
+
import { ReloadContext } from './reload-context.js';
|
|
7
|
+
// Stash a shared cache map on globalThis so duplicate copies of
|
|
8
|
+
// @hono-preact/iso (workspace hoisting quirks) still see the same map.
|
|
9
|
+
// The serverOnlyPlugin emits a `defineLoader(fn, { __moduleKey })` call at
|
|
10
|
+
// EVERY importer of a `.server.*` module, so without this dedup each
|
|
11
|
+
// importer would get its own private LoaderCache and `ref.invalidate()`
|
|
12
|
+
// would only clear the calling importer's copy. That breaks cross-route
|
|
13
|
+
// invalidation (movie.tsx invalidating `moviesListLoader` no longer flushes
|
|
14
|
+
// the list page's cache).
|
|
15
|
+
//
|
|
16
|
+
// CAVEAT — process-global identity. `Symbol.for(...)` produces a key in the
|
|
17
|
+
// process-wide Symbol registry, so this map is shared across every consumer
|
|
18
|
+
// of @hono-preact/iso running in the same V8 isolate. On Cloudflare Workers
|
|
19
|
+
// (process-per-isolate, short-lived) this is fine. On a long-lived Node
|
|
20
|
+
// process serving multiple tenants from one JS realm, the registry IS
|
|
21
|
+
// shared across tenants — a loader registered by tenant A's
|
|
22
|
+
// `pages/movies.server.ts` and tenant B's are colocated. Per-loader cache
|
|
23
|
+
// keys (the `__moduleKey` + cache identity symbol minted at defineLoader
|
|
24
|
+
// time) prevent cross-tenant DATA leaks; what's shared is the cache
|
|
25
|
+
// registry's identity, not the cache contents. Even so, v0.2 should move
|
|
26
|
+
// this to a per-app registry (via runRequestScope or an explicit app
|
|
27
|
+
// handle) so this is not an implicit footgun.
|
|
28
|
+
const SHARED_CACHES_KEY = Symbol.for('@hono-preact/iso/loaderCaches');
|
|
29
|
+
function getSharedCaches() {
|
|
30
|
+
const g = globalThis;
|
|
31
|
+
let map = g[SHARED_CACHES_KEY];
|
|
32
|
+
if (!map) {
|
|
33
|
+
map = new Map();
|
|
34
|
+
g[SHARED_CACHES_KEY] = map;
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
function ViewRenderer({ loaderRef, props, render, }) {
|
|
39
|
+
const data = loaderRef.useData();
|
|
40
|
+
const error = loaderRef.useError();
|
|
41
|
+
const reloadCtx = useContext(ReloadContext);
|
|
42
|
+
const reload = reloadCtx?.reload ?? (() => { });
|
|
43
|
+
return render({ data, error, reload, ...props });
|
|
44
|
+
}
|
|
45
|
+
export function defineLoader(fn, opts) {
|
|
46
|
+
const idKey = opts?.__moduleKey
|
|
47
|
+
? opts.__loaderName
|
|
48
|
+
? `${opts.__moduleKey}::${opts.__loaderName}`
|
|
49
|
+
: opts.__moduleKey
|
|
50
|
+
: null;
|
|
51
|
+
const __id = idKey
|
|
52
|
+
? Symbol.for(`@hono-preact/loader:${idKey}`)
|
|
53
|
+
: Symbol(`@hono-preact/loader:<unkeyed>`);
|
|
54
|
+
let cache = opts?.cache;
|
|
55
|
+
if (!cache) {
|
|
56
|
+
if (opts?.__moduleKey) {
|
|
57
|
+
// Keyed loaders: dedupe the auto-attached cache by __id so every
|
|
58
|
+
// importer of the same .server module shares one LoaderCache.
|
|
59
|
+
const shared = getSharedCaches();
|
|
60
|
+
const existing = shared.get(__id);
|
|
61
|
+
if (existing) {
|
|
62
|
+
cache = existing;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
cache = createCache();
|
|
66
|
+
shared.set(__id, cache);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Unkeyed loaders only happen when consumers call defineLoader(fn)
|
|
71
|
+
// directly without the plugin transform (i.e. in tests). Each call
|
|
72
|
+
// gets a fresh cache.
|
|
73
|
+
cache = createCache();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const ref = {
|
|
77
|
+
__id,
|
|
78
|
+
__moduleKey: opts?.__moduleKey,
|
|
79
|
+
__loaderName: opts?.__loaderName,
|
|
80
|
+
fn,
|
|
81
|
+
cache: cache,
|
|
82
|
+
params: opts?.params ?? [],
|
|
83
|
+
useData() {
|
|
84
|
+
const ctx = useContext(LoaderDataContext);
|
|
85
|
+
if (!ctx) {
|
|
86
|
+
throw new Error('loader.useData() must be called inside a `loader.View` render function or inside a `loader.Boundary`.');
|
|
87
|
+
}
|
|
88
|
+
return ctx.data;
|
|
89
|
+
},
|
|
90
|
+
useError() {
|
|
91
|
+
return useContext(LoaderErrorContext);
|
|
92
|
+
},
|
|
93
|
+
invalidate() {
|
|
94
|
+
cache.invalidate();
|
|
95
|
+
},
|
|
96
|
+
Boundary: null,
|
|
97
|
+
View: null,
|
|
98
|
+
};
|
|
99
|
+
const Boundary = ({ fallback, errorFallback, children, }) => {
|
|
100
|
+
return h(LoaderHost, {
|
|
101
|
+
loader: ref,
|
|
102
|
+
fallback,
|
|
103
|
+
errorFallback,
|
|
104
|
+
children,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
ref.Boundary = Boundary;
|
|
108
|
+
const View = (render, viewOpts) => {
|
|
109
|
+
const Wrapped = (props) => h(ref.Boundary, {
|
|
110
|
+
fallback: viewOpts?.fallback,
|
|
111
|
+
errorFallback: viewOpts?.errorFallback,
|
|
112
|
+
children: h((ViewRenderer), { loaderRef: ref, props, render }),
|
|
113
|
+
});
|
|
114
|
+
return Wrapped;
|
|
115
|
+
};
|
|
116
|
+
ref.View = View;
|
|
117
|
+
return ref;
|
|
118
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentType, FunctionComponent, JSX } from 'preact';
|
|
2
|
+
import type { RouteHook } from 'preact-iso';
|
|
3
|
+
import type { GuardFn } from './guard.js';
|
|
4
|
+
import { type WrapperProps } from './page.js';
|
|
5
|
+
export type PageBindings = {
|
|
6
|
+
Wrapper?: ComponentType<WrapperProps>;
|
|
7
|
+
errorFallback?: JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
|
|
8
|
+
guards?: GuardFn[];
|
|
9
|
+
};
|
|
10
|
+
export declare function definePage(Component: ComponentType, bindings?: PageBindings): FunctionComponent<RouteHook>;
|