hono-preact 0.1.0 → 0.2.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 (88) hide show
  1. package/README.md +2 -1
  2. package/dist/adapter-cloudflare.d.ts +1 -0
  3. package/dist/adapter-cloudflare.d.ts.map +1 -0
  4. package/dist/adapter-cloudflare.js +2 -0
  5. package/dist/adapter-node.d.ts +1 -0
  6. package/dist/adapter-node.d.ts.map +1 -0
  7. package/dist/adapter-node.js +2 -0
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/internal.js +1 -1
  10. package/dist/iso/action.d.ts +10 -14
  11. package/dist/iso/action.js +57 -21
  12. package/dist/iso/define-app.d.ts +7 -0
  13. package/dist/iso/define-app.js +3 -0
  14. package/dist/iso/define-loader.d.ts +19 -0
  15. package/dist/iso/define-loader.js +4 -0
  16. package/dist/iso/define-middleware.d.ts +43 -0
  17. package/dist/iso/define-middleware.js +6 -0
  18. package/dist/iso/define-page.d.ts +7 -2
  19. package/dist/iso/define-page.js +1 -1
  20. package/dist/iso/define-routes.d.ts +24 -1
  21. package/dist/iso/define-routes.js +34 -0
  22. package/dist/iso/define-stream-observer.d.ts +20 -0
  23. package/dist/iso/define-stream-observer.js +3 -0
  24. package/dist/iso/index.d.ts +10 -5
  25. package/dist/iso/index.js +5 -3
  26. package/dist/iso/internal/contexts.d.ts +0 -2
  27. package/dist/iso/internal/contexts.js +0 -1
  28. package/dist/iso/internal/loader-fetch.js +37 -7
  29. package/dist/iso/internal/loader-runner.js +105 -8
  30. package/dist/iso/internal/middleware-runner.d.ts +22 -0
  31. package/dist/iso/internal/middleware-runner.js +79 -0
  32. package/dist/iso/internal/page-middleware-host.d.ts +13 -0
  33. package/dist/iso/internal/page-middleware-host.js +119 -0
  34. package/dist/iso/internal/route-boundary.d.ts +1 -0
  35. package/dist/iso/internal/route-boundary.js +16 -0
  36. package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
  37. package/dist/iso/internal/stream-observer-runner.js +48 -0
  38. package/dist/iso/internal/use-partitioner.d.ts +9 -0
  39. package/dist/iso/internal/use-partitioner.js +11 -0
  40. package/dist/iso/internal/use-types.d.ts +7 -0
  41. package/dist/iso/internal/use-types.js +1 -0
  42. package/dist/iso/internal.d.ts +5 -4
  43. package/dist/iso/internal.js +8 -6
  44. package/dist/iso/outcomes.d.ts +38 -0
  45. package/dist/iso/outcomes.js +56 -0
  46. package/dist/iso/page-only.d.ts +5 -0
  47. package/dist/iso/page-only.js +20 -0
  48. package/dist/iso/page.d.ts +3 -3
  49. package/dist/iso/page.js +3 -3
  50. package/dist/page.d.ts +1 -0
  51. package/dist/page.d.ts.map +1 -0
  52. package/dist/page.js +8 -0
  53. package/dist/server/actions-handler.d.ts +20 -6
  54. package/dist/server/actions-handler.js +83 -47
  55. package/dist/server/context.js +1 -1
  56. package/dist/server/index.d.ts +1 -1
  57. package/dist/server/index.js +1 -1
  58. package/dist/server/loaders-handler.d.ts +16 -0
  59. package/dist/server/loaders-handler.js +94 -17
  60. package/dist/server/render.d.ts +2 -0
  61. package/dist/server/render.js +104 -33
  62. package/dist/server/route-server-modules.d.ts +42 -1
  63. package/dist/server/route-server-modules.js +184 -0
  64. package/dist/server/sse.d.ts +24 -1
  65. package/dist/server/sse.js +56 -4
  66. package/dist/vite/adapter-cloudflare.d.ts +2 -0
  67. package/dist/vite/adapter-cloudflare.js +25 -0
  68. package/dist/vite/adapter-node.d.ts +2 -0
  69. package/dist/vite/adapter-node.js +49 -0
  70. package/dist/vite/adapter.d.ts +29 -0
  71. package/dist/vite/adapter.js +1 -0
  72. package/dist/vite/client-shim.js +5 -4
  73. package/dist/vite/guard-strip.js +52 -27
  74. package/dist/vite/hono-preact.d.ts +6 -6
  75. package/dist/vite/hono-preact.js +48 -77
  76. package/dist/vite/index.d.ts +2 -1
  77. package/dist/vite/index.js +1 -1
  78. package/dist/vite/node-dev-server.d.ts +4 -0
  79. package/dist/vite/node-dev-server.js +121 -0
  80. package/dist/vite/server-entry.d.ts +30 -7
  81. package/dist/vite/server-entry.js +161 -78
  82. package/dist/vite/server-exports-contract.d.ts +6 -0
  83. package/dist/vite/server-exports-contract.js +43 -0
  84. package/dist/vite/server-loader-validation.js +36 -9
  85. package/dist/vite/server-loaders-parser.d.ts +17 -1
  86. package/dist/vite/server-loaders-parser.js +41 -0
  87. package/dist/vite/server-only.js +20 -2
  88. package/package.json +32 -4
package/README.md CHANGED
@@ -37,7 +37,8 @@ Full walkthrough: https://framework.sbesh.com/docs/quick-start
37
37
 
38
38
  ## Subpaths
39
39
 
40
- - `hono-preact`: iso runtime exports (routes, pages, loaders, actions, forms, guards).
40
+ - `hono-preact`: iso runtime exports (routes, pages, loaders, actions, forms, middleware, outcomes).
41
+ - `hono-preact/page`: page-scope outcome kitchen sink (`redirect`, `deny`, `render`, predicates).
41
42
  - `hono-preact/server`: server entry, `renderPage`, SSR streaming helpers.
42
43
  - `hono-preact/vite`: `honoPreact()` plugin for Vite.
43
44
  - `hono-preact/internal`: advanced exports for tooling authors. No stability guarantee.
@@ -0,0 +1 @@
1
+ export * from './vite/adapter-cloudflare';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-cloudflare.d.ts","sourceRoot":"","sources":["../src/adapter-cloudflare.ts"],"names":[],"mappings":"AACA,cAAc,sCAAsC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // packages/hono-preact/src/adapter-cloudflare.ts
2
+ export * from './vite/adapter-cloudflare.js';
@@ -0,0 +1 @@
1
+ export * from './vite/adapter-node';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-node.d.ts","sourceRoot":"","sources":["../src/adapter-node.ts"],"names":[],"mappings":"AACA,cAAc,gCAAgC,CAAC"}
@@ -0,0 +1,2 @@
1
+ // packages/hono-preact/src/adapter-node.ts
2
+ export * from './vite/adapter-node.js';
@@ -1 +1 @@
1
- export * from './iso/internal/index';
1
+ export * from './iso/internal';
package/dist/internal.js CHANGED
@@ -1 +1 @@
1
- export * from './iso/internal/index.js';
1
+ export * from './iso/internal.js';
@@ -1,6 +1,6 @@
1
1
  import type { Context } from 'hono';
2
- import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
2
  import type { LoaderRef } from './define-loader.js';
3
+ import type { ActionUse } from './internal/use-types.js';
4
4
  export type ActionStub<TPayload, TResult, TChunk = never> = {
5
5
  readonly __module: string;
6
6
  readonly __action: string;
@@ -12,7 +12,15 @@ export type ActionCtx = {
12
12
  signal: AbortSignal;
13
13
  };
14
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>;
15
+ export type DefineActionOpts<TChunk = never, TResult = unknown> = {
16
+ /**
17
+ * Per-action middleware and (for streaming actions) stream observers.
18
+ * Attached to the function as a non-enumerable-feeling property; the
19
+ * actions-handler reads it via the dispatcher (Task 18).
20
+ */
21
+ use?: ActionUse<TChunk, TResult, boolean>;
22
+ };
23
+ export declare function defineAction<TPayload, TResult, TChunk = never>(fn: ActionFn<TPayload, TResult, TChunk>, opts?: DefineActionOpts<TChunk, TResult>): ActionStub<TPayload, TResult, TChunk>;
16
24
  export type UseActionOptions<TPayload, TResult, TChunk = never, TSnapshot = unknown> = {
17
25
  /**
18
26
  * How to update loader caches after the action commits. Three modes:
@@ -64,15 +72,3 @@ export type UseActionResult<TPayload, TResult> = {
64
72
  data: TResult | null;
65
73
  };
66
74
  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;
@@ -1,9 +1,23 @@
1
1
  import { useCallback, useContext, useRef, useState } from 'preact/hooks';
2
2
  import { ReloadContext } from './reload-context.js';
3
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.
4
+ export function defineAction(fn, opts) {
5
+ // Runtime no-op for the call itself: returns fn as-is. The ActionStub type
6
+ // is enforced only by TypeScript and the Vite plugin. The dispatcher reads
7
+ // `use` off the function-as-stub when running the chain.
8
+ //
9
+ // Use `Object.defineProperty` instead of direct assignment so a frozen
10
+ // module export (strict ESM, HMR-frozen modules) doesn't throw. The
11
+ // actions-handler reads via `(fn as { use?: ReadonlyArray<unknown> }).use`,
12
+ // which works whether the property was set by assignment or defineProperty.
13
+ if (opts?.use) {
14
+ Object.defineProperty(fn, 'use', {
15
+ value: opts.use,
16
+ configurable: true,
17
+ writable: true,
18
+ enumerable: false,
19
+ });
20
+ }
7
21
  return fn;
8
22
  }
9
23
  function hasFileValues(payload) {
@@ -67,13 +81,44 @@ export function useAction(stub, options) {
67
81
  });
68
82
  }
69
83
  if (!response.ok) {
70
- const body = (await response.json());
71
- throw new Error(body.error ?? `Action failed with status ${response.status}`);
84
+ const body = (await response.json().catch(() => ({})));
85
+ // Deny outcomes carry `message` instead of the legacy `error`
86
+ // field; prefer the descriptive message when present. The deny()
87
+ // constructor defaults the message for first-party callers, but a
88
+ // hand-rolled envelope from custom server middleware might still
89
+ // ship without one; fall back to a deny-aware label so the user
90
+ // sees a hint that the status came from an explicit deny rather
91
+ // than a generic transport failure.
92
+ let msg;
93
+ if (body.__outcome === 'deny') {
94
+ msg =
95
+ typeof body.message === 'string'
96
+ ? body.message
97
+ : `Request denied (${response.status})`;
98
+ }
99
+ else {
100
+ msg = body.error ?? `Action failed with status ${response.status}`;
101
+ }
102
+ throw new Error(msg);
72
103
  }
73
104
  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.
105
+ // Server-side middleware that throws `redirect(...)` comes back as
106
+ // a redirect outcome envelope. Hand off to the browser; the rest of
107
+ // this promise will never settle, but the page is navigating away
108
+ // anyway.
109
+ //
110
+ // Trust boundary: `to` is taken straight from the JSON body and
111
+ // passed to `window.location.assign`. The framework's own handlers
112
+ // emit safe (typically same-origin) values, but a compromised or
113
+ // misconfigured server (or a proxy injecting JSON) could push the
114
+ // client anywhere. We don't validate origin here for v0.1; treat
115
+ // your own server as part of the trusted boundary. A same-origin
116
+ // check is a deferred enhancement (see C4 in the middleware review).
117
+ //
118
+ // We use `response.clone().json()` to peek at the body without
119
+ // consuming it: if the response is NOT a redirect outcome the
120
+ // downstream `await response.json()` still needs to read it. Clone
121
+ // is cheap on a small JSON payload.
77
122
  if (!contentType.includes('text/event-stream')) {
78
123
  const peek = (await response
79
124
  .clone()
@@ -81,11 +126,11 @@ export function useAction(stub, options) {
81
126
  .catch(() => undefined));
82
127
  if (peek !== null &&
83
128
  typeof peek === 'object' &&
84
- peek !== undefined &&
85
- '__redirect' in peek &&
86
- typeof peek.__redirect === 'string') {
129
+ peek.__outcome === 'redirect' &&
130
+ typeof peek.to === 'string') {
131
+ const to = peek.to;
87
132
  if (typeof window !== 'undefined') {
88
- window.location.assign(peek.__redirect);
133
+ window.location.assign(to);
89
134
  }
90
135
  // Cast through `as` because TS can't see this promise never settles.
91
136
  return await new Promise(() => { });
@@ -178,12 +223,3 @@ export function useAction(stub, options) {
178
223
  }, []);
179
224
  return { mutate, pending, error, data };
180
225
  }
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,7 @@
1
+ import type { ServerMiddleware, ClientMiddleware } from './define-middleware.js';
2
+ import type { StreamObserver } from './define-stream-observer.js';
3
+ export type AppUseElement = ServerMiddleware<'page'> | ClientMiddleware | StreamObserver<unknown, never>;
4
+ export type AppConfig = {
5
+ use?: ReadonlyArray<AppUseElement>;
6
+ };
7
+ export declare function defineApp(config: AppConfig): AppConfig;
@@ -0,0 +1,3 @@
1
+ export function defineApp(config) {
2
+ return config;
3
+ }
@@ -2,6 +2,9 @@ import type { ComponentChildren, ComponentType, FunctionComponent } from 'preact
2
2
  import type { Context } from 'hono';
3
3
  import type { RouteHook } from 'preact-iso';
4
4
  import { type LoaderCache } from './cache.js';
5
+ import type { LoaderUse } from './internal/use-types.js';
6
+ import type { Middleware } from './define-middleware.js';
7
+ import type { StreamObserver } from './define-stream-observer.js';
5
8
  export type LoaderCtx = {
6
9
  c: Context;
7
10
  location: RouteHook;
@@ -15,6 +18,15 @@ export interface LoaderRef<T> {
15
18
  readonly fn: Loader<T>;
16
19
  readonly cache: LoaderCache<T>;
17
20
  readonly params: string[] | '*';
21
+ /**
22
+ * Per-loader middleware and (for streaming loaders) stream observers,
23
+ * exactly as authored on `defineLoader({ use })`. The handler-side
24
+ * dispatcher calls `partitionUse(ref.use)` to split middleware from
25
+ * observers; both partitions flow through the SSR/RPC streaming pump.
26
+ * Typed as the union the partitioner accepts so the contract is
27
+ * advertised at the consumer rather than hidden behind `unknown`.
28
+ */
29
+ readonly use: ReadonlyArray<Middleware | StreamObserver<unknown, never>>;
18
30
  useData(): T;
19
31
  useError(): Error | null;
20
32
  invalidate(): void;
@@ -43,5 +55,12 @@ export type DefineLoaderOpts<T> = {
43
55
  __loaderName?: string;
44
56
  cache?: LoaderCache<T>;
45
57
  params?: string[] | '*';
58
+ /**
59
+ * Per-loader middleware and (for streaming loaders) stream observers.
60
+ * The element type LoaderUse<T, Streaming> structurally gates stream
61
+ * observers off non-streaming loaders, but a tighter compile-time gate
62
+ * via defineLoader overloads can be added in a follow-up if needed.
63
+ */
64
+ use?: LoaderUse<T, boolean>;
46
65
  };
47
66
  export declare function defineLoader<T>(fn: Loader<T>, opts?: DefineLoaderOpts<T>): LoaderRef<T>;
@@ -80,6 +80,10 @@ export function defineLoader(fn, opts) {
80
80
  fn,
81
81
  cache: cache,
82
82
  params: opts?.params ?? [],
83
+ // LoaderUse<T, boolean> structurally collapses to the same shape the
84
+ // partitioner accepts; the cast hides only the generic narrowing on
85
+ // StreamObserver's TChunk/TResult which is invariant. Identity-preserving.
86
+ use: (opts?.use ?? []),
83
87
  useData() {
84
88
  const ctx = useContext(LoaderDataContext);
85
89
  if (!ctx) {
@@ -0,0 +1,43 @@
1
+ import type { Context } from 'hono';
2
+ import type { RouteHook } from 'preact-iso';
3
+ import type { Outcome } from './outcomes.js';
4
+ export type Scope = 'page' | 'loader' | 'action';
5
+ export type ServerBaseCtx = {
6
+ c: Context;
7
+ signal: AbortSignal;
8
+ };
9
+ export type ServerPageCtx = ServerBaseCtx & {
10
+ scope: 'page';
11
+ location: RouteHook;
12
+ };
13
+ export type ServerLoaderCtx = ServerBaseCtx & {
14
+ scope: 'loader';
15
+ location: RouteHook;
16
+ module: string;
17
+ loader: string;
18
+ };
19
+ export type ServerActionCtx = ServerBaseCtx & {
20
+ scope: 'action';
21
+ module: string;
22
+ action: string;
23
+ payload: unknown;
24
+ };
25
+ export type ServerCtx<S extends Scope = Scope> = S extends 'page' ? ServerPageCtx : S extends 'loader' ? ServerLoaderCtx : S extends 'action' ? ServerActionCtx : ServerPageCtx | ServerLoaderCtx | ServerActionCtx;
26
+ export type ClientPageCtx = {
27
+ scope: 'page';
28
+ location: RouteHook;
29
+ };
30
+ export type Next = () => Promise<unknown>;
31
+ export type ServerMiddleware<S extends Scope = Scope> = {
32
+ __kind: 'middleware';
33
+ runs: 'server';
34
+ fn: (ctx: ServerCtx<S>, next: Next) => Promise<void | Outcome>;
35
+ };
36
+ export type ClientMiddleware = {
37
+ __kind: 'middleware';
38
+ runs: 'client';
39
+ fn: (ctx: ClientPageCtx, next: Next) => Promise<void | Outcome>;
40
+ };
41
+ export type Middleware = ServerMiddleware | ClientMiddleware;
42
+ export declare function defineServerMiddleware<S extends Scope = Scope>(fn: ServerMiddleware<S>['fn']): ServerMiddleware<S>;
43
+ export declare function defineClientMiddleware(fn: ClientMiddleware['fn']): ClientMiddleware;
@@ -0,0 +1,6 @@
1
+ export function defineServerMiddleware(fn) {
2
+ return { __kind: 'middleware', runs: 'server', fn };
3
+ }
4
+ export function defineClientMiddleware(fn) {
5
+ return { __kind: 'middleware', runs: 'client', fn };
6
+ }
@@ -1,10 +1,15 @@
1
1
  import type { ComponentType, FunctionComponent, JSX } from 'preact';
2
2
  import type { RouteHook } from 'preact-iso';
3
- import type { GuardFn } from './guard.js';
3
+ import type { PageUse } from './internal/use-types.js';
4
4
  import { type WrapperProps } from './page.js';
5
5
  export type PageBindings = {
6
6
  Wrapper?: ComponentType<WrapperProps>;
7
7
  errorFallback?: JSX.Element | ((error: Error, reset: () => void) => JSX.Element);
8
- guards?: GuardFn[];
8
+ /**
9
+ * Page-scope middleware and stream observers. The dispatcher partitions
10
+ * server vs client members by their `runs` tag, so mixed arrays of
11
+ * defineServerMiddleware + defineClientMiddleware work as one list.
12
+ */
13
+ use?: PageUse;
9
14
  };
10
15
  export declare function definePage(Component: ComponentType, bindings?: PageBindings): FunctionComponent<RouteHook>;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "preact/jsx-runtime";
2
2
  import { Page } from './page.js';
3
3
  export function definePage(Component, bindings) {
4
- const PageRoute = (location) => (_jsx(Page, { Wrapper: bindings?.Wrapper, errorFallback: bindings?.errorFallback, guards: bindings?.guards, location: location, children: _jsx(Component, {}) }));
4
+ const PageRoute = (location) => (_jsx(Page, { Wrapper: bindings?.Wrapper, errorFallback: bindings?.errorFallback, use: bindings?.use, location: location, children: _jsx(Component, {}) }));
5
5
  PageRoute.displayName = `definePage(${Component.displayName ?? Component.name ?? 'Anonymous'})`;
6
6
  return PageRoute;
7
7
  }
@@ -7,7 +7,7 @@ export type ViewProps = RouteHook;
7
7
  type LazyImport<T> = () => Promise<{
8
8
  default: T;
9
9
  }>;
10
- type LazyServerImport = () => Promise<unknown>;
10
+ export type LazyServerImport = () => Promise<unknown>;
11
11
  export type RouteDef = {
12
12
  path: string;
13
13
  view?: LazyImport<ComponentType<ViewProps>>;
@@ -20,10 +20,33 @@ export type FlatRoute = {
20
20
  component: ComponentType<ViewProps>;
21
21
  key: string;
22
22
  };
23
+ export type ServerRoute = {
24
+ /** Absolute route path the server module belongs to. */
25
+ path: string;
26
+ /** Lazy `.server.*` module loader. */
27
+ server: LazyServerImport;
28
+ /**
29
+ * Lazy server-module loaders for every server-bearing ancestor in the
30
+ * route tree, outermost first, NOT including this route's own server.
31
+ * Used by the server-side pageUse resolver to compose page-layer
32
+ * middleware along the actual tree of layouts rather than relying on
33
+ * URL-prefix matching (which conflates siblings that share a path
34
+ * prefix, e.g. `/demo/projects` and `/demo/projects/:projectId/...`).
35
+ */
36
+ ancestors: ReadonlyArray<LazyServerImport>;
37
+ };
23
38
  export type RoutesManifest = {
24
39
  tree: ReadonlyArray<RouteDef>;
25
40
  flat: ReadonlyArray<FlatRoute>;
26
41
  serverImports: ReadonlyArray<LazyServerImport>;
42
+ /**
43
+ * Path-keyed view of every server module in the tree. Lets server-side
44
+ * consumers (e.g. the page-layer `use` resolver) load a per-page module
45
+ * by the route path that matched, without re-walking the tree. The order
46
+ * mirrors `serverImports`; the two arrays exist side-by-side because most
47
+ * existing call sites only need the lazy thunks.
48
+ */
49
+ serverRoutes: ReadonlyArray<ServerRoute>;
27
50
  };
28
51
  export declare function defineRoutes(tree: RouteDef[]): RoutesManifest;
29
52
  export type RoutesProps = {
@@ -67,6 +67,39 @@ function collectServerImports(routes) {
67
67
  walk(routes);
68
68
  return out;
69
69
  }
70
+ function collectServerRoutes(routes, parentPath = '') {
71
+ const out = [];
72
+ // `serverStack` tracks the lazy server-thunks for every server-bearing
73
+ // route on the path from the tree root down to (but not including) the
74
+ // node being emitted. Pushing on the way in / popping on the way out
75
+ // means each emitted ServerRoute captures its TRUE tree-walk ancestry,
76
+ // not whichever other patterns happen to share a URL prefix.
77
+ const walk = (rs, pp, serverStack) => {
78
+ for (const r of rs) {
79
+ const here = pp === '' ? r.path : pp + (r.path === '' ? '' : '/' + r.path);
80
+ if (r.server) {
81
+ // Capture the stack BEFORE pushing self -- ancestors exclude self.
82
+ out.push({
83
+ path: here,
84
+ server: r.server,
85
+ ancestors: serverStack.slice(),
86
+ });
87
+ }
88
+ if (r.children) {
89
+ if (r.server) {
90
+ serverStack.push(r.server);
91
+ walk(r.children, here, serverStack);
92
+ serverStack.pop();
93
+ }
94
+ else {
95
+ walk(r.children, here, serverStack);
96
+ }
97
+ }
98
+ }
99
+ };
100
+ walk(routes, parentPath, []);
101
+ return out;
102
+ }
70
103
  /**
71
104
  * Memoize `lazy(view)` per view-thunk identity. When the same `view` thunk is
72
105
  * referenced by multiple route registrations (e.g. `/docs` and `/docs/*`),
@@ -240,6 +273,7 @@ export function defineRoutes(tree) {
240
273
  tree,
241
274
  flat: flattenTree(tree, viewCache, keyCache),
242
275
  serverImports: collectServerImports(tree),
276
+ serverRoutes: collectServerRoutes(tree),
243
277
  };
244
278
  }
245
279
  export const Routes = ({ routes, onRouteChange, }) => {
@@ -0,0 +1,20 @@
1
+ import type { ServerLoaderCtx, ServerActionCtx } from './define-middleware.js';
2
+ export type ServerStreamCtx = ServerLoaderCtx | ServerActionCtx;
3
+ export type StreamObserver<TChunk = unknown, TResult = void> = {
4
+ __kind: 'observer';
5
+ onStart?: (ctx: ServerStreamCtx) => void;
6
+ onChunk?: (ctx: ServerStreamCtx, chunk: TChunk, index: number) => void;
7
+ onEnd?: (ctx: ServerStreamCtx, info: {
8
+ chunks: number;
9
+ result: TResult;
10
+ }) => void;
11
+ onError?: (ctx: ServerStreamCtx, err: unknown, info: {
12
+ chunks: number;
13
+ }) => void;
14
+ onAbort?: (ctx: ServerStreamCtx, info: {
15
+ chunks: number;
16
+ }) => void;
17
+ };
18
+ type Spec<TChunk, TResult> = Omit<StreamObserver<TChunk, TResult>, '__kind'>;
19
+ export declare function defineStreamObserver<TChunk = unknown, TResult = void>(spec: Spec<TChunk, TResult>): StreamObserver<TChunk, TResult>;
20
+ export {};
@@ -0,0 +1,3 @@
1
+ export function defineStreamObserver(spec) {
2
+ return { __kind: 'observer', ...spec };
3
+ }
@@ -4,12 +4,11 @@ export { definePage } from './define-page.js';
4
4
  export type { PageBindings } from './define-page.js';
5
5
  export { Route, Router, lazy, useLocation, useRoute } from 'preact-iso';
6
6
  export { defineRoutes, Routes } from './define-routes.js';
7
- export type { RouteDef, RoutesManifest, FlatRoute, LayoutProps, ViewProps, } from './define-routes.js';
7
+ export type { RouteDef, RoutesManifest, FlatRoute, ServerRoute, LayoutProps, ViewProps, } from './define-routes.js';
8
8
  export { defineLoader } from './define-loader.js';
9
9
  export type { LoaderRef, LoaderCtx, Loader as LoaderFn, } from './define-loader.js';
10
10
  export { defineAction, useAction } from './action.js';
11
- export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, ActionGuardContext, ActionGuardFn, } from './action.js';
12
- export { ActionGuardError, defineActionGuard } from './action.js';
11
+ export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, } from './action.js';
13
12
  export type { ContentfulStatusCode } from 'hono/utils/http-status';
14
13
  export { useReload } from './reload-context.js';
15
14
  export { useOptimistic } from './optimistic.js';
@@ -19,8 +18,14 @@ export type { UseOptimisticActionOptions, UseOptimisticActionResult, } from './o
19
18
  export { Form } from './form.js';
20
19
  export { createCache } from './cache.js';
21
20
  export type { LoaderCache } from './cache.js';
22
- export { defineServerGuard, defineClientGuard, GuardRedirect, runServerGuards, runClientGuards, } from './guard.js';
23
- export type { GuardFn, ServerGuardFn, ClientGuardFn, GuardResult, ServerGuardContext, ClientGuardContext, GuardRunsOn, } from './guard.js';
21
+ export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
22
+ export type { ServerMiddleware, ClientMiddleware, Middleware, ServerBaseCtx, ServerPageCtx, ServerLoaderCtx, ServerActionCtx, ServerCtx, ClientPageCtx, Scope, Next, } from './define-middleware.js';
23
+ export { defineStreamObserver } from './define-stream-observer.js';
24
+ export type { StreamObserver, ServerStreamCtx, } from './define-stream-observer.js';
25
+ export { defineApp } from './define-app.js';
26
+ export type { AppConfig, AppUseElement } from './define-app.js';
27
+ export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
28
+ export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
24
29
  export { prefetch } from './prefetch.js';
25
30
  export { isBrowser, env } from './is-browser.js';
26
31
  export { useRouteChange } from './route-change.js';
package/dist/iso/index.js CHANGED
@@ -9,7 +9,6 @@ export { defineRoutes, Routes } from './define-routes.js';
9
9
  // Server bindings.
10
10
  export { defineLoader } from './define-loader.js';
11
11
  export { defineAction, useAction } from './action.js';
12
- export { ActionGuardError, defineActionGuard } from './action.js';
13
12
  // Hooks.
14
13
  export { useReload } from './reload-context.js';
15
14
  export { useOptimistic } from './optimistic.js';
@@ -18,8 +17,11 @@ export { useOptimisticAction } from './optimistic-action.js';
18
17
  export { Form } from './form.js';
19
18
  // Cache + invalidation.
20
19
  export { createCache } from './cache.js';
21
- // Guards.
22
- export { defineServerGuard, defineClientGuard, GuardRedirect, runServerGuards, runClientGuards, } from './guard.js';
20
+ // Middleware + outcomes (the new system).
21
+ export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
22
+ export { defineStreamObserver } from './define-stream-observer.js';
23
+ export { defineApp } from './define-app.js';
24
+ export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
23
25
  // Utilities.
24
26
  export { prefetch } from './prefetch.js';
25
27
  export { isBrowser, env } from './is-browser.js';
@@ -1,5 +1,4 @@
1
1
  import type { Context } from 'hono';
2
- import type { GuardResult } from '../guard.js';
3
2
  export declare const HonoRequestContext: import("preact").Context<{
4
3
  context?: Context;
5
4
  }>;
@@ -7,6 +6,5 @@ export declare const LoaderIdContext: import("preact").Context<string | null>;
7
6
  export declare const LoaderDataContext: import("preact").Context<{
8
7
  data: unknown;
9
8
  } | null>;
10
- export declare const GuardResultContext: import("preact").Context<GuardResult | null>;
11
9
  export declare const ActiveLoaderIdContext: import("preact").Context<symbol | null>;
12
10
  export declare const LoaderErrorContext: import("preact").Context<Error | null>;
@@ -2,6 +2,5 @@ import { createContext } from 'preact';
2
2
  export const HonoRequestContext = createContext({});
3
3
  export const LoaderIdContext = createContext(null);
4
4
  export const LoaderDataContext = createContext(null);
5
- export const GuardResultContext = createContext(null);
6
5
  export const ActiveLoaderIdContext = createContext(null);
7
6
  export const LoaderErrorContext = createContext(null);
@@ -15,22 +15,52 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
15
15
  signal,
16
16
  });
17
17
  if (!res.ok) {
18
+ // Try to parse a deny outcome envelope first; it carries `message`
19
+ // rather than `error`. Fall back to the legacy `{ error }` shape.
18
20
  const body = (await res.json().catch(() => ({})));
21
+ if (body.__outcome === 'deny') {
22
+ // The `deny()` constructor defaults `message` for first-party
23
+ // callers, but a hand-rolled envelope from custom server middleware
24
+ // might still arrive without one. Surface a deny-aware fallback
25
+ // instead of the generic "Loader failed" so the user sees a hint
26
+ // that the status came from an explicit deny.
27
+ const msg = typeof body.message === 'string'
28
+ ? body.message
29
+ : `Request denied (${res.status})`;
30
+ throw new Error(msg);
31
+ }
19
32
  throw new Error(body.error ?? `Loader failed with status ${res.status}`);
20
33
  }
21
34
  const contentType = res.headers.get('Content-Type') ?? '';
22
35
  if (!contentType.includes('text/event-stream')) {
23
36
  const json = (await res.json());
24
- // Server-side `GuardRedirect` thrown from a loader (or a guard that runs
25
- // inside it) comes back as a `{ __redirect }` envelope. Hand off to the
26
- // browser via `location.assign` and return a promise that never settles:
27
- // the current document is being replaced, no caller will see a value.
37
+ // Collision risk note (deferred from middleware review C6): a loader
38
+ // that legitimately returns data shaped `{ __outcome: 'redirect', to:
39
+ // <string> }` would be misinterpreted here and navigate the browser.
40
+ // The probability is low (the magic key is namespaced and unusual) and
41
+ // a wire-version sentinel like `__envelope: 'hono-preact/redirect'` or
42
+ // a `X-Hono-Preact-Outcome: redirect` response header would close the
43
+ // gap. Deferred for v0.2: body-key sniffing is the documented contract
44
+ // for v0.1.
45
+ // Server-side middleware that throws `redirect(...)` comes back as a
46
+ // redirect outcome envelope. Hand off to the browser via
47
+ // `location.assign` and return a promise that never settles: the
48
+ // current document is being replaced, no caller will see a value.
49
+ //
50
+ // Trust boundary: `to` is taken straight from the JSON body and passed
51
+ // to `window.location.assign`. The framework's own handlers emit safe
52
+ // (typically same-origin) values, but a compromised or misconfigured
53
+ // server (or a proxy injecting JSON) could push the client anywhere.
54
+ // We don't validate origin here for v0.1; treat your own server as
55
+ // part of the trusted boundary. A same-origin check is a deferred
56
+ // enhancement (see C4 in the middleware review).
28
57
  if (json !== null &&
29
58
  typeof json === 'object' &&
30
- '__redirect' in json &&
31
- typeof json.__redirect === 'string') {
59
+ json.__outcome === 'redirect' &&
60
+ typeof json.to === 'string') {
61
+ const to = json.to;
32
62
  if (typeof window !== 'undefined') {
33
- window.location.assign(json.__redirect);
63
+ window.location.assign(to);
34
64
  }
35
65
  return new Promise(() => {
36
66
  /* never resolves; page is navigating */