@rangojs/router 0.19.0 → 0.20.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.
@@ -17,11 +17,19 @@ import {
17
17
  } from "../server/request-context.js";
18
18
  import {
19
19
  getCurrentLoaderBodyId,
20
+ getLoaderRunIdentity,
20
21
  getLoaderBodyTags,
21
22
  isInsideLoaderScope,
23
+ useCacheLoaderIdentityError,
22
24
  type LoaderIdentityRead,
23
25
  type LoaderIdentityReadVerb,
26
+ type LoaderRunIdentity,
24
27
  } from "../server/context.js";
28
+ import {
29
+ getCacheExecScope,
30
+ isInsideIdentityExempt,
31
+ type CacheExecScope,
32
+ } from "./cache-exec-scope.js";
25
33
  import type { ExecutionStart } from "./tag-invalidation.js";
26
34
 
27
35
  const cacheTagStorage = new AsyncLocalStorage<Set<string>>();
@@ -481,6 +489,102 @@ export function recordedIdentityRead(
481
489
  return undefined;
482
490
  }
483
491
 
492
+ /**
493
+ * Loader value identity (#1011). Each loader execution's value promise maps to
494
+ * its LoaderRunIdentity (server/context.ts); a loader-cache binding's value
495
+ * carries its reads on its recorded-tag set instead (tagLoaderValue,
496
+ * markIdentityRead). On globalThis like identityReads: the executor that
497
+ * tracks a value and the reader that checks it must share the map.
498
+ */
499
+ const loaderRuns: WeakMap<object, LoaderRunIdentity> = ((globalThis as any)[
500
+ Symbol.for("rangojs-router:loader-runs")
501
+ ] ??= new WeakMap());
502
+
503
+ /**
504
+ * `value` is the promise of the execution `run` records, started by the
505
+ * current execution (if any), which reads it. Both loader runners call it
506
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
507
+ * createUseFunction).
508
+ */
509
+ export function trackLoaderRun(value: object, run: LoaderRunIdentity): void {
510
+ loaderRuns.set(value, run);
511
+ linkLoaderRun(value);
512
+ }
513
+
514
+ /** The current loader execution reads `value`: its record links the value's. */
515
+ function linkLoaderRun(value: object): void {
516
+ const reader = getLoaderRunIdentity();
517
+ const run = loaderRuns.get(value);
518
+ if (reader && run && reader !== run) (reader.reads ??= new Set()).add(run);
519
+ }
520
+
521
+ /**
522
+ * The identity read behind a loader value: its execution's or one it read
523
+ * (nearest first), or the one its recorded-tag set links to.
524
+ */
525
+ function loaderValueIdentityRead(
526
+ value: object,
527
+ ): LoaderIdentityRead | undefined {
528
+ const root = loaderRuns.get(value);
529
+ if (root) {
530
+ const queue = [root];
531
+ const seen = new Set<LoaderRunIdentity>();
532
+ for (let i = 0; i < queue.length; i++) {
533
+ const run = queue[i]!;
534
+ if (seen.has(run)) continue;
535
+ seen.add(run);
536
+ if (run.read) return run.read;
537
+ for (const linked of run.reads ?? []) queue.push(linked);
538
+ }
539
+ }
540
+ const tags = loaderValueTags.get(value);
541
+ return tags && recordedIdentityRead(tags);
542
+ }
543
+
544
+ /**
545
+ * A ctx.use(Loader) or getRequestContext().use(Loader) read that returns an
546
+ * already-started value (the request memo, a loader cache() binding's value).
547
+ * The current loader execution, if any, links it (it reads the value).
548
+ * Inside a "use cache" body the value is refused when its execution read
549
+ * request identity, as a body entered there would have been
550
+ * (guardIdentityRead). The check runs once the value settles, so a read the
551
+ * body makes after an await counts. Each enclosing execution also notes the
552
+ * value, and checks it again after encoding its result
553
+ * (assertLoaderReadsClean): a read that settles after the value (a nested
554
+ * promise in it) then fails the write instead.
555
+ */
556
+ export function readStartedLoaderValue<T>(
557
+ value: Promise<T>,
558
+ loaderId: string,
559
+ ): Promise<T> {
560
+ linkLoaderRun(value);
561
+ const scope = getCacheExecScope();
562
+ if (!scope || isInsideIdentityExempt()) return value;
563
+ for (let s: CacheExecScope | undefined = scope; s; s = s.parent) {
564
+ (s.loaderReads ??= new Map()).set(value, loaderId);
565
+ }
566
+ const checked = value.then((resolved) => {
567
+ assertLoaderValueClean(value, loaderId);
568
+ return resolved;
569
+ });
570
+ // A read the body never awaits must not crash the process as an unhandled
571
+ // rejection; the write check (assertLoaderReadsClean) still refuses it.
572
+ checked.catch(() => {});
573
+ return checked;
574
+ }
575
+
576
+ /** Throw when a loader value `scope` read carries an identity read. */
577
+ export function assertLoaderReadsClean(scope: CacheExecScope): void {
578
+ for (const [value, loaderId] of scope.loaderReads ?? []) {
579
+ assertLoaderValueClean(value, loaderId);
580
+ }
581
+ }
582
+
583
+ function assertLoaderValueClean(value: object, loaderId: string): void {
584
+ const read = loaderValueIdentityRead(value);
585
+ if (read) throw useCacheLoaderIdentityError(read, loaderId);
586
+ }
587
+
484
588
  /** `root` and every set it links to, transitively. */
485
589
  function linkedSets(root: Set<string>): Set<Set<string>> {
486
590
  const seen = new Set<Set<string>>();
@@ -15,8 +15,10 @@ import {
15
15
  linkValueTags,
16
16
  loaderTagSetsArmed,
17
17
  markTagSetStart,
18
+ readStartedLoaderValue,
18
19
  readValueTags,
19
20
  tagLoaderValue,
21
+ trackLoaderRun,
20
22
  } from "../cache/cache-tag.js";
21
23
  import { executionStart } from "../cache/tag-invalidation.js";
22
24
  import type {
@@ -57,8 +59,10 @@ import {
57
59
  assertNonCacheableReadAllowed,
58
60
  isInsideLoaderScope,
59
61
  runInsideLoaderBodyScope,
62
+ runInsideLoaderRun,
60
63
  isInsidePushCallbackScope,
61
64
  runInsidePushCallbackScope,
65
+ type LoaderRunIdentity,
62
66
  } from "../server/context.js";
63
67
  import { debugLog } from "./logging.js";
64
68
 
@@ -479,7 +483,9 @@ function createLoaderExecutor<TEnv>(
479
483
  const memo = bound || loaderPromises.get(loader.$$id);
480
484
  if (memo) {
481
485
  if (recordTags) readValueTags(memo);
482
- return memo;
486
+ // A "use cache" body reading it refuses an identity read the run made
487
+ // (#1011): the body ran outside the cached function, unguarded.
488
+ return readStartedLoaderValue(memo, loader.$$id);
483
489
  }
484
490
 
485
491
  // Get loader function - either from loader object or fetchable registry
@@ -724,8 +730,10 @@ function createLoaderExecutor<TEnv>(
724
730
  // BAKED copy in every shared artifact (cache(), "use cache", the PPR
725
731
  // shell). The cache() guard permits its identity reads; a "use cache"
726
732
  // body it was entered in and a PPR capture do not (server/context.ts
727
- // guardIdentityRead).
733
+ // guardIdentityRead), and a "use cache" body that reads the value after
734
+ // another reader started it refuses what `identity` recorded (#1011).
728
735
 
736
+ const identity: LoaderRunIdentity = { loaderId: loader.$$id };
729
737
  const recordedTags = recordTags ? new Set<string>() : undefined;
730
738
  // A loader-cache write that stores or reads this value gates on the
731
739
  // earliest start behind it (#977, earliestRecordedStart).
@@ -736,7 +744,10 @@ function createLoaderExecutor<TEnv>(
736
744
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
737
745
  Promise.resolve(
738
746
  runInsideLoaderBodyScope(
739
- () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
747
+ () =>
748
+ runInsideLoaderRun(identity, () =>
749
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
750
+ ),
740
751
  loader.$$id,
741
752
  recordedTags,
742
753
  ),
@@ -747,6 +758,7 @@ function createLoaderExecutor<TEnv>(
747
758
  runEndStore?.settleLoaderRun(loader.$$id);
748
759
  }),
749
760
  );
761
+ trackLoaderRun(promise, identity);
750
762
  if (recordedTags) {
751
763
  tagLoaderValue(promise, recordedTags);
752
764
  readValueTags(promise);
@@ -96,6 +96,7 @@ import {
96
96
  linkRecordedTags,
97
97
  markIdentityRead,
98
98
  markTagSetStart,
99
+ readStartedLoaderValue,
99
100
  readValueTags,
100
101
  recordedIdentityRead,
101
102
  recordLoaderTags,
@@ -591,7 +592,7 @@ function executeLoaderData<TEnv>(
591
592
  // useLoader.
592
593
  linkLoaderTags(item.$$id);
593
594
  readValueTags(cached);
594
- return cached;
595
+ return readStartedLoaderValue(cached, item.$$id);
595
596
  }
596
597
  return originalUse(item);
597
598
  }) as typeof ctx.use;
@@ -1131,18 +1131,91 @@ const IDENTITY_READ_RECORDER_KEY = Symbol.for(
1131
1131
  * (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
1132
1132
  * Response directives do not record: a key cannot make a skipped body's side
1133
1133
  * effect reach a HIT.
1134
+ *
1135
+ * The read also lands on the execution's LoaderRunIdentity (#1011), which
1136
+ * every execution has, from either loader runner (runInsideLoaderRun).
1134
1137
  */
1135
1138
  export function recordLoaderIdentityRead(
1136
1139
  surface: string,
1137
1140
  verb: LoaderIdentityReadVerb = "called",
1138
1141
  ): void {
1139
1142
  if (isInsideIdentityExempt()) return;
1143
+ const run = loaderRunALS.getStore();
1144
+ if (run && run.read === undefined) {
1145
+ run.read = { surface, verb, bodyId: run.loaderId };
1146
+ }
1140
1147
  const recorder = (globalThis as Record<symbol, unknown>)[
1141
1148
  IDENTITY_READ_RECORDER_KEY
1142
1149
  ] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
1143
1150
  recorder?.(surface, verb);
1144
1151
  }
1145
1152
 
1153
+ /**
1154
+ * One loader execution's request-identity record (#1011): the first identity
1155
+ * read its body made, and the executions whose values it read with
1156
+ * ctx.use(Loader). Every execution has one; the recorded-tag sets that carry
1157
+ * the same reads for a loader cache() fill (cache-tag.ts) exist only in a
1158
+ * request that binds one.
1159
+ *
1160
+ * A "use cache" function that reads a loader's value through the request
1161
+ * memo (a handler, the route's loader() binding or a parent layout's started
1162
+ * it first) does not run the body, so guardIdentityRead never sees the read.
1163
+ * Scar: the entry, keyed without the cookie, stored the first visitor's value
1164
+ * and served it to the next. cache-tag.ts readStartedLoaderValue refuses such
1165
+ * a value through this record.
1166
+ */
1167
+ export interface LoaderRunIdentity {
1168
+ readonly loaderId: string;
1169
+ read?: LoaderIdentityRead;
1170
+ reads?: Set<LoaderRunIdentity>;
1171
+ }
1172
+
1173
+ // Its own scope, not a LoaderBodyScope field: the request-context runner
1174
+ // (request-context.ts createUseFunction) records reads too, but does not
1175
+ // enter a loader body scope, whose cache() exemption it never had.
1176
+ const loaderRunALS: AsyncLocalStorage<LoaderRunIdentity> = ((globalThis as any)[
1177
+ Symbol.for("rangojs-router:loader-run-identity")
1178
+ ] ??= new AsyncLocalStorage<LoaderRunIdentity>());
1179
+
1180
+ /**
1181
+ * Run a loader body with `run` as its identity record: both loader runners
1182
+ * (loader-resolution.ts createLoaderExecutor, request-context.ts
1183
+ * createUseFunction) enter it around the loader function.
1184
+ */
1185
+ export function runInsideLoaderRun<T>(run: LoaderRunIdentity, fn: () => T): T {
1186
+ return loaderRunALS.run(run, fn);
1187
+ }
1188
+
1189
+ /** The innermost loader execution's identity record (runInsideLoaderRun). */
1190
+ export function getLoaderRunIdentity(): LoaderRunIdentity | undefined {
1191
+ return loaderRunALS.getStore();
1192
+ }
1193
+
1194
+ /**
1195
+ * The error a "use cache" function's read of a loader value fails with when
1196
+ * the loader's execution, or one whose value it read, made `read` (#1011).
1197
+ * Worded like guardIdentityRead's "use cache" refusal, naming the loaders.
1198
+ */
1199
+ export function useCacheLoaderIdentityError(
1200
+ read: LoaderIdentityRead,
1201
+ loaderId: string,
1202
+ ): Error {
1203
+ const bodyId = read.bodyId ?? loaderId;
1204
+ const source =
1205
+ bodyId === loaderId
1206
+ ? `Loader "${loaderId}" ${read.verb} it, and the cached function reads that loader's value`
1207
+ : `Loader "${bodyId}" ${read.verb} it, and the cached function reads loader "${loaderId}", whose value is built from it`;
1208
+ return new Error(
1209
+ `${read.surface} cannot be ${read.verb} inside a "use cache" function. ` +
1210
+ `${source}. The loader ran outside the function (a handler or a ` +
1211
+ `loader() binding started it first), but its value becomes part of ` +
1212
+ `what the function returns, and the cache key does not include it, so ` +
1213
+ `the first caller's value would be stored and served to later callers. ` +
1214
+ `Read the loader before calling the cached function and pass the value ` +
1215
+ `in as an argument so it becomes part of the cache key.`,
1216
+ );
1217
+ }
1218
+
1146
1219
  /** The error a loader cache() fill with no declared key fails with (#972). */
1147
1220
  export function loaderCacheIdentityError(
1148
1221
  read: LoaderIdentityRead,
@@ -1344,7 +1417,8 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
1344
1417
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
1345
1418
  * never trip the cache() guard — a route cache() never stores loader values.
1346
1419
  * A "use cache" body the loader was entered in still refuses them
1347
- * (guardIdentityRead).
1420
+ * (guardIdentityRead); one that reads the value later refuses what the run
1421
+ * recorded (runInsideLoaderRun, cache-tag.ts readStartedLoaderValue).
1348
1422
  */
1349
1423
  export function runInsideLoaderBodyScope<T>(
1350
1424
  fn: () => T,
@@ -64,7 +64,10 @@ import {
64
64
  assertCachedHeaderWriteAllowed,
65
65
  assertNonCacheableReadAllowed,
66
66
  clearPprHeaderScope,
67
+ runInsideLoaderRun,
68
+ type LoaderRunIdentity,
67
69
  } from "./context.js";
70
+ import { readStartedLoaderValue, trackLoaderRun } from "../cache/cache-tag.js";
68
71
  import {
69
72
  guardRawCookieRead,
70
73
  guardRequestHeaders,
@@ -1859,8 +1862,11 @@ export function createUseFunction<TEnv>(
1859
1862
 
1860
1863
  const loader = item as LoaderDefinition<any, any>;
1861
1864
 
1862
- if (loaderPromises.has(loader.$$id)) {
1863
- return loaderPromises.get(loader.$$id);
1865
+ const memo = loaderPromises.get(loader.$$id);
1866
+ if (memo) {
1867
+ // Same refusal as the handler ctx.use memo hit (#1011): a "use cache"
1868
+ // body reading a value whose run read request identity.
1869
+ return readStartedLoaderValue(memo, loader.$$id);
1864
1870
  }
1865
1871
 
1866
1872
  let loaderFn = loader.fn;
@@ -1947,9 +1953,11 @@ export function createUseFunction<TEnv>(
1947
1953
  // (observePhase), so a loader resolved via this base request-context ctx.use
1948
1954
  // co-emits the "loader:<id>" perf metric AND the "rango.loader" span — no
1949
1955
  // drift between the two ctx.use implementations.
1956
+ const run: LoaderRunIdentity = { loaderId: loader.$$id };
1950
1957
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
1951
- Promise.resolve(loaderFn(loaderCtx)),
1958
+ Promise.resolve(runInsideLoaderRun(run, () => loaderFn(loaderCtx))),
1952
1959
  );
1960
+ trackLoaderRun(promise, run);
1953
1961
 
1954
1962
  loaderPromises.set(loader.$$id, promise);
1955
1963
 
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The client entry's head hint at default fetch priority, after the head chunk
3
+ * scripts (issue #1025).
4
+ *
5
+ * For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
6
+ * fetchPriority="low">` into the head and claims the URL
7
+ * (`moduleScriptResources[url] = null`, react-dom 19.3.0
8
+ * `react-dom-server.edge.production.js:546-565`) before the render starts, so
9
+ * no `preloadModule`/`preinitModule` call can replace it, and the
10
+ * `bootstrapModules` entry shape has no priority field. Chromium fetches that
11
+ * hint at Low, and once it boosts in-viewport images to High after first
12
+ * layout, the entry queues behind them; the executing end-of-shell module
13
+ * script reuses the in-flight Low fetch through the module map. Hydration
14
+ * then waits on a few-hundred-byte file while the head chunk scripts it
15
+ * imports (High) have long arrived: measured +3.8 s on a priority-honouring
16
+ * HTTP/2 link at Slow 4G.
17
+ *
18
+ * The fix serves that one tag without the attribute, so it fetches at
19
+ * Chromium's modulepreload default (High), and moves it after the head
20
+ * chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
21
+ * High in that position it took one of HTTP/1.1's six connections ahead of
22
+ * the react chunk and delayed the last head chunk (hydration +91 ms, LCP
23
+ * +84 ms measured). After the scripts it is the last High request in the
24
+ * queue, so it never displaces a chunk hydration also needs. The accepted
25
+ * cost: an HTTP/2 server that sends equal-priority streams first in, first
26
+ * out delivers it with the last head chunk instead of before it (text-only
27
+ * pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
28
+ * pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
29
+ * stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
30
+ * keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
31
+ * PPR prelude/resume behaviour.
32
+ *
33
+ * The match is the exact tag Fizz writes for a string `bootstrapModules`
34
+ * entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
35
+ * pattern, and only before the first `<body`. A Fizz version that writes it
36
+ * differently leaves the bytes untouched: the output keeps the Low hint.
37
+ *
38
+ * Where the tag goes: right after the run of Fizz resource tags that directly
39
+ * follows the hint, before the first token that is anything else (`</head>`,
40
+ * the app's own head children, or body content in a head-less document).
41
+ * Fizz flushes the head chunk scripts and the bulk preloads straight after
42
+ * the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
43
+ * as `<script …></script>` with no body and `<link …/>`; the scan accepts
44
+ * only those two shapes and stops at any other token without reading into it.
45
+ * It never searches the app's head content for `</head>` or `<body`: React
46
+ * does not escape either inside `<script>` or `<style>` text, so a head script
47
+ * holding the string `"<body>"` once received the tag inside its source and
48
+ * threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
49
+ * Every insertion point is the boundary between two complete tags; a stream
50
+ * that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
51
+ * tag before the unfinished one. Only that one tag is held back while the run
52
+ * streams; each complete run tag is forwarded as soon as it is scanned.
53
+ */
54
+
55
+ const LOW_PRIORITY_ATTR = ' fetchPriority="low"';
56
+ const encoder = new TextEncoder();
57
+ const BODY_OPEN = encoder.encode("<body");
58
+ const LINK_OPEN = encoder.encode("<link");
59
+ const SCRIPT_OPEN = encoder.encode("<script");
60
+ const SCRIPT_CLOSE = encoder.encode("</script>");
61
+ const GT = 0x3e;
62
+
63
+ /**
64
+ * Longest run tag held while its end has not arrived. Fizz's chunk scripts
65
+ * and preload links are a few hundred bytes; past this the tag goes before
66
+ * the held one, which keeps the per-chunk work bounded.
67
+ */
68
+ const MAX_HELD_TAG = 8192;
69
+
70
+ /** React's escapeTextForBrowser: the attribute escaping Fizz applies. */
71
+ function escapeAttr(value: string): string {
72
+ return value.replace(/["&'<>]/g, (c) =>
73
+ c === '"'
74
+ ? "&quot;"
75
+ : c === "&"
76
+ ? "&amp;"
77
+ : c === "'"
78
+ ? "&#x27;"
79
+ : c === "<"
80
+ ? "&lt;"
81
+ : "&gt;",
82
+ );
83
+ }
84
+
85
+ interface EntryHintBytes {
86
+ low: Uint8Array;
87
+ auto: Uint8Array;
88
+ }
89
+
90
+ function entryHintBytes(
91
+ entryUrl: string,
92
+ nonce: string | undefined,
93
+ ): EntryHintBytes {
94
+ const tail =
95
+ (nonce ? ` nonce="${escapeAttr(nonce)}"` : "") +
96
+ ` href="${escapeAttr(entryUrl)}"/>`;
97
+ return {
98
+ low: encoder.encode(`<link rel="modulepreload"${LOW_PRIORITY_ATTR}${tail}`),
99
+ auto: encoder.encode(`<link rel="modulepreload"${tail}`),
100
+ };
101
+ }
102
+
103
+ function indexOfBytes(
104
+ haystack: Uint8Array,
105
+ needle: Uint8Array,
106
+ from = 0,
107
+ ): number {
108
+ const first = needle[0];
109
+ const last = haystack.length - needle.length;
110
+ outer: for (let i = from; i <= last; i++) {
111
+ if (haystack[i] !== first) continue;
112
+ for (let j = 1; j < needle.length; j++) {
113
+ if (haystack[i + j] !== needle[j]) continue outer;
114
+ }
115
+ return i;
116
+ }
117
+ return -1;
118
+ }
119
+
120
+ /**
121
+ * Length of the longest suffix of `buf` that is a proper prefix of `needle`:
122
+ * the bytes a chunk boundary may have split off a match.
123
+ */
124
+ function partialSuffixLength(buf: Uint8Array, needle: Uint8Array): number {
125
+ const max = Math.min(buf.length, needle.length - 1);
126
+ outer: for (let len = max; len > 0; len--) {
127
+ const start = buf.length - len;
128
+ for (let j = 0; j < len; j++) {
129
+ if (buf[start + j] !== needle[j]) continue outer;
130
+ }
131
+ return len;
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
137
+ const out = new Uint8Array(a.length + b.length);
138
+ out.set(a, 0);
139
+ out.set(b, a.length);
140
+ return out;
141
+ }
142
+
143
+ /** Index of the hint in `buf` when it precedes the first `<body`, else -1. */
144
+ function findHint(buf: Uint8Array, hint: EntryHintBytes): number {
145
+ const bodyAt = indexOfBytes(buf, BODY_OPEN);
146
+ return indexOfBytes(bodyAt === -1 ? buf : buf.subarray(0, bodyAt), hint.low);
147
+ }
148
+
149
+ /**
150
+ * 1 when `needle` starts at `pos`, 0 when it does not, -1 when `buf` ends
151
+ * before that can be decided (the available bytes match a prefix).
152
+ */
153
+ function matchAt(buf: Uint8Array, pos: number, needle: Uint8Array): number {
154
+ const n = Math.min(needle.length, buf.length - pos);
155
+ for (let j = 0; j < n; j++) {
156
+ if (buf[pos + j] !== needle[j]) return 0;
157
+ }
158
+ return n === needle.length ? 1 : -1;
159
+ }
160
+
161
+ /** HTML whitespace, `/` or `>`: the byte that ends a tag name. */
162
+ function endsTagName(byte: number): boolean {
163
+ return (
164
+ byte === 0x20 ||
165
+ byte === 0x09 ||
166
+ byte === 0x0a ||
167
+ byte === 0x0c ||
168
+ byte === 0x0d ||
169
+ byte === 0x2f ||
170
+ byte === GT
171
+ );
172
+ }
173
+
174
+ interface RunScan {
175
+ /** The run ended: `at` is the first byte of the token after it. */
176
+ ended: boolean;
177
+ /** Insertion point, or the start of the unfinished token when not ended. */
178
+ at: number;
179
+ }
180
+
181
+ /**
182
+ * Skip the run of Fizz resource tags at the start of `buf`: `<link …>` and
183
+ * `<script …></script>` with nothing between the two tags.
184
+ */
185
+ function scanRun(buf: Uint8Array): RunScan {
186
+ let pos = 0;
187
+ while (pos < buf.length) {
188
+ const link = matchAt(buf, pos, LINK_OPEN);
189
+ const script = link === 1 ? 0 : matchAt(buf, pos, SCRIPT_OPEN);
190
+ if (link !== 1 && script !== 1) {
191
+ return { ended: link === 0 && script === 0, at: pos };
192
+ }
193
+ const nameEnd = pos + (link === 1 ? LINK_OPEN.length : SCRIPT_OPEN.length);
194
+ if (nameEnd >= buf.length) return { ended: false, at: pos };
195
+ if (!endsTagName(buf[nameEnd]!)) return { ended: true, at: pos };
196
+ const gt = buf.indexOf(GT, nameEnd);
197
+ if (gt === -1) return { ended: false, at: pos };
198
+ let end = gt + 1;
199
+ if (script === 1) {
200
+ const close = matchAt(buf, end, SCRIPT_CLOSE);
201
+ // An inline script body: the hint goes before the <script, never in it.
202
+ if (close !== 1) return { ended: close === 0, at: pos };
203
+ end += SCRIPT_CLOSE.length;
204
+ }
205
+ pos = end;
206
+ }
207
+ return { ended: false, at: pos };
208
+ }
209
+
210
+ /** `rest` with `hint` inserted at `at`. */
211
+ function insertAt(rest: Uint8Array, at: number, hint: Uint8Array): Uint8Array {
212
+ const out = new Uint8Array(rest.length + hint.length);
213
+ out.set(rest.subarray(0, at), 0);
214
+ out.set(hint, at);
215
+ out.set(rest.subarray(at), at + hint.length);
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Rewrite a complete document prefix (the PPR shell capture's prelude).
221
+ * Returns `bytes` unchanged when no hint precedes the first `<body`.
222
+ */
223
+ export function rewriteEntryPreloadPriority(
224
+ bytes: Uint8Array,
225
+ entryUrl: string,
226
+ nonce?: string,
227
+ ): Uint8Array {
228
+ const hint = entryHintBytes(entryUrl, nonce);
229
+ const at = findHint(bytes, hint);
230
+ if (at === -1) return bytes;
231
+ const rest = bytes.subarray(at + hint.low.length);
232
+ return concat(
233
+ bytes.subarray(0, at),
234
+ insertAt(rest, scanRun(rest).at, hint.auto),
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Streaming form for live SSR: the same rewrite as
240
+ * {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
241
+ * found, only a chunk tail that may be the start of a split `<body` or hint
242
+ * tag is held back. After it, complete run tags are forwarded as they are
243
+ * scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
244
+ * held, so every byte is scanned a bounded number of times. Once the tag is
245
+ * placed, or once `<body` passes without a hint, chunks are forwarded
246
+ * untouched and unscanned.
247
+ */
248
+ export function entryPreloadPriorityTransform(
249
+ entryUrl: string,
250
+ nonce?: string,
251
+ ): TransformStream<Uint8Array, Uint8Array> {
252
+ const hint = entryHintBytes(entryUrl, nonce);
253
+ let carry: Uint8Array | null = null;
254
+ // The unfinished run tag after the removed hint (possibly empty).
255
+ let held: Uint8Array | null = null;
256
+ let done = false;
257
+ const place = (
258
+ rest: Uint8Array,
259
+ controller: TransformStreamDefaultController<Uint8Array>,
260
+ ): void => {
261
+ const scan = scanRun(rest);
262
+ if (scan.ended || rest.length - scan.at > MAX_HELD_TAG) {
263
+ done = true;
264
+ held = null;
265
+ controller.enqueue(insertAt(rest, scan.at, hint.auto));
266
+ return;
267
+ }
268
+ if (scan.at > 0) controller.enqueue(rest.slice(0, scan.at));
269
+ held = rest.slice(scan.at);
270
+ };
271
+ return new TransformStream<Uint8Array, Uint8Array>({
272
+ transform(chunk, controller) {
273
+ if (done) {
274
+ controller.enqueue(chunk);
275
+ return;
276
+ }
277
+ if (held) {
278
+ place(concat(held, chunk), controller);
279
+ return;
280
+ }
281
+ const buf: Uint8Array = carry ? concat(carry, chunk) : chunk;
282
+ carry = null;
283
+ const at = findHint(buf, hint);
284
+ if (at !== -1) {
285
+ if (at > 0) controller.enqueue(buf.slice(0, at));
286
+ place(buf.subarray(at + hint.low.length), controller);
287
+ return;
288
+ }
289
+ if (indexOfBytes(buf, BODY_OPEN) !== -1) {
290
+ done = true;
291
+ controller.enqueue(buf);
292
+ return;
293
+ }
294
+ const hold = Math.max(
295
+ partialSuffixLength(buf, hint.low),
296
+ partialSuffixLength(buf, BODY_OPEN),
297
+ );
298
+ if (hold > 0) carry = buf.slice(buf.length - hold);
299
+ const out = hold > 0 ? buf.subarray(0, buf.length - hold) : buf;
300
+ if (out.length > 0) controller.enqueue(out);
301
+ },
302
+ flush(controller) {
303
+ if (carry) controller.enqueue(carry);
304
+ // The stream ended inside the run: the tag goes before the unfinished one.
305
+ if (held) controller.enqueue(concat(hint.auto, held));
306
+ },
307
+ });
308
+ }