@tinvar/react 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/channel.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Subscribing to the application's #[Channel]s, by the names codegen writes
|
|
2
|
+
// (ADR 0069).
|
|
3
|
+
//
|
|
4
|
+
// `channel.listen('orders', { order }, (message) => ...)` asks the server
|
|
5
|
+
// for a subscription -- POST /_channel/orders, with the session's CSRF
|
|
6
|
+
// token, its parameters in the body -- and the server lets it in as it lets
|
|
7
|
+
// in any entry point: the tenant, the panel and its stack, the models
|
|
8
|
+
// resolved in the tenant, every #[Authorize]. What it answers with is a
|
|
9
|
+
// token, never a topic: the client names a channel and its parameters, the
|
|
10
|
+
// server works out the topic from what it resolved, and the token is handed
|
|
11
|
+
// to the live module (subscribeChannel), which trades it beside the page's
|
|
12
|
+
// at /_live/subscribe and delivers each message published to it.
|
|
13
|
+
//
|
|
14
|
+
// A refusal -- 401, 403, 404 -- subscribes to nothing and is handed to
|
|
15
|
+
// `onError`; nothing is retried, since asking again is asking the same
|
|
16
|
+
// checks. A 204 is a server with no hub, where nothing is heard: no error.
|
|
17
|
+
//
|
|
18
|
+
// A token that ran out unrenewed -- a tab hidden past its five minutes --
|
|
19
|
+
// is asked for again the same way, the checks run afresh, and `onGap`
|
|
20
|
+
// called once the new one is listened to: messages published meanwhile
|
|
21
|
+
// were not heard. A refusal then goes to `onError`, as the first would.
|
|
22
|
+
// No React: a hook is in use-channel.js.
|
|
23
|
+
|
|
24
|
+
import { csrfHeaders } from './csrf.js';
|
|
25
|
+
import { subscribeChannel } from './live.js';
|
|
26
|
+
|
|
27
|
+
export const CHANNEL_PATH = '/_channel/';
|
|
28
|
+
|
|
29
|
+
/** A channel name, as the server declares them: dot-separated identifiers. */
|
|
30
|
+
const NAME_SHAPE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
31
|
+
|
|
32
|
+
/** A subscription the server refused, or that could not be asked for. */
|
|
33
|
+
export class ChannelError extends Error {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @param {number} status 0 when the request never got an answer
|
|
37
|
+
*/
|
|
38
|
+
constructor(name, status) {
|
|
39
|
+
super(`[tinvar] The channel '${name}' refused a subscription (${status}).`);
|
|
40
|
+
this.name = 'ChannelError';
|
|
41
|
+
this.channel = name;
|
|
42
|
+
this.status = status;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribes to one channel until the returned function is called.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} name
|
|
50
|
+
* @param {Record<string, string | number | boolean>} params
|
|
51
|
+
* @param {(message: unknown) => void} onMessage
|
|
52
|
+
* @param {{ fetch?: typeof fetch, document?: Document, onError?: (error: ChannelError) => void, onGap?: () => void }} [options]
|
|
53
|
+
* @returns {() => void}
|
|
54
|
+
*/
|
|
55
|
+
export function listen(name, params, onMessage, options = {}) {
|
|
56
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
57
|
+
const fail = (/** @type {number} */ status) => options.onError?.(new ChannelError(name, status));
|
|
58
|
+
|
|
59
|
+
if (!NAME_SHAPE.test(name) || !fetchImpl) {
|
|
60
|
+
fail(0);
|
|
61
|
+
return () => {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let closed = false;
|
|
65
|
+
let off = () => {};
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
|
|
68
|
+
/** Asks for the subscription: its token, or null after telling `onError` why not. */
|
|
69
|
+
const ask = async () => {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetchImpl(CHANNEL_PATH + name, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
accept: 'application/json',
|
|
77
|
+
...csrfHeaders(options.document ?? globalThis.document),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({ params }),
|
|
80
|
+
credentials: 'same-origin',
|
|
81
|
+
mode: 'same-origin',
|
|
82
|
+
redirect: 'error',
|
|
83
|
+
cache: 'no-store',
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
if (!closed) fail(0);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (closed) return null;
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
fail(response.status);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// Nothing to hear: the application runs with no hub, as under php -S.
|
|
96
|
+
if (response.status === 204) return null;
|
|
97
|
+
|
|
98
|
+
let body;
|
|
99
|
+
try {
|
|
100
|
+
body = await response.json();
|
|
101
|
+
} catch {
|
|
102
|
+
if (!closed) fail(response.status);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (closed) return null;
|
|
106
|
+
|
|
107
|
+
const token = typeof body?.token === 'string' ? body.token : null;
|
|
108
|
+
if (token === null) fail(response.status);
|
|
109
|
+
return token;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
void ask().then((token) => {
|
|
113
|
+
if (token !== null && !closed) off = subscribeChannel(token, onMessage, options.onGap, ask);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
if (closed) return;
|
|
118
|
+
closed = true;
|
|
119
|
+
controller.abort();
|
|
120
|
+
off();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The typed `channel` codegen exports: `listen()` by name, each name's
|
|
126
|
+
* parameters and message typed by the ChannelMap it is built for.
|
|
127
|
+
*
|
|
128
|
+
* @returns {{ listen: typeof listen }}
|
|
129
|
+
*/
|
|
130
|
+
export function channels() {
|
|
131
|
+
return { listen };
|
|
132
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts watching the RSC client's module loads on `scope` (the global
|
|
3
|
+
* object by default). False where no RSC client is installed.
|
|
4
|
+
*/
|
|
5
|
+
export declare function watchClientModules(scope?: Record<string, unknown>): boolean;
|
|
6
|
+
|
|
7
|
+
/** Settles once every client module load started so far has; null when none is pending. */
|
|
8
|
+
export declare function clientModulesSettled(scope?: Record<string, unknown>): Promise<void> | null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The client components a payload names, and whether their chunks are here.
|
|
2
|
+
//
|
|
3
|
+
// Decoding a page's payload resolves its root as soon as the root's row has
|
|
4
|
+
// arrived. A client component the page renders is a module reference in
|
|
5
|
+
// that payload: the decoder starts loading its chunk when it reads the
|
|
6
|
+
// reference, and React suspends on it where the page renders it. So a
|
|
7
|
+
// commit made the moment the root resolved could wait inside React for a
|
|
8
|
+
// chunk still on the wire -- and the router runs that commit in a view
|
|
9
|
+
// transition, which holds the old page frozen on screen until React is done
|
|
10
|
+
// (ADR 0045).
|
|
11
|
+
//
|
|
12
|
+
// The decoder asks for every client module through one function, the RSC
|
|
13
|
+
// plugin's `__vite_rsc_require__`, looked up on the global object each time:
|
|
14
|
+
// the first call for a module starts its load and hands back the load's
|
|
15
|
+
// promise, later ones the same promise. Wrapped here, every load a payload
|
|
16
|
+
// starts is seen while it is pending, and prepare() waits for those before
|
|
17
|
+
// it hands the router a commit (page-root.js). A load that fails is waited
|
|
18
|
+
// for too, and then left to the page's error boundary, as before.
|
|
19
|
+
//
|
|
20
|
+
// Only what the decoder has asked for by the time the root resolves: a
|
|
21
|
+
// chunk named further down the stream, under a Suspense boundary still
|
|
22
|
+
// streaming, is that boundary's to wait for, not the commit's.
|
|
23
|
+
|
|
24
|
+
/** The global the RSC plugin's client require is installed on. */
|
|
25
|
+
const REQUIRE = '__vite_rsc_require__';
|
|
26
|
+
|
|
27
|
+
/** Set on the wrapper, so it is never wrapped twice. */
|
|
28
|
+
const WATCHED = Symbol.for('tinvar.clientModules.watched');
|
|
29
|
+
|
|
30
|
+
/** @type {WeakMap<object, Set<PromiseLike<unknown>>>} what each global object's decoder is still loading */
|
|
31
|
+
const pendingBy = new WeakMap();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Starts watching the client module loads on $scope -- the global object,
|
|
35
|
+
* unless a test gives another. Nothing to watch where no RSC client is
|
|
36
|
+
* installed; asked again, it watches once.
|
|
37
|
+
*
|
|
38
|
+
* @param {Record<string, unknown>} [scope]
|
|
39
|
+
* @returns {boolean} whether there is a require to watch
|
|
40
|
+
*/
|
|
41
|
+
export function watchClientModules(scope = /** @type {Record<string, unknown>} */ (globalThis)) {
|
|
42
|
+
const original = scope[REQUIRE];
|
|
43
|
+
if (typeof original !== 'function') return false;
|
|
44
|
+
if (/** @type {any} */ (original)[WATCHED] === true) return true;
|
|
45
|
+
|
|
46
|
+
/** @type {Set<PromiseLike<unknown>>} */
|
|
47
|
+
const pending = pendingBy.get(scope) ?? new Set();
|
|
48
|
+
pendingBy.set(scope, pending);
|
|
49
|
+
|
|
50
|
+
/** @param {string} id */
|
|
51
|
+
const watched = function (id) {
|
|
52
|
+
const loaded = original.call(this, id);
|
|
53
|
+
// A promise the decoder has not seen settle: the flight client marks
|
|
54
|
+
// a settled one `fulfilled` on the promise itself.
|
|
55
|
+
if (
|
|
56
|
+
loaded !== null &&
|
|
57
|
+
typeof loaded === 'object' &&
|
|
58
|
+
typeof (/** @type {any} */ (loaded).then) === 'function' &&
|
|
59
|
+
/** @type {any} */ (loaded).status !== 'fulfilled' &&
|
|
60
|
+
!pending.has(loaded)
|
|
61
|
+
) {
|
|
62
|
+
pending.add(loaded);
|
|
63
|
+
const done = () => pending.delete(loaded);
|
|
64
|
+
/** @type {PromiseLike<unknown>} */ (loaded).then(done, done);
|
|
65
|
+
}
|
|
66
|
+
return loaded;
|
|
67
|
+
};
|
|
68
|
+
// What the plugin hung on its require -- a chunk-name lookup, in
|
|
69
|
+
// webpack's shape -- stays reachable through the wrapper.
|
|
70
|
+
Object.assign(watched, original);
|
|
71
|
+
Object.defineProperty(watched, WATCHED, { value: true });
|
|
72
|
+
scope[REQUIRE] = watched;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Settles once every client module load started so far has settled, either
|
|
78
|
+
* way; null when none is pending.
|
|
79
|
+
*
|
|
80
|
+
* @param {Record<string, unknown>} [scope]
|
|
81
|
+
* @returns {Promise<void> | null}
|
|
82
|
+
*/
|
|
83
|
+
export function clientModulesSettled(scope = /** @type {Record<string, unknown>} */ (globalThis)) {
|
|
84
|
+
const pending = pendingBy.get(scope);
|
|
85
|
+
if (pending === undefined || pending.size === 0) return null;
|
|
86
|
+
|
|
87
|
+
return Promise.allSettled([...pending]).then(() => undefined);
|
|
88
|
+
}
|
package/src/client.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export * from './router';
|
|
2
|
+
export { parseDuration, cacheWindow, DEFAULT_CACHE_FOR } from './prefetch-cache';
|
|
3
|
+
export * from './page-root';
|
|
4
|
+
export { useLayoutProps, useSetLayoutProps } from './layout-props';
|
|
5
|
+
export type { LayoutPropsStore } from './layout-props';
|
|
6
|
+
export { FragmentProvider, loadDeferred, useDeferred, LOAD_EVENT } from './deferred-boundary';
|
|
7
|
+
export type { DeferredState } from './deferred-boundary';
|
|
8
|
+
export { beginRevalidation, refreshDeferred } from './deferred-revalidation';
|
|
9
|
+
export { createLive, subscribeLive, readLiveToken, LIVE_BLOCK_ID } from './live';
|
|
10
|
+
export type { Live, LiveOptions } from './live';
|
|
11
|
+
export * from './fragment-client';
|
|
12
|
+
export * from './rescue';
|
|
13
|
+
export { installTrigger, TRIGGERS } from './deferred-triggers';
|
|
14
|
+
export { csrfToken, csrfHeaders, CSRF_COOKIE, CSRF_HEADER } from './csrf';
|
|
15
|
+
export * from './payload';
|
|
16
|
+
export * from './action';
|
|
17
|
+
export {
|
|
18
|
+
Form,
|
|
19
|
+
FormSession,
|
|
20
|
+
useFlash,
|
|
21
|
+
useFormContext,
|
|
22
|
+
RECENTLY_SUCCESSFUL_MS,
|
|
23
|
+
type FormProps,
|
|
24
|
+
type FormSlotProps,
|
|
25
|
+
type SubmitCallbacks,
|
|
26
|
+
type VisitOptions as FormVisitOptions,
|
|
27
|
+
type HttpFailure,
|
|
28
|
+
type Progress,
|
|
29
|
+
type Flashed,
|
|
30
|
+
type ActionResult,
|
|
31
|
+
type PrecognitionSettings,
|
|
32
|
+
} from './form';
|
|
33
|
+
export * from './use-form';
|
|
34
|
+
export * from './use-http';
|
|
35
|
+
export * from './optimistic';
|
|
36
|
+
export * from './when-mounted';
|
package/src/client.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @tinvar/react/client -- browser-only code.
|
|
2
|
+
//
|
|
3
|
+
// Separate from the package root because the root re-exports the server
|
|
4
|
+
// helpers, which import node:async_hooks. A browser bundle importing the
|
|
5
|
+
// root would pull that in; a client entry that imports only this cannot.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
createRouter,
|
|
9
|
+
readPageMetadata,
|
|
10
|
+
bindingOf,
|
|
11
|
+
invalidatedTags,
|
|
12
|
+
flushInvalidated,
|
|
13
|
+
clearHistory,
|
|
14
|
+
INVALIDATED_TAGS_HEADER,
|
|
15
|
+
HISTORY_KEY_HEADER,
|
|
16
|
+
VisitCancelled,
|
|
17
|
+
VisitHttpError,
|
|
18
|
+
VisitNetworkError,
|
|
19
|
+
PAGE_METADATA_ID,
|
|
20
|
+
PAGE_HEADER,
|
|
21
|
+
PARTIAL_HEADER,
|
|
22
|
+
PREFETCH_PURPOSE,
|
|
23
|
+
BACKGROUND_HEADER,
|
|
24
|
+
RSC_CONTENT_TYPE,
|
|
25
|
+
ROUTE_TOKEN_HEADER,
|
|
26
|
+
LIVE_TOKEN_HEADER,
|
|
27
|
+
SCROLL_REGION_ATTRIBUTE,
|
|
28
|
+
} from './router.js';
|
|
29
|
+
export { createLive, subscribeLive, readLiveToken, LIVE_BLOCK_ID } from './live.js';
|
|
30
|
+
export { parseDuration, cacheWindow, DEFAULT_CACHE_FOR } from './prefetch-cache.js';
|
|
31
|
+
export { createPageRoot } from './page-root.js';
|
|
32
|
+
export { useLayoutProps, useSetLayoutProps } from './layout-props.js';
|
|
33
|
+
export { FragmentProvider, loadDeferred, useDeferred, LOAD_EVENT } from './deferred-boundary.js';
|
|
34
|
+
export { beginRevalidation, refreshDeferred } from './deferred-revalidation.js';
|
|
35
|
+
export { FragmentHandle, FragmentError, serverFailure } from './fragment-client.js';
|
|
36
|
+
export { isRescued, RESCUED_KEY } from './rescue.js';
|
|
37
|
+
export { installTrigger, TRIGGERS } from './deferred-triggers.js';
|
|
38
|
+
export { csrfToken, csrfHeaders, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
39
|
+
export { inlinePayloadStream, payloadRowBytes } from './payload.js';
|
|
40
|
+
export { action, actionName, actionUrl, isActionReference, ActionError, ACTION_PREFIX } from './action.js';
|
|
41
|
+
export * from './forms.js';
|
|
42
|
+
export { WhenMounted } from './when-mounted.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** The segment the page itself renders as. */
|
|
4
|
+
export declare const PAGE_SEGMENT: 'page';
|
|
5
|
+
|
|
6
|
+
export interface LayoutSegment {
|
|
7
|
+
/** The layout's key, as in `LayoutProps<'shop'>`. */
|
|
8
|
+
key: string;
|
|
9
|
+
render: (children: ReactNode) => ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* What an instant visit under this layout shows where its child goes
|
|
12
|
+
* while the next page is fetched (ADR 0092): a skeleton, drawn from
|
|
13
|
+
* nothing the next page's loader returns.
|
|
14
|
+
*/
|
|
15
|
+
loading?: () => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What a partial reload sends: each recomputed segment, rendered alone. */
|
|
19
|
+
export interface PartialRoute {
|
|
20
|
+
segments: Record<string, ReactNode>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The whole document, every segment in its <Segment> slot; or, given the
|
|
25
|
+
* frame's `segments`, only those, as `{ segments }`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function composeRoute(route: {
|
|
28
|
+
/** Outermost first, as their loaders run. */
|
|
29
|
+
layouts: readonly LayoutSegment[];
|
|
30
|
+
page: () => ReactNode;
|
|
31
|
+
document?: (children: ReactNode) => ReactNode;
|
|
32
|
+
segments?: readonly string[] | null | undefined;
|
|
33
|
+
}): ReactNode | PartialRoute;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// What a route renders: the whole document, or only what a partial reload
|
|
2
|
+
// recomputed.
|
|
3
|
+
//
|
|
4
|
+
// ADR 0015. The renderer is handed a frame whose `segments`, when present,
|
|
5
|
+
// name the layouts and page a partial reload recomputed -- and whose loader
|
|
6
|
+
// data holds theirs alone. Those are rendered on their own, each as it
|
|
7
|
+
// would sit in the page, and sent as `{ segments }` for the page root to put
|
|
8
|
+
// in place. Without `segments` the route renders whole, every segment
|
|
9
|
+
// wrapped in the <Segment> slot that receives them later.
|
|
10
|
+
//
|
|
11
|
+
// Both shapes are built here, from one description of the route, so an
|
|
12
|
+
// application's entry cannot render the slots one way and the partial
|
|
13
|
+
// payload another.
|
|
14
|
+
//
|
|
15
|
+
// A layout may give a loading boundary (ADR 0092): what an instant visit
|
|
16
|
+
// shows in its child's slot while the next page under it is fetched. It is
|
|
17
|
+
// rendered here, with the page, and rides on that slot as a prop -- the
|
|
18
|
+
// browser holds it from then on, drawn by the server for this request like
|
|
19
|
+
// everything else on the page.
|
|
20
|
+
//
|
|
21
|
+
// No Node and no DOM: this runs in the RSC environment, where <Segment> is
|
|
22
|
+
// a client reference.
|
|
23
|
+
|
|
24
|
+
import { createElement } from 'react';
|
|
25
|
+
import { Segment } from './segment.js';
|
|
26
|
+
|
|
27
|
+
/** The segment the page itself renders as. */
|
|
28
|
+
export const PAGE_SEGMENT = 'page';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* key: string,
|
|
33
|
+
* render: (children: unknown) => unknown,
|
|
34
|
+
* loading?: () => unknown,
|
|
35
|
+
* }} LayoutSegment
|
|
36
|
+
*
|
|
37
|
+
* @param {{
|
|
38
|
+
* layouts: readonly LayoutSegment[],
|
|
39
|
+
* page: () => unknown,
|
|
40
|
+
* document?: (children: unknown) => unknown,
|
|
41
|
+
* segments?: readonly string[] | null,
|
|
42
|
+
* }} route layouts outermost first, as their loaders run; `segments` from
|
|
43
|
+
* the frame
|
|
44
|
+
* @returns {unknown} the document, or `{ segments }` for a partial reload
|
|
45
|
+
*/
|
|
46
|
+
export function composeRoute({ layouts, page, document, segments }) {
|
|
47
|
+
const keys = [...layouts.map((layout) => layout.key), PAGE_SEGMENT];
|
|
48
|
+
if (new Set(keys).size !== keys.length) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[tinvar] A route's segments must have distinct keys, and none may be '${PAGE_SEGMENT}'.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (segments === undefined || segments === null) {
|
|
55
|
+
let tree = createElement(Segment, slot(PAGE_SEGMENT, layouts.at(-1), page()));
|
|
56
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
57
|
+
const layout = /** @type {LayoutSegment} */ (layouts[i]);
|
|
58
|
+
tree = createElement(Segment, slot(layout.key, layouts[i - 1], layout.render(tree)));
|
|
59
|
+
}
|
|
60
|
+
return document ? document(tree) : tree;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @type {Record<string, unknown>} */
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const key of segments) {
|
|
66
|
+
const at = keys.indexOf(key);
|
|
67
|
+
// Not one of this route's: nothing to render for it.
|
|
68
|
+
if (at === -1) continue;
|
|
69
|
+
|
|
70
|
+
if (key === PAGE_SEGMENT) {
|
|
71
|
+
out[key] = page();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Its child was not recomputed, so it is an empty slot, which keeps
|
|
76
|
+
// what the page already shows there.
|
|
77
|
+
const layout = /** @type {LayoutSegment} */ (layouts[at]);
|
|
78
|
+
out[key] = layout.render(createElement(Segment, slot(/** @type {string} */ (keys[at + 1]), layout)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { segments: out };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The props of the slot segment `id` sits in, inside `parent`: its children,
|
|
86
|
+
* and its parent's loading boundary after them, when the parent gave one.
|
|
87
|
+
*
|
|
88
|
+
* After, because Flight renders a client component's props in their order,
|
|
89
|
+
* and what a server component asks for goes to PHP by the tick it is asked
|
|
90
|
+
* in. With the boundary first, the shop's catalog sent its 24 availability
|
|
91
|
+
* calls and the header's shared read in two frames, not one; with the page
|
|
92
|
+
* first, its queries are asked for before anything of the boundary is drawn.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} id
|
|
95
|
+
* @param {LayoutSegment | undefined} parent
|
|
96
|
+
* @param {unknown} [children] none for the empty slot of a layout re-rendered alone
|
|
97
|
+
* @returns {{ id: string, children?: unknown, parent?: string, loading?: unknown }}
|
|
98
|
+
*/
|
|
99
|
+
function slot(id, parent, children) {
|
|
100
|
+
const own = children === undefined ? { id } : { id, children };
|
|
101
|
+
if (parent?.loading === undefined) return own;
|
|
102
|
+
|
|
103
|
+
const loading = parent.loading();
|
|
104
|
+
// Nothing to show is no boundary: undefined would read as none, and the
|
|
105
|
+
// slot would then show its children during an instant visit.
|
|
106
|
+
return loading === undefined ? own : { ...own, parent: parent.key, loading };
|
|
107
|
+
}
|
package/src/csrf.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The cookie PHP issues the session's CSRF token in. */
|
|
2
|
+
export declare const CSRF_COOKIE: '__Host-XSRF-TOKEN';
|
|
3
|
+
|
|
4
|
+
/** The header a state-changing request carries it back in. */
|
|
5
|
+
export declare const CSRF_HEADER: 'X-XSRF-TOKEN';
|
|
6
|
+
|
|
7
|
+
/** The current CSRF token, read from the cookie at call time, or null. */
|
|
8
|
+
export declare function csrfToken(doc?: { cookie?: string } | null): string | null;
|
|
9
|
+
|
|
10
|
+
/** `{ 'X-XSRF-TOKEN': token }`, or `{}` when the page has no token. */
|
|
11
|
+
export declare function csrfHeaders(doc?: { cookie?: string } | null): Record<string, string>;
|
package/src/csrf.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The CSRF token, for every request that changes something.
|
|
2
|
+
//
|
|
3
|
+
// tinvar-spec.md, "HTTP": "Requests from JavaScript use the CSRF cookie and
|
|
4
|
+
// header." PHP issues the token as a cookie script can read --
|
|
5
|
+
// `__Host-XSRF-TOKEN`, bound to the session -- and a POST carries it back in
|
|
6
|
+
// `X-XSRF-TOKEN`. Without the header every action, query and bootstrap POST
|
|
7
|
+
// is refused with 419.
|
|
8
|
+
//
|
|
9
|
+
// Read from the cookie at send time, never kept: the server issues a new
|
|
10
|
+
// token when the session rotates, and one remembered from page load would be
|
|
11
|
+
// the old session's.
|
|
12
|
+
|
|
13
|
+
export const CSRF_COOKIE = '__Host-XSRF-TOKEN';
|
|
14
|
+
export const CSRF_HEADER = 'X-XSRF-TOKEN';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The current CSRF token, or null when the page has none.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ cookie?: string } | null | undefined} [doc]
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function csrfToken(doc = globalThis.document) {
|
|
23
|
+
const cookies = typeof doc?.cookie === 'string' ? doc.cookie : '';
|
|
24
|
+
|
|
25
|
+
for (const pair of cookies.split(';')) {
|
|
26
|
+
const at = pair.indexOf('=');
|
|
27
|
+
// The exact name: a cookie whose name merely ends in it -- or one a
|
|
28
|
+
// sibling path set under a similar name -- is not this one.
|
|
29
|
+
if (at === -1 || pair.slice(0, at).trim() !== CSRF_COOKIE) continue;
|
|
30
|
+
|
|
31
|
+
const value = pair.slice(at + 1).trim();
|
|
32
|
+
return value === '' ? null : value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The headers a state-changing request adds: the token, or nothing when
|
|
40
|
+
* there is none -- the server refuses that request, which is the right
|
|
41
|
+
* outcome, rather than this sending an empty header it would refuse anyway.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ cookie?: string } | null | undefined} [doc]
|
|
44
|
+
* @returns {Record<string, string>}
|
|
45
|
+
*/
|
|
46
|
+
export function csrfHeaders(doc = globalThis.document) {
|
|
47
|
+
const token = csrfToken(doc);
|
|
48
|
+
return token === null ? {} : { [CSRF_HEADER]: token };
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export declare const LOAD_EVENT: 'tinvar:load';
|
|
4
|
+
|
|
5
|
+
export interface FragmentProviderProps {
|
|
6
|
+
/** Turns a fragment's RSC stream into elements, with the app's own client bundle. */
|
|
7
|
+
decode: (stream: ReadableStream<Uint8Array>) => Promise<ReactNode>;
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
onError?: (error: unknown, element: Element) => void;
|
|
10
|
+
/** The full page load a 401 calls for; `location.assign` by default. */
|
|
11
|
+
hardNavigate?: (url: string) => void;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export declare function FragmentProvider(props: FragmentProviderProps): ReactNode;
|
|
16
|
+
|
|
17
|
+
/** Loads a `load="manual"` boundary, or any boundary early. */
|
|
18
|
+
export declare function loadDeferred(target: string | Element): void;
|
|
19
|
+
|
|
20
|
+
export interface DeferredBoundaryProps {
|
|
21
|
+
token: string;
|
|
22
|
+
load?: 'visible' | 'idle' | 'interaction' | 'manual';
|
|
23
|
+
buffer?: number;
|
|
24
|
+
always?: boolean;
|
|
25
|
+
/** Boundaries on a page sharing a group load together, in one request. */
|
|
26
|
+
group?: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
fallback?: ReactNode;
|
|
29
|
+
/** Shown beside the content while the boundary fetches it again. */
|
|
30
|
+
fetching?: ReactNode;
|
|
31
|
+
/** Shown once the server's render of the fragment throws; see useDeferred(). */
|
|
32
|
+
rescue?: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export declare function DeferredBoundary(props: DeferredBoundaryProps): ReactNode;
|
|
36
|
+
|
|
37
|
+
export interface DeferredState {
|
|
38
|
+
/**
|
|
39
|
+
* A load is in flight, from the trigger until what it brought is on
|
|
40
|
+
* screen. The boundary's element carries `data-fetching` meanwhile.
|
|
41
|
+
*/
|
|
42
|
+
fetching: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The content on screen is about to be replaced: an action is revalidating
|
|
45
|
+
* the page, or the boundary is fetching what it brought back. The
|
|
46
|
+
* boundary's element carries `data-reloading` meanwhile. See
|
|
47
|
+
* beginRevalidation().
|
|
48
|
+
*/
|
|
49
|
+
reloading: boolean;
|
|
50
|
+
/** Set once the server's render threw and the `rescue` slot is up, with the digest it was logged under. */
|
|
51
|
+
rescued: { digest: string | null } | null;
|
|
52
|
+
/** Loads the boundary again. */
|
|
53
|
+
retry: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The state of the <Deferred> boundary a client component is rendered in:
|
|
58
|
+
* its fallback, its content or its `rescue` slot. A hook, because a render
|
|
59
|
+
* prop cannot cross from a server component to the client tree.
|
|
60
|
+
*/
|
|
61
|
+
export declare function useDeferred(): DeferredState;
|