@rsc-kit/core 0.24.3 → 0.26.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/changed.d.ts +113 -0
- package/dist/changed.js +399 -0
- package/dist/changed.js.map +1 -0
- package/dist/conformance.d.ts +2 -0
- package/dist/conformance.js +28 -0
- package/dist/conformance.js.map +1 -1
- package/dist/events.d.ts +25 -9
- package/dist/events.js.map +1 -1
- package/dist/headers.d.ts +2 -0
- package/dist/headers.js +2 -0
- package/dist/headers.js.map +1 -1
- package/dist/host.js +15 -5
- package/dist/host.js.map +1 -1
- package/dist/js/Changes.d.ts +11 -0
- package/dist/js/Changes.js +19 -0
- package/dist/js/Changes.js.map +1 -0
- package/dist/js/changesStore.d.ts +9 -0
- package/dist/js/changesStore.js +145 -0
- package/dist/js/changesStore.js.map +1 -0
- package/dist/js/refreshOn.d.ts +21 -0
- package/dist/js/refreshOn.js +53 -0
- package/dist/js/refreshOn.js.map +1 -0
- package/dist/js/section.d.ts +10 -1
- package/dist/js/section.js +20 -4
- package/dist/js/section.js.map +1 -1
- package/dist/js/useEvents.d.ts +11 -5
- package/dist/js/useEvents.js +70 -21
- package/dist/js/useEvents.js.map +1 -1
- package/dist/request.d.ts +11 -0
- package/dist/request.js +17 -0
- package/dist/request.js.map +1 -1
- package/dist/routes.d.ts +31 -0
- package/dist/routes.js.map +1 -1
- package/dist/testHost.d.ts +22 -0
- package/dist/testHost.js +23 -0
- package/dist/testHost.js.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/vite.js +60 -7
- package/dist/vite.js.map +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names: a name for data a page depends on, so whatever changes it can say so
|
|
3
|
+
* from anywhere - a webhook, a job, another visitor's action - and every open
|
|
4
|
+
* tab showing it refreshes.
|
|
5
|
+
*
|
|
6
|
+
* // a section says what it depends on
|
|
7
|
+
* export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })
|
|
8
|
+
*
|
|
9
|
+
* // and whatever changes it says so
|
|
10
|
+
* changed(`team:${team}:repos`) // here, from a route.ts or an action
|
|
11
|
+
* rsckit.Changed(ctx, "team:1:repos") // or the backend, from its webhook
|
|
12
|
+
* Rsc::changed("team:$team:repos"); // Laravel
|
|
13
|
+
*
|
|
14
|
+
* A name has a version: a number that moves whenever something says the name
|
|
15
|
+
* changed. Nothing else - not what changed, not the data. A page learns the
|
|
16
|
+
* versions at render; one stream per open tab watches them; when one moves,
|
|
17
|
+
* the sections carrying that name ask for themselves again, the way refresh()
|
|
18
|
+
* does. Data is never pushed, which is what keeps this working on a host that
|
|
19
|
+
* cannot hold state: a version is a read.
|
|
20
|
+
*
|
|
21
|
+
* Where versions live is the source's business. Without a backend they are in
|
|
22
|
+
* this process, which is one instance. With one, the backend keeps them - in
|
|
23
|
+
* its cache or a table, shared by every instance it runs - and answers
|
|
24
|
+
* `__rsc.changed`: given the versions a watcher holds, the ones that differ now.
|
|
25
|
+
* A backend that can wait holds the call until one does; one that cannot
|
|
26
|
+
* answers at once, and the watcher asks again on its interval. The protocol
|
|
27
|
+
* is the same either way, and so is the page.
|
|
28
|
+
*/
|
|
29
|
+
/** The reserved name a backend answers name versions on. */
|
|
30
|
+
export declare const CHANGED_FUNCTION = "__rsc.changed";
|
|
31
|
+
/** What `__rsc.changed` is asked. */
|
|
32
|
+
export interface ChangedQuery {
|
|
33
|
+
/** The versions the asker holds. A name never changed is 0; -1 asks for every current version. */
|
|
34
|
+
since: Record<string, number>;
|
|
35
|
+
/** How long the backend may hold the call waiting for one to differ, in ms. It may answer at once. */
|
|
36
|
+
wait?: number;
|
|
37
|
+
}
|
|
38
|
+
/** What `__rsc.changed` answers: the versions among `since` that differ now. */
|
|
39
|
+
export interface ChangedAnswer {
|
|
40
|
+
versions: Record<string, number>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Where name versions are read from, and - when they live here - bumped.
|
|
44
|
+
*
|
|
45
|
+
* `changed` answers the versions among `since` that differ now, and may wait
|
|
46
|
+
* up to `wait` ms for one to. Nothing else is required of it, which is why a
|
|
47
|
+
* backend's cache, a table, this process's memory, or a pub/sub that answers
|
|
48
|
+
* the moment a name moves all fit behind it.
|
|
49
|
+
*/
|
|
50
|
+
export interface VersionSource {
|
|
51
|
+
changed(since: Record<string, number>, wait: number): Promise<Record<string, number>>;
|
|
52
|
+
/** Say these names changed. Only a source that holds its own versions can. */
|
|
53
|
+
bump(names: string[]): void | Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/** Versions kept in this process: the source without a backend, and the one tests use. */
|
|
56
|
+
export declare function memoryVersions(): VersionSource;
|
|
57
|
+
type HostFn = (name: string, ...args: unknown[]) => Promise<unknown>;
|
|
58
|
+
/**
|
|
59
|
+
* Versions kept by the backend, read through `__rsc.changed`.
|
|
60
|
+
*
|
|
61
|
+
* A backend that has not got the function - an adapter from before names, or
|
|
62
|
+
* a host of plain rpc functions - is answered from this process instead,
|
|
63
|
+
* found out on the first ask and remembered, so a page with names still works
|
|
64
|
+
* on it: one instance, versions bumped by `changed()` here.
|
|
65
|
+
*/
|
|
66
|
+
export declare function backendVersions(call: HostFn): VersionSource;
|
|
67
|
+
/** Install where versions are read from; `null` restores this process's own. */
|
|
68
|
+
export declare function installVersionSource(source: VersionSource | null): void;
|
|
69
|
+
/** The source in use: the backend's when a host is installed, this process's otherwise. */
|
|
70
|
+
export declare function versionSource(): VersionSource;
|
|
71
|
+
/**
|
|
72
|
+
* Say these names changed, so every open tab showing them refreshes.
|
|
73
|
+
*
|
|
74
|
+
* export async function POST(request: Request) { // a webhook, in a route.ts
|
|
75
|
+
* const { team } = await request.json()
|
|
76
|
+
* changed(`team:${team}:repos`)
|
|
77
|
+
* return new Response(null, { status: 204 })
|
|
78
|
+
* }
|
|
79
|
+
*
|
|
80
|
+
* For an app that keeps its versions here. With a backend the versions are
|
|
81
|
+
* its, and so is the call: `rsckit.Changed`, `Rsc::changed`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function changed(...names: string[]): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* The key names are signed with. Set from `RSC_HOST_CALL_SECRET` when it is
|
|
86
|
+
* read; otherwise random, which is right for one process and wrong for two -
|
|
87
|
+
* a tab served by one instance presents its names to another and is refused.
|
|
88
|
+
*/
|
|
89
|
+
export declare function configureChanged(options: {
|
|
90
|
+
secret?: string | null;
|
|
91
|
+
}): void;
|
|
92
|
+
/** A name's signature: the first 16 bytes of an HMAC, base64url, which is what rides in a url. */
|
|
93
|
+
export declare function sign(name: string): Promise<string>;
|
|
94
|
+
/** What a page hands the client for one name: its version at render, and its signature. */
|
|
95
|
+
export type SignedName = [version: number, signature: string];
|
|
96
|
+
/** How often the source is asked, when it answered at once. */
|
|
97
|
+
export declare const CHANGES_INTERVAL_MS = 2000;
|
|
98
|
+
/** How long the source may hold one ask. Bounds how long a new tab waits to join the shared ask. */
|
|
99
|
+
export declare const CHANGES_WAIT_MS = 5000;
|
|
100
|
+
/**
|
|
101
|
+
* The stream a tab watches its names on: `GET /_rsc/changes?w=[[name, version, signature], …]`.
|
|
102
|
+
*
|
|
103
|
+
* One `data:` line per version that moved, `{ name, version }`, from the moment
|
|
104
|
+
* the stream opens - a name that moved between the render and now is reported
|
|
105
|
+
* at once, so nothing that happened in the gap is missed. Keepalives as an
|
|
106
|
+
* events() route sends them; ends when the browser goes away.
|
|
107
|
+
*/
|
|
108
|
+
export declare function changes(request: Request): Promise<Response>;
|
|
109
|
+
/** For tests: forget every stream and version this process has seen. */
|
|
110
|
+
export declare function resetChanges(): void;
|
|
111
|
+
/** The url a tab opens to watch, for the client. */
|
|
112
|
+
export declare const CHANGES_PATH: "/_rsc/changes";
|
|
113
|
+
export {};
|
package/dist/changed.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names: a name for data a page depends on, so whatever changes it can say so
|
|
3
|
+
* from anywhere - a webhook, a job, another visitor's action - and every open
|
|
4
|
+
* tab showing it refreshes.
|
|
5
|
+
*
|
|
6
|
+
* // a section says what it depends on
|
|
7
|
+
* export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })
|
|
8
|
+
*
|
|
9
|
+
* // and whatever changes it says so
|
|
10
|
+
* changed(`team:${team}:repos`) // here, from a route.ts or an action
|
|
11
|
+
* rsckit.Changed(ctx, "team:1:repos") // or the backend, from its webhook
|
|
12
|
+
* Rsc::changed("team:$team:repos"); // Laravel
|
|
13
|
+
*
|
|
14
|
+
* A name has a version: a number that moves whenever something says the name
|
|
15
|
+
* changed. Nothing else - not what changed, not the data. A page learns the
|
|
16
|
+
* versions at render; one stream per open tab watches them; when one moves,
|
|
17
|
+
* the sections carrying that name ask for themselves again, the way refresh()
|
|
18
|
+
* does. Data is never pushed, which is what keeps this working on a host that
|
|
19
|
+
* cannot hold state: a version is a read.
|
|
20
|
+
*
|
|
21
|
+
* Where versions live is the source's business. Without a backend they are in
|
|
22
|
+
* this process, which is one instance. With one, the backend keeps them - in
|
|
23
|
+
* its cache or a table, shared by every instance it runs - and answers
|
|
24
|
+
* `__rsc.changed`: given the versions a watcher holds, the ones that differ now.
|
|
25
|
+
* A backend that can wait holds the call until one does; one that cannot
|
|
26
|
+
* answers at once, and the watcher asks again on its interval. The protocol
|
|
27
|
+
* is the same either way, and so is the page.
|
|
28
|
+
*/
|
|
29
|
+
import { KEEPALIVE_MS, frame } from "./events.js";
|
|
30
|
+
import { HEADER } from "./headers.js";
|
|
31
|
+
/** The reserved name a backend answers name versions on. */
|
|
32
|
+
export const CHANGED_FUNCTION = "__rsc.changed";
|
|
33
|
+
/** Versions kept in this process: the source without a backend, and the one tests use. */
|
|
34
|
+
export function memoryVersions() {
|
|
35
|
+
const versions = new Map();
|
|
36
|
+
const waiters = new Set();
|
|
37
|
+
const differing = (since) => {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const [name, held] of Object.entries(since)) {
|
|
40
|
+
const current = versions.get(name) ?? 0;
|
|
41
|
+
if (current !== held)
|
|
42
|
+
out[name] = current;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
async changed(since, wait) {
|
|
48
|
+
const now = differing(since);
|
|
49
|
+
if (Object.keys(now).length > 0 || wait <= 0)
|
|
50
|
+
return now;
|
|
51
|
+
await new Promise((resolve) => {
|
|
52
|
+
const done = () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
waiters.delete(done);
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
const timer = setTimeout(done, wait);
|
|
58
|
+
// A test or a build must not be held open by a watcher's wait.
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
waiters.add(done);
|
|
61
|
+
});
|
|
62
|
+
return differing(since);
|
|
63
|
+
},
|
|
64
|
+
bump(names) {
|
|
65
|
+
for (const name of names)
|
|
66
|
+
versions.set(name, (versions.get(name) ?? 0) + 1);
|
|
67
|
+
for (const wake of [...waiters])
|
|
68
|
+
wake();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Versions kept by the backend, read through `__rsc.changed`.
|
|
74
|
+
*
|
|
75
|
+
* A backend that has not got the function - an adapter from before names, or
|
|
76
|
+
* a host of plain rpc functions - is answered from this process instead,
|
|
77
|
+
* found out on the first ask and remembered, so a page with names still works
|
|
78
|
+
* on it: one instance, versions bumped by `changed()` here.
|
|
79
|
+
*/
|
|
80
|
+
export function backendVersions(call) {
|
|
81
|
+
let local = null;
|
|
82
|
+
// A backend's "no such function", or the test host's "no handler for".
|
|
83
|
+
const unknown = (error) => /no host function named|has no handler for/i.test(error instanceof Error ? error.message : String(error));
|
|
84
|
+
return {
|
|
85
|
+
async changed(since, wait) {
|
|
86
|
+
if (local)
|
|
87
|
+
return local.changed(since, wait);
|
|
88
|
+
try {
|
|
89
|
+
const answer = (await call(CHANGED_FUNCTION, {
|
|
90
|
+
since,
|
|
91
|
+
wait,
|
|
92
|
+
}));
|
|
93
|
+
return answer?.versions ?? {};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!unknown(error))
|
|
97
|
+
throw error;
|
|
98
|
+
local = memoryVersions();
|
|
99
|
+
return local.changed(since, wait);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
bump(names) {
|
|
103
|
+
if (local)
|
|
104
|
+
return local.bump(names);
|
|
105
|
+
throw new Error("changed() bumps versions this process keeps, and a backend keeps these: say the name changed there - " +
|
|
106
|
+
"rsckit.Changed(ctx, ...) in Go, Rsc::changed(...) in Laravel.");
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const SOURCE = Symbol.for("rsc-kit.version-source");
|
|
111
|
+
const globals = globalThis;
|
|
112
|
+
/** Install where versions are read from; `null` restores this process's own. */
|
|
113
|
+
export function installVersionSource(source) {
|
|
114
|
+
globals[SOURCE] = source;
|
|
115
|
+
}
|
|
116
|
+
/** The source in use: the backend's when a host is installed, this process's otherwise. */
|
|
117
|
+
export function versionSource() {
|
|
118
|
+
return (globals[SOURCE] ??= memoryVersions());
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Say these names changed, so every open tab showing them refreshes.
|
|
122
|
+
*
|
|
123
|
+
* export async function POST(request: Request) { // a webhook, in a route.ts
|
|
124
|
+
* const { team } = await request.json()
|
|
125
|
+
* changed(`team:${team}:repos`)
|
|
126
|
+
* return new Response(null, { status: 204 })
|
|
127
|
+
* }
|
|
128
|
+
*
|
|
129
|
+
* For an app that keeps its versions here. With a backend the versions are
|
|
130
|
+
* its, and so is the call: `rsckit.Changed`, `Rsc::changed`.
|
|
131
|
+
*/
|
|
132
|
+
export async function changed(...names) {
|
|
133
|
+
await versionSource().bump(names);
|
|
134
|
+
}
|
|
135
|
+
// ── Tokens ───────────────────────────────────────────────────────────────────
|
|
136
|
+
//
|
|
137
|
+
// A tab may watch exactly the names its page was rendered with. Rather than
|
|
138
|
+
// remembering what each tab was given - state, which a Worker cannot keep
|
|
139
|
+
// between requests - every name goes to the client signed, and comes back
|
|
140
|
+
// with its signature. Verified, not looked up, so a watch request is
|
|
141
|
+
// stateless, and a browser cannot make up a name to watch.
|
|
142
|
+
let secret = null;
|
|
143
|
+
let keyed = null;
|
|
144
|
+
const signatures = new Map();
|
|
145
|
+
/**
|
|
146
|
+
* The key names are signed with. Set from `RSC_HOST_CALL_SECRET` when it is
|
|
147
|
+
* read; otherwise random, which is right for one process and wrong for two -
|
|
148
|
+
* a tab served by one instance presents its names to another and is refused.
|
|
149
|
+
*/
|
|
150
|
+
export function configureChanged(options) {
|
|
151
|
+
secret = options.secret ?? null;
|
|
152
|
+
keyed = null;
|
|
153
|
+
signatures.clear();
|
|
154
|
+
}
|
|
155
|
+
async function key() {
|
|
156
|
+
return (keyed ??= (async () => {
|
|
157
|
+
let raw;
|
|
158
|
+
if (!secret && typeof process !== "undefined") {
|
|
159
|
+
secret = process.env?.RSC_HOST_CALL_SECRET ?? null;
|
|
160
|
+
}
|
|
161
|
+
if (secret)
|
|
162
|
+
raw = new TextEncoder().encode(secret);
|
|
163
|
+
else
|
|
164
|
+
raw = crypto.getRandomValues(new Uint8Array(32));
|
|
165
|
+
return await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
166
|
+
})());
|
|
167
|
+
}
|
|
168
|
+
/** A name's signature: the first 16 bytes of an HMAC, base64url, which is what rides in a url. */
|
|
169
|
+
export function sign(name) {
|
|
170
|
+
let pending = signatures.get(name);
|
|
171
|
+
if (!pending) {
|
|
172
|
+
pending = (async () => {
|
|
173
|
+
const mac = await crypto.subtle.sign("HMAC", await key(), new TextEncoder().encode(name));
|
|
174
|
+
return base64url(new Uint8Array(mac).slice(0, 16));
|
|
175
|
+
})();
|
|
176
|
+
// Bounded: a name with a visitor's id in it is a new entry per visitor.
|
|
177
|
+
if (signatures.size > 10_000)
|
|
178
|
+
signatures.clear();
|
|
179
|
+
signatures.set(name, pending);
|
|
180
|
+
}
|
|
181
|
+
return pending;
|
|
182
|
+
}
|
|
183
|
+
async function verify(name, signature) {
|
|
184
|
+
const expected = await sign(name);
|
|
185
|
+
if (expected.length !== signature.length)
|
|
186
|
+
return false;
|
|
187
|
+
let differ = 0;
|
|
188
|
+
for (let i = 0; i < expected.length; i++)
|
|
189
|
+
differ |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
190
|
+
return differ === 0;
|
|
191
|
+
}
|
|
192
|
+
function base64url(bytes) {
|
|
193
|
+
let text = "";
|
|
194
|
+
for (const byte of bytes)
|
|
195
|
+
text += String.fromCharCode(byte);
|
|
196
|
+
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
197
|
+
}
|
|
198
|
+
// ── Watching ─────────────────────────────────────────────────────────────────
|
|
199
|
+
//
|
|
200
|
+
// One stream per open tab, and one ask of the source per process: every
|
|
201
|
+
// stream's names are asked about together, on an interval, and a version that
|
|
202
|
+
// moved is written to every stream holding its name. A thousand tabs on a
|
|
203
|
+
// long-lived server cost the backend one call per interval, not a thousand.
|
|
204
|
+
// On a host that answers each request in its own isolate the sharing is
|
|
205
|
+
// whatever the isolate serves at once, and a lone stream asks for itself -
|
|
206
|
+
// the same code, degrading to one small call per tab per interval.
|
|
207
|
+
/** How often the source is asked, when it answered at once. */
|
|
208
|
+
export const CHANGES_INTERVAL_MS = 2_000;
|
|
209
|
+
/** How long the source may hold one ask. Bounds how long a new tab waits to join the shared ask. */
|
|
210
|
+
export const CHANGES_WAIT_MS = 5_000;
|
|
211
|
+
const streams = new Set();
|
|
212
|
+
/** The latest version seen for each name any stream holds. */
|
|
213
|
+
const known = new Map();
|
|
214
|
+
let running = false;
|
|
215
|
+
let wake = null;
|
|
216
|
+
let warned = false;
|
|
217
|
+
function observe(name, version) {
|
|
218
|
+
if (known.get(name) === version)
|
|
219
|
+
return;
|
|
220
|
+
known.set(name, version);
|
|
221
|
+
for (const stream of streams)
|
|
222
|
+
if (stream.names.has(name))
|
|
223
|
+
stream.send(name, version);
|
|
224
|
+
}
|
|
225
|
+
/** Mapped to this process's set of streams: what to ask about, relative to what is known. */
|
|
226
|
+
function sinceAll() {
|
|
227
|
+
const since = {};
|
|
228
|
+
for (const stream of streams)
|
|
229
|
+
for (const name of stream.names)
|
|
230
|
+
since[name] = known.get(name) ?? 0;
|
|
231
|
+
return since;
|
|
232
|
+
}
|
|
233
|
+
async function loop() {
|
|
234
|
+
running = true;
|
|
235
|
+
try {
|
|
236
|
+
while (streams.size > 0) {
|
|
237
|
+
const started = Date.now();
|
|
238
|
+
try {
|
|
239
|
+
const moved = await versionSource().changed(sinceAll(), CHANGES_WAIT_MS);
|
|
240
|
+
for (const [name, version] of Object.entries(moved))
|
|
241
|
+
observe(name, version);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// A backend blip is not the end of watching. Said once, not per interval.
|
|
245
|
+
if (!warned) {
|
|
246
|
+
warned = true;
|
|
247
|
+
console.warn("[rsc-kit] asking the backend for name versions failed: " +
|
|
248
|
+
(error instanceof Error ? error.message : String(error)));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// A source that waited used the interval; one that answered at once is
|
|
252
|
+
// asked again after it. A stream opening meanwhile wakes this early.
|
|
253
|
+
const remaining = CHANGES_INTERVAL_MS - (Date.now() - started);
|
|
254
|
+
if (remaining > 0 && streams.size > 0) {
|
|
255
|
+
await new Promise((resolve) => {
|
|
256
|
+
const timer = setTimeout(resolve, remaining);
|
|
257
|
+
timer.unref?.();
|
|
258
|
+
wake = () => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
resolve();
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
wake = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
running = false;
|
|
269
|
+
// A stream that opened as the loop was ending starts it again.
|
|
270
|
+
if (streams.size > 0)
|
|
271
|
+
void loop();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function parseWatch(url) {
|
|
275
|
+
const raw = url.searchParams.get("w");
|
|
276
|
+
if (!raw)
|
|
277
|
+
return null;
|
|
278
|
+
let entries;
|
|
279
|
+
try {
|
|
280
|
+
entries = JSON.parse(raw);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200)
|
|
286
|
+
return null;
|
|
287
|
+
const parsed = [];
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (!Array.isArray(entry) || entry.length !== 3)
|
|
290
|
+
return null;
|
|
291
|
+
const [name, version, signature] = entry;
|
|
292
|
+
if (typeof name !== "string" ||
|
|
293
|
+
typeof version !== "number" ||
|
|
294
|
+
typeof signature !== "string")
|
|
295
|
+
return null;
|
|
296
|
+
parsed.push({ name, version, signature });
|
|
297
|
+
}
|
|
298
|
+
return parsed;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The stream a tab watches its names on: `GET /_rsc/changes?w=[[name, version, signature], …]`.
|
|
302
|
+
*
|
|
303
|
+
* One `data:` line per version that moved, `{ name, version }`, from the moment
|
|
304
|
+
* the stream opens - a name that moved between the render and now is reported
|
|
305
|
+
* at once, so nothing that happened in the gap is missed. Keepalives as an
|
|
306
|
+
* events() route sends them; ends when the browser goes away.
|
|
307
|
+
*/
|
|
308
|
+
export async function changes(request) {
|
|
309
|
+
const entries = parseWatch(new URL(request.url));
|
|
310
|
+
if (!entries)
|
|
311
|
+
return new Response("Bad watch request.", { status: 400 });
|
|
312
|
+
for (const { name, signature } of entries) {
|
|
313
|
+
if (!(await verify(name, signature))) {
|
|
314
|
+
return new Response("Not a name this page was rendered with.", {
|
|
315
|
+
status: 403,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const since = {};
|
|
320
|
+
for (const { name, version } of entries)
|
|
321
|
+
since[name] = version;
|
|
322
|
+
const encoder = new TextEncoder();
|
|
323
|
+
let stream = null;
|
|
324
|
+
const body = new ReadableStream({
|
|
325
|
+
start(sink) {
|
|
326
|
+
const write = (text) => {
|
|
327
|
+
try {
|
|
328
|
+
sink.enqueue(encoder.encode(text));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// Closed under us; the cancel below ends the rest.
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
write("retry: 2000\n\n");
|
|
335
|
+
const keepalive = setInterval(() => write(": keepalive\n\n"), KEEPALIVE_MS);
|
|
336
|
+
keepalive.unref?.();
|
|
337
|
+
const me = {
|
|
338
|
+
names: new Set(Object.keys(since)),
|
|
339
|
+
send: (name, version) => write(frame({ name, version })),
|
|
340
|
+
};
|
|
341
|
+
stream = me;
|
|
342
|
+
streams.add(me);
|
|
343
|
+
const end = () => {
|
|
344
|
+
clearInterval(keepalive);
|
|
345
|
+
streams.delete(me);
|
|
346
|
+
try {
|
|
347
|
+
sink.close();
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// Already closed.
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
request.signal.addEventListener("abort", end, { once: true });
|
|
354
|
+
// What is known already answers at once; what is not is seeded with
|
|
355
|
+
// what this tab holds, so the shared ask compares against it.
|
|
356
|
+
for (const [name, version] of Object.entries(since)) {
|
|
357
|
+
const seen = known.get(name);
|
|
358
|
+
if (seen === undefined)
|
|
359
|
+
known.set(name, version);
|
|
360
|
+
else if (seen !== version)
|
|
361
|
+
me.send(name, seen);
|
|
362
|
+
}
|
|
363
|
+
// This tab's own first ask, holding as long as the source will: the
|
|
364
|
+
// shared ask may be mid-wait on an older set of names, and a name moving
|
|
365
|
+
// in the seconds before this one joins it must not go unnoticed.
|
|
366
|
+
void versionSource()
|
|
367
|
+
.changed(since, CHANGES_WAIT_MS)
|
|
368
|
+
.then((moved) => {
|
|
369
|
+
for (const [name, version] of Object.entries(moved))
|
|
370
|
+
observe(name, version);
|
|
371
|
+
})
|
|
372
|
+
.catch(() => { });
|
|
373
|
+
if (!running)
|
|
374
|
+
void loop();
|
|
375
|
+
else
|
|
376
|
+
wake?.();
|
|
377
|
+
},
|
|
378
|
+
cancel() {
|
|
379
|
+
if (stream)
|
|
380
|
+
streams.delete(stream);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return new Response(body, {
|
|
384
|
+
headers: {
|
|
385
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
386
|
+
"Cache-Control": "no-store",
|
|
387
|
+
"X-Accel-Buffering": "no",
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** For tests: forget every stream and version this process has seen. */
|
|
392
|
+
export function resetChanges() {
|
|
393
|
+
streams.clear();
|
|
394
|
+
known.clear();
|
|
395
|
+
warned = false;
|
|
396
|
+
}
|
|
397
|
+
/** The url a tab opens to watch, for the client. */
|
|
398
|
+
export const CHANGES_PATH = HEADER.changesPath;
|
|
399
|
+
//# sourceMappingURL=changed.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changed.js","sourceRoot":"","sources":["../src/changed.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Names: a name for data a page depends on, so whatever changes it can say so\n * from anywhere - a webhook, a job, another visitor's action - and every open\n * tab showing it refreshes.\n *\n * // a section says what it depends on\n * export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })\n *\n * // and whatever changes it says so\n * changed(`team:${team}:repos`) // here, from a route.ts or an action\n * rsckit.Changed(ctx, \"team:1:repos\") // or the backend, from its webhook\n * Rsc::changed(\"team:$team:repos\"); // Laravel\n *\n * A name has a version: a number that moves whenever something says the name\n * changed. Nothing else - not what changed, not the data. A page learns the\n * versions at render; one stream per open tab watches them; when one moves,\n * the sections carrying that name ask for themselves again, the way refresh()\n * does. Data is never pushed, which is what keeps this working on a host that\n * cannot hold state: a version is a read.\n *\n * Where versions live is the source's business. Without a backend they are in\n * this process, which is one instance. With one, the backend keeps them - in\n * its cache or a table, shared by every instance it runs - and answers\n * `__rsc.changed`: given the versions a watcher holds, the ones that differ now.\n * A backend that can wait holds the call until one does; one that cannot\n * answers at once, and the watcher asks again on its interval. The protocol\n * is the same either way, and so is the page.\n */\n\nimport { KEEPALIVE_MS, frame } from \"./events.js\";\nimport { HEADER } from \"./headers.js\";\n\n/** The reserved name a backend answers name versions on. */\nexport const CHANGED_FUNCTION = \"__rsc.changed\";\n\n/** What `__rsc.changed` is asked. */\nexport interface ChangedQuery {\n /** The versions the asker holds. A name never changed is 0; -1 asks for every current version. */\n since: Record<string, number>;\n /** How long the backend may hold the call waiting for one to differ, in ms. It may answer at once. */\n wait?: number;\n}\n\n/** What `__rsc.changed` answers: the versions among `since` that differ now. */\nexport interface ChangedAnswer {\n versions: Record<string, number>;\n}\n\n/**\n * Where name versions are read from, and - when they live here - bumped.\n *\n * `changed` answers the versions among `since` that differ now, and may wait\n * up to `wait` ms for one to. Nothing else is required of it, which is why a\n * backend's cache, a table, this process's memory, or a pub/sub that answers\n * the moment a name moves all fit behind it.\n */\nexport interface VersionSource {\n changed(\n since: Record<string, number>,\n wait: number,\n ): Promise<Record<string, number>>;\n /** Say these names changed. Only a source that holds its own versions can. */\n bump(names: string[]): void | Promise<void>;\n}\n\n/** Versions kept in this process: the source without a backend, and the one tests use. */\nexport function memoryVersions(): VersionSource {\n const versions = new Map<string, number>();\n const waiters = new Set<() => void>();\n\n const differing = (since: Record<string, number>): Record<string, number> => {\n const out: Record<string, number> = {};\n\n for (const [name, held] of Object.entries(since)) {\n const current = versions.get(name) ?? 0;\n\n if (current !== held) out[name] = current;\n }\n\n return out;\n };\n\n return {\n async changed(since, wait) {\n const now = differing(since);\n\n if (Object.keys(now).length > 0 || wait <= 0) return now;\n\n await new Promise<void>((resolve) => {\n const done = () => {\n clearTimeout(timer);\n waiters.delete(done);\n resolve();\n };\n const timer = setTimeout(done, wait);\n\n // A test or a build must not be held open by a watcher's wait.\n (timer as { unref?: () => void }).unref?.();\n waiters.add(done);\n });\n\n return differing(since);\n },\n bump(names) {\n for (const name of names) versions.set(name, (versions.get(name) ?? 0) + 1);\n for (const wake of [...waiters]) wake();\n },\n };\n}\n\ntype HostFn = (name: string, ...args: unknown[]) => Promise<unknown>;\n\n/**\n * Versions kept by the backend, read through `__rsc.changed`.\n *\n * A backend that has not got the function - an adapter from before names, or\n * a host of plain rpc functions - is answered from this process instead,\n * found out on the first ask and remembered, so a page with names still works\n * on it: one instance, versions bumped by `changed()` here.\n */\nexport function backendVersions(call: HostFn): VersionSource {\n let local: VersionSource | null = null;\n\n // A backend's \"no such function\", or the test host's \"no handler for\".\n const unknown = (error: unknown): boolean =>\n /no host function named|has no handler for/i.test(\n error instanceof Error ? error.message : String(error),\n );\n\n return {\n async changed(since, wait) {\n if (local) return local.changed(since, wait);\n\n try {\n const answer = (await call(CHANGED_FUNCTION, {\n since,\n wait,\n } satisfies ChangedQuery)) as Partial<ChangedAnswer> | null;\n\n return answer?.versions ?? {};\n } catch (error) {\n if (!unknown(error)) throw error;\n\n local = memoryVersions();\n\n return local.changed(since, wait);\n }\n },\n bump(names) {\n if (local) return local.bump(names);\n\n throw new Error(\n \"changed() bumps versions this process keeps, and a backend keeps these: say the name changed there - \" +\n \"rsckit.Changed(ctx, ...) in Go, Rsc::changed(...) in Laravel.\",\n );\n },\n };\n}\n\nconst SOURCE = Symbol.for(\"rsc-kit.version-source\");\nconst globals = globalThis as Record<symbol, unknown>;\n\n/** Install where versions are read from; `null` restores this process's own. */\nexport function installVersionSource(source: VersionSource | null): void {\n globals[SOURCE] = source;\n}\n\n/** The source in use: the backend's when a host is installed, this process's otherwise. */\nexport function versionSource(): VersionSource {\n return ((globals[SOURCE] as VersionSource | undefined) ??= memoryVersions());\n}\n\n/**\n * Say these names changed, so every open tab showing them refreshes.\n *\n * export async function POST(request: Request) { // a webhook, in a route.ts\n * const { team } = await request.json()\n * changed(`team:${team}:repos`)\n * return new Response(null, { status: 204 })\n * }\n *\n * For an app that keeps its versions here. With a backend the versions are\n * its, and so is the call: `rsckit.Changed`, `Rsc::changed`.\n */\nexport async function changed(...names: string[]): Promise<void> {\n await versionSource().bump(names);\n}\n\n// ── Tokens ───────────────────────────────────────────────────────────────────\n//\n// A tab may watch exactly the names its page was rendered with. Rather than\n// remembering what each tab was given - state, which a Worker cannot keep\n// between requests - every name goes to the client signed, and comes back\n// with its signature. Verified, not looked up, so a watch request is\n// stateless, and a browser cannot make up a name to watch.\n\nlet secret: string | null = null;\nlet keyed: Promise<CryptoKey> | null = null;\nconst signatures = new Map<string, Promise<string>>();\n\n/**\n * The key names are signed with. Set from `RSC_HOST_CALL_SECRET` when it is\n * read; otherwise random, which is right for one process and wrong for two -\n * a tab served by one instance presents its names to another and is refused.\n */\nexport function configureChanged(options: { secret?: string | null }): void {\n secret = options.secret ?? null;\n keyed = null;\n signatures.clear();\n}\n\nasync function key(): Promise<CryptoKey> {\n return (keyed ??= (async () => {\n let raw: Uint8Array<ArrayBuffer>;\n\n if (!secret && typeof process !== \"undefined\") {\n secret = process.env?.RSC_HOST_CALL_SECRET ?? null;\n }\n\n if (secret) raw = new TextEncoder().encode(secret);\n else raw = crypto.getRandomValues(new Uint8Array(32));\n\n return await crypto.subtle.importKey(\n \"raw\",\n raw,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n })());\n}\n\n/** A name's signature: the first 16 bytes of an HMAC, base64url, which is what rides in a url. */\nexport function sign(name: string): Promise<string> {\n let pending = signatures.get(name);\n\n if (!pending) {\n pending = (async () => {\n const mac = await crypto.subtle.sign(\n \"HMAC\",\n await key(),\n new TextEncoder().encode(name),\n );\n\n return base64url(new Uint8Array(mac).slice(0, 16));\n })();\n\n // Bounded: a name with a visitor's id in it is a new entry per visitor.\n if (signatures.size > 10_000) signatures.clear();\n signatures.set(name, pending);\n }\n\n return pending;\n}\n\nasync function verify(name: string, signature: string): Promise<boolean> {\n const expected = await sign(name);\n\n if (expected.length !== signature.length) return false;\n\n let differ = 0;\n\n for (let i = 0; i < expected.length; i++)\n differ |= expected.charCodeAt(i) ^ signature.charCodeAt(i);\n\n return differ === 0;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let text = \"\";\n\n for (const byte of bytes) text += String.fromCharCode(byte);\n\n return btoa(text).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** What a page hands the client for one name: its version at render, and its signature. */\nexport type SignedName = [version: number, signature: string];\n\n// ── Watching ─────────────────────────────────────────────────────────────────\n//\n// One stream per open tab, and one ask of the source per process: every\n// stream's names are asked about together, on an interval, and a version that\n// moved is written to every stream holding its name. A thousand tabs on a\n// long-lived server cost the backend one call per interval, not a thousand.\n// On a host that answers each request in its own isolate the sharing is\n// whatever the isolate serves at once, and a lone stream asks for itself -\n// the same code, degrading to one small call per tab per interval.\n\n/** How often the source is asked, when it answered at once. */\nexport const CHANGES_INTERVAL_MS = 2_000;\n\n/** How long the source may hold one ask. Bounds how long a new tab waits to join the shared ask. */\nexport const CHANGES_WAIT_MS = 5_000;\n\ninterface Stream {\n names: Set<string>;\n send(name: string, version: number): void;\n}\n\nconst streams = new Set<Stream>();\n\n/** The latest version seen for each name any stream holds. */\nconst known = new Map<string, number>();\n\nlet running = false;\nlet wake: (() => void) | null = null;\nlet warned = false;\n\nfunction observe(name: string, version: number): void {\n if (known.get(name) === version) return;\n\n known.set(name, version);\n\n for (const stream of streams)\n if (stream.names.has(name)) stream.send(name, version);\n}\n\n/** Mapped to this process's set of streams: what to ask about, relative to what is known. */\nfunction sinceAll(): Record<string, number> {\n const since: Record<string, number> = {};\n\n for (const stream of streams)\n for (const name of stream.names) since[name] = known.get(name) ?? 0;\n\n return since;\n}\n\nasync function loop(): Promise<void> {\n running = true;\n\n try {\n while (streams.size > 0) {\n const started = Date.now();\n\n try {\n const moved = await versionSource().changed(\n sinceAll(),\n CHANGES_WAIT_MS,\n );\n\n for (const [name, version] of Object.entries(moved))\n observe(name, version);\n } catch (error) {\n // A backend blip is not the end of watching. Said once, not per interval.\n if (!warned) {\n warned = true;\n console.warn(\n \"[rsc-kit] asking the backend for name versions failed: \" +\n (error instanceof Error ? error.message : String(error)),\n );\n }\n }\n\n // A source that waited used the interval; one that answered at once is\n // asked again after it. A stream opening meanwhile wakes this early.\n const remaining = CHANGES_INTERVAL_MS - (Date.now() - started);\n\n if (remaining > 0 && streams.size > 0) {\n await new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, remaining);\n\n (timer as { unref?: () => void }).unref?.();\n wake = () => {\n clearTimeout(timer);\n resolve();\n };\n });\n wake = null;\n }\n }\n } finally {\n running = false;\n // A stream that opened as the loop was ending starts it again.\n if (streams.size > 0) void loop();\n }\n}\n\nfunction parseWatch(\n url: URL,\n): { name: string; version: number; signature: string }[] | null {\n const raw = url.searchParams.get(\"w\");\n\n if (!raw) return null;\n\n let entries: unknown;\n\n try {\n entries = JSON.parse(raw);\n } catch {\n return null;\n }\n\n if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200)\n return null;\n\n const parsed: { name: string; version: number; signature: string }[] = [];\n\n for (const entry of entries) {\n if (!Array.isArray(entry) || entry.length !== 3) return null;\n\n const [name, version, signature] = entry as unknown[];\n\n if (\n typeof name !== \"string\" ||\n typeof version !== \"number\" ||\n typeof signature !== \"string\"\n )\n return null;\n\n parsed.push({ name, version, signature });\n }\n\n return parsed;\n}\n\n/**\n * The stream a tab watches its names on: `GET /_rsc/changes?w=[[name, version, signature], …]`.\n *\n * One `data:` line per version that moved, `{ name, version }`, from the moment\n * the stream opens - a name that moved between the render and now is reported\n * at once, so nothing that happened in the gap is missed. Keepalives as an\n * events() route sends them; ends when the browser goes away.\n */\nexport async function changes(request: Request): Promise<Response> {\n const entries = parseWatch(new URL(request.url));\n\n if (!entries) return new Response(\"Bad watch request.\", { status: 400 });\n\n for (const { name, signature } of entries) {\n if (!(await verify(name, signature))) {\n return new Response(\"Not a name this page was rendered with.\", {\n status: 403,\n });\n }\n }\n\n const since: Record<string, number> = {};\n\n for (const { name, version } of entries) since[name] = version;\n\n const encoder = new TextEncoder();\n let stream: Stream | null = null;\n\n const body = new ReadableStream<Uint8Array>({\n start(sink) {\n const write = (text: string) => {\n try {\n sink.enqueue(encoder.encode(text));\n } catch {\n // Closed under us; the cancel below ends the rest.\n }\n };\n\n write(\"retry: 2000\\n\\n\");\n\n const keepalive = setInterval(\n () => write(\": keepalive\\n\\n\"),\n KEEPALIVE_MS,\n );\n\n (keepalive as { unref?: () => void }).unref?.();\n\n const me: Stream = {\n names: new Set(Object.keys(since)),\n send: (name, version) => write(frame({ name, version })),\n };\n\n stream = me;\n streams.add(me);\n\n const end = () => {\n clearInterval(keepalive);\n streams.delete(me);\n\n try {\n sink.close();\n } catch {\n // Already closed.\n }\n };\n\n request.signal.addEventListener(\"abort\", end, { once: true });\n\n // What is known already answers at once; what is not is seeded with\n // what this tab holds, so the shared ask compares against it.\n for (const [name, version] of Object.entries(since)) {\n const seen = known.get(name);\n\n if (seen === undefined) known.set(name, version);\n else if (seen !== version) me.send(name, seen);\n }\n\n // This tab's own first ask, holding as long as the source will: the\n // shared ask may be mid-wait on an older set of names, and a name moving\n // in the seconds before this one joins it must not go unnoticed.\n void versionSource()\n .changed(since, CHANGES_WAIT_MS)\n .then((moved) => {\n for (const [name, version] of Object.entries(moved))\n observe(name, version);\n })\n .catch(() => {});\n\n if (!running) void loop();\n else wake?.();\n },\n cancel() {\n if (stream) streams.delete(stream);\n },\n });\n\n return new Response(body, {\n headers: {\n \"Content-Type\": \"text/event-stream; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n \"X-Accel-Buffering\": \"no\",\n },\n });\n}\n\n/** For tests: forget every stream and version this process has seen. */\nexport function resetChanges(): void {\n streams.clear();\n known.clear();\n warned = false;\n}\n\n/** The url a tab opens to watch, for the client. */\nexport const CHANGES_PATH = HEADER.changesPath;\n"]}
|
package/dist/conformance.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export declare const CASES: {
|
|
|
44
44
|
readonly 'Conformance.fail': 'Fail unexpectedly: an ordinary error, not a refusal.';
|
|
45
45
|
readonly 'Conformance.authorization': "Return the request's Authorization header, as the renderer forwarded it.";
|
|
46
46
|
readonly '__rsc.middleware': 'Guards: "conformance-allow" passes; "conformance-deny" refuses; a name with no guard refuses.';
|
|
47
|
+
readonly 'Conformance.change': 'Say the name "conformance:changed" changed, the way the adapter does from a webhook, and return "ok".';
|
|
48
|
+
readonly '__rsc.changed': string;
|
|
47
49
|
};
|
|
48
50
|
/**
|
|
49
51
|
* Run the suite against a client. Every case runs; none stops another.
|
package/dist/conformance.js
CHANGED
|
@@ -46,6 +46,9 @@ export const CASES = {
|
|
|
46
46
|
'Conformance.fail': 'Fail unexpectedly: an ordinary error, not a refusal.',
|
|
47
47
|
'Conformance.authorization': "Return the request's Authorization header, as the renderer forwarded it.",
|
|
48
48
|
'__rsc.middleware': 'Guards: "conformance-allow" passes; "conformance-deny" refuses; a name with no guard refuses.',
|
|
49
|
+
'Conformance.change': 'Say the name "conformance:changed" changed, the way the adapter does from a webhook, and return "ok".',
|
|
50
|
+
'__rsc.changed': 'Given { since: { name: version }, wait }: answer { versions } with every name whose version differs from since now. ' +
|
|
51
|
+
'A name never changed is at 0. May hold the call up to wait ms for one to differ; may answer at once.',
|
|
49
52
|
};
|
|
50
53
|
const INSTANT = Date.parse('2026-01-02T03:04:05Z');
|
|
51
54
|
const ECHOES = [null, 0, -1.5, 'héllo ✓ "quoted"', [1, 'a', null], { a: { b: [true, false] }, c: '' }];
|
|
@@ -180,6 +183,31 @@ export async function conformance(call, options) {
|
|
|
180
183
|
const value = await call('__rsc.middleware', ['conformance-nobody-registered-this']).catch((e) => e);
|
|
181
184
|
expect(value !== true, 'it answered true');
|
|
182
185
|
});
|
|
186
|
+
const versionsOf = async (query) => {
|
|
187
|
+
const answer = (await call('__rsc.changed', query));
|
|
188
|
+
expect(typeof answer?.versions === 'object' && answer.versions !== null, 'got ' + JSON.stringify(answer));
|
|
189
|
+
return answer.versions;
|
|
190
|
+
};
|
|
191
|
+
await check('a name nobody changed is at version 0, and unchanged from 0', async () => {
|
|
192
|
+
const all = await versionsOf({ since: { 'conformance:never': -1 } });
|
|
193
|
+
expect(all['conformance:never'] === 0, 'from -1: ' + JSON.stringify(all));
|
|
194
|
+
const none = await versionsOf({ since: { 'conformance:never': 0 } });
|
|
195
|
+
expect(Object.keys(none).length === 0, 'from 0: ' + JSON.stringify(none));
|
|
196
|
+
});
|
|
197
|
+
await check('saying a name changed moves its version', async () => {
|
|
198
|
+
const before = (await versionsOf({ since: { 'conformance:changed': -1 } }))['conformance:changed'] ?? 0;
|
|
199
|
+
const value = await call('Conformance.change');
|
|
200
|
+
expect(value === 'ok', 'got ' + JSON.stringify(value));
|
|
201
|
+
const moved = await versionsOf({ since: { 'conformance:changed': before } });
|
|
202
|
+
expect(typeof moved['conformance:changed'] === 'number' && moved['conformance:changed'] !== before, 'from ' + before + ': ' + JSON.stringify(moved));
|
|
203
|
+
});
|
|
204
|
+
await check('waiting for a name to change is bounded by wait', async () => {
|
|
205
|
+
const started = Date.now();
|
|
206
|
+
const none = await versionsOf({ since: { 'conformance:never': 0 }, wait: 300 });
|
|
207
|
+
const took = Date.now() - started;
|
|
208
|
+
expect(Object.keys(none).length === 0, 'got ' + JSON.stringify(none));
|
|
209
|
+
expect(took < 3_000, 'took ' + took + 'ms');
|
|
210
|
+
});
|
|
183
211
|
if (wrongSecret) {
|
|
184
212
|
await check('a call with the wrong secret is refused', async () => {
|
|
185
213
|
const value = await wrongSecret('Conformance.echo', 'should not run').catch((e) => e);
|