/** * Structural splitter for large OpenAPI documents written in clean block YAML. * * The whole-document parse of a 37MB spec (Microsoft Graph: 16.5k operations, * 8.2k schemas) builds a ~300MB JS tree that OOMs the 128MB Cloudflare Workers * isolate. This splitter avoids ever holding that tree: it scans the text once, * recording the byte range of each top-level key, each path-item (the indent-2 * entries under `paths:`), and each schema (the indent-4 entries under * `components.schemas:`). The streaming compile then slices one item at a time, * de-indents it back to column 0, hands the isolated fragment to a real YAML * parser, and discards the result before moving on. Peak memory stays near the * size of the largest single item plus the raw text, not the parsed whole. * * This is not a YAML reimplementation: it extracts safe byte ranges from a * constrained document shape, then defers every actual parse to `js-yaml`. It * is only valid for the block-YAML profile `isStreamableSpec` accepts (2-space * block maps, top-level keys at column 0, no anchors/aliases/merge keys). Block * scalars (`|` / `>`) in descriptions are tracked so their indented content is * never mistaken for structure. */ import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml"; export interface ByteRange { readonly start: number; readonly end: number;} export interface SpecStructure { /** Raw spec text. Every range below indexes into this string. */ readonly text: string; /** Byte ranges of the top-level keys that are not `paths` / `components` * (openapi, info, servers, tags, security, ...). Concatenated and parsed as * one small document for the head (servers, info). */ readonly headRanges: readonly ByteRange[]; /** One range per path-item: the indent-2 entries under `paths:`. */ readonly pathItems: readonly ByteRange[]; /** One range per schema entry: the indent-4 entries under * `components.schemas:`. */ readonly schemas: readonly ByteRange[]; /** Ranges of the indent-2 `components` subkeys we keep whole because they are * small and may be `$ref`'d by a kept operation (parameters / requestBodies / * responses / headers / links / securitySchemes). Excludes the huge `schemas` * (streamed and pruned separately) and `examples` (never referenced by a * binding). */ readonly smallComponentRanges: readonly ByteRange[];} const SPACE = 32;const HASH = 35;const DASH = 45; /** `components` subkeys kept whole during a reduce: small, and a kept operation * may `$ref` into any of them. `schemas` is streamed/pruned separately and * `examples` is dropped (large, never referenced by a binding). */const SMALL_COMPONENT_SECTIONS = new Set([ "parameters", "requestBodies", "responses", "headers", "links", "securitySchemes",]); /** Count of leading spaces on the line starting at `lineStart`. */const indentOf = (text: string, lineStart: number, lineEnd: number): number => { let i = lineStart; while (i < lineEnd && text.charCodeAt(i) === SPACE) i++; return i - lineStart;}; /** True for a blank (whitespace-only) or comment line. */const isBlankOrComment = (text: string, contentStart: number, lineEnd: number): boolean => contentStart >= lineEnd || text.charCodeAt(contentStart) === HASH; /** * True when the line opens a block scalar (`key: |`, `key: >-`, etc.), meaning * every following more-indented line is literal content, not structure. */const opensBlockScalar = (line: string): boolean => /:\s*[|>][+-]?\d*\s*(#.*)?$/.test(line) || /^\s*[|>][+-]?\d*\s*(#.*)?$/.test(line); interface LineCursor { readonly lineStart: number; readonly lineEnd: number; readonly nextStart: number; readonly indent: number; readonly contentStart: number;} /** Advance to the next significant line at offset `from`, returning null at EOF. * Blank lines, comments, and block-scalar content (lines indented deeper than * the scalar key, tracked by the caller) are still returned; the caller skips * them. */const lineAt = (text: string, from: number, limit: number): LineCursor | null => { if (from >= limit) return null; let lineEnd = text.indexOf("\n", from); if (lineEnd === -1 || lineEnd > limit) lineEnd = limit; const indent = indentOf(text, from, lineEnd); const contentStart = from + indent; return { lineStart: from, lineEnd, nextStart: lineEnd + 1, indent, contentStart, };}; /** * Find the start offset of every key line at exactly `indent` within * `[start, end)`, skipping blank/comment lines, deeper child lines, and * block-scalar content. Sequence items (`- `) at the target indent are not * treated as keys. Returns offsets in document order. */const keyLineStartsAtIndent = ( text: string, start: number, end: number, indent: number,): number[] => { const starts: number[] = []; let blockScalarIndent = -1; let pos = start; while (pos < end) { const line = lineAt(text, pos, end); if (!line) break; pos = line.nextStart; if (isBlankOrComment(text, line.contentStart, line.lineEnd)) continue; if (blockScalarIndent >= 0) { if (line.indent > blockScalarIndent) continue; blockScalarIndent = -1; } if (line.indent === indent && text.charCodeAt(line.contentStart) !== DASH) { starts.push(line.lineStart); } if (line.indent <= indent && opensBlockScalar(text.slice(line.contentStart, line.lineEnd))) { blockScalarIndent = line.indent; } } return starts;}; /** Contiguous ranges from a sorted list of block-start offsets within * `[blockStart, blockEnd)`: each range runs to the next start (or blockEnd). */const rangesFromStarts = (starts: readonly number[], blockEnd: number): ByteRange[] => starts.map((s, i) => ({ start: s, end: i + 1 < starts.length ? starts[i + 1]! : blockEnd })); /** The simple (unquoted) key name on a key line, e.g. `paths`, `schemas`. */const keyNameAt = (text: string, lineStart: number, lineEnd: number): string => { const indent = indentOf(text, lineStart, lineEnd); const colon = text.indexOf(":", lineStart + indent); const keyEnd = colon === -1 || colon > lineEnd ? lineEnd : colon; return text.slice(lineStart + indent, keyEnd).trim();}; /** * Scan a document into its structural ranges. Pure and synchronous; never * parses. Returns null when the document does not present the expected shape * (no `paths:` block), so the caller can fall back to a whole-document parse. */export const structuralSplit = (text: string): SpecStructure | null => { const len = text.length; // Top-level keys (column 0). Record each key's start + name, respecting // block scalars whose content could sit at column >= 1. const topStarts: number[] = []; const topNames: string[] = []; { let blockScalarIndent = -1; let pos = 0; while (pos < len) { const line = lineAt(text, pos, len); if (!line) break; pos = line.nextStart; if (isBlankOrComment(text, line.contentStart, line.lineEnd)) continue; if (blockScalarIndent >= 0) { if (line.indent > blockScalarIndent) continue; blockScalarIndent = -1; } if (line.indent === 0) { topStarts.push(line.lineStart); topNames.push(keyNameAt(text, line.lineStart, line.lineEnd)); } if (opensBlockScalar(text.slice(line.contentStart, line.lineEnd))) { blockScalarIndent = line.indent; } } } const topRanges = rangesFromStarts(topStarts, len); const pathsIdx = topNames.indexOf("paths"); if (pathsIdx === -1) return null; const componentsIdx = topNames.indexOf("components"); const headRanges: ByteRange[] = []; for (let i = 0; i < topRanges.length; i++) { if (i === pathsIdx || i === componentsIdx) continue; headRanges.push(topRanges[i]!); } // Path-items: indent-2 keys inside the `paths:` block (after its key line). const pathsRange = topRanges[pathsIdx]!; const pathsBodyStart = lineAt(text, pathsRange.start, pathsRange.end)?.nextStart ?? pathsRange.end; const pathItems = rangesFromStarts( keyLineStartsAtIndent(text, pathsBodyStart, pathsRange.end, 2), pathsRange.end, ); const schemas: ByteRange[] = []; const smallComponentRanges: ByteRange[] = []; if (componentsIdx !== -1) { const componentsRange = topRanges[componentsIdx]!; const componentsBodyStart = lineAt(text, componentsRange.start, componentsRange.end)?.nextStart ?? componentsRange.end; const subStarts = keyLineStartsAtIndent(text, componentsBodyStart, componentsRange.end, 2); const subRanges = rangesFromStarts(subStarts, componentsRange.end); for (const range of subRanges) { const name = keyNameAt(text, range.start, lineAt(text, range.start, range.end)!.lineEnd); if (name === "schemas") { const bodyStart = lineAt(text, range.start, range.end)?.nextStart ?? range.end; for (const s of rangesFromStarts( keyLineStartsAtIndent(text, bodyStart, range.end, 4), range.end, )) { schemas.push(s); } } else if (SMALL_COMPONENT_SECTIONS.has(name)) { smallComponentRanges.push(range); } // `examples` and any other subkey are intentionally dropped: no binding // field references them, and they can be large. } } return { text, headRanges, pathItems, schemas, smallComponentRanges };}; /** Strip exactly `indent` leading spaces from every line of `fragment`, lifting * an indent-N block back to column 0 so it parses as a standalone document. */const dedent = (fragment: string, indent: number): string => indent === 0 ? fragment : fragment.replace(new RegExp(`^ {1,${indent}}`, "gm"), ""); const parseYaml = (text: string): unknown => parseYamlDocument(text, { json: true, schema: JSON_SCHEMA }); /** * Parse a single indent-N entry range (a path-item or a schema) in isolation. * Returns `[name, value]` where `name` is the entry's key and `value` its * parsed body, or null when the fragment does not parse to a single-key map. */export const parseEntry = ( text: string, range: ByteRange, indent: number,): readonly [string, unknown] | null => { const parsed = parseYaml(dedent(text.slice(range.start, range.end), indent)); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; const entries = Object.entries(parsed as Record<string, unknown>); if (entries.length !== 1) return null; return entries[0]!;}; /** Parse the concatenation of the head ranges into the document head (openapi, * info, servers, tags). Small; safe to materialize whole. */export const parseHead = (structure: SpecStructure): Record<string, unknown> => { const text = structure.headRanges.map((r) => structure.text.slice(r.start, r.end)).join(""); const parsed = parseYaml(text); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};}; /** Parse the small component subkeys (parameters / requestBodies / responses) * into a `components`-shaped object for `$ref` resolution. */export const parseSmallComponents = (structure: SpecStructure): Record<string, unknown> => { if (structure.smallComponentRanges.length === 0) return {}; const body = structure.smallComponentRanges .map((r) => structure.text.slice(r.start, r.end)) .join(""); const parsed = parseYaml(`components:\n${body}`); const components = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>).components : null; return components && typeof components === "object" ? (components as Record<string, unknown>) : {};}; /** * Accept only the block-YAML profile the splitter can safely slice: no tabs, no * anchors/aliases, no merge keys, and a `paths:` block present. Block scalars * are allowed (tracked during the scan). Conservative by design: a false * negative just routes a spec through the whole-document parse. */export const isStreamableSpec = (text: string): boolean => { if (text.indexOf("\t") !== -1) return false; // Anchors/aliases/merge keys: cheap substring rejects before structural work. if (/(^|\s)[&*][A-Za-z0-9_]/m.test(text)) return false; if (/<<\s*:/.test(text)) return false; return /^paths:/m.test(text);}; const SCHEMA_REF_PREFIX = "#/components/schemas/"; /** Map each `components.schemas` entry name to its byte range, reading only the * key line (never parsing the schema body). The schema name is the raw YAML * key, which matches the trailing segment of a `#/components/schemas/<name>` * reference. */export const indexSchemas = (structure: SpecStructure): ReadonlyMap<string, ByteRange> => { const index = new Map<string, ByteRange>(); for (const range of structure.schemas) { const line = lineAt(structure.text, range.start, range.end); if (!line) continue; const name = keyNameAt(structure.text, range.start, line.lineEnd); if (name) index.set(name, range); } return index;}; /** Decode a `#/components/schemas/<segment>` name segment. Schema names are raw * YAML keys, so only JSON-pointer tilde escaping can appear (no `%`-encoding * in the wild specs we target); decode `~1`/`~0` and leave the rest verbatim. */const decodeSchemaRefName = (segment: string): string => segment.replace(/~1/g, "/").replace(/~0/g, "~"); /** Collect the names of every `#/components/schemas/X` reference reachable in * `value`, without resolving them. */const collectSchemaRefNames = (value: unknown, into: Set<string>): void => { if (typeof value === "string") { if (value.startsWith(SCHEMA_REF_PREFIX)) { const name = decodeSchemaRefName(value.slice(SCHEMA_REF_PREFIX.length)); if (name.length > 0) into.add(name); } return; } if (Array.isArray(value)) { for (const item of value) collectSchemaRefNames(item, into); return; } if (value !== null && typeof value === "object") { for (const item of Object.values(value as Record<string, unknown>)) { collectSchemaRefNames(item, into); } }}; /** * Resolve the transitive closure of schemas referenced by `roots`, parsing each * referenced schema once from its byte range (BFS over `$ref`s). Schemas not * reachable from `roots` are never parsed, so peak memory tracks the kept * subset rather than the full `components.schemas` map. */export const collectReferencedSchemas = ( structure: SpecStructure, index: ReadonlyMap<string, ByteRange>, roots: readonly unknown[],): Record<string, unknown> => { const result: Record<string, unknown> = {}; const wanted = new Set<string>(); for (const root of roots) collectSchemaRefNames(root, wanted); const queue = [...wanted]; for (let i = 0; i < queue.length; i += 1) { const name = queue[i]!; if (Object.prototype.hasOwnProperty.call(result, name)) continue; const range = index.get(name); if (!range) continue; const entry = parseEntry(structure.text, range, 4); if (!entry) continue; result[name] = entry[1]; const next = new Set<string>(); collectSchemaRefNames(entry[1], next); for (const ref of next) { if (!Object.prototype.hasOwnProperty.call(result, ref)) queue.push(ref); } } return result;}; /** A path-item filter for the streaming compile: given a parsed path-item, * return the (possibly trimmed) value to keep, or null to drop the path * entirely. Applied per path-item by `streamOperationBindingsFromStructure`. */export type KeepPathItem = ( path: string, pathItem: Record<string, unknown>,) => Record<string, unknown> | null;
/**
* Structural splitter for large OpenAPI documents written in clean block YAML.
*
* The whole-document parse of a 37MB spec (Microsoft Graph: 16.5k operations,
* 8.2k schemas) builds a ~300MB JS tree that OOMs the 128MB Cloudflare Workers
* isolate. This splitter avoids ever holding that tree: it scans the text once,
* recording the byte range of each top-level key, each path-item (the indent-2
* entries under `paths:`), and each schema (the indent-4 entries under
* `components.schemas:`). The streaming compile then slices one item at a time,
* de-indents it back to column 0, hands the isolated fragment to a real YAML
* parser, and discards the result before moving on. Peak memory stays near the
* size of the largest single item plus the raw text, not the parsed whole.
*
* This is not a YAML reimplementation: it extracts safe byte ranges from a
* constrained document shape, then defers every actual parse to `js-yaml`. It
* is only valid for the block-YAML profile `isStreamableSpec` accepts (2-space
* block maps, top-level keys at column 0, no anchors/aliases/merge keys). Block
* scalars (`|` / `>`) in descriptions are tracked so their indented content is
* never mistaken for structure.
*/
import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml";
export interface ByteRange {
readonly start: number;
readonly end: number;
}
export interface SpecStructure {
/** Raw spec text. Every range below indexes into this string. */
readonly text: string;
/** Byte ranges of the top-level keys that are not `paths` / `components`
* (openapi, info, servers, tags, security, ...). Concatenated and parsed as
* one small document for the head (servers, info). */
readonly headRanges: readonly ByteRange[];
/** One range per path-item: the indent-2 entries under `paths:`. */
readonly pathItems: readonly ByteRange[];
/** One range per schema entry: the indent-4 entries under
* `components.schemas:`. */
readonly schemas: readonly ByteRange[];
/** Ranges of the indent-2 `components` subkeys we keep whole because they are
* small and may be `$ref`'d by a kept operation (parameters / requestBodies /
* responses / headers / links / securitySchemes). Excludes the huge `schemas`
* (streamed and pruned separately) and `examples` (never referenced by a
* binding). */
readonly smallComponentRanges: readonly ByteRange[];
}
const SPACE = 32;
const HASH = 35;
const DASH = 45;
/** `components` subkeys kept whole during a reduce: small, and a kept operation
* may `$ref` into any of them. `schemas` is streamed/pruned separately and
* `examples` is dropped (large, never referenced by a binding). */
const SMALL_COMPONENT_SECTIONS = new Set([
"parameters",
"requestBodies",
"responses",
"headers",
"links",
"securitySchemes",
]);
/** Count of leading spaces on the line starting at `lineStart`. */
const indentOf = (text: string, lineStart: number, lineEnd: number): number => {
let i = lineStart;
while (i < lineEnd && text.charCodeAt(i) === SPACE) i++;
return i - lineStart;
};
/** True for a blank (whitespace-only) or comment line. */
const isBlankOrComment = (text: string, contentStart: number, lineEnd: number): boolean =>
contentStart >= lineEnd || text.charCodeAt(contentStart) === HASH;
/**
* True when the line opens a block scalar (`key: |`, `key: >-`, etc.), meaning
* every following more-indented line is literal content, not structure.
*/
const opensBlockScalar = (line: string): boolean =>
/:\s*[|>][+-]?\d*\s*(#.*)?$/.test(line) || /^\s*[|>][+-]?\d*\s*(#.*)?$/.test(line);
interface LineCursor {
readonly lineStart: number;
readonly lineEnd: number;
readonly nextStart: number;
readonly indent: number;
readonly contentStart: number;
}
/** Advance to the next significant line at offset `from`, returning null at EOF.
* Blank lines, comments, and block-scalar content (lines indented deeper than
* the scalar key, tracked by the caller) are still returned; the caller skips
* them. */
const lineAt = (text: string, from: number, limit: number): LineCursor | null => {
if (from >= limit) return null;
let lineEnd = text.indexOf("\n", from);
if (lineEnd === -1 || lineEnd > limit) lineEnd = limit;
const indent = indentOf(text, from, lineEnd);
const contentStart = from + indent;
return {
lineStart: from,
lineEnd,
nextStart: lineEnd + 1,
indent,
contentStart,
};
};
/**
* Find the start offset of every key line at exactly `indent` within
* `[start, end)`, skipping blank/comment lines, deeper child lines, and
* block-scalar content. Sequence items (`- `) at the target indent are not
* treated as keys. Returns offsets in document order.
*/
const keyLineStartsAtIndent = (
text: string,
start: number,
end: number,
indent: number,
): number[] => {
const starts: number[] = [];
let blockScalarIndent = -1;
let pos = start;
while (pos < end) {
const line = lineAt(text, pos, end);
if (!line) break;
pos = line.nextStart;
if (isBlankOrComment(text, line.contentStart, line.lineEnd)) continue;
if (blockScalarIndent >= 0) {
if (line.indent > blockScalarIndent) continue;
blockScalarIndent = -1;
}
if (line.indent === indent && text.charCodeAt(line.contentStart) !== DASH) {
starts.push(line.lineStart);
}
if (line.indent <= indent && opensBlockScalar(text.slice(line.contentStart, line.lineEnd))) {
blockScalarIndent = line.indent;
}
}
return starts;
};
/** Contiguous ranges from a sorted list of block-start offsets within
* `[blockStart, blockEnd)`: each range runs to the next start (or blockEnd). */
const rangesFromStarts = (starts: readonly number[], blockEnd: number): ByteRange[] =>
starts.map((s, i) => ({ start: s, end: i + 1 < starts.length ? starts[i + 1]! : blockEnd }));
/** The simple (unquoted) key name on a key line, e.g. `paths`, `schemas`. */
const keyNameAt = (text: string, lineStart: number, lineEnd: number): string => {
const indent = indentOf(text, lineStart, lineEnd);
const colon = text.indexOf(":", lineStart + indent);
const keyEnd = colon === -1 || colon > lineEnd ? lineEnd : colon;
return text.slice(lineStart + indent, keyEnd).trim();
};
/**
* Scan a document into its structural ranges. Pure and synchronous; never
* parses. Returns null when the document does not present the expected shape
* (no `paths:` block), so the caller can fall back to a whole-document parse.
*/
export const structuralSplit = (text: string): SpecStructure | null => {
const len = text.length;
// Top-level keys (column 0). Record each key's start + name, respecting
// block scalars whose content could sit at column >= 1.
const topStarts: number[] = [];
const topNames: string[] = [];
{
let blockScalarIndent = -1;
let pos = 0;
while (pos < len) {
const line = lineAt(text, pos, len);
if (!line) break;
pos = line.nextStart;
if (isBlankOrComment(text, line.contentStart, line.lineEnd)) continue;
if (blockScalarIndent >= 0) {
if (line.indent > blockScalarIndent) continue;
blockScalarIndent = -1;
}
if (line.indent === 0) {
topStarts.push(line.lineStart);
topNames.push(keyNameAt(text, line.lineStart, line.lineEnd));
}
if (opensBlockScalar(text.slice(line.contentStart, line.lineEnd))) {
blockScalarIndent = line.indent;
}
}
}
const topRanges = rangesFromStarts(topStarts, len);
const pathsIdx = topNames.indexOf("paths");
if (pathsIdx === -1) return null;
const componentsIdx = topNames.indexOf("components");
const headRanges: ByteRange[] = [];
for (let i = 0; i < topRanges.length; i++) {
if (i === pathsIdx || i === componentsIdx) continue;
headRanges.push(topRanges[i]!);
}
// Path-items: indent-2 keys inside the `paths:` block (after its key line).
const pathsRange = topRanges[pathsIdx]!;
const pathsBodyStart =
lineAt(text, pathsRange.start, pathsRange.end)?.nextStart ?? pathsRange.end;
const pathItems = rangesFromStarts(
keyLineStartsAtIndent(text, pathsBodyStart, pathsRange.end, 2),
pathsRange.end,
);
const schemas: ByteRange[] = [];
const smallComponentRanges: ByteRange[] = [];
if (componentsIdx !== -1) {
const componentsRange = topRanges[componentsIdx]!;
const componentsBodyStart =
lineAt(text, componentsRange.start, componentsRange.end)?.nextStart ?? componentsRange.end;
const subStarts = keyLineStartsAtIndent(text, componentsBodyStart, componentsRange.end, 2);
const subRanges = rangesFromStarts(subStarts, componentsRange.end);
for (const range of subRanges) {
const name = keyNameAt(text, range.start, lineAt(text, range.start, range.end)!.lineEnd);
if (name === "schemas") {
const bodyStart = lineAt(text, range.start, range.end)?.nextStart ?? range.end;
for (const s of rangesFromStarts(
keyLineStartsAtIndent(text, bodyStart, range.end, 4),
range.end,
)) {
schemas.push(s);
}
} else if (SMALL_COMPONENT_SECTIONS.has(name)) {
smallComponentRanges.push(range);
}
// `examples` and any other subkey are intentionally dropped: no binding
// field references them, and they can be large.
}
}
return { text, headRanges, pathItems, schemas, smallComponentRanges };
};
/** Strip exactly `indent` leading spaces from every line of `fragment`, lifting
* an indent-N block back to column 0 so it parses as a standalone document. */
const dedent = (fragment: string, indent: number): string =>
indent === 0 ? fragment : fragment.replace(new RegExp(`^ {1,${indent}}`, "gm"), "");
const parseYaml = (text: string): unknown =>
parseYamlDocument(text, { json: true, schema: JSON_SCHEMA });
/**
* Parse a single indent-N entry range (a path-item or a schema) in isolation.
* Returns `[name, value]` where `name` is the entry's key and `value` its
* parsed body, or null when the fragment does not parse to a single-key map.
*/
export const parseEntry = (
text: string,
range: ByteRange,
indent: number,
): readonly [string, unknown] | null => {
const parsed = parseYaml(dedent(text.slice(range.start, range.end), indent));
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const entries = Object.entries(parsed as Record<string, unknown>);
if (entries.length !== 1) return null;
return entries[0]!;
};
/** Parse the concatenation of the head ranges into the document head (openapi,
* info, servers, tags). Small; safe to materialize whole. */
export const parseHead = (structure: SpecStructure): Record<string, unknown> => {
const text = structure.headRanges.map((r) => structure.text.slice(r.start, r.end)).join("");
const parsed = parseYaml(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
};
/** Parse the small component subkeys (parameters / requestBodies / responses)
* into a `components`-shaped object for `$ref` resolution. */
export const parseSmallComponents = (structure: SpecStructure): Record<string, unknown> => {
if (structure.smallComponentRanges.length === 0) return {};
const body = structure.smallComponentRanges
.map((r) => structure.text.slice(r.start, r.end))
.join("");
const parsed = parseYaml(`components:\n${body}`);
const components =
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>).components : null;
return components && typeof components === "object"
? (components as Record<string, unknown>)
: {};
};
/**
* Accept only the block-YAML profile the splitter can safely slice: no tabs, no
* anchors/aliases, no merge keys, and a `paths:` block present. Block scalars
* are allowed (tracked during the scan). Conservative by design: a false
* negative just routes a spec through the whole-document parse.
*/
export const isStreamableSpec = (text: string): boolean => {
if (text.indexOf("\t") !== -1) return false;
// Anchors/aliases/merge keys: cheap substring rejects before structural work.
if (/(^|\s)[&*][A-Za-z0-9_]/m.test(text)) return false;
if (/<<\s*:/.test(text)) return false;
return /^paths:/m.test(text);
};
const SCHEMA_REF_PREFIX = "#/components/schemas/";
/** Map each `components.schemas` entry name to its byte range, reading only the
* key line (never parsing the schema body). The schema name is the raw YAML
* key, which matches the trailing segment of a `#/components/schemas/<name>`
* reference. */
export const indexSchemas = (structure: SpecStructure): ReadonlyMap<string, ByteRange> => {
const index = new Map<string, ByteRange>();
for (const range of structure.schemas) {
const line = lineAt(structure.text, range.start, range.end);
if (!line) continue;
const name = keyNameAt(structure.text, range.start, line.lineEnd);
if (name) index.set(name, range);
}
return index;
};
/** Decode a `#/components/schemas/<segment>` name segment. Schema names are raw
* YAML keys, so only JSON-pointer tilde escaping can appear (no `%`-encoding
* in the wild specs we target); decode `~1`/`~0` and leave the rest verbatim. */
const decodeSchemaRefName = (segment: string): string =>
segment.replace(/~1/g, "/").replace(/~0/g, "~");
/** Collect the names of every `#/components/schemas/X` reference reachable in
* `value`, without resolving them. */
const collectSchemaRefNames = (value: unknown, into: Set<string>): void => {
if (typeof value === "string") {
if (value.startsWith(SCHEMA_REF_PREFIX)) {
const name = decodeSchemaRefName(value.slice(SCHEMA_REF_PREFIX.length));
if (name.length > 0) into.add(name);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) collectSchemaRefNames(item, into);
return;
}
if (value !== null && typeof value === "object") {
for (const item of Object.values(value as Record<string, unknown>)) {
collectSchemaRefNames(item, into);
}
}
};
/**
* Resolve the transitive closure of schemas referenced by `roots`, parsing each
* referenced schema once from its byte range (BFS over `$ref`s). Schemas not
* reachable from `roots` are never parsed, so peak memory tracks the kept
* subset rather than the full `components.schemas` map.
*/
export const collectReferencedSchemas = (
structure: SpecStructure,
index: ReadonlyMap<string, ByteRange>,
roots: readonly unknown[],
): Record<string, unknown> => {
const result: Record<string, unknown> = {};
const wanted = new Set<string>();
for (const root of roots) collectSchemaRefNames(root, wanted);
const queue = [...wanted];
for (let i = 0; i < queue.length; i += 1) {
const name = queue[i]!;
if (Object.prototype.hasOwnProperty.call(result, name)) continue;
const range = index.get(name);
if (!range) continue;
const entry = parseEntry(structure.text, range, 4);
if (!entry) continue;
result[name] = entry[1];
const next = new Set<string>();
collectSchemaRefNames(entry[1], next);
for (const ref of next) {
if (!Object.prototype.hasOwnProperty.call(result, ref)) queue.push(ref);
}
}
return result;
};
/** A path-item filter for the streaming compile: given a parsed path-item,
* return the (possibly trimmed) value to keep, or null to drop the path
* entirely. Applied per path-item by `streamOperationBindingsFromStructure`. */
export type KeepPathItem = (
path: string,
pathItem: Record<string, unknown>,
) => Record<string, unknown> | null;