hono-preact 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/adapter-cloudflare.d.ts +1 -0
- package/dist/adapter-cloudflare.d.ts.map +1 -0
- package/dist/adapter-cloudflare.js +2 -0
- package/dist/adapter-node.d.ts +1 -0
- package/dist/adapter-node.d.ts.map +1 -0
- package/dist/adapter-node.js +2 -0
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/iso/action-result-context.d.ts +22 -0
- package/dist/iso/action-result-context.js +2 -0
- package/dist/iso/action.d.ts +60 -25
- package/dist/iso/action.js +210 -58
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +14 -0
- package/dist/iso/define-app.js +3 -0
- package/dist/iso/define-loader.d.ts +31 -0
- package/dist/iso/define-loader.js +30 -16
- package/dist/iso/define-middleware.d.ts +43 -0
- package/dist/iso/define-middleware.js +6 -0
- package/dist/iso/define-page.d.ts +7 -2
- package/dist/iso/define-page.js +1 -1
- package/dist/iso/define-routes.d.ts +24 -1
- package/dist/iso/define-routes.js +34 -0
- package/dist/iso/define-stream-observer.d.ts +20 -0
- package/dist/iso/define-stream-observer.js +3 -0
- package/dist/iso/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +15 -7
- package/dist/iso/index.js +9 -4
- package/dist/iso/internal/action-envelope.d.ts +37 -0
- package/dist/iso/internal/action-envelope.js +47 -0
- package/dist/iso/internal/action-result-store.d.ts +28 -0
- package/dist/iso/internal/action-result-store.js +35 -0
- package/dist/iso/internal/contexts.d.ts +0 -2
- package/dist/iso/internal/contexts.js +0 -1
- package/dist/iso/internal/envelope.js +1 -2
- package/dist/iso/internal/form-submit-store.d.ts +9 -0
- package/dist/iso/internal/form-submit-store.js +32 -0
- package/dist/iso/internal/loader-fetch.js +102 -41
- package/dist/iso/internal/loader-runner.js +105 -8
- package/dist/iso/internal/loader.d.ts +3 -3
- package/dist/iso/internal/middleware-runner.d.ts +22 -0
- package/dist/iso/internal/middleware-runner.js +79 -0
- package/dist/iso/internal/page-middleware-host.d.ts +13 -0
- package/dist/iso/internal/page-middleware-host.js +119 -0
- package/dist/iso/internal/route-boundary.d.ts +5 -4
- package/dist/iso/internal/route-boundary.js +16 -0
- package/dist/iso/internal/safe-redirect.d.ts +7 -0
- package/dist/iso/internal/safe-redirect.js +27 -0
- package/dist/iso/internal/sse-decoder.d.ts +1 -1
- package/dist/iso/internal/sse-decoder.js +40 -26
- package/dist/iso/internal/stream-observer-runner.d.ts +13 -0
- package/dist/iso/internal/stream-observer-runner.js +48 -0
- package/dist/iso/internal/use-partitioner.d.ts +9 -0
- package/dist/iso/internal/use-partitioner.js +11 -0
- package/dist/iso/internal/use-types.d.ts +7 -0
- package/dist/iso/internal/use-types.js +1 -0
- package/dist/iso/internal.d.ts +12 -5
- package/dist/iso/internal.js +16 -7
- package/dist/iso/optimistic-action.d.ts +10 -1
- package/dist/iso/optimistic-action.js +11 -3
- package/dist/iso/optimistic.d.ts +10 -1
- package/dist/iso/optimistic.js +45 -5
- package/dist/iso/outcomes.d.ts +50 -0
- package/dist/iso/outcomes.js +67 -0
- package/dist/iso/page-only.d.ts +5 -0
- package/dist/iso/page-only.js +20 -0
- package/dist/iso/page.d.ts +3 -3
- package/dist/iso/page.js +3 -3
- package/dist/iso/use-action-result.d.ts +25 -0
- package/dist/iso/use-action-result.js +39 -0
- package/dist/iso/use-form-status.d.ts +5 -0
- package/dist/iso/use-form-status.js +13 -0
- package/dist/page.d.ts +1 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +8 -0
- package/dist/server/actions-handler.d.ts +27 -6
- package/dist/server/actions-handler.js +121 -52
- package/dist/server/context.js +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/loaders-handler.d.ts +24 -0
- package/dist/server/loaders-handler.js +128 -18
- package/dist/server/page-action-handler.d.ts +63 -0
- package/dist/server/page-action-handler.js +274 -0
- package/dist/server/page-action-resolvers.d.ts +28 -0
- package/dist/server/page-action-resolvers.js +147 -0
- package/dist/server/render.d.ts +2 -0
- package/dist/server/render.js +142 -33
- package/dist/server/route-server-modules.d.ts +48 -8
- package/dist/server/route-server-modules.js +190 -7
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +50 -12
- package/dist/server/sse.js +130 -53
- package/dist/vite/adapter-cloudflare.d.ts +2 -0
- package/dist/vite/adapter-cloudflare.js +25 -0
- package/dist/vite/adapter-node.d.ts +2 -0
- package/dist/vite/adapter-node.js +49 -0
- package/dist/vite/adapter.d.ts +29 -0
- package/dist/vite/adapter.js +1 -0
- package/dist/vite/client-shim.js +5 -4
- package/dist/vite/guard-strip.js +52 -27
- package/dist/vite/hono-preact.d.ts +6 -6
- package/dist/vite/hono-preact.js +48 -77
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/dist/vite/node-dev-server.d.ts +4 -0
- package/dist/vite/node-dev-server.js +121 -0
- package/dist/vite/server-entry.d.ts +30 -7
- package/dist/vite/server-entry.js +170 -79
- package/dist/vite/server-exports-contract.d.ts +6 -0
- package/dist/vite/server-exports-contract.js +43 -0
- package/dist/vite/server-loader-validation.js +36 -9
- package/dist/vite/server-loaders-parser.d.ts +17 -1
- package/dist/vite/server-loaders-parser.js +41 -0
- package/dist/vite/server-only.js +20 -2
- package/package.json +33 -5
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type Listener = () => void;
|
|
2
|
+
export type StoredActionResult = {
|
|
3
|
+
kind: 'success';
|
|
4
|
+
data: unknown;
|
|
5
|
+
submittedPayload: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'deny';
|
|
8
|
+
status: number;
|
|
9
|
+
message: string;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
submittedPayload: unknown;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'error';
|
|
14
|
+
message: string;
|
|
15
|
+
submittedPayload: unknown | null;
|
|
16
|
+
};
|
|
17
|
+
type Entry = StoredActionResult & {
|
|
18
|
+
module: string;
|
|
19
|
+
action: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function setLastActionResult(module: string, action: string, result: StoredActionResult): void;
|
|
22
|
+
export declare function clearLastActionResult(module: string, action: string): void;
|
|
23
|
+
export declare function getLastActionResult(stub?: {
|
|
24
|
+
__module: string;
|
|
25
|
+
__action: string;
|
|
26
|
+
}): Entry | null;
|
|
27
|
+
export declare function subscribeActionResults(listener: Listener): () => void;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const results = new Map();
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
function key(module, action) {
|
|
4
|
+
return `${module}::${action}`;
|
|
5
|
+
}
|
|
6
|
+
export function setLastActionResult(module, action, result) {
|
|
7
|
+
const k = key(module, action);
|
|
8
|
+
// Delete-then-set to bump to most-recent position in Map iteration order,
|
|
9
|
+
// so no-stub readers see the latest action result, not the earliest.
|
|
10
|
+
results.delete(k);
|
|
11
|
+
results.set(k, { ...result, module, action });
|
|
12
|
+
for (const l of listeners)
|
|
13
|
+
l();
|
|
14
|
+
}
|
|
15
|
+
export function clearLastActionResult(module, action) {
|
|
16
|
+
if (results.delete(key(module, action))) {
|
|
17
|
+
for (const l of listeners)
|
|
18
|
+
l();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function getLastActionResult(stub) {
|
|
22
|
+
if (stub)
|
|
23
|
+
return results.get(key(stub.__module, stub.__action)) ?? null;
|
|
24
|
+
// No stub: return the most recently written entry.
|
|
25
|
+
let last = null;
|
|
26
|
+
for (const entry of results.values())
|
|
27
|
+
last = entry;
|
|
28
|
+
return last;
|
|
29
|
+
}
|
|
30
|
+
export function subscribeActionResults(listener) {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
return () => {
|
|
33
|
+
listeners.delete(listener);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -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);
|
|
@@ -13,8 +13,7 @@ export const Envelope = ({ as = 'section', children, }) => {
|
|
|
13
13
|
const dataLoader = isBrowser() ? 'null' : JSON.stringify(ctx.data ?? null);
|
|
14
14
|
if (typeof as === 'string') {
|
|
15
15
|
const Tag = as;
|
|
16
|
-
|
|
17
|
-
return (_jsx(Tag, { ...props, children: children }));
|
|
16
|
+
return (_jsx(Tag, { id: id, "data-loader": dataLoader, children: children }));
|
|
18
17
|
}
|
|
19
18
|
const Wrapper = as;
|
|
20
19
|
return (_jsx(Wrapper, { id: id, "data-loader": dataLoader, children: children }));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Listener = () => void;
|
|
2
|
+
export declare function beginSubmit(module: string, action: string): void;
|
|
3
|
+
export declare function endSubmit(module: string, action: string): void;
|
|
4
|
+
export declare function isPending(stub?: {
|
|
5
|
+
__module: string;
|
|
6
|
+
__action: string;
|
|
7
|
+
}): boolean;
|
|
8
|
+
export declare function subscribe(listener: Listener): () => void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const counts = new Map();
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
function key(module, action) {
|
|
4
|
+
return `${module}::${action}`;
|
|
5
|
+
}
|
|
6
|
+
export function beginSubmit(module, action) {
|
|
7
|
+
const k = key(module, action);
|
|
8
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
9
|
+
for (const l of listeners)
|
|
10
|
+
l();
|
|
11
|
+
}
|
|
12
|
+
export function endSubmit(module, action) {
|
|
13
|
+
const k = key(module, action);
|
|
14
|
+
const n = (counts.get(k) ?? 0) - 1;
|
|
15
|
+
if (n <= 0)
|
|
16
|
+
counts.delete(k);
|
|
17
|
+
else
|
|
18
|
+
counts.set(k, n);
|
|
19
|
+
for (const l of listeners)
|
|
20
|
+
l();
|
|
21
|
+
}
|
|
22
|
+
export function isPending(stub) {
|
|
23
|
+
if (stub)
|
|
24
|
+
return (counts.get(key(stub.__module, stub.__action)) ?? 0) > 0;
|
|
25
|
+
return counts.size > 0;
|
|
26
|
+
}
|
|
27
|
+
export function subscribe(listener) {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => {
|
|
30
|
+
listeners.delete(listener);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readSSE } from './sse-decoder.js';
|
|
2
|
+
import { TimeoutError } from '../action.js';
|
|
2
3
|
/**
|
|
3
4
|
* POST to /__loaders and consume the response.
|
|
4
5
|
*
|
|
@@ -15,22 +16,55 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
15
16
|
signal,
|
|
16
17
|
});
|
|
17
18
|
if (!res.ok) {
|
|
19
|
+
// Try to parse a deny outcome envelope first; it carries `message`
|
|
20
|
+
// rather than `error`. Fall back to the legacy `{ error }` shape.
|
|
18
21
|
const body = (await res.json().catch(() => ({})));
|
|
22
|
+
if (body.__outcome === 'timeout' && typeof body.timeoutMs === 'number') {
|
|
23
|
+
throw new TimeoutError(body.timeoutMs);
|
|
24
|
+
}
|
|
25
|
+
if (body.__outcome === 'deny') {
|
|
26
|
+
// The `deny()` constructor defaults `message` for first-party
|
|
27
|
+
// callers, but a hand-rolled envelope from custom server middleware
|
|
28
|
+
// might still arrive without one. Surface a deny-aware fallback
|
|
29
|
+
// instead of the generic "Loader failed" so the user sees a hint
|
|
30
|
+
// that the status came from an explicit deny.
|
|
31
|
+
const msg = typeof body.message === 'string'
|
|
32
|
+
? body.message
|
|
33
|
+
: `Request denied (${res.status})`;
|
|
34
|
+
throw new Error(msg);
|
|
35
|
+
}
|
|
19
36
|
throw new Error(body.error ?? `Loader failed with status ${res.status}`);
|
|
20
37
|
}
|
|
21
38
|
const contentType = res.headers.get('Content-Type') ?? '';
|
|
22
39
|
if (!contentType.includes('text/event-stream')) {
|
|
23
40
|
const json = (await res.json());
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
41
|
+
// Collision risk note (deferred from middleware review C6): a loader
|
|
42
|
+
// that legitimately returns data shaped `{ __outcome: 'redirect', to:
|
|
43
|
+
// <string> }` would be misinterpreted here and navigate the browser.
|
|
44
|
+
// The probability is low (the magic key is namespaced and unusual) and
|
|
45
|
+
// a wire-version sentinel like `__envelope: 'hono-preact/redirect'` or
|
|
46
|
+
// a `X-Hono-Preact-Outcome: redirect` response header would close the
|
|
47
|
+
// gap. Deferred for v0.2: body-key sniffing is the documented contract
|
|
48
|
+
// for v0.1.
|
|
49
|
+
// Server-side middleware that throws `redirect(...)` comes back as a
|
|
50
|
+
// redirect outcome envelope. Hand off to the browser via
|
|
51
|
+
// `location.assign` and return a promise that never settles: the
|
|
52
|
+
// current document is being replaced, no caller will see a value.
|
|
53
|
+
//
|
|
54
|
+
// Trust boundary: `to` is taken straight from the JSON body and passed
|
|
55
|
+
// to `window.location.assign`. The framework's own handlers emit safe
|
|
56
|
+
// (typically same-origin) values, but a compromised or misconfigured
|
|
57
|
+
// server (or a proxy injecting JSON) could push the client anywhere.
|
|
58
|
+
// We don't validate origin here for v0.1; treat your own server as
|
|
59
|
+
// part of the trusted boundary. A same-origin check is a deferred
|
|
60
|
+
// enhancement (see C4 in the middleware review).
|
|
28
61
|
if (json !== null &&
|
|
29
62
|
typeof json === 'object' &&
|
|
30
|
-
'
|
|
31
|
-
typeof json.
|
|
63
|
+
json.__outcome === 'redirect' &&
|
|
64
|
+
typeof json.to === 'string') {
|
|
65
|
+
const to = json.to;
|
|
32
66
|
if (typeof window !== 'undefined') {
|
|
33
|
-
window.location.assign(
|
|
67
|
+
window.location.assign(to);
|
|
34
68
|
}
|
|
35
69
|
return new Promise(() => {
|
|
36
70
|
/* never resolves; page is navigating */
|
|
@@ -44,40 +78,7 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
44
78
|
// SSE: read the first message event synchronously (await first chunk),
|
|
45
79
|
// then kick off an async loop that pushes subsequent chunks to callbacks.
|
|
46
80
|
const iter = readSSE(res.body);
|
|
47
|
-
|
|
48
|
-
while (true) {
|
|
49
|
-
const step = await iter.next();
|
|
50
|
-
if (step.done) {
|
|
51
|
-
// Stream closed before any data event: error
|
|
52
|
-
throw new Error('Streaming loader closed before emitting any data');
|
|
53
|
-
}
|
|
54
|
-
const ev = step.value;
|
|
55
|
-
if (ev.event === 'message') {
|
|
56
|
-
try {
|
|
57
|
-
firstChunk = JSON.parse(ev.data);
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
throw new Error('Malformed first chunk in streaming loader');
|
|
61
|
-
}
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
if (ev.event === 'error') {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(ev.data);
|
|
67
|
-
const err = new Error(parsed.message ?? 'Streamed error');
|
|
68
|
-
if (parsed.name)
|
|
69
|
-
err.name = parsed.name;
|
|
70
|
-
throw err;
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
if (e instanceof Error && e.message.startsWith('Malformed')) {
|
|
74
|
-
throw new Error('Malformed error event in streaming loader');
|
|
75
|
-
}
|
|
76
|
-
throw e;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// Other events (result, etc.): ignore for loaders
|
|
80
|
-
}
|
|
81
|
+
const firstChunk = await readFirstChunk(iter);
|
|
81
82
|
// Continue consuming chunks in the background. Each subsequent message
|
|
82
83
|
// pushes a value via onChunk. Errors fire onError. End fires onEnd.
|
|
83
84
|
(async () => {
|
|
@@ -97,6 +98,16 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
97
98
|
// malformed mid-stream chunk: skip
|
|
98
99
|
}
|
|
99
100
|
}
|
|
101
|
+
else if (ev.event === 'timeout') {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(ev.data);
|
|
104
|
+
callbacks.onError(new TimeoutError(parsed.timeoutMs ?? 0));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
callbacks.onError(new Error('Malformed timeout event in streaming loader'));
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
100
111
|
else if (ev.event === 'error') {
|
|
101
112
|
try {
|
|
102
113
|
const parsed = JSON.parse(ev.data);
|
|
@@ -121,3 +132,53 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
121
132
|
})();
|
|
122
133
|
return firstChunk;
|
|
123
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Drain the SSE stream until the first `message` event and parse it as `T`.
|
|
137
|
+
* `timeout` / `error` events before the first message reject with the
|
|
138
|
+
* appropriate error. Other event types are ignored.
|
|
139
|
+
*/
|
|
140
|
+
async function readFirstChunk(iter) {
|
|
141
|
+
while (true) {
|
|
142
|
+
const step = await iter.next();
|
|
143
|
+
if (step.done) {
|
|
144
|
+
throw new Error('Streaming loader closed before emitting any data');
|
|
145
|
+
}
|
|
146
|
+
const ev = step.value;
|
|
147
|
+
if (ev.event === 'message') {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(ev.data);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error('Malformed first chunk in streaming loader');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (ev.event === 'timeout') {
|
|
156
|
+
let timeoutMs = 0;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(ev.data);
|
|
159
|
+
timeoutMs = parsed.timeoutMs ?? 0;
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw new Error(`Malformed timeout event in streaming loader: ${e instanceof Error ? e.message : String(e)}`);
|
|
163
|
+
}
|
|
164
|
+
throw new TimeoutError(timeoutMs);
|
|
165
|
+
}
|
|
166
|
+
if (ev.event === 'error') {
|
|
167
|
+
let message = 'Streamed error';
|
|
168
|
+
let name;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(ev.data);
|
|
171
|
+
message = parsed.message ?? message;
|
|
172
|
+
name = parsed.name;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
throw new Error('Malformed error event in streaming loader');
|
|
176
|
+
}
|
|
177
|
+
const err = new Error(message);
|
|
178
|
+
if (name)
|
|
179
|
+
err.name = name;
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
// Other events (result, etc.): ignore for loaders
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -2,6 +2,9 @@ import { isBrowser } from '../is-browser.js';
|
|
|
2
2
|
import { getRequestHonoContext } from '../cache.js';
|
|
3
3
|
import { fetchLoaderData } from './loader-fetch.js';
|
|
4
4
|
import { registerServerStreamingLoader } from './streaming-ssr.js';
|
|
5
|
+
import { dispatchServer } from './middleware-runner.js';
|
|
6
|
+
import { partitionUse } from './use-partitioner.js';
|
|
7
|
+
import { fanStart, fanChunk, fanEnd, fanError, } from './stream-observer-runner.js';
|
|
5
8
|
function isAsyncGenerator(value) {
|
|
6
9
|
return (value != null &&
|
|
7
10
|
typeof value === 'object' &&
|
|
@@ -45,15 +48,109 @@ export function runLoader(loaderRef, location, id, signal, callbacks) {
|
|
|
45
48
|
return c;
|
|
46
49
|
},
|
|
47
50
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// Partition the loader's `use` array into middleware + observers. The
|
|
52
|
+
// dispatcher only consumes middleware; observers attach to the streaming
|
|
53
|
+
// pump below so chunks emitted during SSR flush observer hooks the same
|
|
54
|
+
// way the RPC/SSE path does.
|
|
55
|
+
const { middleware: allMiddleware, observers } = partitionUse((loaderRef.use ?? []));
|
|
56
|
+
const serverMw = allMiddleware.filter((m) => m.runs === 'server');
|
|
57
|
+
// Pre-build a ServerLoaderCtx so observers and middleware see the same
|
|
58
|
+
// ctx shape they see on the RPC path. `c` proxies through the lazy
|
|
59
|
+
// getter so test paths (no request scope) keep working when no consumer
|
|
60
|
+
// reads it.
|
|
61
|
+
const serverCtx = Object.defineProperties({
|
|
62
|
+
scope: 'loader',
|
|
63
|
+
signal,
|
|
64
|
+
location,
|
|
65
|
+
module: loaderRef.__moduleKey ?? '<unkeyed>',
|
|
66
|
+
loader: loaderName,
|
|
67
|
+
}, {
|
|
68
|
+
c: {
|
|
69
|
+
get: () => ctx.c,
|
|
70
|
+
enumerable: true,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Wrap a generator that has already yielded its first chunk so that
|
|
75
|
+
* subsequent yields fire observer hooks. Index 0 has already fired
|
|
76
|
+
* before this wrapper runs (see runInner below), so we count from 1.
|
|
77
|
+
*
|
|
78
|
+
* Implemented as a real `async function*` (not a hand-rolled object)
|
|
79
|
+
* so the result conforms to AsyncGenerator's full structural contract
|
|
80
|
+
* (including `[Symbol.asyncDispose]` on newer libs).
|
|
81
|
+
*/
|
|
82
|
+
function wrapGeneratorWithObservers(gen, startIndex) {
|
|
83
|
+
async function* wrapped() {
|
|
84
|
+
let chunks = startIndex;
|
|
85
|
+
try {
|
|
86
|
+
while (true) {
|
|
87
|
+
const step = await gen.next();
|
|
88
|
+
if (step.done) {
|
|
89
|
+
fanEnd(observers, serverCtx, {
|
|
90
|
+
chunks,
|
|
91
|
+
result: step.value,
|
|
92
|
+
});
|
|
93
|
+
return step.value;
|
|
94
|
+
}
|
|
95
|
+
fanChunk(observers, serverCtx, step.value, chunks);
|
|
96
|
+
chunks += 1;
|
|
97
|
+
yield step.value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
fanError(observers, serverCtx, err, { chunks });
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
53
104
|
}
|
|
54
|
-
|
|
55
|
-
|
|
105
|
+
return wrapped();
|
|
106
|
+
}
|
|
107
|
+
const runInner = async () => {
|
|
108
|
+
const result = await loaderRef.fn(ctx);
|
|
109
|
+
if (isAsyncGenerator(result)) {
|
|
110
|
+
if (observers.length > 0) {
|
|
111
|
+
fanStart(observers, serverCtx);
|
|
112
|
+
}
|
|
113
|
+
const step = await result.next();
|
|
114
|
+
if (step.done) {
|
|
115
|
+
// Generator returned without yielding. Fire onEnd with chunks=0
|
|
116
|
+
// so observers see a clean lifecycle even on empty streams.
|
|
117
|
+
if (observers.length > 0) {
|
|
118
|
+
fanEnd(observers, serverCtx, {
|
|
119
|
+
chunks: 0,
|
|
120
|
+
result: step.value,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
if (observers.length > 0) {
|
|
126
|
+
fanChunk(observers, serverCtx, step.value, 0);
|
|
127
|
+
// Register the OBSERVED wrapper so renderPage's drain fires
|
|
128
|
+
// onChunk for every subsequent yield and onEnd / onError at
|
|
129
|
+
// termination.
|
|
130
|
+
registerServerStreamingLoader(id, wrapGeneratorWithObservers(result, 1));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
registerServerStreamingLoader(id, result);
|
|
134
|
+
}
|
|
135
|
+
return step.value;
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
// Empty middleware path bypasses the dispatcher so existing call sites
|
|
140
|
+
// keep their exact prior behavior. Observer fanout above still fires
|
|
141
|
+
// through runInner; the dispatcher is only about ordered middleware
|
|
142
|
+
// before/after the inner.
|
|
143
|
+
if (serverMw.length === 0) {
|
|
144
|
+
return (await runInner());
|
|
145
|
+
}
|
|
146
|
+
const dispatch = await dispatchServer({
|
|
147
|
+
middleware: serverMw,
|
|
148
|
+
ctx: serverCtx,
|
|
149
|
+
inner: runInner,
|
|
150
|
+
});
|
|
151
|
+
if (dispatch.kind === 'outcome') {
|
|
152
|
+
throw dispatch.outcome;
|
|
56
153
|
}
|
|
57
|
-
return
|
|
154
|
+
return dispatch.value;
|
|
58
155
|
})();
|
|
59
156
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { ComponentChildren
|
|
1
|
+
import type { ComponentChildren } from 'preact';
|
|
2
2
|
import type { RouteHook } from 'preact-iso';
|
|
3
3
|
import type { LoaderRef } from '../define-loader.js';
|
|
4
4
|
export { serializeLocationForCache } from './cache-key.js';
|
|
5
5
|
type LoaderHostProps<T> = {
|
|
6
6
|
loader: LoaderRef<T>;
|
|
7
7
|
location?: RouteHook;
|
|
8
|
-
fallback?:
|
|
8
|
+
fallback?: ComponentChildren;
|
|
9
9
|
errorFallback?: ComponentChildren | ((err: Error, reset: () => void) => ComponentChildren);
|
|
10
10
|
children: ComponentChildren;
|
|
11
11
|
};
|
|
12
|
-
export declare function LoaderHost<T>({ loader: loaderRef, location: locationProp, fallback, errorFallback, children, }: LoaderHostProps<T>): JSX.Element;
|
|
12
|
+
export declare function LoaderHost<T>({ loader: loaderRef, location: locationProp, fallback, errorFallback, children, }: LoaderHostProps<T>): import("preact").JSX.Element;
|
|
13
13
|
export { LoaderHost as Loader };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ServerMiddleware, ClientMiddleware, ServerCtx, ClientPageCtx, Scope } from '../define-middleware.js';
|
|
2
|
+
import { type Outcome } from '../outcomes.js';
|
|
3
|
+
export type DispatchResult<T> = {
|
|
4
|
+
kind: 'ok';
|
|
5
|
+
value: T;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'outcome';
|
|
8
|
+
outcome: Outcome;
|
|
9
|
+
};
|
|
10
|
+
type ServerDispatchArgs<T, S extends Scope> = {
|
|
11
|
+
middleware: ReadonlyArray<ServerMiddleware<S>>;
|
|
12
|
+
ctx: ServerCtx<S>;
|
|
13
|
+
inner: () => Promise<T>;
|
|
14
|
+
};
|
|
15
|
+
export declare function dispatchServer<T, S extends Scope = Scope>(args: ServerDispatchArgs<T, S>): Promise<DispatchResult<T>>;
|
|
16
|
+
type ClientDispatchArgs<T> = {
|
|
17
|
+
middleware: ReadonlyArray<ClientMiddleware>;
|
|
18
|
+
ctx: ClientPageCtx;
|
|
19
|
+
inner: () => Promise<T>;
|
|
20
|
+
};
|
|
21
|
+
export declare function dispatchClient<T>(args: ClientDispatchArgs<T>): Promise<DispatchResult<T>>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isOutcome } from '../outcomes.js';
|
|
2
|
+
export async function dispatchServer(args) {
|
|
3
|
+
let innerResult;
|
|
4
|
+
const runChain = async (index) => {
|
|
5
|
+
if (index >= args.middleware.length) {
|
|
6
|
+
innerResult = await args.inner();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const mw = args.middleware[index];
|
|
10
|
+
let nextCalled = false;
|
|
11
|
+
const next = async () => {
|
|
12
|
+
if (nextCalled) {
|
|
13
|
+
throw new Error(`Middleware at index ${index} called next() more than once. ` +
|
|
14
|
+
`Each middleware must call next() exactly once: a second call ` +
|
|
15
|
+
`would re-run the downstream chain (and the inner function) ` +
|
|
16
|
+
`with the original ctx, producing duplicate side effects.`);
|
|
17
|
+
}
|
|
18
|
+
nextCalled = true;
|
|
19
|
+
await runChain(index + 1);
|
|
20
|
+
return innerResult;
|
|
21
|
+
};
|
|
22
|
+
const ret = await mw.fn(args.ctx, next);
|
|
23
|
+
if (isOutcome(ret)) {
|
|
24
|
+
throw ret;
|
|
25
|
+
}
|
|
26
|
+
if (!nextCalled) {
|
|
27
|
+
throw new Error(`Middleware at index ${index} returned without calling next() or short-circuiting via a thrown outcome. ` +
|
|
28
|
+
`Middleware must either: (a) await/return next() to pass control on, or (b) throw a redirect/deny/render outcome to short-circuit. ` +
|
|
29
|
+
`Returning silently is ambiguous and would let downstream code run.`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
await runChain(0);
|
|
34
|
+
}
|
|
35
|
+
catch (thrown) {
|
|
36
|
+
if (isOutcome(thrown)) {
|
|
37
|
+
return { kind: 'outcome', outcome: thrown };
|
|
38
|
+
}
|
|
39
|
+
throw thrown;
|
|
40
|
+
}
|
|
41
|
+
return { kind: 'ok', value: innerResult };
|
|
42
|
+
}
|
|
43
|
+
export async function dispatchClient(args) {
|
|
44
|
+
let innerResult;
|
|
45
|
+
const runChain = async (index) => {
|
|
46
|
+
if (index >= args.middleware.length) {
|
|
47
|
+
innerResult = await args.inner();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const mw = args.middleware[index];
|
|
51
|
+
let nextCalled = false;
|
|
52
|
+
const next = async () => {
|
|
53
|
+
if (nextCalled) {
|
|
54
|
+
throw new Error(`Middleware at index ${index} called next() more than once. ` +
|
|
55
|
+
`Each middleware must call next() exactly once: a second call ` +
|
|
56
|
+
`would re-run the downstream chain (and the inner function) ` +
|
|
57
|
+
`with the original ctx, producing duplicate side effects.`);
|
|
58
|
+
}
|
|
59
|
+
nextCalled = true;
|
|
60
|
+
await runChain(index + 1);
|
|
61
|
+
return innerResult;
|
|
62
|
+
};
|
|
63
|
+
const ret = await mw.fn(args.ctx, next);
|
|
64
|
+
if (isOutcome(ret))
|
|
65
|
+
throw ret;
|
|
66
|
+
if (!nextCalled) {
|
|
67
|
+
throw new Error(`Middleware at index ${index} returned without calling next() or short-circuiting via a thrown outcome.`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
await runChain(0);
|
|
72
|
+
}
|
|
73
|
+
catch (thrown) {
|
|
74
|
+
if (isOutcome(thrown))
|
|
75
|
+
return { kind: 'outcome', outcome: thrown };
|
|
76
|
+
throw thrown;
|
|
77
|
+
}
|
|
78
|
+
return { kind: 'ok', value: innerResult };
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ComponentChildren, type FunctionComponent, type JSX } from 'preact';
|
|
2
|
+
import { type RouteHook } from 'preact-iso';
|
|
3
|
+
import type { Middleware } from '../define-middleware.js';
|
|
4
|
+
import type { StreamObserver } from '../define-stream-observer.js';
|
|
5
|
+
type AnyObserver = StreamObserver<unknown, never>;
|
|
6
|
+
type UseEntry = Middleware | AnyObserver;
|
|
7
|
+
export declare const PageMiddlewareHost: FunctionComponent<{
|
|
8
|
+
use?: ReadonlyArray<UseEntry>;
|
|
9
|
+
location: RouteHook;
|
|
10
|
+
fallback?: JSX.Element;
|
|
11
|
+
children: ComponentChildren;
|
|
12
|
+
}>;
|
|
13
|
+
export {};
|