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.
Files changed (130) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +1 -0
  5. package/dist/internal.d.ts +1 -0
  6. package/dist/internal.d.ts.map +1 -0
  7. package/dist/internal.js +1 -0
  8. package/dist/iso/action.d.ts +78 -0
  9. package/dist/iso/action.js +189 -0
  10. package/dist/iso/cache.d.ts +17 -0
  11. package/dist/iso/cache.js +122 -0
  12. package/dist/iso/client-script.d.ts +2 -0
  13. package/dist/iso/client-script.js +13 -0
  14. package/dist/iso/define-loader.d.ts +47 -0
  15. package/dist/iso/define-loader.js +118 -0
  16. package/dist/iso/define-page.d.ts +10 -0
  17. package/dist/iso/define-page.js +7 -0
  18. package/dist/iso/define-routes.d.ts +34 -0
  19. package/dist/iso/define-routes.js +251 -0
  20. package/dist/iso/form.d.ts +7 -0
  21. package/dist/iso/form.js +40 -0
  22. package/dist/iso/guard.d.ts +33 -0
  23. package/dist/iso/guard.js +32 -0
  24. package/dist/iso/head.d.ts +6 -0
  25. package/dist/iso/head.js +4 -0
  26. package/dist/iso/index.d.ts +30 -0
  27. package/dist/iso/index.js +29 -0
  28. package/dist/iso/internal/cache-key.d.ts +2 -0
  29. package/dist/iso/internal/cache-key.js +8 -0
  30. package/dist/iso/internal/contexts.d.ts +12 -0
  31. package/dist/iso/internal/contexts.js +7 -0
  32. package/dist/iso/internal/envelope.d.ts +8 -0
  33. package/dist/iso/internal/envelope.js +21 -0
  34. package/dist/iso/internal/guard-noop.d.ts +7 -0
  35. package/dist/iso/internal/guard-noop.js +6 -0
  36. package/dist/iso/internal/guards.d.ts +14 -0
  37. package/dist/iso/internal/guards.js +54 -0
  38. package/dist/iso/internal/loader-fetch.d.ts +20 -0
  39. package/dist/iso/internal/loader-fetch.js +123 -0
  40. package/dist/iso/internal/loader-runner.d.ts +15 -0
  41. package/dist/iso/internal/loader-runner.js +59 -0
  42. package/dist/iso/internal/loader-stub.d.ts +8 -0
  43. package/dist/iso/internal/loader-stub.js +19 -0
  44. package/dist/iso/internal/loader.d.ts +13 -0
  45. package/dist/iso/internal/loader.js +31 -0
  46. package/dist/iso/internal/optimistic-overlay.d.ts +10 -0
  47. package/dist/iso/internal/optimistic-overlay.js +11 -0
  48. package/dist/iso/internal/preload.d.ts +15 -0
  49. package/dist/iso/internal/preload.js +36 -0
  50. package/dist/iso/internal/route-boundary.d.ts +25 -0
  51. package/dist/iso/internal/route-boundary.js +24 -0
  52. package/dist/iso/internal/route-change.d.ts +4 -0
  53. package/dist/iso/internal/route-change.js +18 -0
  54. package/dist/iso/internal/route-locations.d.ts +11 -0
  55. package/dist/iso/internal/route-locations.js +15 -0
  56. package/dist/iso/internal/sse-decoder.d.ts +5 -0
  57. package/dist/iso/internal/sse-decoder.js +43 -0
  58. package/dist/iso/internal/stream-registry.d.ts +60 -0
  59. package/dist/iso/internal/stream-registry.js +98 -0
  60. package/dist/iso/internal/streaming-ssr.d.ts +17 -0
  61. package/dist/iso/internal/streaming-ssr.js +32 -0
  62. package/dist/iso/internal/use-loader-runner.d.ts +12 -0
  63. package/dist/iso/internal/use-loader-runner.js +185 -0
  64. package/dist/iso/internal/wrap-promise.d.ts +4 -0
  65. package/dist/iso/internal/wrap-promise.js +24 -0
  66. package/dist/iso/internal.d.ts +19 -0
  67. package/dist/iso/internal.js +49 -0
  68. package/dist/iso/is-browser.d.ts +4 -0
  69. package/dist/iso/is-browser.js +6 -0
  70. package/dist/iso/optimistic-action.d.ts +19 -0
  71. package/dist/iso/optimistic-action.js +25 -0
  72. package/dist/iso/optimistic.d.ts +5 -0
  73. package/dist/iso/optimistic.js +31 -0
  74. package/dist/iso/page.d.ts +16 -0
  75. package/dist/iso/page.js +10 -0
  76. package/dist/iso/prefetch.d.ts +22 -0
  77. package/dist/iso/prefetch.js +78 -0
  78. package/dist/iso/reload-context.d.ts +6 -0
  79. package/dist/iso/reload-context.js +9 -0
  80. package/dist/iso/route-change.d.ts +2 -0
  81. package/dist/iso/route-change.js +10 -0
  82. package/dist/iso/view-transitions.d.ts +1 -0
  83. package/dist/iso/view-transitions.js +6 -0
  84. package/dist/server/actions-handler.d.ts +33 -0
  85. package/dist/server/actions-handler.js +159 -0
  86. package/dist/server/context.d.ts +6 -0
  87. package/dist/server/context.js +6 -0
  88. package/dist/server/index.d.ts +5 -0
  89. package/dist/server/index.js +5 -0
  90. package/dist/server/loaders-handler.d.ts +30 -0
  91. package/dist/server/loaders-handler.js +117 -0
  92. package/dist/server/middleware/location.d.ts +1 -0
  93. package/dist/server/middleware/location.js +10 -0
  94. package/dist/server/render.d.ts +5 -0
  95. package/dist/server/render.js +203 -0
  96. package/dist/server/route-server-modules.d.ts +12 -0
  97. package/dist/server/route-server-modules.js +13 -0
  98. package/dist/server/sse.d.ts +22 -0
  99. package/dist/server/sse.js +83 -0
  100. package/dist/server.d.ts +1 -0
  101. package/dist/server.d.ts.map +1 -0
  102. package/dist/server.js +1 -0
  103. package/dist/vite/client-entry.d.ts +10 -0
  104. package/dist/vite/client-entry.js +47 -0
  105. package/dist/vite/client-shim.d.ts +12 -0
  106. package/dist/vite/client-shim.js +62 -0
  107. package/dist/vite/guard-strip.d.ts +2 -0
  108. package/dist/vite/guard-strip.js +96 -0
  109. package/dist/vite/hono-preact.d.ts +12 -0
  110. package/dist/vite/hono-preact.js +111 -0
  111. package/dist/vite/index.d.ts +7 -0
  112. package/dist/vite/index.js +7 -0
  113. package/dist/vite/module-key-plugin.d.ts +12 -0
  114. package/dist/vite/module-key-plugin.js +114 -0
  115. package/dist/vite/module-key.d.ts +11 -0
  116. package/dist/vite/module-key.js +20 -0
  117. package/dist/vite/parser-options.d.ts +16 -0
  118. package/dist/vite/parser-options.js +22 -0
  119. package/dist/vite/server-entry.d.ts +26 -0
  120. package/dist/vite/server-entry.js +201 -0
  121. package/dist/vite/server-loader-validation.d.ts +2 -0
  122. package/dist/vite/server-loader-validation.js +73 -0
  123. package/dist/vite/server-loaders-parser.d.ts +22 -0
  124. package/dist/vite/server-loaders-parser.js +64 -0
  125. package/dist/vite/server-only.d.ts +3 -0
  126. package/dist/vite/server-only.js +244 -0
  127. package/dist/vite.d.ts +1 -0
  128. package/dist/vite.d.ts.map +1 -0
  129. package/dist/vite.js +1 -0
  130. 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
@@ -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"}
@@ -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,2 @@
1
+ import type { VNode } from 'preact';
2
+ export declare function ClientScript(): VNode;
@@ -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>;