@rsc-kit/core 0.26.0 → 0.27.1
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 +152 -5
- package/dist/changed.js +260 -20
- package/dist/changed.js.map +1 -1
- package/dist/compress.js +8 -1
- package/dist/compress.js.map +1 -1
- package/dist/events.d.ts +21 -3
- package/dist/events.js +31 -6
- package/dist/events.js.map +1 -1
- package/dist/headers.d.ts +6 -0
- package/dist/headers.js +6 -0
- package/dist/headers.js.map +1 -1
- package/dist/host.d.ts +3 -1
- package/dist/host.js +15 -2
- package/dist/host.js.map +1 -1
- package/dist/js/changesStore.js +40 -7
- package/dist/js/changesStore.js.map +1 -1
- package/dist/js/navigate.d.ts +1 -1
- package/dist/js/navigate.js +4 -1
- package/dist/js/navigate.js.map +1 -1
- package/dist/js/refreshOn.js +6 -1
- package/dist/js/refreshOn.js.map +1 -1
- package/dist/js/router.d.ts +2 -0
- package/dist/js/router.js +7 -0
- package/dist/js/router.js.map +1 -1
- package/dist/js/section.d.ts +11 -0
- package/dist/js/section.js +12 -3
- package/dist/js/section.js.map +1 -1
- package/dist/manifest.d.ts +6 -0
- package/dist/manifest.js.map +1 -1
- package/dist/sharedRenders.d.ts +9 -0
- package/dist/sharedRenders.js +41 -0
- package/dist/sharedRenders.js.map +1 -0
- package/dist/testing.js +6 -0
- package/dist/testing.js.map +1 -1
- package/dist/vite.js +45 -7
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
package/dist/changed.d.ts
CHANGED
|
@@ -64,9 +64,137 @@ type HostFn = (name: string, ...args: unknown[]) => Promise<unknown>;
|
|
|
64
64
|
* on it: one instance, versions bumped by `changed()` here.
|
|
65
65
|
*/
|
|
66
66
|
export declare function backendVersions(call: HostFn): VersionSource;
|
|
67
|
-
/**
|
|
67
|
+
/** Runs one statement with positional parameters and resolves to its rows. */
|
|
68
|
+
export type SqlQuery = (text: string, params: unknown[]) => Promise<readonly Record<string, unknown>[]>;
|
|
69
|
+
export interface SqlVersionsOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Your driver, as a function of SQL text and parameters:
|
|
72
|
+
*
|
|
73
|
+
* query: (text, params) => sql.unsafe(text, params) // postgres.js, Bun.sql
|
|
74
|
+
* query: async (text, params) => (await pool.query(text, params)).rows // node-postgres
|
|
75
|
+
*/
|
|
76
|
+
query: SqlQuery;
|
|
77
|
+
/** The table: `CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)`. */
|
|
78
|
+
table?: string;
|
|
79
|
+
/** How the n-th parameter is written (1-based). Default `?`; Postgres is `(n) => '$' + n`. */
|
|
80
|
+
placeholder?: (n: number) => string;
|
|
81
|
+
/**
|
|
82
|
+
* Called once with a function that wakes every waiting ask, for a database
|
|
83
|
+
* that can say the moment a version moved - Postgres's LISTEN:
|
|
84
|
+
*
|
|
85
|
+
* listen: (wake) => sql.listen('rsc_versions', wake)
|
|
86
|
+
*
|
|
87
|
+
* Without it, a waiting ask reads the table every `poll` ms.
|
|
88
|
+
*/
|
|
89
|
+
listen?: (wake: () => void) => unknown;
|
|
90
|
+
/**
|
|
91
|
+
* Run after every bump, to tell the other instances - Postgres's NOTIFY:
|
|
92
|
+
*
|
|
93
|
+
* notify: () => sql.notify('rsc_versions', '')
|
|
94
|
+
*/
|
|
95
|
+
notify?: () => unknown;
|
|
96
|
+
/** How often a waiting ask reads the table. Default 1000ms. */
|
|
97
|
+
poll?: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Versions in a table every instance shares: the web servers, and the worker
|
|
101
|
+
* that finishes a job and says so. The same table and columns Go's
|
|
102
|
+
* `SQLVersions` uses, so a Go service and a JavaScript worker can share one.
|
|
103
|
+
*
|
|
104
|
+
* installVersionSource(sqlVersions({
|
|
105
|
+
* query: (text, params) => sql.unsafe(text, params),
|
|
106
|
+
* placeholder: (n) => '$' + n,
|
|
107
|
+
* listen: (wake) => sql.listen('rsc_versions', wake),
|
|
108
|
+
* notify: () => sql.notify('rsc_versions', ''),
|
|
109
|
+
* }))
|
|
110
|
+
*
|
|
111
|
+
* Written with an UPDATE and, the first time, an INSERT - two statements any
|
|
112
|
+
* SQL dialect has - rather than an upsert each spells differently. Takes the
|
|
113
|
+
* app's own driver, so the engine has no database dependency of its own.
|
|
114
|
+
*/
|
|
115
|
+
export declare function sqlVersions(options: SqlVersionsOptions): VersionSource;
|
|
116
|
+
/**
|
|
117
|
+
* Where versions are kept, as the app writes it for its own database: the
|
|
118
|
+
* three things rsc-kit cannot know about your storage, and nothing else.
|
|
119
|
+
* `createVersions` does the rest - comparing, waiting, waking.
|
|
120
|
+
*
|
|
121
|
+
* // Drizzle
|
|
122
|
+
* read: async (names) => Object.fromEntries(
|
|
123
|
+
* (await db.select().from(rscVersions).where(inArray(rscVersions.name, names)))
|
|
124
|
+
* .map((r) => [r.name, Number(r.version)])),
|
|
125
|
+
* bump: async (names) => { for (const name of names) await db.insert(rscVersions)
|
|
126
|
+
* .values({ name, version: 1 })
|
|
127
|
+
* .onConflictDoUpdate({ target: rscVersions.name, set: { version: sql`${rscVersions.version} + 1` } }) },
|
|
128
|
+
*
|
|
129
|
+
* // Redis
|
|
130
|
+
* read: async (names) => Object.fromEntries((await redis.mget(names.map(k)))
|
|
131
|
+
* .map((v, i) => [names[i], Number(v ?? 0)])),
|
|
132
|
+
* bump: async (names) => { await Promise.all(names.map((n) => redis.incr(k(n)))) },
|
|
133
|
+
*/
|
|
134
|
+
export interface VersionStore {
|
|
135
|
+
/** The current version of each name asked for. A name it has never seen may be left out: it is 0. */
|
|
136
|
+
read(names: string[]): Promise<Record<string, number>>;
|
|
137
|
+
/** Move each name's version - by one, or to anything new: watchers compare, they do not count. */
|
|
138
|
+
bump(names: string[]): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Called once with a function that wakes every waiting ask, when the store
|
|
141
|
+
* can say the moment a version moved - Postgres LISTEN, Redis SUBSCRIBE.
|
|
142
|
+
* Without it a waiting ask reads again every `poll` ms.
|
|
143
|
+
*/
|
|
144
|
+
listen?(wake: () => void): unknown;
|
|
145
|
+
/** Run after a bump, to tell the other instances listening: Postgres NOTIFY, Redis PUBLISH. */
|
|
146
|
+
notify?(): unknown;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A version source over any store: SQL through any driver or ORM - Drizzle,
|
|
150
|
+
* Prisma, Kysely, Knex - or Redis, or anything with a read and an increment.
|
|
151
|
+
*
|
|
152
|
+
* installVersionSource(createVersions({ read, bump, listen, notify }))
|
|
153
|
+
*
|
|
154
|
+
* rsc-kit owns the rest: an ask waits up to the time it was given for a
|
|
155
|
+
* version to differ, reading every `poll` ms (1000 by default) or the moment
|
|
156
|
+
* `listen` wakes it, and a bump in this process wakes this process's asks at
|
|
157
|
+
* once whatever the store.
|
|
158
|
+
*/
|
|
159
|
+
export declare function createVersions(store: VersionStore, options?: {
|
|
160
|
+
poll?: number;
|
|
161
|
+
}): VersionSource;
|
|
162
|
+
/** The parts of a Postgres client `postgresVersions` uses: postgres.js and `Bun.sql` both have them. */
|
|
163
|
+
export interface PostgresClient {
|
|
164
|
+
unsafe(text: string, params?: unknown[]): PromiseLike<readonly Record<string, unknown>[]>;
|
|
165
|
+
/** postgres.js has it; with it, a waiting ask hears another instance at once. */
|
|
166
|
+
listen?(channel: string, onNotify: () => void): unknown;
|
|
167
|
+
notify?(channel: string, payload: string): unknown;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Versions in Postgres, from the client the app already has:
|
|
171
|
+
*
|
|
172
|
+
* installVersionSource(postgresVersions(sql))
|
|
173
|
+
*
|
|
174
|
+
* `sqlVersions` with Postgres's placeholders, and - when the client can
|
|
175
|
+
* LISTEN, as postgres.js can - a change on one instance wakes the waiting asks
|
|
176
|
+
* on every other at once, rather than on their next read of the table.
|
|
177
|
+
* Without it (`Bun.sql`), the table is read once a second while an ask waits.
|
|
178
|
+
*
|
|
179
|
+
* CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
|
|
180
|
+
*/
|
|
181
|
+
export declare function postgresVersions(sql: PostgresClient, options?: {
|
|
182
|
+
table?: string;
|
|
183
|
+
channel?: string;
|
|
184
|
+
poll?: number;
|
|
185
|
+
}): VersionSource;
|
|
186
|
+
/**
|
|
187
|
+
* Install where versions are read from - a table, Redis, any store - for this
|
|
188
|
+
* process; `null` takes it out again.
|
|
189
|
+
*
|
|
190
|
+
* The app's choice wins over the backend's: with a Go or Laravel backend that
|
|
191
|
+
* writes its versions to a store this process can read too, reading it here
|
|
192
|
+
* means watching costs the backend nothing.
|
|
193
|
+
*/
|
|
68
194
|
export declare function installVersionSource(source: VersionSource | null): void;
|
|
69
|
-
/** The
|
|
195
|
+
/** The engine's, when a backend is installed: its versions, read through it. Not for apps. */
|
|
196
|
+
export declare function installBackendVersionSource(source: VersionSource | null): void;
|
|
197
|
+
/** The source in use: the app's, else the backend's, else this process's own. */
|
|
70
198
|
export declare function versionSource(): VersionSource;
|
|
71
199
|
/**
|
|
72
200
|
* Say these names changed, so every open tab showing them refreshes.
|
|
@@ -81,14 +209,33 @@ export declare function versionSource(): VersionSource;
|
|
|
81
209
|
* its, and so is the call: `rsckit.Changed`, `Rsc::changed`.
|
|
82
210
|
*/
|
|
83
211
|
export declare function changed(...names: string[]): Promise<void>;
|
|
212
|
+
/** Thrown when a production server has no key to sign names with. */
|
|
213
|
+
export declare class MissingSigningSecret extends Error {
|
|
214
|
+
constructor();
|
|
215
|
+
}
|
|
84
216
|
/**
|
|
85
|
-
* The
|
|
86
|
-
*
|
|
87
|
-
* a tab served by one
|
|
217
|
+
* The secret names are signed with, when it does not come from the
|
|
218
|
+
* environment. Normally it does: `RSC_SIGNING_SECRET`, the same on every
|
|
219
|
+
* instance, or a tab served by one is refused by another.
|
|
220
|
+
*
|
|
221
|
+
* Its own secret, not the backend's host-call secret: that one authorises
|
|
222
|
+
* calls between the renderer and a backend, and an app with no backend has
|
|
223
|
+
* no reason to have it. Two jobs, two keys - one leaking does not hand out
|
|
224
|
+
* the other.
|
|
88
225
|
*/
|
|
89
226
|
export declare function configureChanged(options: {
|
|
90
227
|
secret?: string | null;
|
|
91
228
|
}): void;
|
|
229
|
+
/**
|
|
230
|
+
* Refuse, in production, to sign with a key nobody else has.
|
|
231
|
+
*
|
|
232
|
+
* A random key is right for one process - a dev server - and silently wrong
|
|
233
|
+
* for two: every tab served by one instance is refused by the next, and never
|
|
234
|
+
* refreshes. The server calls this on its first request when the app uses
|
|
235
|
+
* refreshOn, so the misconfiguration is an error at once rather than a page
|
|
236
|
+
* that quietly stops updating.
|
|
237
|
+
*/
|
|
238
|
+
export declare function assertSigningSecret(): void;
|
|
92
239
|
/** A name's signature: the first 16 bytes of an HMAC, base64url, which is what rides in a url. */
|
|
93
240
|
export declare function sign(name: string): Promise<string>;
|
|
94
241
|
/** What a page hands the client for one name: its version at render, and its signature. */
|
package/dist/changed.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* answers at once, and the watcher asks again on its interval. The protocol
|
|
27
27
|
* is the same either way, and so is the page.
|
|
28
28
|
*/
|
|
29
|
-
import {
|
|
29
|
+
import { frame, keepaliveMs } from "./events.js";
|
|
30
30
|
import { HEADER } from "./headers.js";
|
|
31
31
|
/** The reserved name a backend answers name versions on. */
|
|
32
32
|
export const CHANGED_FUNCTION = "__rsc.changed";
|
|
@@ -107,15 +107,199 @@ export function backendVersions(call) {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Versions in a table every instance shares: the web servers, and the worker
|
|
112
|
+
* that finishes a job and says so. The same table and columns Go's
|
|
113
|
+
* `SQLVersions` uses, so a Go service and a JavaScript worker can share one.
|
|
114
|
+
*
|
|
115
|
+
* installVersionSource(sqlVersions({
|
|
116
|
+
* query: (text, params) => sql.unsafe(text, params),
|
|
117
|
+
* placeholder: (n) => '$' + n,
|
|
118
|
+
* listen: (wake) => sql.listen('rsc_versions', wake),
|
|
119
|
+
* notify: () => sql.notify('rsc_versions', ''),
|
|
120
|
+
* }))
|
|
121
|
+
*
|
|
122
|
+
* Written with an UPDATE and, the first time, an INSERT - two statements any
|
|
123
|
+
* SQL dialect has - rather than an upsert each spells differently. Takes the
|
|
124
|
+
* app's own driver, so the engine has no database dependency of its own.
|
|
125
|
+
*/
|
|
126
|
+
export function sqlVersions(options) {
|
|
127
|
+
const { query } = options;
|
|
128
|
+
const table = options.table ?? "rsc_versions";
|
|
129
|
+
const mark = options.placeholder ?? (() => "?");
|
|
130
|
+
return createVersions({
|
|
131
|
+
async read(names) {
|
|
132
|
+
const rows = await query(`SELECT name, version FROM ${table} WHERE name IN (${names.map((_, i) => mark(i + 1)).join(", ")})`, names);
|
|
133
|
+
// A BIGINT comes back as a string from most drivers.
|
|
134
|
+
return Object.fromEntries(rows.map((row) => [String(row.name), Number(row.version)]));
|
|
135
|
+
},
|
|
136
|
+
async bump(names) {
|
|
137
|
+
const update = `UPDATE ${table} SET version = version + 1 WHERE name = ${mark(1)}`;
|
|
138
|
+
const insert = `INSERT INTO ${table} (name, version) VALUES (${mark(1)}, 1)`;
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
// The row count is not something every driver reports the same way,
|
|
141
|
+
// so ask: a name with no row yet gets one.
|
|
142
|
+
const [exists] = await query(`SELECT 1 AS found FROM ${table} WHERE name = ${mark(1)}`, [name]);
|
|
143
|
+
if (exists) {
|
|
144
|
+
await query(update, [name]);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await query(insert, [name]);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Another instance inserted it first: bump that one.
|
|
152
|
+
await query(update, [name]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
listen: options.listen,
|
|
157
|
+
notify: options.notify,
|
|
158
|
+
}, { poll: options.poll });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A version source over any store: SQL through any driver or ORM - Drizzle,
|
|
162
|
+
* Prisma, Kysely, Knex - or Redis, or anything with a read and an increment.
|
|
163
|
+
*
|
|
164
|
+
* installVersionSource(createVersions({ read, bump, listen, notify }))
|
|
165
|
+
*
|
|
166
|
+
* rsc-kit owns the rest: an ask waits up to the time it was given for a
|
|
167
|
+
* version to differ, reading every `poll` ms (1000 by default) or the moment
|
|
168
|
+
* `listen` wakes it, and a bump in this process wakes this process's asks at
|
|
169
|
+
* once whatever the store.
|
|
170
|
+
*/
|
|
171
|
+
export function createVersions(store, options = {}) {
|
|
172
|
+
const poll = options.poll ?? 1_000;
|
|
173
|
+
const waiters = new Set();
|
|
174
|
+
const wakeAll = () => {
|
|
175
|
+
for (const wake of [...waiters])
|
|
176
|
+
wake();
|
|
177
|
+
};
|
|
178
|
+
// Listening opens a connection, and an open connection keeps a process
|
|
179
|
+
// alive: started here, a store installed in register() - which a build
|
|
180
|
+
// runs too - held the build open for ever. So it starts with the first
|
|
181
|
+
// ask that waits, which only a server with a tab watching ever makes.
|
|
182
|
+
let listening = false;
|
|
183
|
+
const listen = () => {
|
|
184
|
+
if (listening || !store.listen)
|
|
185
|
+
return;
|
|
186
|
+
listening = true;
|
|
187
|
+
void Promise.resolve(store.listen(wakeAll)).catch(() => {
|
|
188
|
+
listening = false;
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
const read = async (names) => {
|
|
192
|
+
if (names.length === 0)
|
|
193
|
+
return {};
|
|
194
|
+
const found = await store.read(names);
|
|
195
|
+
return Object.fromEntries(names.map((name) => [name, Number(found[name] ?? 0)]));
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
async changed(since, wait) {
|
|
199
|
+
if (wait > 0)
|
|
200
|
+
listen();
|
|
201
|
+
const names = Object.keys(since);
|
|
202
|
+
const deadline = Date.now() + Math.max(0, wait);
|
|
203
|
+
for (;;) {
|
|
204
|
+
const versions = await read(names);
|
|
205
|
+
const differ = {};
|
|
206
|
+
for (const name of names)
|
|
207
|
+
if (versions[name] !== since[name])
|
|
208
|
+
differ[name] = versions[name] ?? 0;
|
|
209
|
+
const remaining = deadline - Date.now();
|
|
210
|
+
if (Object.keys(differ).length > 0 || remaining <= 0)
|
|
211
|
+
return differ;
|
|
212
|
+
await new Promise((resolve) => {
|
|
213
|
+
const done = () => {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
waiters.delete(done);
|
|
216
|
+
resolve();
|
|
217
|
+
};
|
|
218
|
+
const timer = setTimeout(done, Math.min(poll, remaining));
|
|
219
|
+
timer.unref?.();
|
|
220
|
+
waiters.add(done);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
async bump(names) {
|
|
225
|
+
const unique = [...new Set(names)];
|
|
226
|
+
if (unique.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
await store.bump(unique);
|
|
229
|
+
wakeAll();
|
|
230
|
+
if (store.notify)
|
|
231
|
+
await store.notify();
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Versions in Postgres, from the client the app already has:
|
|
237
|
+
*
|
|
238
|
+
* installVersionSource(postgresVersions(sql))
|
|
239
|
+
*
|
|
240
|
+
* `sqlVersions` with Postgres's placeholders, and - when the client can
|
|
241
|
+
* LISTEN, as postgres.js can - a change on one instance wakes the waiting asks
|
|
242
|
+
* on every other at once, rather than on their next read of the table.
|
|
243
|
+
* Without it (`Bun.sql`), the table is read once a second while an ask waits.
|
|
244
|
+
*
|
|
245
|
+
* CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
|
|
246
|
+
*/
|
|
247
|
+
export function postgresVersions(sql, options = {}) {
|
|
248
|
+
const channel = options.channel ?? "rsc_versions";
|
|
249
|
+
const listen = sql.listen?.bind(sql);
|
|
250
|
+
const notify = sql.notify?.bind(sql);
|
|
251
|
+
return sqlVersions({
|
|
252
|
+
query: async (text, params) => await sql.unsafe(text, params),
|
|
253
|
+
placeholder: (n) => "$" + n,
|
|
254
|
+
table: options.table,
|
|
255
|
+
poll: options.poll,
|
|
256
|
+
listen: listen ? (wake) => listen(channel, wake) : undefined,
|
|
257
|
+
notify: listen && notify ? () => notify(channel, "") : undefined,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
110
260
|
const SOURCE = Symbol.for("rsc-kit.version-source");
|
|
261
|
+
const BACKEND_SOURCE = Symbol.for("rsc-kit.backend-version-source");
|
|
262
|
+
const LOCAL_SOURCE = Symbol.for("rsc-kit.local-version-source");
|
|
111
263
|
const globals = globalThis;
|
|
112
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* Install where versions are read from - a table, Redis, any store - for this
|
|
266
|
+
* process; `null` takes it out again.
|
|
267
|
+
*
|
|
268
|
+
* The app's choice wins over the backend's: with a Go or Laravel backend that
|
|
269
|
+
* writes its versions to a store this process can read too, reading it here
|
|
270
|
+
* means watching costs the backend nothing.
|
|
271
|
+
*/
|
|
113
272
|
export function installVersionSource(source) {
|
|
114
273
|
globals[SOURCE] = source;
|
|
115
274
|
}
|
|
116
|
-
/** The
|
|
275
|
+
/** The engine's, when a backend is installed: its versions, read through it. Not for apps. */
|
|
276
|
+
export function installBackendVersionSource(source) {
|
|
277
|
+
globals[BACKEND_SOURCE] = source;
|
|
278
|
+
}
|
|
279
|
+
/** The source in use: the app's, else the backend's, else this process's own. */
|
|
117
280
|
export function versionSource() {
|
|
118
|
-
|
|
281
|
+
const chosen = globals[SOURCE] ??
|
|
282
|
+
globals[BACKEND_SOURCE];
|
|
283
|
+
if (chosen)
|
|
284
|
+
return chosen;
|
|
285
|
+
let local = globals[LOCAL_SOURCE];
|
|
286
|
+
if (!local) {
|
|
287
|
+
local = globals[LOCAL_SOURCE] = memoryVersions();
|
|
288
|
+
// The fallback works in development and in tests, which are one process,
|
|
289
|
+
// and fails silently in production with two: a change on one instance
|
|
290
|
+
// never reaches the tabs on another. Said once, at the first use. An app
|
|
291
|
+
// that really is one instance installs memoryVersions() itself, which is
|
|
292
|
+
// a choice rather than a fallback, and is not warned.
|
|
293
|
+
if (production()) {
|
|
294
|
+
console.warn("[rsc-kit] refreshOn versions are kept in this process only: a change made on another " +
|
|
295
|
+
"instance, or in a worker, will not reach the tabs this one serves. Install a store every " +
|
|
296
|
+
"process shares with installVersionSource() in instrumentation.ts - sqlVersions for MySQL " +
|
|
297
|
+
"or SQLite, postgresVersions for Postgres, createVersions for anything else: " +
|
|
298
|
+
"https://docs.rsc-kit.dev/guides/sections#where-the-versions-live. For a deploy that is one " +
|
|
299
|
+
"instance, installVersionSource(memoryVersions()) says so.");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return local;
|
|
119
303
|
}
|
|
120
304
|
/**
|
|
121
305
|
* Say these names changed, so every open tab showing them refreshes.
|
|
@@ -139,34 +323,87 @@ export async function changed(...names) {
|
|
|
139
323
|
// between requests - every name goes to the client signed, and comes back
|
|
140
324
|
// with its signature. Verified, not looked up, so a watch request is
|
|
141
325
|
// stateless, and a browser cannot make up a name to watch.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
326
|
+
/** Thrown when a production server has no key to sign names with. */
|
|
327
|
+
export class MissingSigningSecret extends Error {
|
|
328
|
+
constructor() {
|
|
329
|
+
super("refreshOn needs a signing secret in production, the same on every instance: set RSC_SIGNING_SECRET " +
|
|
330
|
+
"to a long random string. Without one, each instance would sign with its own random key, and a tab " +
|
|
331
|
+
"served by one would be refused by the next.");
|
|
332
|
+
this.name = "MissingSigningSecret";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// On the global rather than in this module: an app imports this file through
|
|
336
|
+
// its own copy of the package, and a key configured there must be the key the
|
|
337
|
+
// engine's copy signs and verifies with.
|
|
338
|
+
const KEY = Symbol.for("rsc-kit.signing-key");
|
|
339
|
+
function keying() {
|
|
340
|
+
return (globals[KEY] ??= {
|
|
341
|
+
secret: null,
|
|
342
|
+
keyed: null,
|
|
343
|
+
signatures: new Map(),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const env = (name) => globalThis
|
|
347
|
+
.process?.env?.[name] || undefined;
|
|
348
|
+
const production = () => env("NODE_ENV") === "production";
|
|
145
349
|
/**
|
|
146
|
-
* The
|
|
147
|
-
*
|
|
148
|
-
* a tab served by one
|
|
350
|
+
* The secret names are signed with, when it does not come from the
|
|
351
|
+
* environment. Normally it does: `RSC_SIGNING_SECRET`, the same on every
|
|
352
|
+
* instance, or a tab served by one is refused by another.
|
|
353
|
+
*
|
|
354
|
+
* Its own secret, not the backend's host-call secret: that one authorises
|
|
355
|
+
* calls between the renderer and a backend, and an app with no backend has
|
|
356
|
+
* no reason to have it. Two jobs, two keys - one leaking does not hand out
|
|
357
|
+
* the other.
|
|
149
358
|
*/
|
|
150
359
|
export function configureChanged(options) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
360
|
+
const state = keying();
|
|
361
|
+
state.secret = options.secret ?? null;
|
|
362
|
+
state.keyed = null;
|
|
363
|
+
state.signatures.clear();
|
|
364
|
+
}
|
|
365
|
+
/** The secret in use: configured, or from the environment; null when there is none. */
|
|
366
|
+
function secretInUse() {
|
|
367
|
+
const state = keying();
|
|
368
|
+
return state.secret ?? env("RSC_SIGNING_SECRET") ?? null;
|
|
154
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Refuse, in production, to sign with a key nobody else has.
|
|
372
|
+
*
|
|
373
|
+
* A random key is right for one process - a dev server - and silently wrong
|
|
374
|
+
* for two: every tab served by one instance is refused by the next, and never
|
|
375
|
+
* refreshes. The server calls this on its first request when the app uses
|
|
376
|
+
* refreshOn, so the misconfiguration is an error at once rather than a page
|
|
377
|
+
* that quietly stops updating.
|
|
378
|
+
*/
|
|
379
|
+
export function assertSigningSecret() {
|
|
380
|
+
if (!secretInUse() && production())
|
|
381
|
+
throw new MissingSigningSecret();
|
|
382
|
+
}
|
|
383
|
+
let warnedRandom = false;
|
|
155
384
|
async function key() {
|
|
156
|
-
|
|
385
|
+
const state = keying();
|
|
386
|
+
return (state.keyed ??= (async () => {
|
|
387
|
+
const secret = secretInUse();
|
|
157
388
|
let raw;
|
|
158
|
-
if (!secret && typeof process !== "undefined") {
|
|
159
|
-
secret = process.env?.RSC_HOST_CALL_SECRET ?? null;
|
|
160
|
-
}
|
|
161
389
|
if (secret)
|
|
162
390
|
raw = new TextEncoder().encode(secret);
|
|
163
|
-
else
|
|
391
|
+
else if (production())
|
|
392
|
+
throw new MissingSigningSecret();
|
|
393
|
+
else {
|
|
394
|
+
if (!warnedRandom) {
|
|
395
|
+
warnedRandom = true;
|
|
396
|
+
console.warn("[rsc-kit] refreshOn is signing with a random key, which is fine for one dev server. " +
|
|
397
|
+
"Set RSC_SIGNING_SECRET before running more than one instance.");
|
|
398
|
+
}
|
|
164
399
|
raw = crypto.getRandomValues(new Uint8Array(32));
|
|
400
|
+
}
|
|
165
401
|
return await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
166
402
|
})());
|
|
167
403
|
}
|
|
168
404
|
/** A name's signature: the first 16 bytes of an HMAC, base64url, which is what rides in a url. */
|
|
169
405
|
export function sign(name) {
|
|
406
|
+
const { signatures } = keying();
|
|
170
407
|
let pending = signatures.get(name);
|
|
171
408
|
if (!pending) {
|
|
172
409
|
pending = (async () => {
|
|
@@ -332,7 +569,7 @@ export async function changes(request) {
|
|
|
332
569
|
}
|
|
333
570
|
};
|
|
334
571
|
write("retry: 2000\n\n");
|
|
335
|
-
const keepalive = setInterval(() => write(": keepalive\n\n"),
|
|
572
|
+
const keepalive = setInterval(() => write(": keepalive\n\n"), keepaliveMs());
|
|
336
573
|
keepalive.unref?.();
|
|
337
574
|
const me = {
|
|
338
575
|
names: new Set(Object.keys(since)),
|
|
@@ -383,7 +620,9 @@ export async function changes(request) {
|
|
|
383
620
|
return new Response(body, {
|
|
384
621
|
headers: {
|
|
385
622
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
386
|
-
|
|
623
|
+
// no-transform: a proxy or CDN in front must not compress or buffer
|
|
624
|
+
// it either - Cloudflare compresses text unless told not to.
|
|
625
|
+
"Cache-Control": "no-store, no-transform",
|
|
387
626
|
"X-Accel-Buffering": "no",
|
|
388
627
|
},
|
|
389
628
|
});
|
|
@@ -393,6 +632,7 @@ export function resetChanges() {
|
|
|
393
632
|
streams.clear();
|
|
394
633
|
known.clear();
|
|
395
634
|
warned = false;
|
|
635
|
+
delete globals[LOCAL_SOURCE];
|
|
396
636
|
}
|
|
397
637
|
/** The url a tab opens to watch, for the client. */
|
|
398
638
|
export const CHANGES_PATH = HEADER.changesPath;
|