/**
* Suspense-style out-of-order streaming for SSR.
*
* Renders the synchronous shell with `defer(...)` placeholders wrapped in
* `…` markers, flushes that initial chunk,
* then streams a `…` plus a tiny inline
* patch script for every resolved promise. The patch script swaps the
* template content into the placeholder and removes both.
*
* Honours `SSRContext.signal` for cancellation and propagates the context
* nonce onto every emitted `` injection, validating the prefix at the boundary is defense in
* depth. Allows ASCII letters, digits, `-` and `_`.
*/
const SAFE_ID_RE = /^[A-Za-z][\w-]*$/;
/** Enforces a lowercase custom-element-style tag name with a required hyphen. */
const SAFE_SLOT_TAG_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
const sanitizeSlotPrefix = (prefix: string, fallback: string): string => {
if (typeof prefix !== 'string' || !SAFE_ID_RE.test(prefix)) return fallback;
return prefix;
};
const sanitizeSlotTag = (tag: string | undefined, fallback: string): string => {
if (typeof tag !== 'string' || !SAFE_SLOT_TAG_RE.test(tag)) return fallback;
return tag;
};
/**
* Derives a template ID prefix that cannot collide with placeholder slot IDs.
* The default `bq-s` becomes `bq-r`; custom prefixes without that suffix get
* `-r` appended, so `slot` produces `slot-r` instead of a duplicate `slot`.
* Prefixes already ending in `-r` get `-template` to avoid `-r-r`.
*/
const getResolvedIdPrefix = (slotIdPrefix: string): string => {
const candidate = slotIdPrefix.replace(/-s$/, '-r');
if (candidate === slotIdPrefix && slotIdPrefix.endsWith('-r')) {
return `${slotIdPrefix}-template`;
}
return candidate === slotIdPrefix ? `${slotIdPrefix}-r` : candidate;
};
/**
* Build a synchronous rendering context where every `defer(...)` value is
* replaced by a placeholder string and every other Promise/loader is
* replaced by its fallback (`undefined`). The deferred values are recorded
* so the streaming loop can resolve them after the shell flushes.
*/
const splitDeferred = (
context: BindingContext,
prefix: string
): { syncContext: BindingContext; slots: SuspenseSlot[] } => {
const syncContext: BindingContext = {};
const slots: SuspenseSlot[] = [];
let counter = 0;
for (const [key, value] of Object.entries(context)) {
if (isReactive(value)) {
syncContext[key] = value as Signal;
continue;
}
if (isDeferredLike(value)) {
const id = `${prefix}-${counter++}`;
slots.push({ id, key, promise: value.promise });
// Render with the fallback so the synchronous shell has *something*.
syncContext[key] = value.fallback;
continue;
}
if (value && typeof (value as Promise).then === 'function') {
// Bare promises become deferred slots without a fallback.
const id = `${prefix}-${counter++}`;
slots.push({ id, key, promise: value as Promise });
syncContext[key] = undefined;
continue;
}
syncContext[key] = value;
}
return { syncContext, slots };
};
const escapeHtml = (s: string): string =>
s.replace(/&/g, '&').replace(//g, '>');
const escapeAttr = (s: string): string => escapeHtml(s).replace(/"/g, '"');
const escapeScriptBody = (s: string): string =>
s
.replace(/<\/(script)/gi, '<\\/$1')
.replace(/