@ultimat3/cli 27.6.1 → 27.8.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/CLAUDE.md +1 -1
- package/package.json +28 -28
- package/src/admin-registration.ts +15 -14
- package/src/api-registration.ts +48 -47
- package/src/app-manifest.ts +15 -0
- package/src/cmd-generate.ts +65 -49
- package/src/dev-boot.ts +10 -1
- package/src/generate-disk.ts +38 -0
- package/src/generate-files.ts +6 -1
- package/src/generate-follow-ups.ts +64 -0
- package/src/generate-grant-findings.ts +8 -6
- package/src/generate-grants.ts +79 -52
- package/src/generate-kinds.ts +7 -2
- package/src/generate-plural.ts +11 -0
- package/src/generate-sibling.ts +34 -0
- package/src/generated-imports.ts +9 -6
- package/src/handle-registration.ts +71 -29
- package/src/i18n-index.ts +30 -14
- package/src/isr-query-advice.ts +63 -0
- package/src/isr-query-guard.ts +49 -0
- package/src/mcp-host.ts +2 -1
- package/src/messages.ts +4 -0
- package/src/role-sync-bus-wait.ts +66 -0
- package/src/role-sync.ts +6 -2
- package/src/runtime-bus.ts +198 -0
- package/src/runtime-cache.ts +158 -10
- package/src/runtime-isr-outcome.ts +60 -13
- package/src/runtime-isr.ts +23 -0
- package/src/runtime-render.ts +4 -3
- package/src/runtime-services.ts +30 -37
- package/src/seo-routes.ts +114 -14
- package/src/serve-boot.ts +4 -1
- package/src/serve-types.ts +6 -0
- package/src/serve.ts +8 -1
- package/src/source-list-edit.ts +218 -0
- package/src/templates/entity-module.ts +29 -0
- package/src/templates/entity.ts +30 -14
- package/src/verify-checks.ts +5 -0
- package/src/workspace-dep-edit.ts +13 -10
package/src/runtime-cache.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
|
|
|
8
8
|
import {
|
|
9
9
|
CacheDriverUnavailableError,
|
|
10
10
|
cdnTier,
|
|
11
|
+
flushProcessTiers,
|
|
11
12
|
isNoopPurgeDriver,
|
|
12
13
|
lruTier,
|
|
13
14
|
memoTier,
|
|
15
|
+
purgeEdgeAgain,
|
|
14
16
|
receiveInvalidationBroadcast,
|
|
15
17
|
redisTier,
|
|
16
18
|
registerInvalidationBroadcast,
|
|
@@ -18,7 +20,13 @@ import {
|
|
|
18
20
|
resetTiers,
|
|
19
21
|
} from '@ultimat3/cache';
|
|
20
22
|
import type { AppConfig, CacheTierName } from '@ultimat3/core';
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
backoffDelay,
|
|
25
|
+
defineConfig,
|
|
26
|
+
isOutageMilestone,
|
|
27
|
+
logger,
|
|
28
|
+
renderThrowable,
|
|
29
|
+
} from '@ultimat3/core';
|
|
22
30
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
23
31
|
import type { Env } from './runtime-bindings';
|
|
24
32
|
|
|
@@ -29,6 +37,19 @@ import type { Env } from './runtime-bindings';
|
|
|
29
37
|
*/
|
|
30
38
|
export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
|
|
31
39
|
|
|
40
|
+
/**
|
|
41
|
+
* "Drop everything you hold in process", on the same subject. An object, where a bust is an array
|
|
42
|
+
* of wire tags: a replica from before this marker existed parses it, finds no array and ignores
|
|
43
|
+
* it — its entries wait for their TTL, which is what a lost bust always cost it.
|
|
44
|
+
*/
|
|
45
|
+
export const CACHE_FLUSH_ALL = '{"flush":"all"}';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many refused wire tags a process keeps for the bus's return. A bound, not a queue: past it
|
|
49
|
+
* the set is dropped and ONE flush-all is owed instead, so memory does not grow with the outage.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_DEFERRED_TAGS = 1_024;
|
|
52
|
+
|
|
32
53
|
export interface CacheTiersOptions {
|
|
33
54
|
readonly env: Env;
|
|
34
55
|
/** Already resolved by the boot — a `cdn` rung is built against a real edge or not at all. */
|
|
@@ -152,8 +173,92 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
152
173
|
for (const tier of built) registerTier(tier);
|
|
153
174
|
warnUnnamed(options);
|
|
154
175
|
|
|
176
|
+
const send = (payload: string): Promise<void> =>
|
|
177
|
+
options.transport.publish(CACHE_INVALIDATE_SUBJECT, payload);
|
|
178
|
+
// What the bus refused, kept for its return. A publish with no live connection is refused, not
|
|
179
|
+
// buffered (`NatsTransport`), so without this a bust made during a two-second NATS restart
|
|
180
|
+
// reached no peer at all. De-duplicated — a tag is a fact, not an event — and bounded.
|
|
181
|
+
const deferred = new Set<string>();
|
|
182
|
+
let flushOwed = false;
|
|
183
|
+
const defer = (wireTags: readonly string[]): void => {
|
|
184
|
+
if (flushOwed) return;
|
|
185
|
+
for (const wire of wireTags) deferred.add(wire);
|
|
186
|
+
if (deferred.size <= MAX_DEFERRED_TAGS) return;
|
|
187
|
+
deferred.clear();
|
|
188
|
+
flushOwed = true;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* One pass over what is owed. It is TAKEN before the publish and put back if the bus refuses, so
|
|
192
|
+
* a bust refused while this publish is in flight is owed afresh rather than cleared by a success
|
|
193
|
+
* that never carried it — and a flush-all sent before that bust cannot stand in for it.
|
|
194
|
+
*/
|
|
195
|
+
const settleOnce = async (): Promise<void> => {
|
|
196
|
+
if (flushOwed) {
|
|
197
|
+
flushOwed = false;
|
|
198
|
+
try {
|
|
199
|
+
await send(CACHE_FLUSH_ALL);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
deferred.clear();
|
|
202
|
+
flushOwed = true;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (deferred.size === 0) return;
|
|
208
|
+
const batch = [...deferred];
|
|
209
|
+
deferred.clear();
|
|
210
|
+
try {
|
|
211
|
+
await send(JSON.stringify(batch));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
defer(batch);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
// The edge AGAIN, now that the peers are finally being told. It was purged when the bust ran,
|
|
217
|
+
// but every peer still held its copy then, and each request one of them answered since handed
|
|
218
|
+
// the edge the old page back — with no purge left to come. (Each peer purges after its own
|
|
219
|
+
// delete too; this is the sender's half, and does not depend on hearing its own publish.)
|
|
220
|
+
await purgeEdgeAgain(batch);
|
|
221
|
+
};
|
|
222
|
+
// ONE settle at a time. It is asked for by every reconnect and after every accepted publish,
|
|
223
|
+
// and two that overlapped both published the same batch (and the same flush-all). A call that
|
|
224
|
+
// arrives mid-run asks for one more pass instead, which finds whatever the run did not carry.
|
|
225
|
+
let settling: Promise<void> | undefined;
|
|
226
|
+
let again = false;
|
|
227
|
+
const settle = (): Promise<void> => {
|
|
228
|
+
if (settling !== undefined) {
|
|
229
|
+
again = true;
|
|
230
|
+
return settling;
|
|
231
|
+
}
|
|
232
|
+
settling = (async (): Promise<void> => {
|
|
233
|
+
try {
|
|
234
|
+
do {
|
|
235
|
+
again = false;
|
|
236
|
+
await settleOnce();
|
|
237
|
+
} while (again);
|
|
238
|
+
} finally {
|
|
239
|
+
again = false;
|
|
240
|
+
settling = undefined;
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
return settling;
|
|
244
|
+
};
|
|
245
|
+
const settleQuietly = (): void => {
|
|
246
|
+
void settle().catch((error: unknown) => {
|
|
247
|
+
logger.warn('cache.broadcast.deferred-failed', { error: broadcastErrorText(error) });
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
155
251
|
registerInvalidationBroadcast(async (wireTags) => {
|
|
156
|
-
|
|
252
|
+
try {
|
|
253
|
+
await send(JSON.stringify(wireTags));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
defer(wireTags);
|
|
256
|
+
// Still thrown: the report must say the peers have not been told YET.
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
// The bus took this one, so it takes what an earlier publish could not — the case no
|
|
260
|
+
// reconnect announces, a refusal with the connection up.
|
|
261
|
+
if (flushOwed || deferred.size > 0) settleQuietly();
|
|
157
262
|
});
|
|
158
263
|
// Not awaited HERE: the boot must not block on a subscribe. The PROMISE is held rather than a
|
|
159
264
|
// handle assigned inside a `.then`, because the release ran first whenever `stop()` beat the round
|
|
@@ -162,28 +267,60 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
162
267
|
// logged once and never asked again, so the process missed every peer invalidation for its life.
|
|
163
268
|
const released = Promise.withResolvers<undefined>();
|
|
164
269
|
let stopped = false;
|
|
270
|
+
let subscribed = false;
|
|
271
|
+
/** Cuts the retry's wait short: the bus coming up is a better signal than a backoff. */
|
|
272
|
+
let wake: () => void = () => undefined;
|
|
165
273
|
const retryMs = options.subscribeRetryMs ?? defaultSubscribeRetryMs;
|
|
274
|
+
// This process heard no peer for a window nobody measured — since boot, or since the connection
|
|
275
|
+
// dropped — so nothing it holds in its own heap is known fresh. One cold cache, never a stale one.
|
|
276
|
+
const flushDeaf = (source: string): void => {
|
|
277
|
+
void flushProcessTiers(source);
|
|
278
|
+
};
|
|
166
279
|
const subscribing = (async (): Promise<TransportSubscription | undefined> => {
|
|
167
280
|
for (let attempt = 1; !stopped; attempt += 1) {
|
|
168
281
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
282
|
+
const subscription = await options.transport.subscribe(
|
|
283
|
+
CACHE_INVALIDATE_SUBJECT,
|
|
284
|
+
(payload: string) => {
|
|
285
|
+
void applyBroadcast(payload);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
subscribed = true;
|
|
289
|
+
if (attempt > 1) {
|
|
290
|
+
// The outage's one closing line, beside the thinned warnings it ends.
|
|
291
|
+
logger.info('cache.broadcast.subscribe-failed recovered', { after: attempt - 1 });
|
|
292
|
+
flushDeaf('cache.bus-subscribed');
|
|
293
|
+
}
|
|
294
|
+
return subscription;
|
|
172
295
|
} catch (error) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
296
|
+
// Attempts 1, 2, 4, 8, …: the dial's own thinning (`NatsTransport`), so an outage is not
|
|
297
|
+
// one line per retry per pod for as long as it lasts.
|
|
298
|
+
if (isOutageMilestone(attempt)) {
|
|
299
|
+
logger.warn('cache.broadcast.subscribe-failed', {
|
|
300
|
+
error: broadcastErrorText(error),
|
|
301
|
+
attempt,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const woken = Promise.withResolvers<undefined>();
|
|
305
|
+
wake = (): void => woken.resolve(undefined);
|
|
306
|
+
await Promise.race([Bun.sleep(retryMs(attempt)), released.promise, woken.promise]);
|
|
178
307
|
}
|
|
179
308
|
}
|
|
180
309
|
return undefined;
|
|
181
310
|
})();
|
|
311
|
+
// The bus is (back) up. The subscription came back with the connection, so from here this
|
|
312
|
+
// process hears again: drop what it held while deaf, and say what it could not say.
|
|
313
|
+
const offReconnect = options.transport.onReconnect(() => {
|
|
314
|
+
wake();
|
|
315
|
+
if (subscribed) flushDeaf('cache.bus-reconnect');
|
|
316
|
+
settleQuietly();
|
|
317
|
+
});
|
|
182
318
|
|
|
183
319
|
// `resetTiers()` drops the registry AND the broadcast in one call: this boot is the only thing
|
|
184
320
|
// that registers either, and a tier left behind would purge for a process that has stopped.
|
|
185
321
|
return async () => {
|
|
186
322
|
stopped = true;
|
|
323
|
+
offReconnect();
|
|
187
324
|
released.resolve(undefined);
|
|
188
325
|
(await subscribing)?.unsubscribe();
|
|
189
326
|
resetTiers();
|
|
@@ -200,6 +337,13 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
200
337
|
*/
|
|
201
338
|
export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
|
|
202
339
|
|
|
340
|
+
/** `CACHE_FLUSH_ALL`, parsed: a peer had more refused busts than it could keep. */
|
|
341
|
+
const isFlushAll = (parsed: unknown): boolean =>
|
|
342
|
+
typeof parsed === 'object' &&
|
|
343
|
+
parsed !== null &&
|
|
344
|
+
!Array.isArray(parsed) &&
|
|
345
|
+
Reflect.get(parsed, 'flush') === 'all';
|
|
346
|
+
|
|
203
347
|
/**
|
|
204
348
|
* A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
|
|
205
349
|
* kill the subscriber loop, because that would silently end cross-instance invalidation for the
|
|
@@ -208,6 +352,10 @@ export const broadcastErrorText = (error: unknown): string => renderThrowable(er
|
|
|
208
352
|
async function applyBroadcast(payload: string): Promise<void> {
|
|
209
353
|
try {
|
|
210
354
|
const parsed: unknown = JSON.parse(payload);
|
|
355
|
+
if (isFlushAll(parsed)) {
|
|
356
|
+
await flushProcessTiers('cache.broadcast');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
211
359
|
if (!Array.isArray(parsed)) return;
|
|
212
360
|
const wire = parsed.filter((value): value is string => typeof value === 'string');
|
|
213
361
|
if (wire.length > 0) await receiveInvalidationBroadcast(wire);
|
|
@@ -2,25 +2,66 @@
|
|
|
2
2
|
// decided. A returned discriminated outcome, never a throw: the controller's regeneration treats
|
|
3
3
|
// anything thrown as a failure and logs it, and a redirect is an ordinary answer, not a failure.
|
|
4
4
|
|
|
5
|
+
import { logger } from '@ultimat3/core';
|
|
5
6
|
import type { RedirectIntent, RequestContext, RouteParams } from '@ultimat3/http';
|
|
6
7
|
import { asCtx, takeRedirect } from '@ultimat3/http';
|
|
7
8
|
import type { RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
8
|
-
import { routeDataFor, routeStatusOf } from '@ultimat3/render';
|
|
9
|
+
import { routeDataFor, routeNoStoreOf, routeStatusOf } from '@ultimat3/render';
|
|
9
10
|
import type { IsrController, IsrEntry } from '@ultimat3/render/server';
|
|
10
|
-
import { isrKey } from '@ultimat3/render/server';
|
|
11
|
+
import { isrKey, isrRequestUrl, undeclaredQuery } from '@ultimat3/render/server';
|
|
11
12
|
|
|
12
13
|
export type IsrOutcome =
|
|
13
14
|
| { readonly kind: 'page'; readonly result: RenderResult }
|
|
14
15
|
| { readonly kind: 'redirect'; readonly to: RedirectIntent };
|
|
15
16
|
|
|
17
|
+
/** What `load` and `meta` are given; `url` is a string because that is what `ld.*` embeds. */
|
|
18
|
+
type IsrData = { readonly url: string; readonly params: RouteParams } & Record<string, unknown>;
|
|
19
|
+
|
|
16
20
|
export interface IsrRequest {
|
|
17
21
|
readonly entry: RouteEntry;
|
|
18
|
-
/**
|
|
19
|
-
readonly data:
|
|
22
|
+
/** The request as it arrived. `load`, `meta` and the page get `keyedData`'s narrowing of it. */
|
|
23
|
+
readonly data: IsrData;
|
|
20
24
|
readonly ctx: RequestContext;
|
|
21
25
|
readonly isr: IsrController;
|
|
22
|
-
/**
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The route's whole document for what `load` returned (`runtime-render.ts`'s `documentFrom`),
|
|
28
|
+
* rendered AT the data this file hands it — the narrowed URL, never the request's own.
|
|
29
|
+
*/
|
|
30
|
+
readonly document: (loaded: RouteData, at: IsrData) => Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Route files already told, once, about a request whose query they never declared. */
|
|
34
|
+
const toldUndeclared = new Set<string>();
|
|
35
|
+
|
|
36
|
+
/** Names are the visitor's: a few, cut short, is all a log line needs to name the edit. */
|
|
37
|
+
const NAMED_PARAMS = 5;
|
|
38
|
+
const NAMED_PARAM_CHARS = 40;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The request narrowed to what the route declared it varies on (`revalidate.query`). ONE narrowing
|
|
42
|
+
* feeds the store key, `load`, `meta` and the page, so a parameter outside the key cannot reach
|
|
43
|
+
* the document stored under it.
|
|
44
|
+
*
|
|
45
|
+
* A route that declared NOTHING still keys on the whole query, and is told so once, the first time
|
|
46
|
+
* a visitor uses that: every distinct query string is then a render and a stored page of its own.
|
|
47
|
+
*/
|
|
48
|
+
function keyedData(entry: RouteEntry, data: IsrData): IsrData {
|
|
49
|
+
const url = new URL(data.url);
|
|
50
|
+
const declared = entry.config.revalidate?.query ?? null;
|
|
51
|
+
if (declared === null && !toldUndeclared.has(entry.file)) {
|
|
52
|
+
const minted = undeclaredQuery(url, declared);
|
|
53
|
+
if (minted.length > 0) {
|
|
54
|
+
toldUndeclared.add(entry.file);
|
|
55
|
+
logger.warn('isr.query.undeclared', {
|
|
56
|
+
route: entry.file,
|
|
57
|
+
params: minted.slice(0, NAMED_PARAMS).map((name) => name.slice(0, NAMED_PARAM_CHARS)),
|
|
58
|
+
cause:
|
|
59
|
+
'the route declares no revalidate.query, so every distinct query string is rendered and stored as its own page',
|
|
60
|
+
fix: `revalidate: { query: [] } // in ${entry.file}; or query: ['<name>'] for each parameter the page varies on`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { ...data, url: isrRequestUrl(url, declared).href };
|
|
24
65
|
}
|
|
25
66
|
|
|
26
67
|
/**
|
|
@@ -54,11 +95,12 @@ function dropPage(isr: IsrController, key: string): void {
|
|
|
54
95
|
* the request's own (its `load` may have read a cookie), so a request that joined a run whose load
|
|
55
96
|
* redirected decides for itself, exactly as the static single-flight in `appRoutes` does.
|
|
56
97
|
*/
|
|
57
|
-
export async function isrOutcome(
|
|
98
|
+
export async function isrOutcome(arrived: IsrRequest): Promise<IsrOutcome> {
|
|
99
|
+
const request: IsrRequest = { ...arrived, data: keyedData(arrived.entry, arrived.data) };
|
|
58
100
|
const { entry, data, ctx, isr } = request;
|
|
59
|
-
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered —
|
|
60
|
-
// reads `data.url` — so two URLs differing
|
|
61
|
-
//
|
|
101
|
+
// `isrKey(url, locale)`, never `url.pathname`: the DECLARED query is part of what was rendered —
|
|
102
|
+
// `meta` reads `data.url` — so two URLs differing in it are two documents (#171). The locale is
|
|
103
|
+
// `ctx.locale`, the one the `locale` stage negotiated for THIS request.
|
|
62
104
|
const key = isrKey(new URL(data.url), asCtx(ctx).locale);
|
|
63
105
|
let decided: RedirectIntent | undefined;
|
|
64
106
|
// `{ html, status }`, never the bare string: the entry stores the status beside the HTML and
|
|
@@ -66,8 +108,13 @@ export async function isrOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
|
66
108
|
const served = await isr.serve(key, async () => {
|
|
67
109
|
const loaded = await routeDataFor(entry.config, data);
|
|
68
110
|
const to = takeRedirect(ctx);
|
|
69
|
-
if (to === undefined)
|
|
70
|
-
return {
|
|
111
|
+
if (to === undefined) {
|
|
112
|
+
return {
|
|
113
|
+
html: await request.document(loaded, data),
|
|
114
|
+
status: routeStatusOf(loaded),
|
|
115
|
+
noStore: routeNoStoreOf(loaded),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
71
118
|
// Set by THIS request's producer only. On a stale page it runs behind the answer, and when it
|
|
72
119
|
// has already decided by the time `serve` returns, the redirect is still this request's own.
|
|
73
120
|
decided = to;
|
|
@@ -88,7 +135,7 @@ async function ownOutcome(request: IsrRequest): Promise<IsrOutcome> {
|
|
|
88
135
|
const loaded = await routeDataFor(request.entry.config, request.data);
|
|
89
136
|
const to = takeRedirect(request.ctx);
|
|
90
137
|
if (to !== undefined) return { kind: 'redirect', to };
|
|
91
|
-
const body = await request.document(loaded);
|
|
138
|
+
const body = await request.document(loaded, request.data);
|
|
92
139
|
return {
|
|
93
140
|
kind: 'page',
|
|
94
141
|
result: {
|
package/src/runtime-isr.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// and handed back with the detach the boot puts on its stop list. One function for `x dev` and the
|
|
3
3
|
// container, so neither can build a controller `invalidateTags` never reaches (plan 101, s2-con #1).
|
|
4
4
|
|
|
5
|
+
import { describePages, RouteModeInvalidError } from '@ultimat3/render';
|
|
5
6
|
import type { IsrController, IsrStore } from '@ultimat3/render/server';
|
|
6
7
|
import { isrController } from '@ultimat3/render/server';
|
|
7
8
|
|
|
@@ -18,10 +19,16 @@ export interface AttachedIsr {
|
|
|
18
19
|
export function attachedIsr(options: {
|
|
19
20
|
readonly buildId: string;
|
|
20
21
|
readonly store?: IsrStore;
|
|
22
|
+
/** `isrController`'s own; a test turns the cooldown off rather than waiting one out. */
|
|
23
|
+
readonly failureCooldownMs?: number;
|
|
21
24
|
}): AttachedIsr {
|
|
25
|
+
if (options.store !== undefined) assertPurgeSafe(options.store);
|
|
22
26
|
const isr = isrController({
|
|
23
27
|
buildId: options.buildId,
|
|
24
28
|
...(options.store === undefined ? {} : { store: options.store }),
|
|
29
|
+
...(options.failureCooldownMs === undefined
|
|
30
|
+
? {}
|
|
31
|
+
: { failureCooldownMs: options.failureCooldownMs }),
|
|
25
32
|
});
|
|
26
33
|
const detach = isr.attach();
|
|
27
34
|
let released = false;
|
|
@@ -34,3 +41,19 @@ export function attachedIsr(options: {
|
|
|
34
41
|
},
|
|
35
42
|
};
|
|
36
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A store the deployment supplied is one other processes may write. Under `onInvalidate: 'purge'`
|
|
47
|
+
* a replica that began a render before the purge, and has not heard of it yet, writes the purged
|
|
48
|
+
* page back unless the STORE refuses the write (`IsrStore.tagFence`) — its own fence only knows
|
|
49
|
+
* the busts it was told about. Refused here, by route, rather than left to happen in production.
|
|
50
|
+
*/
|
|
51
|
+
function assertPurgeSafe(store: IsrStore): void {
|
|
52
|
+
if (store.tagFence !== undefined) return;
|
|
53
|
+
const purging = describePages().find((route) => route.revalidateOnInvalidate === 'purge');
|
|
54
|
+
if (purging === undefined) return;
|
|
55
|
+
throw new RouteModeInvalidError(
|
|
56
|
+
`${purging.file} declares revalidate.onInvalidate: 'purge', and the runtime's isrStore has no tagFence — a replica whose render began before a purge would write the purged page back into it`,
|
|
57
|
+
'tagFence: { sample, bump, setIfCurrent } // implement IsrTagFence on the isrStore in apps/web/runtime.ts (setIfCurrent atomic in the store), or drop isrStore to keep one memory store per replica',
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/runtime-render.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
streamResult,
|
|
46
46
|
} from '@ultimat3/render/server';
|
|
47
47
|
import type { DocumentOptions } from './document-options';
|
|
48
|
+
import { routeQuery } from './isr-query-guard';
|
|
48
49
|
import {
|
|
49
50
|
type NavigationDocumentHead,
|
|
50
51
|
navigationMetaOf,
|
|
@@ -193,7 +194,7 @@ export async function routeBody(
|
|
|
193
194
|
data,
|
|
194
195
|
params: ctx.params,
|
|
195
196
|
url: ctx.url,
|
|
196
|
-
query:
|
|
197
|
+
query: routeQuery(entry, url),
|
|
197
198
|
},
|
|
198
199
|
entry.file,
|
|
199
200
|
{ islands },
|
|
@@ -486,8 +487,8 @@ async function renderIsr(
|
|
|
486
487
|
options: DevRenderOptions,
|
|
487
488
|
isr: IsrController,
|
|
488
489
|
): Promise<Rendered> {
|
|
489
|
-
const document = (loaded: RouteData): Promise<string> =>
|
|
490
|
-
documentFrom(entry,
|
|
490
|
+
const document = (loaded: RouteData, at: DevRouteData): Promise<string> =>
|
|
491
|
+
documentFrom(entry, at, loaded, options);
|
|
491
492
|
const outcome = await isrOutcome({ entry, data, ctx, isr, document });
|
|
492
493
|
if (outcome.kind === 'redirect') return { respond: () => loadRedirect(entry, outcome.to) };
|
|
493
494
|
return { respond: () => responseOf(outcome.result) };
|
package/src/runtime-services.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
|
|
|
10
10
|
import {
|
|
11
11
|
isLocal,
|
|
12
12
|
type RealtimeConfig,
|
|
13
|
+
type Role,
|
|
13
14
|
registerReadinessCheck,
|
|
14
15
|
renderThrowable,
|
|
15
16
|
resolveEnvironment,
|
|
@@ -26,14 +27,14 @@ import {
|
|
|
26
27
|
resetMailDriver,
|
|
27
28
|
setMailDriver,
|
|
28
29
|
} from '@ultimat3/mail';
|
|
29
|
-
import type {
|
|
30
|
-
import { selectTransport, setChannelTransport } from '@ultimat3/realtime/server';
|
|
30
|
+
import type { BusUse, Transport } from '@ultimat3/realtime/server';
|
|
31
31
|
import type { Storage } from '@ultimat3/storage';
|
|
32
32
|
import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
|
|
33
33
|
import { loadAppConfig } from './app-config-load';
|
|
34
34
|
import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
|
|
35
35
|
import { msg } from './messages';
|
|
36
36
|
import type { DevServices, Env } from './runtime-bindings';
|
|
37
|
+
import { selectBus } from './runtime-bus';
|
|
37
38
|
import { cacheTiersOf, startCacheTiers } from './runtime-cache';
|
|
38
39
|
import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
|
|
39
40
|
import { selectAppMailDriver } from './runtime-mail';
|
|
@@ -74,6 +75,13 @@ export interface RunningServices {
|
|
|
74
75
|
readonly mailDetail: string;
|
|
75
76
|
/** Same rule as `mailDetail`: the env key that selected the bus, never the url behind it. */
|
|
76
77
|
readonly transportDetail: string;
|
|
78
|
+
/**
|
|
79
|
+
* What this process does with the bus, which decided whether the boot waited for it
|
|
80
|
+
* (`runtime-bus.ts`). Optional for the reason `workerConfig` is: a hand-built runtime has none.
|
|
81
|
+
*/
|
|
82
|
+
readonly busUse?: BusUse;
|
|
83
|
+
/** `x dev` booted without a bus its `sync` role needs: that role waits for it (`role-sync.ts`). */
|
|
84
|
+
readonly busTolerated?: boolean;
|
|
77
85
|
/**
|
|
78
86
|
* What the sync role must give `PresenceRegistry`. It travels with the transport because the KV
|
|
79
87
|
* bucket's age limit was derived from it — a registry given a longer TTL than the bucket honours
|
|
@@ -278,14 +286,6 @@ function probedReadinessCheck(name: string, probe: () => Promise<unknown>): () =
|
|
|
278
286
|
});
|
|
279
287
|
}
|
|
280
288
|
|
|
281
|
-
/** A transport that says whether it is connected. NATS does; the in-process bus has nothing to be. */
|
|
282
|
-
interface ConnectableTransport {
|
|
283
|
-
readonly connected: boolean;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const saysConnected = (transport: Transport): transport is Transport & ConnectableTransport =>
|
|
287
|
-
typeof (transport as Partial<ConnectableTransport>).connected === 'boolean';
|
|
288
|
-
|
|
289
289
|
/**
|
|
290
290
|
* Release what has already started, newest first, and return every failure instead of throwing on
|
|
291
291
|
* the first: a step that rejects must not skip the ones after it, or one transport that will not
|
|
@@ -324,6 +324,13 @@ export async function startServices(
|
|
|
324
324
|
overrides?: RuntimeOverrides,
|
|
325
325
|
/** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
|
|
326
326
|
schema: SchemaMode = 'apply',
|
|
327
|
+
/**
|
|
328
|
+
* The roles this process will start, which decide whether the boot waits for the bus: only a
|
|
329
|
+
* `sync` node or a replicator does (`runtime-bus.ts`). Unset is the strictest reading.
|
|
330
|
+
*/
|
|
331
|
+
roles?: readonly Role[],
|
|
332
|
+
/** `x dev`: a bus the realtime roles need is waited for in the background, never refused. */
|
|
333
|
+
dev = false,
|
|
327
334
|
): Promise<RunningServices> {
|
|
328
335
|
// The app's config is loaded ONCE, here, first, and every section below is read out of that one
|
|
329
336
|
// validated object — mail's `retainMime` is the first reader, so it is loaded before mail.
|
|
@@ -341,13 +348,13 @@ export async function startServices(
|
|
|
341
348
|
// service starts, rather than quietly keeping the in-process bus. Which transport, which KV
|
|
342
349
|
// bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
|
|
343
350
|
// `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
|
|
344
|
-
// container it stands in for.
|
|
351
|
+
// container it stands in for. What the bus is USED for is the roles' (`runtime-bus.ts`).
|
|
345
352
|
const realtime = realtimeConfigOf(config);
|
|
346
353
|
// Fourth: `pwa.push` and its VAPID pair (`runtime-push.ts`). Resolving the pair reads env and
|
|
347
354
|
// signs one probe — a deploy with no keys, or two halves of different pairs, refuses here.
|
|
348
355
|
const push = await selectWebPush(config, env);
|
|
349
356
|
const workerConfig = workerConfigOf(config, env);
|
|
350
|
-
const bus
|
|
357
|
+
const bus = selectBus({ env, realtime, roles, override: overrides?.transport, dev });
|
|
351
358
|
// `env`, not the ambient one: this function is HANDED the boot's environment and every other
|
|
352
359
|
// reader here already uses it, so a queue that asked `process.env` would decide the standby from
|
|
353
360
|
// a different answer than the middleware that routes to it.
|
|
@@ -405,28 +412,14 @@ export async function startServices(
|
|
|
405
412
|
// before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
|
|
406
413
|
// healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
|
|
407
414
|
started.push(probedReadinessCheck('database', () => db.ping()));
|
|
408
|
-
// Dialled here rather than at selection:
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
transport = bus.transport;
|
|
417
|
-
}
|
|
418
|
-
// The bus an app's own code publishes a channel event on (`publishChannelEvent`), in EVERY
|
|
419
|
-
// role: the hub is the `sync` role's, so a job or an action reached none, and an events-only
|
|
420
|
-
// channel — the one kind that needs no replicator — could be fed by nothing but a socket.
|
|
421
|
-
started.push(setChannelTransport(transport));
|
|
422
|
-
// Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
|
|
423
|
-
// synchronous getter over the client's own state, so no probe is needed; the in-process bus
|
|
424
|
-
// has nothing to lose a connection to, and a check that can only answer `true` is a number in
|
|
425
|
-
// `registered` that means nothing.
|
|
426
|
-
if (saysConnected(transport)) {
|
|
427
|
-
const connectable = transport;
|
|
428
|
-
started.push(registerReadinessCheck('transport', () => connectable.connected));
|
|
429
|
-
}
|
|
415
|
+
// Dialled here rather than at selection: the socket is a resource the unwind below has to
|
|
416
|
+
// release. AWAITED only by a process that cannot work without the bus — a `sync` node, a
|
|
417
|
+
// replicator — where an unreachable one must fail the boot, not the first change nobody
|
|
418
|
+
// receives. Every other role dials in the background and boots regardless; the channel bus and
|
|
419
|
+
// the `transport` readiness check are installed either way (`runtime-bus.ts`).
|
|
420
|
+
const running = await bus.start();
|
|
421
|
+
started.push(() => running.stop());
|
|
422
|
+
const transport = running.transport;
|
|
430
423
|
// Before `loadApp` for the auth limiters' reason: `pushSubscribe()` is declared when the app's
|
|
431
424
|
// modules import, and reads this runtime per call.
|
|
432
425
|
if (push !== undefined) started.push(await installAppWebPush(push, executor));
|
|
@@ -467,9 +460,9 @@ export async function startServices(
|
|
|
467
460
|
// table: three readers of one executor, resolved once. `startWeb` is what an override
|
|
468
461
|
// replaces it at.
|
|
469
462
|
rateLimitStore,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
463
|
+
transportDetail: running.detail,
|
|
464
|
+
busUse: bus.use,
|
|
465
|
+
busTolerated: bus.tolerant,
|
|
473
466
|
presenceTtlMs: bus.presenceTtlMs,
|
|
474
467
|
purge,
|
|
475
468
|
purgeDetail: cdn.detail,
|