@rsc-kit/core 0.7.2 → 0.9.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/headers.d.ts +20 -0
- package/dist/headers.js +20 -0
- package/dist/headers.js.map +1 -1
- package/dist/host.d.ts +11 -0
- package/dist/host.js +60 -0
- package/dist/host.js.map +1 -1
- package/dist/js/SegmentBoundary.js +14 -7
- package/dist/js/SegmentBoundary.js.map +1 -1
- package/dist/js/createViteRscApp.js +21 -1
- package/dist/js/createViteRscApp.js.map +1 -1
- package/dist/js/navigate.d.ts +2 -1
- package/dist/js/navigate.js +40 -7
- package/dist/js/navigate.js.map +1 -1
- package/dist/js/queryClient.d.ts +26 -0
- package/dist/js/queryClient.js +173 -0
- package/dist/js/queryClient.js.map +1 -0
- package/dist/js/segmentStore.d.ts +41 -1
- package/dist/js/segmentStore.js +49 -3
- package/dist/js/segmentStore.js.map +1 -1
- package/dist/metadata.d.ts +69 -0
- package/dist/metadata.js +17 -0
- package/dist/metadata.js.map +1 -0
- package/dist/query.d.ts +36 -0
- package/dist/query.js +74 -0
- package/dist/query.js.map +1 -0
- package/dist/vite.d.ts +45 -0
- package/dist/vite.js +321 -16
- package/dist/vite.js.map +1 -1
- package/package.json +19 -10
- package/dist/types.d.ts +0 -82
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The browser half of `query()`: send a read as a GET.
|
|
2
|
+
//
|
|
3
|
+
// That is the whole of it. There is no cache here, no batching and no
|
|
4
|
+
// deduplication, because TanStack Query and SWR already do those and do them
|
|
5
|
+
// better — this owns the transport and they own everything above it.
|
|
6
|
+
//
|
|
7
|
+
// The awkward fact it is built around: a server function's id is not readable
|
|
8
|
+
// from the client. React keeps it in a module-private WeakMap and exposes no
|
|
9
|
+
// getter, so there is no `ref.$$id` to build a url from. What the client *can*
|
|
10
|
+
// do is call the reference and be handed the id by React itself — the stub's
|
|
11
|
+
// whole body is `callServer(id, args)`, and this package already owns
|
|
12
|
+
// `callServer`.
|
|
13
|
+
//
|
|
14
|
+
// So a read opens a one-shot slot and calls the reference; the transport claims
|
|
15
|
+
// the slot and sends a GET instead of posting. The slot is also what says this
|
|
16
|
+
// is a read at all: calling `getListings(kind)` directly is still a POST, and
|
|
17
|
+
// `fetchQuery(getListings, [kind])` is the same call as a GET. Nothing has to
|
|
18
|
+
// know a list of query ids, on either side of the build.
|
|
19
|
+
/** Where reads are answered. Must match HEADER.queryPath on the server. */
|
|
20
|
+
const QUERY_PATH = '/_rsc/query';
|
|
21
|
+
/**
|
|
22
|
+
* Sent on every read, and required by the endpoint.
|
|
23
|
+
*
|
|
24
|
+
* Not decoration. A GET with no unusual header is a *simple* request, so any
|
|
25
|
+
* page anywhere can trigger one with `<img src="…/_rsc/query?…">` and it goes
|
|
26
|
+
* out with the visitor's cookies — CORS stops the attacker reading the answer,
|
|
27
|
+
* but the read still runs. This header is not CORS-safelisted, so the browser
|
|
28
|
+
* preflights it, and nothing here answers a preflight.
|
|
29
|
+
*
|
|
30
|
+
* That restores exactly the protection a POST had: a `POST` carrying
|
|
31
|
+
* `X-RSC-Action` is non-simple for the same reason.
|
|
32
|
+
*/
|
|
33
|
+
const QUERY_HEADER = 'X-RSC-Query';
|
|
34
|
+
/**
|
|
35
|
+
* How long a url may get before the read goes as a POST instead.
|
|
36
|
+
*
|
|
37
|
+
* Conservative. Proxies and CDNs start refusing somewhere between 8k and 16k,
|
|
38
|
+
* and the failure is a 414 from a machine that is not ours.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_URL = 6_000;
|
|
41
|
+
/**
|
|
42
|
+
* The slot a read opens before calling the reference.
|
|
43
|
+
*
|
|
44
|
+
* One-shot and synchronous: React's stub calls `callServer` in its own body
|
|
45
|
+
* with nothing awaited in between, so exactly one transport call can claim it.
|
|
46
|
+
*/
|
|
47
|
+
let slot = null;
|
|
48
|
+
/**
|
|
49
|
+
* Claim the open slot, if a read opened one.
|
|
50
|
+
*
|
|
51
|
+
* Called from the app's `callServer` before it posts. Returns null when this is
|
|
52
|
+
* an ordinary action, which is every call that did not come through
|
|
53
|
+
* `fetchQuery`.
|
|
54
|
+
*/
|
|
55
|
+
export function claimRead(id, args) {
|
|
56
|
+
if (!slot || slot.claimed)
|
|
57
|
+
return null;
|
|
58
|
+
slot.claimed = true;
|
|
59
|
+
slot.promise = send(id, args);
|
|
60
|
+
return slot.promise;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read a query, over GET.
|
|
64
|
+
*
|
|
65
|
+
* Hand this to a cache library and let it decide everything else:
|
|
66
|
+
*
|
|
67
|
+
* queryFn: () => fetchQuery(getListings, [kind])
|
|
68
|
+
* useSWR(['listings', kind], () => fetchQuery(getListings, [kind]))
|
|
69
|
+
*
|
|
70
|
+
* Every call goes to the server, which is what a fetcher needs — staleness,
|
|
71
|
+
* revalidation, retries, polling and deduplication all belong to the library
|
|
72
|
+
* holding the answer, not to the thing that fetches it.
|
|
73
|
+
*/
|
|
74
|
+
export function fetchQuery(reference, args = []) {
|
|
75
|
+
if (typeof window === 'undefined') {
|
|
76
|
+
// React's SSR runtime refuses a server-function call during the initial
|
|
77
|
+
// render, and reaching a query's id means calling its reference — so this
|
|
78
|
+
// cannot work here. Raised with the fix in it rather than left to surface
|
|
79
|
+
// as React's more general message about fetch waterfalls.
|
|
80
|
+
//
|
|
81
|
+
// A cache library runs its fetcher in an effect, so the server render never
|
|
82
|
+
// reaches this; a server component that wants the data during render should
|
|
83
|
+
// await the query directly, which needs none of this.
|
|
84
|
+
throw new Error('A query was read during server rendering. Read it in a server component with await, or through a cache library, whose fetcher runs after hydration.');
|
|
85
|
+
}
|
|
86
|
+
const opened = { claimed: false, promise: null };
|
|
87
|
+
slot = opened;
|
|
88
|
+
try {
|
|
89
|
+
// React's stub reaches callServer synchronously, so the slot is claimed by
|
|
90
|
+
// the time this returns. The stub's own promise is discarded: the one the
|
|
91
|
+
// transport made is the one that settles with the answer.
|
|
92
|
+
;
|
|
93
|
+
reference(...args);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
// Cleared here and nowhere else, so a reference that throws on the way in
|
|
97
|
+
// does not leave the slot open for the next read to claim by accident.
|
|
98
|
+
// Nothing is returned from this block: a `return` in `finally` discards
|
|
99
|
+
// whatever the `try` was throwing, which would turn a reference that threw
|
|
100
|
+
// synchronously into the misleading bound-reference error below.
|
|
101
|
+
slot = null;
|
|
102
|
+
}
|
|
103
|
+
if (!opened.claimed || !opened.promise) {
|
|
104
|
+
// The only way here is a reference whose call path is asynchronous, which
|
|
105
|
+
// today means one that was `.bind()`-ed. Loud, because the quiet version is
|
|
106
|
+
// a read that silently went out as a POST.
|
|
107
|
+
throw new Error('A query was called through a bound reference. Pass the exported query itself, unbound.');
|
|
108
|
+
}
|
|
109
|
+
return opened.promise;
|
|
110
|
+
}
|
|
111
|
+
async function send(id, args) {
|
|
112
|
+
const encoded = await encode(args);
|
|
113
|
+
// encodeReply answers with FormData the moment an argument holds a File, and
|
|
114
|
+
// a url cannot carry one. Rather than refuse a call that would have worked as
|
|
115
|
+
// an action, the read falls back to a POST: it stops being cacheable, which
|
|
116
|
+
// is the only thing it loses.
|
|
117
|
+
if (typeof encoded !== 'string')
|
|
118
|
+
return await post(id, args, 'an argument contained a File');
|
|
119
|
+
const url = `${QUERY_PATH}?id=${encodeURIComponent(id)}&args=${encodeURIComponent(encoded)}`;
|
|
120
|
+
if (url.length > MAX_URL)
|
|
121
|
+
return await post(id, args, 'the arguments are too large for a url');
|
|
122
|
+
const res = await fetch(url, {
|
|
123
|
+
headers: {
|
|
124
|
+
[QUERY_HEADER]: '1',
|
|
125
|
+
Accept: 'text/x-component',
|
|
126
|
+
// Where the read came from. Same reason an action sends it: a host that
|
|
127
|
+
// guards by route needs to know which page is asking.
|
|
128
|
+
'X-RSC-Referer': window.location.pathname + window.location.search,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok || !res.body) {
|
|
132
|
+
throw new Error((await res.text().catch(() => '')) || `Query failed: ${res.status}`);
|
|
133
|
+
}
|
|
134
|
+
return await deserialize(res.body);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The same read, as an action.
|
|
138
|
+
*
|
|
139
|
+
* Reached only when the arguments cannot ride in a url. Warned about in
|
|
140
|
+
* development rather than silently tolerated, because the read still works and
|
|
141
|
+
* the thing that changed — it is no longer a GET, so nothing can cache it — is
|
|
142
|
+
* otherwise invisible.
|
|
143
|
+
*/
|
|
144
|
+
async function post(id, args, why) {
|
|
145
|
+
if (import.meta.env?.DEV) {
|
|
146
|
+
console.warn(`[rsc-kit] A query was sent as a POST because ${why}. It will not be cached.`);
|
|
147
|
+
}
|
|
148
|
+
return await asAction(id, args);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The Flight codec and the action transport, installed by the app bootstrap.
|
|
152
|
+
*
|
|
153
|
+
* Injected rather than imported, and not for testing: this module is reached
|
|
154
|
+
* from client components, so importing the browser runtime here would pull a
|
|
155
|
+
* second copy of it into that graph — two client-reference registries, where
|
|
156
|
+
* components resolve to undefined with nothing logged. The bootstrap already
|
|
157
|
+
* holds the one true copy.
|
|
158
|
+
*/
|
|
159
|
+
let deserialize = () => {
|
|
160
|
+
throw new Error('No Flight decoder installed. createViteRscApp() sets one up.');
|
|
161
|
+
};
|
|
162
|
+
let encode = () => {
|
|
163
|
+
throw new Error('No Flight encoder installed. createViteRscApp() sets one up.');
|
|
164
|
+
};
|
|
165
|
+
let asAction = () => {
|
|
166
|
+
throw new Error('No action transport installed. createViteRscApp() sets one up.');
|
|
167
|
+
};
|
|
168
|
+
export function setQueryCodec(codec) {
|
|
169
|
+
deserialize = codec.deserialize;
|
|
170
|
+
encode = codec.encode;
|
|
171
|
+
asAction = codec.asAction;
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=queryClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queryClient.js","sourceRoot":"","sources":["../../src/js/queryClient.ts"],"names":[],"mappings":"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","sourcesContent":["// The browser half of `query()`: send a read as a GET.\n//\n// That is the whole of it. There is no cache here, no batching and no\n// deduplication, because TanStack Query and SWR already do those and do them\n// better — this owns the transport and they own everything above it.\n//\n// The awkward fact it is built around: a server function's id is not readable\n// from the client. React keeps it in a module-private WeakMap and exposes no\n// getter, so there is no `ref.$$id` to build a url from. What the client *can*\n// do is call the reference and be handed the id by React itself — the stub's\n// whole body is `callServer(id, args)`, and this package already owns\n// `callServer`.\n//\n// So a read opens a one-shot slot and calls the reference; the transport claims\n// the slot and sends a GET instead of posting. The slot is also what says this\n// is a read at all: calling `getListings(kind)` directly is still a POST, and\n// `fetchQuery(getListings, [kind])` is the same call as a GET. Nothing has to\n// know a list of query ids, on either side of the build.\n\n/** Where reads are answered. Must match HEADER.queryPath on the server. */\nconst QUERY_PATH = '/_rsc/query'\n\n/**\n * Sent on every read, and required by the endpoint.\n *\n * Not decoration. A GET with no unusual header is a *simple* request, so any\n * page anywhere can trigger one with `<img src=\"…/_rsc/query?…\">` and it goes\n * out with the visitor's cookies — CORS stops the attacker reading the answer,\n * but the read still runs. This header is not CORS-safelisted, so the browser\n * preflights it, and nothing here answers a preflight.\n *\n * That restores exactly the protection a POST had: a `POST` carrying\n * `X-RSC-Action` is non-simple for the same reason.\n */\nconst QUERY_HEADER = 'X-RSC-Query'\n\n/**\n * How long a url may get before the read goes as a POST instead.\n *\n * Conservative. Proxies and CDNs start refusing somewhere between 8k and 16k,\n * and the failure is a 414 from a machine that is not ours.\n */\nconst MAX_URL = 6_000\n\ntype Reader = (...args: unknown[]) => Promise<unknown>\n\n/**\n * The slot a read opens before calling the reference.\n *\n * One-shot and synchronous: React's stub calls `callServer` in its own body\n * with nothing awaited in between, so exactly one transport call can claim it.\n */\nlet slot: { claimed: boolean; promise: Promise<unknown> | null } | null = null\n\n/**\n * Claim the open slot, if a read opened one.\n *\n * Called from the app's `callServer` before it posts. Returns null when this is\n * an ordinary action, which is every call that did not come through\n * `fetchQuery`.\n */\nexport function claimRead(id: string, args: unknown[]): Promise<unknown> | null {\n if (!slot || slot.claimed) return null\n\n slot.claimed = true\n slot.promise = send(id, args)\n\n return slot.promise\n}\n\n/**\n * Read a query, over GET.\n *\n * Hand this to a cache library and let it decide everything else:\n *\n * queryFn: () => fetchQuery(getListings, [kind])\n * useSWR(['listings', kind], () => fetchQuery(getListings, [kind]))\n *\n * Every call goes to the server, which is what a fetcher needs — staleness,\n * revalidation, retries, polling and deduplication all belong to the library\n * holding the answer, not to the thing that fetches it.\n */\nexport function fetchQuery<Data>(\n reference: (...args: never[]) => Promise<Data>,\n args: unknown[] = [],\n): Promise<Data> {\n if (typeof window === 'undefined') {\n // React's SSR runtime refuses a server-function call during the initial\n // render, and reaching a query's id means calling its reference — so this\n // cannot work here. Raised with the fix in it rather than left to surface\n // as React's more general message about fetch waterfalls.\n //\n // A cache library runs its fetcher in an effect, so the server render never\n // reaches this; a server component that wants the data during render should\n // await the query directly, which needs none of this.\n throw new Error(\n 'A query was read during server rendering. Read it in a server component with await, or through a cache library, whose fetcher runs after hydration.',\n )\n }\n\n const opened = { claimed: false, promise: null as Promise<unknown> | null }\n\n slot = opened\n\n try {\n // React's stub reaches callServer synchronously, so the slot is claimed by\n // the time this returns. The stub's own promise is discarded: the one the\n // transport made is the one that settles with the answer.\n ;(reference as unknown as Reader)(...(args as unknown[]))\n } finally {\n // Cleared here and nowhere else, so a reference that throws on the way in\n // does not leave the slot open for the next read to claim by accident.\n // Nothing is returned from this block: a `return` in `finally` discards\n // whatever the `try` was throwing, which would turn a reference that threw\n // synchronously into the misleading bound-reference error below.\n slot = null\n }\n\n if (!opened.claimed || !opened.promise) {\n // The only way here is a reference whose call path is asynchronous, which\n // today means one that was `.bind()`-ed. Loud, because the quiet version is\n // a read that silently went out as a POST.\n throw new Error(\n 'A query was called through a bound reference. Pass the exported query itself, unbound.',\n )\n }\n\n return opened.promise as Promise<Data>\n}\n\nasync function send(id: string, args: unknown[]): Promise<unknown> {\n const encoded = await encode(args)\n\n // encodeReply answers with FormData the moment an argument holds a File, and\n // a url cannot carry one. Rather than refuse a call that would have worked as\n // an action, the read falls back to a POST: it stops being cacheable, which\n // is the only thing it loses.\n if (typeof encoded !== 'string') return await post(id, args, 'an argument contained a File')\n\n const url = `${QUERY_PATH}?id=${encodeURIComponent(id)}&args=${encodeURIComponent(encoded)}`\n\n if (url.length > MAX_URL) return await post(id, args, 'the arguments are too large for a url')\n\n const res = await fetch(url, {\n headers: {\n [QUERY_HEADER]: '1',\n Accept: 'text/x-component',\n // Where the read came from. Same reason an action sends it: a host that\n // guards by route needs to know which page is asking.\n 'X-RSC-Referer': window.location.pathname + window.location.search,\n },\n })\n\n if (!res.ok || !res.body) {\n throw new Error((await res.text().catch(() => '')) || `Query failed: ${res.status}`)\n }\n\n return await deserialize(res.body)\n}\n\n/**\n * The same read, as an action.\n *\n * Reached only when the arguments cannot ride in a url. Warned about in\n * development rather than silently tolerated, because the read still works and\n * the thing that changed — it is no longer a GET, so nothing can cache it — is\n * otherwise invisible.\n */\nasync function post(id: string, args: unknown[], why: string): Promise<unknown> {\n if (import.meta.env?.DEV) {\n console.warn(`[rsc-kit] A query was sent as a POST because ${why}. It will not be cached.`)\n }\n\n return await asAction(id, args)\n}\n\n/**\n * The Flight codec and the action transport, installed by the app bootstrap.\n *\n * Injected rather than imported, and not for testing: this module is reached\n * from client components, so importing the browser runtime here would pull a\n * second copy of it into that graph — two client-reference registries, where\n * components resolve to undefined with nothing logged. The bootstrap already\n * holds the one true copy.\n */\nlet deserialize: (stream: ReadableStream) => Promise<unknown> = () => {\n throw new Error('No Flight decoder installed. createViteRscApp() sets one up.')\n}\n\nlet encode: (args: unknown[]) => Promise<string | FormData> = () => {\n throw new Error('No Flight encoder installed. createViteRscApp() sets one up.')\n}\n\nlet asAction: (id: string, args: unknown[]) => Promise<unknown> = () => {\n throw new Error('No action transport installed. createViteRscApp() sets one up.')\n}\n\nexport function setQueryCodec(codec: {\n deserialize: (stream: ReadableStream) => Promise<unknown>\n encode: (args: unknown[]) => Promise<string | FormData>\n asAction: (id: string, args: unknown[]) => Promise<unknown>\n}): void {\n deserialize = codec.deserialize\n encode = codec.encode\n asAction = codec.asAction\n}\n"]}
|
|
@@ -9,12 +9,42 @@
|
|
|
9
9
|
* Entries are keyed by page (the URL, or its intercept variant), so a boundary
|
|
10
10
|
* can hold several and reveal one. Empty is meaningful: a boundary with nothing
|
|
11
11
|
* stored renders the children the server gave it.
|
|
12
|
+
*
|
|
13
|
+
* A store rather than state, and not a preference — three things rule state out.
|
|
14
|
+
* A boundary is inserted between every layout level, so there are several and a
|
|
15
|
+
* navigation targets one by depth; they are separated by server components, so
|
|
16
|
+
* no setter can be threaded down to them, because a function does not cross
|
|
17
|
+
* that boundary; and navigate.ts is a plain module with no component instance
|
|
18
|
+
* to call one on. Addressing a component you hold no reference to is what an
|
|
19
|
+
* external store is for.
|
|
20
|
+
*
|
|
21
|
+
* Underneath it is the wire protocol. A partial navigation sends only the
|
|
22
|
+
* segment that changed — see the X-RSC-Segments headers — so there is nothing
|
|
23
|
+
* for a root to re-render with even if it held the state. Next.js keeps its
|
|
24
|
+
* router in useState and derives every segment from it; that is the same trade
|
|
25
|
+
* in the other direction.
|
|
26
|
+
*
|
|
27
|
+
* What it costs: React pins an external store's updates to synchronous
|
|
28
|
+
* priority, because a store cannot be safely time-sliced. Synchronous is never
|
|
29
|
+
* a transition, and anything that only runs for one — React's <ViewTransition>
|
|
30
|
+
* among them — never ran for a navigation.
|
|
31
|
+
*
|
|
32
|
+
* Which is why SegmentBoundary does not read this with useSyncExternalStore
|
|
33
|
+
* any more. The store still does the addressing, which is the part only it can
|
|
34
|
+
* do; the boundary copies into state, so the render is a transition. See the
|
|
35
|
+
* view transitions guide.
|
|
12
36
|
*/
|
|
13
37
|
type Tree = unknown;
|
|
14
38
|
type Listener = () => void;
|
|
15
39
|
interface Entry {
|
|
16
40
|
key: string;
|
|
17
41
|
tree: Tree;
|
|
42
|
+
/**
|
|
43
|
+
* When this tree arrived, so a link can decide whether it is still worth
|
|
44
|
+
* revealing. The back button never asks — it means "the page I was on",
|
|
45
|
+
* however long ago that was.
|
|
46
|
+
*/
|
|
47
|
+
at: number;
|
|
18
48
|
}
|
|
19
49
|
/**
|
|
20
50
|
* Immutable: useSyncExternalStore compares snapshots by identity, so a new
|
|
@@ -57,7 +87,17 @@ export declare function seedSegment(depth: number, key: string, tree: Tree): voi
|
|
|
57
87
|
* deeper boundary, so they delegate to whatever it is showing. One that does
|
|
58
88
|
* hold the key is switched to it, since that is a real change at its level.
|
|
59
89
|
*/
|
|
60
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Reveal a page still being held, if it is worth revealing.
|
|
92
|
+
*
|
|
93
|
+
* `maxAge` is what a link passes and the back button does not. Going back is
|
|
94
|
+
* unambiguous — it names a moment, and the page from that moment is the right
|
|
95
|
+
* answer however old. A link says "go here", and answering it with a tree from
|
|
96
|
+
* twenty minutes ago is stale data presented as fresh, which is the objection
|
|
97
|
+
* this design started with. Recent enough, and it is the same page you were
|
|
98
|
+
* just on, with the form you were filling in still filled in.
|
|
99
|
+
*/
|
|
100
|
+
export declare function restoreSegments(key: string, maxAge?: number): boolean;
|
|
61
101
|
/**
|
|
62
102
|
* Drop everything, so boundaries fall back to their server-given children.
|
|
63
103
|
*
|
package/dist/js/segmentStore.js
CHANGED
|
@@ -9,6 +9,30 @@
|
|
|
9
9
|
* Entries are keyed by page (the URL, or its intercept variant), so a boundary
|
|
10
10
|
* can hold several and reveal one. Empty is meaningful: a boundary with nothing
|
|
11
11
|
* stored renders the children the server gave it.
|
|
12
|
+
*
|
|
13
|
+
* A store rather than state, and not a preference — three things rule state out.
|
|
14
|
+
* A boundary is inserted between every layout level, so there are several and a
|
|
15
|
+
* navigation targets one by depth; they are separated by server components, so
|
|
16
|
+
* no setter can be threaded down to them, because a function does not cross
|
|
17
|
+
* that boundary; and navigate.ts is a plain module with no component instance
|
|
18
|
+
* to call one on. Addressing a component you hold no reference to is what an
|
|
19
|
+
* external store is for.
|
|
20
|
+
*
|
|
21
|
+
* Underneath it is the wire protocol. A partial navigation sends only the
|
|
22
|
+
* segment that changed — see the X-RSC-Segments headers — so there is nothing
|
|
23
|
+
* for a root to re-render with even if it held the state. Next.js keeps its
|
|
24
|
+
* router in useState and derives every segment from it; that is the same trade
|
|
25
|
+
* in the other direction.
|
|
26
|
+
*
|
|
27
|
+
* What it costs: React pins an external store's updates to synchronous
|
|
28
|
+
* priority, because a store cannot be safely time-sliced. Synchronous is never
|
|
29
|
+
* a transition, and anything that only runs for one — React's <ViewTransition>
|
|
30
|
+
* among them — never ran for a navigation.
|
|
31
|
+
*
|
|
32
|
+
* Which is why SegmentBoundary does not read this with useSyncExternalStore
|
|
33
|
+
* any more. The store still does the addressing, which is the part only it can
|
|
34
|
+
* do; the boundary copies into state, so the render is a transition. See the
|
|
35
|
+
* view transitions guide.
|
|
12
36
|
*/
|
|
13
37
|
/** Pages kept alive per boundary. Four covers ordinary back-and-forth. */
|
|
14
38
|
export const RETENTION = 4;
|
|
@@ -46,7 +70,10 @@ function retain(entries, order, activeKey) {
|
|
|
46
70
|
}
|
|
47
71
|
function put(depth, key, tree) {
|
|
48
72
|
const state = depths.get(depth);
|
|
49
|
-
const entries = [
|
|
73
|
+
const entries = [
|
|
74
|
+
...(state?.entries ?? []).filter((entry) => entry.key !== key),
|
|
75
|
+
{ key, tree, at: Date.now() },
|
|
76
|
+
];
|
|
50
77
|
const order = [...(state?.order ?? []).filter((k) => k !== key), key];
|
|
51
78
|
depths.set(depth, retain(entries, order, key));
|
|
52
79
|
}
|
|
@@ -80,7 +107,7 @@ export function seedSegment(depth, key, tree) {
|
|
|
80
107
|
}
|
|
81
108
|
// Older than whatever is showing, so it goes to the front of the eviction
|
|
82
109
|
// order — and crucially does not become the active page.
|
|
83
|
-
depths.set(depth, retain([...state.entries, { key, tree }], [key, ...state.order.filter((k) => k !== key)], state.activeKey));
|
|
110
|
+
depths.set(depth, retain([...state.entries, { key, tree, at: Date.now() }], [key, ...state.order.filter((k) => k !== key)], state.activeKey));
|
|
84
111
|
notify(depth);
|
|
85
112
|
}
|
|
86
113
|
/**
|
|
@@ -96,10 +123,29 @@ export function seedSegment(depth, key, tree) {
|
|
|
96
123
|
* deeper boundary, so they delegate to whatever it is showing. One that does
|
|
97
124
|
* hold the key is switched to it, since that is a real change at its level.
|
|
98
125
|
*/
|
|
99
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Reveal a page still being held, if it is worth revealing.
|
|
128
|
+
*
|
|
129
|
+
* `maxAge` is what a link passes and the back button does not. Going back is
|
|
130
|
+
* unambiguous — it names a moment, and the page from that moment is the right
|
|
131
|
+
* answer however old. A link says "go here", and answering it with a tree from
|
|
132
|
+
* twenty minutes ago is stale data presented as fresh, which is the objection
|
|
133
|
+
* this design started with. Recent enough, and it is the same page you were
|
|
134
|
+
* just on, with the form you were filling in still filled in.
|
|
135
|
+
*/
|
|
136
|
+
export function restoreSegments(key, maxAge) {
|
|
100
137
|
const holding = [...depths.keys()].filter((d) => depths.get(d).entries.some((entry) => entry.key === key));
|
|
101
138
|
if (holding.length === 0)
|
|
102
139
|
return false;
|
|
140
|
+
if (maxAge !== undefined) {
|
|
141
|
+
const ages = holding.flatMap((d) => depths.get(d).entries.filter((entry) => entry.key === key).map((entry) => entry.at));
|
|
142
|
+
// The oldest layer decides: revealing a fresh page under a stale layout
|
|
143
|
+
// would be a chain nobody rendered together.
|
|
144
|
+
// >= rather than >, so a window of 0 means never rather than "only within
|
|
145
|
+
// the same millisecond".
|
|
146
|
+
if (Date.now() - Math.min(...ages) >= maxAge)
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
103
149
|
const anchor = Math.max(...holding);
|
|
104
150
|
for (const d of [...depths.keys()].filter((d) => d > anchor)) {
|
|
105
151
|
depths.delete(d);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"segmentStore.js","sourceRoot":"","sources":["../../src/js/segmentStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAsBH,0EAA0E;AAC1E,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAA;AAE1B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAsB,CAAA;AAC5C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAyB,CAAA;AAElD,SAAS,MAAM,CAAC,KAAa;IAC3B,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,CAAA;AAC/D,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,KAAa,EAAE,QAAkB;IAClE,IAAI,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IAE9B,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,GAAG,GAAG,IAAI,GAAG,EAAE,CAAA;QACf,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;IAC3B,CAAC;IAED,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAEjB,OAAO,GAAG,EAAE;QACV,GAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;QACrB,IAAI,GAAI,CAAC,IAAI,KAAK,CAAC;YAAE,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC9C,CAAC,CAAA;AACH,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI,CAAA;AAClC,CAAC;AAED,uDAAuD;AACvD,SAAS,MAAM,CAAC,OAAyB,EAAE,KAAwB,EAAE,SAAiB;IACpF,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAA;IAEpC,OAAO;QACL,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC5D,KAAK,EAAE,IAAI;QACX,SAAS;KACV,CAAA;AACH,CAAC;AAED,SAAS,GAAG,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IACjD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IAC/B,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/F,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAA;IAErE,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC,CAAA;AAChD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IAC/D,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAA;IAErB,MAAM,KAAK,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,CAAA;IACzD,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAEvC,MAAM,CAAC,KAAK,CAAC,CAAA;IACb,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,MAAM,CAAC,CAAC,CAAC,CAAA;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IAChE,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;IAE/B,IAAI,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC;QAAE,OAAM;IAE7D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAA;QACrB,MAAM,CAAC,KAAK,CAAC,CAAA;QAEb,OAAM;IACR,CAAC;IAED,0EAA0E;IAC1E,yDAAyD;IACzD,MAAM,CAAC,GAAG,CACR,KAAK,EACL,MAAM,CAAC,CAAC,GAAG,KAAK,CAAC,OAAO,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAC3G,CAAA;IAED,MAAM,CAAC,KAAK,CAAC,CAAA;AACf,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAC9C,MAAM,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC,CAC1D,CAAA;IAED,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEtC,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAA;IAEnC,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;QAC7D,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;QAChB,MAAM,CAAC,CAAC,CAAC,CAAA;IACX,CAAC;IAED,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAE,CAAA;QAE5B,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAA;QACzF,MAAM,CAAC,CAAC,CAAC,CAAA;IACX,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,GAAG,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;IAC9B,MAAM,CAAC,KAAK,EAAE,CAAA;IACd,KAAK,MAAM,KAAK,IAAI,GAAG;QAAE,MAAM,CAAC,KAAK,CAAC,CAAA;AACxC,CAAC","sourcesContent":["/**\n * What each segment boundary is showing, and what it is keeping alive behind it.\n *\n * A navigation replaces one segment. Keeping the previous one mounted — hidden,\n * not unmounted — is what lets going back restore it with its client state:\n * the half-typed form, the open disclosure, the scrolled list. Unmounting\n * throws all of that away, which is what replacing the root used to do.\n *\n * Entries are keyed by page (the URL, or its intercept variant), so a boundary\n * can hold several and reveal one. Empty is meaningful: a boundary with nothing\n * stored renders the children the server gave it.\n */\n\ntype Tree = unknown\ntype Listener = () => void\n\ninterface Entry {\n key: string\n tree: Tree\n}\n\n/**\n * Immutable: useSyncExternalStore compares snapshots by identity, so a new\n * object per read reads as \"changed every render\" and loops forever. Every\n * mutation replaces this wholesale; nothing edits one in place.\n */\ninterface DepthState {\n readonly entries: readonly Entry[]\n readonly activeKey: string\n /** Most recently shown last; eviction takes from the front. */\n readonly order: readonly string[]\n}\n\n/** Pages kept alive per boundary. Four covers ordinary back-and-forth. */\nexport const RETENTION = 4\n\nconst depths = new Map<number, DepthState>()\nconst listeners = new Map<number, Set<Listener>>()\n\nfunction notify(depth: number): void {\n for (const listener of listeners.get(depth) ?? []) listener()\n}\n\nexport function subscribeToSegment(depth: number, listener: Listener): () => void {\n let set = listeners.get(depth)\n\n if (!set) {\n set = new Set()\n listeners.set(depth, set)\n }\n\n set.add(listener)\n\n return () => {\n set!.delete(listener)\n if (set!.size === 0) listeners.delete(depth)\n }\n}\n\n/** Everything a boundary at this depth needs to render, or null for \"use children\". */\nexport function getSegmentState(depth: number): DepthState | null {\n return depths.get(depth) ?? null\n}\n\n/** Apply the retention window to a candidate state. */\nfunction retain(entries: readonly Entry[], order: readonly string[], activeKey: string): DepthState {\n const kept = order.slice(-RETENTION)\n\n return {\n entries: entries.filter((entry) => kept.includes(entry.key)),\n order: kept,\n activeKey,\n }\n}\n\nfunction put(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth)\n const entries = [...(state?.entries ?? []).filter((entry) => entry.key !== key), { key, tree }]\n const order = [...(state?.order ?? []).filter((k) => k !== key), key]\n\n depths.set(depth, retain(entries, order, key))\n}\n\n/**\n * Show `tree` at `depth` for `key`, retaining what was there.\n *\n * Deeper segments belonged to the page being replaced; leaving them would\n * render the previous page inside the new one.\n */\nexport function setSegment(depth: number, key: string, tree: Tree): void {\n put(depth, key, tree)\n\n const stale = [...depths.keys()].filter((d) => d > depth)\n for (const d of stale) depths.delete(d)\n\n notify(depth)\n for (const d of stale) notify(d)\n}\n\n/**\n * Record the children the server rendered, so the page you arrived on can be\n * returned to later. Never changes what is showing.\n */\nexport function seedSegment(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth)\n\n if (state?.entries.some((entry) => entry.key === key)) return\n\n if (!state) {\n put(depth, key, tree)\n notify(depth)\n\n return\n }\n\n // Older than whatever is showing, so it goes to the front of the eviction\n // order — and crucially does not become the active page.\n depths.set(\n depth,\n retain([...state.entries, { key, tree }], [key, ...state.order.filter((k) => k !== key)], state.activeKey),\n )\n\n notify(depth)\n}\n\n/**\n * Reveal a page the boundaries are still holding, without asking the server.\n *\n * Restoring is anchored on the deepest boundary that can show the page. Deeper\n * ones than that belonged to the page being left — a section with its own\n * layout adds a boundary the page you are going back to never had — so they\n * are dropped, exactly as setSegment drops them. Requiring every boundary to\n * hold the key instead made any such page refuse to restore.\n *\n * Shallower boundaries need no key of their own: their trees contain the\n * deeper boundary, so they delegate to whatever it is showing. One that does\n * hold the key is switched to it, since that is a real change at its level.\n */\nexport function restoreSegments(key: string): boolean {\n const holding = [...depths.keys()].filter((d) =>\n depths.get(d)!.entries.some((entry) => entry.key === key),\n )\n\n if (holding.length === 0) return false\n\n const anchor = Math.max(...holding)\n\n for (const d of [...depths.keys()].filter((d) => d > anchor)) {\n depths.delete(d)\n notify(d)\n }\n\n for (const d of holding) {\n const state = depths.get(d)!\n\n depths.set(d, retain(state.entries, [...state.order.filter((k) => k !== key), key], key))\n notify(d)\n }\n\n return true\n}\n\n/**\n * Drop everything, so boundaries fall back to their server-given children.\n *\n * A deployment invalidates them all: a segment from the previous build has no\n * claim on being correct for this one.\n */\nexport function clearSegments(): void {\n const all = [...depths.keys()]\n depths.clear()\n for (const depth of all) notify(depth)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"segmentStore.js","sourceRoot":"","sources":["../../src/js/segmentStore.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What each segment boundary is showing, and what it is keeping alive behind it.\n *\n * A navigation replaces one segment. Keeping the previous one mounted — hidden,\n * not unmounted — is what lets going back restore it with its client state:\n * the half-typed form, the open disclosure, the scrolled list. Unmounting\n * throws all of that away, which is what replacing the root used to do.\n *\n * Entries are keyed by page (the URL, or its intercept variant), so a boundary\n * can hold several and reveal one. Empty is meaningful: a boundary with nothing\n * stored renders the children the server gave it.\n *\n * A store rather than state, and not a preference — three things rule state out.\n * A boundary is inserted between every layout level, so there are several and a\n * navigation targets one by depth; they are separated by server components, so\n * no setter can be threaded down to them, because a function does not cross\n * that boundary; and navigate.ts is a plain module with no component instance\n * to call one on. Addressing a component you hold no reference to is what an\n * external store is for.\n *\n * Underneath it is the wire protocol. A partial navigation sends only the\n * segment that changed — see the X-RSC-Segments headers — so there is nothing\n * for a root to re-render with even if it held the state. Next.js keeps its\n * router in useState and derives every segment from it; that is the same trade\n * in the other direction.\n *\n * What it costs: React pins an external store's updates to synchronous\n * priority, because a store cannot be safely time-sliced. Synchronous is never\n * a transition, and anything that only runs for one — React's <ViewTransition>\n * among them — never ran for a navigation.\n *\n * Which is why SegmentBoundary does not read this with useSyncExternalStore\n * any more. The store still does the addressing, which is the part only it can\n * do; the boundary copies into state, so the render is a transition. See the\n * view transitions guide.\n */\n\ntype Tree = unknown\ntype Listener = () => void\n\ninterface Entry {\n key: string\n tree: Tree\n /**\n * When this tree arrived, so a link can decide whether it is still worth\n * revealing. The back button never asks — it means \"the page I was on\",\n * however long ago that was.\n */\n at: number\n}\n\n/**\n * Immutable: useSyncExternalStore compares snapshots by identity, so a new\n * object per read reads as \"changed every render\" and loops forever. Every\n * mutation replaces this wholesale; nothing edits one in place.\n */\ninterface DepthState {\n readonly entries: readonly Entry[]\n readonly activeKey: string\n /** Most recently shown last; eviction takes from the front. */\n readonly order: readonly string[]\n}\n\n/** Pages kept alive per boundary. Four covers ordinary back-and-forth. */\nexport const RETENTION = 4\n\nconst depths = new Map<number, DepthState>()\nconst listeners = new Map<number, Set<Listener>>()\n\nfunction notify(depth: number): void {\n for (const listener of listeners.get(depth) ?? []) listener()\n}\n\nexport function subscribeToSegment(depth: number, listener: Listener): () => void {\n let set = listeners.get(depth)\n\n if (!set) {\n set = new Set()\n listeners.set(depth, set)\n }\n\n set.add(listener)\n\n return () => {\n set!.delete(listener)\n if (set!.size === 0) listeners.delete(depth)\n }\n}\n\n/** Everything a boundary at this depth needs to render, or null for \"use children\". */\nexport function getSegmentState(depth: number): DepthState | null {\n return depths.get(depth) ?? null\n}\n\n/** Apply the retention window to a candidate state. */\nfunction retain(entries: readonly Entry[], order: readonly string[], activeKey: string): DepthState {\n const kept = order.slice(-RETENTION)\n\n return {\n entries: entries.filter((entry) => kept.includes(entry.key)),\n order: kept,\n activeKey,\n }\n}\n\nfunction put(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth)\n const entries = [\n ...(state?.entries ?? []).filter((entry) => entry.key !== key),\n { key, tree, at: Date.now() },\n ]\n const order = [...(state?.order ?? []).filter((k) => k !== key), key]\n\n depths.set(depth, retain(entries, order, key))\n}\n\n/**\n * Show `tree` at `depth` for `key`, retaining what was there.\n *\n * Deeper segments belonged to the page being replaced; leaving them would\n * render the previous page inside the new one.\n */\nexport function setSegment(depth: number, key: string, tree: Tree): void {\n put(depth, key, tree)\n\n const stale = [...depths.keys()].filter((d) => d > depth)\n for (const d of stale) depths.delete(d)\n\n notify(depth)\n for (const d of stale) notify(d)\n}\n\n/**\n * Record the children the server rendered, so the page you arrived on can be\n * returned to later. Never changes what is showing.\n */\nexport function seedSegment(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth)\n\n if (state?.entries.some((entry) => entry.key === key)) return\n\n if (!state) {\n put(depth, key, tree)\n notify(depth)\n\n return\n }\n\n\n // Older than whatever is showing, so it goes to the front of the eviction\n // order — and crucially does not become the active page.\n depths.set(\n depth,\n retain(\n [...state.entries, { key, tree, at: Date.now() }],\n [key, ...state.order.filter((k) => k !== key)],\n state.activeKey,\n ),\n )\n\n notify(depth)\n}\n\n/**\n * Reveal a page the boundaries are still holding, without asking the server.\n *\n * Restoring is anchored on the deepest boundary that can show the page. Deeper\n * ones than that belonged to the page being left — a section with its own\n * layout adds a boundary the page you are going back to never had — so they\n * are dropped, exactly as setSegment drops them. Requiring every boundary to\n * hold the key instead made any such page refuse to restore.\n *\n * Shallower boundaries need no key of their own: their trees contain the\n * deeper boundary, so they delegate to whatever it is showing. One that does\n * hold the key is switched to it, since that is a real change at its level.\n */\n/**\n * Reveal a page still being held, if it is worth revealing.\n *\n * `maxAge` is what a link passes and the back button does not. Going back is\n * unambiguous — it names a moment, and the page from that moment is the right\n * answer however old. A link says \"go here\", and answering it with a tree from\n * twenty minutes ago is stale data presented as fresh, which is the objection\n * this design started with. Recent enough, and it is the same page you were\n * just on, with the form you were filling in still filled in.\n */\nexport function restoreSegments(key: string, maxAge?: number): boolean {\n const holding = [...depths.keys()].filter((d) =>\n depths.get(d)!.entries.some((entry) => entry.key === key),\n )\n\n if (holding.length === 0) return false\n\n if (maxAge !== undefined) {\n const ages = holding.flatMap((d) =>\n depths.get(d)!.entries.filter((entry) => entry.key === key).map((entry) => entry.at),\n )\n\n // The oldest layer decides: revealing a fresh page under a stale layout\n // would be a chain nobody rendered together.\n // >= rather than >, so a window of 0 means never rather than \"only within\n // the same millisecond\".\n if (Date.now() - Math.min(...ages) >= maxAge) return false\n }\n\n const anchor = Math.max(...holding)\n\n for (const d of [...depths.keys()].filter((d) => d > anchor)) {\n depths.delete(d)\n notify(d)\n }\n\n for (const d of holding) {\n const state = depths.get(d)!\n\n depths.set(d, retain(state.entries, [...state.order.filter((k) => k !== key), key], key))\n notify(d)\n }\n\n return true\n}\n\n/**\n * Drop everything, so boundaries fall back to their server-given children.\n *\n * A deployment invalidates them all: a segment from the previous build has no\n * claim on being correct for this one.\n */\nexport function clearSegments(): void {\n const all = [...depths.keys()]\n depths.clear()\n for (const depth of all) notify(depth)\n}\n"]}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface IconDescriptor {
|
|
2
|
+
url: string | URL;
|
|
3
|
+
type?: string;
|
|
4
|
+
sizes?: string;
|
|
5
|
+
color?: string;
|
|
6
|
+
rel?: string;
|
|
7
|
+
media?: string;
|
|
8
|
+
fetchPriority?: 'high' | 'low' | 'auto';
|
|
9
|
+
}
|
|
10
|
+
export type IconURL = string | URL;
|
|
11
|
+
export interface Icons {
|
|
12
|
+
icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
|
|
13
|
+
apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
|
|
14
|
+
shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[];
|
|
15
|
+
other?: IconDescriptor | IconDescriptor[];
|
|
16
|
+
}
|
|
17
|
+
/** A layout's title, wrapping the titles of the pages beneath it. */
|
|
18
|
+
export interface TitleTemplate {
|
|
19
|
+
/** `%s` stands in for the page's own title. */
|
|
20
|
+
template?: string;
|
|
21
|
+
/** Used by a page that exports no title of its own. */
|
|
22
|
+
default?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface Metadata {
|
|
25
|
+
/** A string on a page; a template on a layout, applied to the pages below it. */
|
|
26
|
+
title?: string | TitleTemplate;
|
|
27
|
+
description?: string;
|
|
28
|
+
keywords?: string | string[];
|
|
29
|
+
author?: string;
|
|
30
|
+
robots?: string;
|
|
31
|
+
icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null;
|
|
32
|
+
'og:title'?: string;
|
|
33
|
+
'og:description'?: string;
|
|
34
|
+
'og:image'?: string;
|
|
35
|
+
'og:url'?: string;
|
|
36
|
+
'og:type'?: string;
|
|
37
|
+
'og:site_name'?: string;
|
|
38
|
+
'twitter:card'?: string;
|
|
39
|
+
'twitter:title'?: string;
|
|
40
|
+
'twitter:description'?: string;
|
|
41
|
+
'twitter:image'?: string;
|
|
42
|
+
'twitter:site'?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Any other meta tag, by name.
|
|
45
|
+
*
|
|
46
|
+
* other: { 'fb:app_id': '123', 'theme-color': '#000' }
|
|
47
|
+
*
|
|
48
|
+
* Here rather than alongside the named keys, and that is what makes the rest
|
|
49
|
+
* of this interface worth annotating. An index signature on the interface
|
|
50
|
+
* itself made every key legal — so `titel` was accepted in silence, and an
|
|
51
|
+
* editor offered no completions at all, because with any identifier valid
|
|
52
|
+
* TypeScript reads an unfinished key as a shorthand property and goes looking
|
|
53
|
+
* for a variable by that name.
|
|
54
|
+
*/
|
|
55
|
+
other?: Record<string, string | string[] | null | undefined>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Metadata that depends on the request.
|
|
59
|
+
*
|
|
60
|
+
* Receives the same awaitables a page does, so one shape is learned rather
|
|
61
|
+
* than two:
|
|
62
|
+
*
|
|
63
|
+
* export const generateMetadata: GenerateMetadata<{ slug: string }> =
|
|
64
|
+
* async ({ params }) => ({ title: (await params).slug })
|
|
65
|
+
*/
|
|
66
|
+
export type GenerateMetadata<P = Record<string, string>> = (args: {
|
|
67
|
+
params: Promise<P>;
|
|
68
|
+
searchParams: Promise<URLSearchParams>;
|
|
69
|
+
}) => Metadata | Promise<Metadata>;
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// What a page says about itself, as importable types.
|
|
2
|
+
//
|
|
3
|
+
// import type { Metadata } from '@rsc-kit/core/metadata'
|
|
4
|
+
//
|
|
5
|
+
// export const metadata: Metadata = { title: 'Orders' }
|
|
6
|
+
//
|
|
7
|
+
// Imported rather than ambient, and that is the whole point of the move. An
|
|
8
|
+
// ambient declaration has to be COPIED into the project, which means it is not
|
|
9
|
+
// there until the build has run once — so a freshly cloned app reports "Cannot
|
|
10
|
+
// find name 'Metadata'" on every page until someone runs the dev server. An
|
|
11
|
+
// import resolves from node_modules the moment dependencies are installed.
|
|
12
|
+
//
|
|
13
|
+
// The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these
|
|
14
|
+
// rather than restating them, so there is one definition and two ways to reach
|
|
15
|
+
// it.
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=metadata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.js","sourceRoot":"","sources":["../src/metadata.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,EAAE;AACF,6DAA6D;AAC7D,EAAE;AACF,4DAA4D;AAC5D,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,MAAM","sourcesContent":["// What a page says about itself, as importable types.\n//\n// import type { Metadata } from '@rsc-kit/core/metadata'\n//\n// export const metadata: Metadata = { title: 'Orders' }\n//\n// Imported rather than ambient, and that is the whole point of the move. An\n// ambient declaration has to be COPIED into the project, which means it is not\n// there until the build has run once — so a freshly cloned app reports \"Cannot\n// find name 'Metadata'\" on every page until someone runs the dev server. An\n// import resolves from node_modules the moment dependencies are installed.\n//\n// The ambient names still work. `.rsc-kit/rsc-types.d.ts` now aliases these\n// rather than restating them, so there is one definition and two ways to reach\n// it.\n\nexport interface IconDescriptor {\n url: string | URL\n type?: string\n sizes?: string\n color?: string\n rel?: string\n media?: string\n fetchPriority?: 'high' | 'low' | 'auto'\n}\n\nexport type IconURL = string | URL\n\nexport interface Icons {\n icon?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n apple?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n shortcut?: IconURL | IconDescriptor | (IconURL | IconDescriptor)[]\n other?: IconDescriptor | IconDescriptor[]\n}\n\n/** A layout's title, wrapping the titles of the pages beneath it. */\nexport interface TitleTemplate {\n /** `%s` stands in for the page's own title. */\n template?: string\n /** Used by a page that exports no title of its own. */\n default?: string\n}\n\nexport interface Metadata {\n /** A string on a page; a template on a layout, applied to the pages below it. */\n title?: string | TitleTemplate\n description?: string\n keywords?: string | string[]\n author?: string\n robots?: string\n icons?: IconURL | (IconURL | IconDescriptor)[] | Icons | null\n 'og:title'?: string\n 'og:description'?: string\n 'og:image'?: string\n 'og:url'?: string\n 'og:type'?: string\n 'og:site_name'?: string\n 'twitter:card'?: string\n 'twitter:title'?: string\n 'twitter:description'?: string\n 'twitter:image'?: string\n 'twitter:site'?: string\n\n /**\n * Any other meta tag, by name.\n *\n * other: { 'fb:app_id': '123', 'theme-color': '#000' }\n *\n * Here rather than alongside the named keys, and that is what makes the rest\n * of this interface worth annotating. An index signature on the interface\n * itself made every key legal — so `titel` was accepted in silence, and an\n * editor offered no completions at all, because with any identifier valid\n * TypeScript reads an unfinished key as a shorthand property and goes looking\n * for a variable by that name.\n */\n other?: Record<string, string | string[] | null | undefined>\n}\n\n/**\n * Metadata that depends on the request.\n *\n * Receives the same awaitables a page does, so one shape is learned rather\n * than two:\n *\n * export const generateMetadata: GenerateMetadata<{ slug: string }> =\n * async ({ params }) => ({ title: (await params).slug })\n */\nexport type GenerateMetadata<P = Record<string, string>> = (args: {\n params: Promise<P>\n searchParams: Promise<URLSearchParams>\n}) => Metadata | Promise<Metadata>\n"]}
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** How the answer may be stored. Conservative by default — see `QueryOptions`. */
|
|
2
|
+
export interface QueryOptions {
|
|
3
|
+
/**
|
|
4
|
+
* What `Cache-Control` the answer carries.
|
|
5
|
+
*
|
|
6
|
+
* The default is `private, no-store`, and it is deliberately the safe one: a
|
|
7
|
+
* query may read the session, and a cacheable answer to a personal read is
|
|
8
|
+
* how one visitor is served another's data. Widen it per query, once you
|
|
9
|
+
* have looked at what that query returns.
|
|
10
|
+
*
|
|
11
|
+
* `'private'` lets the visitor's own browser and service worker keep it and
|
|
12
|
+
* nothing else. `'public'` lets a CDN keep it, and is only ever right for a
|
|
13
|
+
* read whose answer does not depend on who is asking.
|
|
14
|
+
*/
|
|
15
|
+
cache?: 'no-store' | 'private' | 'public';
|
|
16
|
+
/** Seconds the answer stays fresh. Ignored when `cache` is `'no-store'`. */
|
|
17
|
+
maxAge?: number;
|
|
18
|
+
}
|
|
19
|
+
/** A function that may be read over GET. */
|
|
20
|
+
export type QueryFn<Args extends unknown[], Data> = (...args: Args) => Promise<Data>;
|
|
21
|
+
/**
|
|
22
|
+
* Declare a read.
|
|
23
|
+
*
|
|
24
|
+
* The wrapper is what gets registered and what the id points at, so the mark
|
|
25
|
+
* travels with the thing the endpoint actually loads. Wrapping rather than
|
|
26
|
+
* annotating the original also keeps `query(fn)` honest when someone exports
|
|
27
|
+
* the raw `fn` beside it: that export is an action, not a query, and the GET
|
|
28
|
+
* endpoint will refuse it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function query<Args extends unknown[], Data>(fn: (...args: Args) => Promise<Data> | Data, options?: QueryOptions): QueryFn<Args, Data>;
|
|
31
|
+
/** Whether this function was declared with `query()`, whichever copy declared it. */
|
|
32
|
+
export declare function isQuery(fn: unknown): boolean;
|
|
33
|
+
/** What the query asked for, or the safe default when it asked for nothing. */
|
|
34
|
+
export declare function queryOptions(fn: unknown): QueryOptions;
|
|
35
|
+
/** The `Cache-Control` a query's answer goes out with. */
|
|
36
|
+
export declare function queryCacheControl(fn: unknown): string;
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reads from the server, over GET, without an endpoint to write.
|
|
2
|
+
//
|
|
3
|
+
// // listings.ts
|
|
4
|
+
// "use server"
|
|
5
|
+
// import { query } from '@rsc-kit/core/query'
|
|
6
|
+
//
|
|
7
|
+
// export const getListings = query(async (filters: Filters) =>
|
|
8
|
+
// listingService.getFiltered(filters))
|
|
9
|
+
//
|
|
10
|
+
// A server function is a POST, and a POST is cacheable by nothing: not an HTTP
|
|
11
|
+
// cache, not a CDN, not the service worker this package generates. Routing
|
|
12
|
+
// reads through one makes every dynamic read the single thing that cannot
|
|
13
|
+
// survive without a network. That is the whole reason this exists alongside
|
|
14
|
+
// actions rather than on top of them.
|
|
15
|
+
//
|
|
16
|
+
// The module still carries "use server", because that is what registers the
|
|
17
|
+
// function and gives it an id — this does not invent a second registry. What
|
|
18
|
+
// it adds is a mark saying the function is a READ, which the GET endpoint
|
|
19
|
+
// checks before invoking anything. Without that check, `/_rsc/query?id=…`
|
|
20
|
+
// would run any registered action over GET, which is the classic
|
|
21
|
+
// a-crawler-emptied-the-database bug.
|
|
22
|
+
/**
|
|
23
|
+
* Marks a function as safe to invoke over GET.
|
|
24
|
+
*
|
|
25
|
+
* A property rather than a WeakSet or `instanceof`, for the reason this
|
|
26
|
+
* project keeps meeting: an app's server functions are bundled separately from
|
|
27
|
+
* the engine, so each side gets its own copy of this module. Anything holding
|
|
28
|
+
* identity across that seam — a set, a class — is simply empty on the other
|
|
29
|
+
* side, and every query would answer "not a query" with nothing logged.
|
|
30
|
+
*
|
|
31
|
+
* Symbol.for, so both copies agree on the key as well as the value.
|
|
32
|
+
*/
|
|
33
|
+
const QUERY_MARK = Symbol.for('@rsc-kit/core.query');
|
|
34
|
+
const QUERY_OPTIONS = Symbol.for('@rsc-kit/core.query-options');
|
|
35
|
+
/**
|
|
36
|
+
* Declare a read.
|
|
37
|
+
*
|
|
38
|
+
* The wrapper is what gets registered and what the id points at, so the mark
|
|
39
|
+
* travels with the thing the endpoint actually loads. Wrapping rather than
|
|
40
|
+
* annotating the original also keeps `query(fn)` honest when someone exports
|
|
41
|
+
* the raw `fn` beside it: that export is an action, not a query, and the GET
|
|
42
|
+
* endpoint will refuse it.
|
|
43
|
+
*/
|
|
44
|
+
export function query(fn, options = {}) {
|
|
45
|
+
const read = async (...args) => await fn(...args);
|
|
46
|
+
// Non-enumerable, so the mark does not show up in anything that walks the
|
|
47
|
+
// function's own keys — a bundler's export analysis, a test's snapshot.
|
|
48
|
+
Object.defineProperty(read, QUERY_MARK, { value: true });
|
|
49
|
+
Object.defineProperty(read, QUERY_OPTIONS, { value: options });
|
|
50
|
+
return read;
|
|
51
|
+
}
|
|
52
|
+
/** Whether this function was declared with `query()`, whichever copy declared it. */
|
|
53
|
+
export function isQuery(fn) {
|
|
54
|
+
return typeof fn === 'function' && fn[QUERY_MARK] === true;
|
|
55
|
+
}
|
|
56
|
+
/** What the query asked for, or the safe default when it asked for nothing. */
|
|
57
|
+
export function queryOptions(fn) {
|
|
58
|
+
if (typeof fn !== 'function')
|
|
59
|
+
return {};
|
|
60
|
+
return fn[QUERY_OPTIONS] ?? {};
|
|
61
|
+
}
|
|
62
|
+
/** The `Cache-Control` a query's answer goes out with. */
|
|
63
|
+
export function queryCacheControl(fn) {
|
|
64
|
+
const { cache = 'no-store', maxAge } = queryOptions(fn);
|
|
65
|
+
if (cache === 'no-store')
|
|
66
|
+
return 'private, no-store';
|
|
67
|
+
const age = typeof maxAge === 'number' && maxAge > 0 ? Math.floor(maxAge) : 0;
|
|
68
|
+
// must-revalidate at zero rather than omitting max-age: with no directive at
|
|
69
|
+
// all, whether an intermediary keeps it comes down to that intermediary's
|
|
70
|
+
// heuristics, and this package does not leave that to chance in either
|
|
71
|
+
// direction.
|
|
72
|
+
return age > 0 ? `${cache}, max-age=${age}` : `${cache}, max-age=0, must-revalidate`;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,qBAAqB;AACrB,mBAAmB;AACnB,kDAAkD;AAClD,EAAE;AACF,mEAAmE;AACnE,6CAA6C;AAC7C,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,0EAA0E;AAC1E,4EAA4E;AAC5E,sCAAsC;AACtC,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,0EAA0E;AAC1E,0EAA0E;AAC1E,iEAAiE;AACjE,sCAAsC;AAEtC;;;;;;;;;;GAUG;AACH,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAA;AACpD,MAAM,aAAa,GAAG,MAAM,CAAC,GAAG,CAAC,6BAA6B,CAAC,CAAA;AAwB/D;;;;;;;;GAQG;AACH,MAAM,UAAU,KAAK,CACnB,EAA2C,EAC3C,OAAO,GAAiB,EAAE;IAE1B,MAAM,IAAI,GAAG,KAAK,EAAE,GAAG,IAAU,EAAiB,EAAE,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,CAAA;IAEtE,0EAA0E;IAC1E,wEAAwE;IACxE,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;IACxD,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAA;IAE9D,OAAO,IAA2B,CAAA;AACpC,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,OAAO,CAAC,EAAW;IACjC,OAAO,OAAO,EAAE,KAAK,UAAU,IAAK,EAAyC,CAAC,UAAU,CAAC,KAAK,IAAI,CAAA;AACpG,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,YAAY,CAAC,EAAW;IACtC,IAAI,OAAO,EAAE,KAAK,UAAU;QAAE,OAAO,EAAE,CAAA;IAEvC,OAAS,EAAyC,CAAC,aAAa,CAAkB,IAAI,EAAE,CAAA;AAC1F,CAAC;AAED,0DAA0D;AAC1D,MAAM,UAAU,iBAAiB,CAAC,EAAW;IAC3C,MAAM,EAAE,KAAK,GAAG,UAAU,EAAE,MAAM,EAAE,GAAG,YAAY,CAAC,EAAE,CAAC,CAAA;IAEvD,IAAI,KAAK,KAAK,UAAU;QAAE,OAAO,mBAAmB,CAAA;IAEpD,MAAM,GAAG,GAAG,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IAE7E,6EAA6E;IAC7E,0EAA0E;IAC1E,uEAAuE;IACvE,aAAa;IACb,OAAO,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,aAAa,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,8BAA8B,CAAA;AACtF,CAAC","sourcesContent":["// Reads from the server, over GET, without an endpoint to write.\n//\n// // listings.ts\n// \"use server\"\n// import { query } from '@rsc-kit/core/query'\n//\n// export const getListings = query(async (filters: Filters) =>\n// listingService.getFiltered(filters))\n//\n// A server function is a POST, and a POST is cacheable by nothing: not an HTTP\n// cache, not a CDN, not the service worker this package generates. Routing\n// reads through one makes every dynamic read the single thing that cannot\n// survive without a network. That is the whole reason this exists alongside\n// actions rather than on top of them.\n//\n// The module still carries \"use server\", because that is what registers the\n// function and gives it an id — this does not invent a second registry. What\n// it adds is a mark saying the function is a READ, which the GET endpoint\n// checks before invoking anything. Without that check, `/_rsc/query?id=…`\n// would run any registered action over GET, which is the classic\n// a-crawler-emptied-the-database bug.\n\n/**\n * Marks a function as safe to invoke over GET.\n *\n * A property rather than a WeakSet or `instanceof`, for the reason this\n * project keeps meeting: an app's server functions are bundled separately from\n * the engine, so each side gets its own copy of this module. Anything holding\n * identity across that seam — a set, a class — is simply empty on the other\n * side, and every query would answer \"not a query\" with nothing logged.\n *\n * Symbol.for, so both copies agree on the key as well as the value.\n */\nconst QUERY_MARK = Symbol.for('@rsc-kit/core.query')\nconst QUERY_OPTIONS = Symbol.for('@rsc-kit/core.query-options')\n\n/** How the answer may be stored. Conservative by default — see `QueryOptions`. */\nexport interface QueryOptions {\n /**\n * What `Cache-Control` the answer carries.\n *\n * The default is `private, no-store`, and it is deliberately the safe one: a\n * query may read the session, and a cacheable answer to a personal read is\n * how one visitor is served another's data. Widen it per query, once you\n * have looked at what that query returns.\n *\n * `'private'` lets the visitor's own browser and service worker keep it and\n * nothing else. `'public'` lets a CDN keep it, and is only ever right for a\n * read whose answer does not depend on who is asking.\n */\n cache?: 'no-store' | 'private' | 'public'\n /** Seconds the answer stays fresh. Ignored when `cache` is `'no-store'`. */\n maxAge?: number\n}\n\n/** A function that may be read over GET. */\nexport type QueryFn<Args extends unknown[], Data> = (...args: Args) => Promise<Data>\n\n/**\n * Declare a read.\n *\n * The wrapper is what gets registered and what the id points at, so the mark\n * travels with the thing the endpoint actually loads. Wrapping rather than\n * annotating the original also keeps `query(fn)` honest when someone exports\n * the raw `fn` beside it: that export is an action, not a query, and the GET\n * endpoint will refuse it.\n */\nexport function query<Args extends unknown[], Data>(\n fn: (...args: Args) => Promise<Data> | Data,\n options: QueryOptions = {},\n): QueryFn<Args, Data> {\n const read = async (...args: Args): Promise<Data> => await fn(...args)\n\n // Non-enumerable, so the mark does not show up in anything that walks the\n // function's own keys — a bundler's export analysis, a test's snapshot.\n Object.defineProperty(read, QUERY_MARK, { value: true })\n Object.defineProperty(read, QUERY_OPTIONS, { value: options })\n\n return read as QueryFn<Args, Data>\n}\n\n/** Whether this function was declared with `query()`, whichever copy declared it. */\nexport function isQuery(fn: unknown): boolean {\n return typeof fn === 'function' && (fn as unknown as Record<symbol, unknown>)[QUERY_MARK] === true\n}\n\n/** What the query asked for, or the safe default when it asked for nothing. */\nexport function queryOptions(fn: unknown): QueryOptions {\n if (typeof fn !== 'function') return {}\n\n return ((fn as unknown as Record<symbol, unknown>)[QUERY_OPTIONS] as QueryOptions) ?? {}\n}\n\n/** The `Cache-Control` a query's answer goes out with. */\nexport function queryCacheControl(fn: unknown): string {\n const { cache = 'no-store', maxAge } = queryOptions(fn)\n\n if (cache === 'no-store') return 'private, no-store'\n\n const age = typeof maxAge === 'number' && maxAge > 0 ? Math.floor(maxAge) : 0\n\n // must-revalidate at zero rather than omitting max-age: with no directive at\n // all, whether an intermediary keeps it comes down to that intermediary's\n // heuristics, and this package does not leave that to chance in either\n // direction.\n return age > 0 ? `${cache}, max-age=${age}` : `${cache}, max-age=0, must-revalidate`\n}\n"]}
|