hono-preact 0.2.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/dist/iso/action-result-context.d.ts +22 -0
- package/dist/iso/action-result-context.js +2 -0
- package/dist/iso/action.d.ts +52 -13
- package/dist/iso/action.js +204 -88
- package/dist/iso/cache.d.ts +9 -0
- package/dist/iso/cache.js +26 -0
- package/dist/iso/define-app.d.ts +7 -0
- package/dist/iso/define-loader.d.ts +12 -0
- package/dist/iso/define-loader.js +26 -16
- package/dist/iso/form.d.ts +13 -4
- package/dist/iso/form.js +115 -33
- package/dist/iso/index.d.ts +7 -4
- package/dist/iso/index.js +5 -2
- 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/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 +65 -34
- package/dist/iso/internal/loader.d.ts +3 -3
- package/dist/iso/internal/route-boundary.d.ts +4 -4
- 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.d.ts +7 -1
- package/dist/iso/internal.js +8 -1
- 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 +14 -2
- package/dist/iso/outcomes.js +14 -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/server/actions-handler.d.ts +7 -0
- package/dist/server/actions-handler.js +42 -9
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/loaders-handler.d.ts +8 -0
- package/dist/server/loaders-handler.js +37 -4
- 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.js +41 -3
- package/dist/server/route-server-modules.d.ts +7 -8
- package/dist/server/route-server-modules.js +7 -8
- package/dist/server/speculation-rules.d.ts +3 -0
- package/dist/server/speculation-rules.js +8 -0
- package/dist/server/sse.d.ts +43 -28
- package/dist/server/sse.js +113 -88
- package/dist/vite/server-entry.js +10 -2
- package/package.json +2 -2
|
@@ -42,7 +42,15 @@ function ViewRenderer({ loaderRef, props, render, }) {
|
|
|
42
42
|
const reload = reloadCtx?.reload ?? (() => { });
|
|
43
43
|
return render({ data, error, reload, ...props });
|
|
44
44
|
}
|
|
45
|
+
function validateTimeoutMs(value, context) {
|
|
46
|
+
if (value === undefined || value === false)
|
|
47
|
+
return;
|
|
48
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
49
|
+
throw new RangeError(`${context}: timeoutMs must be a non-negative finite number or false, got ${String(value)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
45
52
|
export function defineLoader(fn, opts) {
|
|
53
|
+
validateTimeoutMs(opts?.timeoutMs, 'defineLoader');
|
|
46
54
|
const idKey = opts?.__moduleKey
|
|
47
55
|
? opts.__loaderName
|
|
48
56
|
? `${opts.__moduleKey}::${opts.__loaderName}`
|
|
@@ -80,6 +88,7 @@ export function defineLoader(fn, opts) {
|
|
|
80
88
|
fn,
|
|
81
89
|
cache: cache,
|
|
82
90
|
params: opts?.params ?? [],
|
|
91
|
+
timeoutMs: opts?.timeoutMs,
|
|
83
92
|
// LoaderUse<T, boolean> structurally collapses to the same shape the
|
|
84
93
|
// partitioner accepts; the cast hides only the generic narrowing on
|
|
85
94
|
// StreamObserver's TChunk/TResult which is invariant. Identity-preserving.
|
|
@@ -97,26 +106,27 @@ export function defineLoader(fn, opts) {
|
|
|
97
106
|
invalidate() {
|
|
98
107
|
cache.invalidate();
|
|
99
108
|
},
|
|
100
|
-
Boundary
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return h(LoaderHost, {
|
|
109
|
+
// `Boundary` and `View` close over `ref`. The captures are by reference
|
|
110
|
+
// and only deref at call time (component render), so the cycle is safe;
|
|
111
|
+
// both are fully initialized before any consumer can invoke them.
|
|
112
|
+
Boundary: ({ fallback, errorFallback, children }) => h((LoaderHost), {
|
|
105
113
|
loader: ref,
|
|
106
114
|
fallback,
|
|
107
115
|
errorFallback,
|
|
108
116
|
children,
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
}),
|
|
118
|
+
View: (render, viewOpts) => {
|
|
119
|
+
const Wrapped = (props) => h(ref.Boundary, {
|
|
120
|
+
fallback: viewOpts?.fallback,
|
|
121
|
+
errorFallback: viewOpts?.errorFallback,
|
|
122
|
+
children: h((ViewRenderer), {
|
|
123
|
+
loaderRef: ref,
|
|
124
|
+
props,
|
|
125
|
+
render,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
return Wrapped;
|
|
129
|
+
},
|
|
119
130
|
};
|
|
120
|
-
ref.View = View;
|
|
121
131
|
return ref;
|
|
122
132
|
}
|
package/dist/iso/form.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { JSX, ComponentChildren } from 'preact';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import type { ActionStub } from './action.js';
|
|
3
|
+
import { type UseOptimisticActionResult } from './optimistic-action.js';
|
|
4
|
+
/**
|
|
5
|
+
* The `action` prop accepts either a plain action stub or the branded value
|
|
6
|
+
* returned by `useOptimisticAction`. The union lets `<Form>` discover the
|
|
7
|
+
* optimistic apply via `OPTIMISTIC_BRAND in action` narrowing without
|
|
8
|
+
* casting away the type.
|
|
9
|
+
*/
|
|
10
|
+
type FormActionInput<TPayload, TResult> = ActionStub<TPayload, TResult, never> | UseOptimisticActionResult<TPayload, TResult, unknown>;
|
|
11
|
+
export type FormProps<TPayload, TResult> = Omit<JSX.HTMLAttributes<HTMLFormElement>, 'action' | 'method' | 'onSubmit' | 'enctype'> & {
|
|
12
|
+
action: FormActionInput<TPayload, TResult>;
|
|
5
13
|
children?: ComponentChildren;
|
|
6
14
|
};
|
|
7
|
-
export declare function Form<TPayload
|
|
15
|
+
export declare function Form<TPayload, TResult>({ action, children, ...rest }: FormProps<TPayload, TResult>): JSX.Element;
|
|
16
|
+
export {};
|
package/dist/iso/form.js
CHANGED
|
@@ -1,40 +1,122 @@
|
|
|
1
|
-
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Single-value fields stay scalar. Files survive as `File` instances.
|
|
11
|
-
*
|
|
12
|
-
* Consumers should type their `defineAction<TPayload, ...>` to match:
|
|
13
|
-
* `tags: string[]`, `photos: File[]`, etc. for fields that may have multiple
|
|
14
|
-
* values; scalar types for fields that won't.
|
|
15
|
-
*/
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useMemo } from 'preact/hooks';
|
|
3
|
+
import { OPTIMISTIC_BRAND, } from './optimistic-action.js';
|
|
4
|
+
import { beginSubmit, endSubmit } from './internal/form-submit-store.js';
|
|
5
|
+
import { setLastActionResult } from './internal/action-result-store.js';
|
|
6
|
+
import { assignSafeRedirect } from './internal/safe-redirect.js';
|
|
7
|
+
function hasOptimisticBrand(action) {
|
|
8
|
+
return OPTIMISTIC_BRAND in action;
|
|
9
|
+
}
|
|
16
10
|
function collectFormData(fd) {
|
|
17
|
-
const
|
|
11
|
+
const out = {};
|
|
18
12
|
for (const [key, value] of fd.entries()) {
|
|
19
|
-
if (key
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
if (key === '__module' || key === '__action')
|
|
14
|
+
continue;
|
|
15
|
+
const existing = out[key];
|
|
16
|
+
out[key] =
|
|
17
|
+
existing === undefined
|
|
18
|
+
? value
|
|
19
|
+
: Array.isArray(existing)
|
|
20
|
+
? [...existing, value]
|
|
21
|
+
: [existing, value];
|
|
28
22
|
}
|
|
29
|
-
return
|
|
23
|
+
return out;
|
|
30
24
|
}
|
|
31
|
-
export function Form({
|
|
32
|
-
const
|
|
25
|
+
export function Form({ action, children, ...rest }) {
|
|
26
|
+
const [pending, setPending] = useState(false);
|
|
27
|
+
const moduleKey = action.__module;
|
|
28
|
+
const actionName = action.__action;
|
|
29
|
+
const optimistic = useMemo(() => (hasOptimisticBrand(action) ? action[OPTIMISTIC_BRAND] : undefined), [action]);
|
|
30
|
+
const handleSubmit = useCallback(async (e) => {
|
|
33
31
|
e.preventDefault();
|
|
34
32
|
const formEl = e.currentTarget;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
const target = typeof window !== 'undefined'
|
|
34
|
+
? window.location.pathname + window.location.search
|
|
35
|
+
: '/';
|
|
36
|
+
const fd = new FormData(formEl);
|
|
37
|
+
const payload = collectFormData(fd);
|
|
38
|
+
let handle;
|
|
39
|
+
if (optimistic)
|
|
40
|
+
handle = optimistic.addOptimistic(payload);
|
|
41
|
+
setPending(true);
|
|
42
|
+
beginSubmit(moduleKey, actionName);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(target, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: fd,
|
|
47
|
+
headers: { Accept: 'application/json' },
|
|
48
|
+
});
|
|
49
|
+
const env = (await res.json().catch(() => null));
|
|
50
|
+
if (!env) {
|
|
51
|
+
handle?.revert();
|
|
52
|
+
if (typeof window !== 'undefined')
|
|
53
|
+
window.location.reload();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (env.__outcome === 'redirect' && typeof env.to === 'string') {
|
|
57
|
+
const navigated = assignSafeRedirect(env.to);
|
|
58
|
+
if (navigated) {
|
|
59
|
+
handle?.settle();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Cross-origin: revert optimistic, surface as error result so useActionResult sees it.
|
|
63
|
+
handle?.revert();
|
|
64
|
+
setLastActionResult(moduleKey, actionName, {
|
|
65
|
+
kind: 'error',
|
|
66
|
+
message: `Refused cross-origin redirect to ${env.to}`,
|
|
67
|
+
submittedPayload: payload,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (env.__outcome === 'success') {
|
|
72
|
+
handle?.settle();
|
|
73
|
+
setLastActionResult(moduleKey, actionName, {
|
|
74
|
+
kind: 'success',
|
|
75
|
+
data: env.data,
|
|
76
|
+
submittedPayload: payload,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (env.__outcome === 'deny') {
|
|
81
|
+
handle?.revert();
|
|
82
|
+
setLastActionResult(moduleKey, actionName, {
|
|
83
|
+
kind: 'deny',
|
|
84
|
+
status: env.status ?? res.status,
|
|
85
|
+
message: env.message ?? `Request denied (${env.status ?? res.status})`,
|
|
86
|
+
data: env.data,
|
|
87
|
+
submittedPayload: payload,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (env.__outcome === 'error') {
|
|
92
|
+
handle?.revert();
|
|
93
|
+
setLastActionResult(moduleKey, actionName, {
|
|
94
|
+
kind: 'error',
|
|
95
|
+
message: env.message ?? 'Action failed',
|
|
96
|
+
submittedPayload: payload,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Unknown outcome (e.g. 'timeout'): treat as error.
|
|
101
|
+
handle?.revert();
|
|
102
|
+
setLastActionResult(moduleKey, actionName, {
|
|
103
|
+
kind: 'error',
|
|
104
|
+
message: env.message ?? `Unexpected outcome: ${env.__outcome ?? 'unknown'}`,
|
|
105
|
+
submittedPayload: payload,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
handle?.revert();
|
|
110
|
+
setLastActionResult(moduleKey, actionName, {
|
|
111
|
+
kind: 'error',
|
|
112
|
+
message: err instanceof Error ? err.message : String(err),
|
|
113
|
+
submittedPayload: payload,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
setPending(false);
|
|
118
|
+
endSubmit(moduleKey, actionName);
|
|
119
|
+
}
|
|
120
|
+
}, [moduleKey, actionName, optimistic]);
|
|
121
|
+
return (_jsxs("form", { ...rest, method: "post", enctype: "multipart/form-data", onSubmit: handleSubmit, children: [_jsx("input", { type: "hidden", name: "__module", value: moduleKey }), _jsx("input", { type: "hidden", name: "__action", value: actionName }), _jsx("fieldset", { disabled: pending, class: "hp-form-fieldset", children: children })] }));
|
|
40
122
|
}
|
package/dist/iso/index.d.ts
CHANGED
|
@@ -7,15 +7,18 @@ export { defineRoutes, Routes } from './define-routes.js';
|
|
|
7
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
|
-
export { defineAction, useAction } from './action.js';
|
|
10
|
+
export { defineAction, useAction, TimeoutError } from './action.js';
|
|
11
11
|
export type { ActionStub, UseActionOptions, UseActionResult, MutateResult, } from './action.js';
|
|
12
12
|
export type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
13
13
|
export { useReload } from './reload-context.js';
|
|
14
14
|
export { useOptimistic } from './optimistic.js';
|
|
15
|
-
export type { OptimisticHandle } from './optimistic.js';
|
|
15
|
+
export type { OptimisticHandle, UseOptimisticOptions } from './optimistic.js';
|
|
16
16
|
export { useOptimisticAction } from './optimistic-action.js';
|
|
17
17
|
export type { UseOptimisticActionOptions, UseOptimisticActionResult, } from './optimistic-action.js';
|
|
18
18
|
export { Form } from './form.js';
|
|
19
|
+
export { useActionResult, type ActionResult } from './use-action-result.js';
|
|
20
|
+
export { ActionResultContext, type ActionResultContextValue, } from './action-result-context.js';
|
|
21
|
+
export { useFormStatus, type FormStatus } from './use-form-status.js';
|
|
19
22
|
export { createCache } from './cache.js';
|
|
20
23
|
export type { LoaderCache } from './cache.js';
|
|
21
24
|
export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
|
|
@@ -24,8 +27,8 @@ export { defineStreamObserver } from './define-stream-observer.js';
|
|
|
24
27
|
export type { StreamObserver, ServerStreamCtx, } from './define-stream-observer.js';
|
|
25
28
|
export { defineApp } from './define-app.js';
|
|
26
29
|
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';
|
|
30
|
+
export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
|
|
31
|
+
export type { Outcome, RedirectOutcome, DenyOutcome, RenderOutcome, TimeoutOutcome, RedirectStatusCode, ErrorStatusCode, } from './outcomes.js';
|
|
29
32
|
export { prefetch } from './prefetch.js';
|
|
30
33
|
export { isBrowser, env } from './is-browser.js';
|
|
31
34
|
export { useRouteChange } from './route-change.js';
|
package/dist/iso/index.js
CHANGED
|
@@ -8,20 +8,23 @@ export { Route, Router, lazy, useLocation, useRoute } from 'preact-iso';
|
|
|
8
8
|
export { defineRoutes, Routes } from './define-routes.js';
|
|
9
9
|
// Server bindings.
|
|
10
10
|
export { defineLoader } from './define-loader.js';
|
|
11
|
-
export { defineAction, useAction } from './action.js';
|
|
11
|
+
export { defineAction, useAction, TimeoutError } from './action.js';
|
|
12
12
|
// Hooks.
|
|
13
13
|
export { useReload } from './reload-context.js';
|
|
14
14
|
export { useOptimistic } from './optimistic.js';
|
|
15
15
|
export { useOptimisticAction } from './optimistic-action.js';
|
|
16
16
|
// Forms.
|
|
17
17
|
export { Form } from './form.js';
|
|
18
|
+
export { useActionResult } from './use-action-result.js';
|
|
19
|
+
export { ActionResultContext, } from './action-result-context.js';
|
|
20
|
+
export { useFormStatus } from './use-form-status.js';
|
|
18
21
|
// Cache + invalidation.
|
|
19
22
|
export { createCache } from './cache.js';
|
|
20
23
|
// Middleware + outcomes (the new system).
|
|
21
24
|
export { defineServerMiddleware, defineClientMiddleware, } from './define-middleware.js';
|
|
22
25
|
export { defineStreamObserver } from './define-stream-observer.js';
|
|
23
26
|
export { defineApp } from './define-app.js';
|
|
24
|
-
export { redirect, deny, isOutcome, isRedirect, isDeny, isRender, } from './outcomes.js';
|
|
27
|
+
export { redirect, deny, timeoutOutcome, isOutcome, isRedirect, isDeny, isRender, isTimeout, } from './outcomes.js';
|
|
25
28
|
// Utilities.
|
|
26
29
|
export { prefetch } from './prefetch.js';
|
|
27
30
|
export { isBrowser, env } from './is-browser.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
2
|
+
import type { Outcome } from '../outcomes.js';
|
|
3
|
+
export type ActionEnvelope = {
|
|
4
|
+
__outcome: 'success';
|
|
5
|
+
data: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
__outcome: 'redirect';
|
|
8
|
+
to: string;
|
|
9
|
+
status: number;
|
|
10
|
+
} | {
|
|
11
|
+
__outcome: 'deny';
|
|
12
|
+
status: number;
|
|
13
|
+
message: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
} | {
|
|
16
|
+
__outcome: 'error';
|
|
17
|
+
message: string;
|
|
18
|
+
} | {
|
|
19
|
+
__outcome: 'timeout';
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
};
|
|
22
|
+
export type ActionResolution = {
|
|
23
|
+
kind: 'success';
|
|
24
|
+
data: unknown;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'outcome';
|
|
27
|
+
outcome: Outcome;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'error';
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
export type SerializedEnvelope = {
|
|
33
|
+
body: ActionEnvelope;
|
|
34
|
+
status: ContentfulStatusCode;
|
|
35
|
+
headers: Record<string, string> | undefined;
|
|
36
|
+
};
|
|
37
|
+
export declare function serializeActionOutcome(resolution: ActionResolution): SerializedEnvelope;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function serializeActionOutcome(resolution) {
|
|
2
|
+
if (resolution.kind === 'success') {
|
|
3
|
+
return {
|
|
4
|
+
body: { __outcome: 'success', data: resolution.data },
|
|
5
|
+
status: 200,
|
|
6
|
+
headers: undefined,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
if (resolution.kind === 'error') {
|
|
10
|
+
return {
|
|
11
|
+
body: { __outcome: 'error', message: resolution.message },
|
|
12
|
+
status: 500,
|
|
13
|
+
headers: undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const { outcome } = resolution;
|
|
17
|
+
if (outcome.__outcome === 'redirect') {
|
|
18
|
+
return {
|
|
19
|
+
body: { __outcome: 'redirect', to: outcome.to, status: outcome.status },
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: outcome.headers,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (outcome.__outcome === 'deny') {
|
|
25
|
+
const body = {
|
|
26
|
+
__outcome: 'deny',
|
|
27
|
+
status: outcome.status,
|
|
28
|
+
message: outcome.message,
|
|
29
|
+
};
|
|
30
|
+
if (outcome.data !== undefined)
|
|
31
|
+
body.data = outcome.data;
|
|
32
|
+
return { body, status: outcome.status, headers: outcome.headers };
|
|
33
|
+
}
|
|
34
|
+
if (outcome.__outcome === 'timeout') {
|
|
35
|
+
return {
|
|
36
|
+
body: { __outcome: 'timeout', timeoutMs: outcome.timeoutMs },
|
|
37
|
+
status: 504,
|
|
38
|
+
headers: undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// 'render' outcome is page-scope only; should never reach an action.
|
|
42
|
+
return {
|
|
43
|
+
body: { __outcome: 'error', message: 'render outcome is page-scope only' },
|
|
44
|
+
status: 500,
|
|
45
|
+
headers: undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
*
|
|
@@ -18,6 +19,9 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
18
19
|
// Try to parse a deny outcome envelope first; it carries `message`
|
|
19
20
|
// rather than `error`. Fall back to the legacy `{ error }` shape.
|
|
20
21
|
const body = (await res.json().catch(() => ({})));
|
|
22
|
+
if (body.__outcome === 'timeout' && typeof body.timeoutMs === 'number') {
|
|
23
|
+
throw new TimeoutError(body.timeoutMs);
|
|
24
|
+
}
|
|
21
25
|
if (body.__outcome === 'deny') {
|
|
22
26
|
// The `deny()` constructor defaults `message` for first-party
|
|
23
27
|
// callers, but a hand-rolled envelope from custom server middleware
|
|
@@ -74,40 +78,7 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
74
78
|
// SSE: read the first message event synchronously (await first chunk),
|
|
75
79
|
// then kick off an async loop that pushes subsequent chunks to callbacks.
|
|
76
80
|
const iter = readSSE(res.body);
|
|
77
|
-
|
|
78
|
-
while (true) {
|
|
79
|
-
const step = await iter.next();
|
|
80
|
-
if (step.done) {
|
|
81
|
-
// Stream closed before any data event: error
|
|
82
|
-
throw new Error('Streaming loader closed before emitting any data');
|
|
83
|
-
}
|
|
84
|
-
const ev = step.value;
|
|
85
|
-
if (ev.event === 'message') {
|
|
86
|
-
try {
|
|
87
|
-
firstChunk = JSON.parse(ev.data);
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
throw new Error('Malformed first chunk in streaming loader');
|
|
91
|
-
}
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
if (ev.event === 'error') {
|
|
95
|
-
try {
|
|
96
|
-
const parsed = JSON.parse(ev.data);
|
|
97
|
-
const err = new Error(parsed.message ?? 'Streamed error');
|
|
98
|
-
if (parsed.name)
|
|
99
|
-
err.name = parsed.name;
|
|
100
|
-
throw err;
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
if (e instanceof Error && e.message.startsWith('Malformed')) {
|
|
104
|
-
throw new Error('Malformed error event in streaming loader');
|
|
105
|
-
}
|
|
106
|
-
throw e;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// Other events (result, etc.): ignore for loaders
|
|
110
|
-
}
|
|
81
|
+
const firstChunk = await readFirstChunk(iter);
|
|
111
82
|
// Continue consuming chunks in the background. Each subsequent message
|
|
112
83
|
// pushes a value via onChunk. Errors fire onError. End fires onEnd.
|
|
113
84
|
(async () => {
|
|
@@ -127,6 +98,16 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
127
98
|
// malformed mid-stream chunk: skip
|
|
128
99
|
}
|
|
129
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
|
+
}
|
|
130
111
|
else if (ev.event === 'error') {
|
|
131
112
|
try {
|
|
132
113
|
const parsed = JSON.parse(ev.data);
|
|
@@ -151,3 +132,53 @@ export async function fetchLoaderData(moduleKey, loaderName, location, signal, c
|
|
|
151
132
|
})();
|
|
152
133
|
return firstChunk;
|
|
153
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
|
+
}
|
|
@@ -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 };
|