@poe-platform/safe-fs 0.1.811 → 0.1.813

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/README.md CHANGED
@@ -10,6 +10,11 @@ working data. Supply `{fs, cwd, env, signal}` and an optional page-cache count
10
10
  `env.TMPDIR` or `cwd`, with conditional unlink and cleanup through `close()`.
11
11
  Providers must support those capabilities; there is no host fallback. Use an
12
12
  external backend for large data because memory filesystems retain spilled bytes.
13
+ To bound aggregate pages across multiple live stores, pass the same
14
+ `new PagedStorageCache(16)` as the third argument to each `PagedStorage` constructor.
15
+ This shares a 256 KiB resident-page budget; `cache.residentBytes` reports current
16
+ page retention. Eviction uses each store's filesystem, and each store still owns
17
+ its `close()` cleanup. The budget excludes caller buffers and backend storage.
13
18
  `IntegerTable.entries()` replays records in unsigned key order with a fixed
14
19
  16-level node stack and batches of at most 128 records. It accepts zero-filled
15
20
  caller storage with `allocate`, `read` and `write`; mutation during traversal
@@ -8,8 +8,8 @@ export * from "./fs/object-publication/index.js";
8
8
  export * from "./fs/webdav/index.js";
9
9
  export * from "./bridge/index.js";
10
10
  export * from "./python/index.js";
11
- export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
12
- export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
11
+ export { parseXml, parseXmlSteps, parseXmlSourceSteps, normalizeXmlChunks, parseXmlStream, XmlLimitError } from "./xml.js";
12
+ export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits, XmlParserFrame, XmlFrameRequest } from "./xml.js";
13
13
  export * from "./contracts/object.js";
14
14
  export { ObjectAuthority } from "./fs/object-authority.js";
15
15
  export * from "./fs/s3/index.js";
@@ -18,4 +18,4 @@ export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./
18
18
  export type { FileSystemConfig, FileSystemAdapterDescriptor, FileSystemAdapterRegistry } from "./config.js";
19
19
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
20
20
  export { readFileStream } from "./fs/read-file-stream.js";
21
- export { PagedStorage, IntegerTable, type PagedStorageContext } from "./storage.js";
21
+ export { PagedStorage, PagedStorageCache, IntegerTable, type PagedStorageContext } from "./storage.js";
@@ -8,7 +8,7 @@ export * from "./fs/object-publication/index.js";
8
8
  export * from "./fs/webdav/index.js";
9
9
  export * from "./bridge/index.js";
10
10
  export * from "./python/index.js";
11
- export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
11
+ export { parseXml, parseXmlSteps, parseXmlSourceSteps, normalizeXmlChunks, parseXmlStream, XmlLimitError } from "./xml.js";
12
12
  export * from "./contracts/object.js";
13
13
  export { ObjectAuthority } from "./fs/object-authority.js";
14
14
  export * from "./fs/s3/index.js";
@@ -16,4 +16,4 @@ export * from "./fs/s3/http/index.js";
16
16
  export { createFileSystem, readConfigRecord, validateFileSystemConfig } from "./config.js";
17
17
  export { createPortableFileSystemAdapterRegistry } from "./config.portable.js";
18
18
  export { readFileStream } from "./fs/read-file-stream.js";
19
- export { PagedStorage, IntegerTable } from "./storage.js";
19
+ export { PagedStorage, PagedStorageCache, IntegerTable } from "./storage.js";
@@ -5,12 +5,31 @@ export interface PagedStorageContext {
5
5
  readonly env: Readonly<Record<string, string | undefined>>;
6
6
  readonly signal: AbortSignal;
7
7
  }
8
+ type Page = {
9
+ bytes: Uint8Array;
10
+ dirty: boolean;
11
+ };
12
+ /** Shares one resident-page limit across stores, retaining each store's caller
13
+ * filesystem and cleanup ownership. Serializes cache users through eviction IO. */
14
+ export declare class PagedStorageCache {
15
+ private readonly maxPages;
16
+ private readonly pages;
17
+ private active;
18
+ constructor(maxPages: number);
19
+ get residentBytes(): number;
20
+ run<T>(action: () => Promise<T>): Promise<T>;
21
+ acquire(): Promise<Page>;
22
+ retain(page: Page, release: () => Promise<void>): void;
23
+ touch(page: Page): void;
24
+ forget(page: Page): void;
25
+ }
8
26
  /** A bounded page cache backed only by the caller filesystem. The default is
9
27
  * one MiB; callers own close() on every outcome. Memory filesystems still retain
10
28
  * spilled data in RAM, so large workloads require an external backing provider. */
11
29
  export declare class PagedStorage {
12
30
  private readonly context;
13
31
  private readonly maxPages;
32
+ private readonly cache?;
14
33
  private readonly pages;
15
34
  private readonly controller;
16
35
  private readonly signal;
@@ -23,7 +42,7 @@ export declare class PagedStorage {
23
42
  private end;
24
43
  private active;
25
44
  private closing;
26
- constructor(context: PagedStorageContext, maxPages?: number);
45
+ constructor(context: PagedStorageContext, maxPages?: number, cache?: PagedStorageCache | undefined);
27
46
  allocate(length: number): number;
28
47
  private operation;
29
48
  private spill;
@@ -55,3 +74,4 @@ export declare class IntegerTable {
55
74
  private lookup;
56
75
  private persist;
57
76
  }
77
+ export {};
@@ -1,12 +1,50 @@
1
1
  import { FsError, isFsError } from "./contracts/errors.js";
2
2
  const pageBytes = 16 * 1024;
3
3
  let serial = 0;
4
+ /** Shares one resident-page limit across stores, retaining each store's caller
5
+ * filesystem and cleanup ownership. Serializes cache users through eviction IO. */
6
+ export class PagedStorageCache {
7
+ maxPages;
8
+ pages = new Map();
9
+ active = Promise.resolve();
10
+ constructor(maxPages) {
11
+ this.maxPages = maxPages;
12
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1)
13
+ throw new RangeError("Invalid storage page cache size");
14
+ }
15
+ get residentBytes() { return this.pages.size * pageBytes; }
16
+ run(action) {
17
+ const work = this.active.then(action);
18
+ this.active = work.then(() => undefined, () => undefined);
19
+ return work;
20
+ }
21
+ async acquire() {
22
+ if (this.pages.size < this.maxPages)
23
+ return { bytes: new Uint8Array(pageBytes), dirty: false };
24
+ const [page, release] = this.pages.entries().next().value;
25
+ await release();
26
+ this.pages.delete(page);
27
+ page.bytes.fill(0);
28
+ return page;
29
+ }
30
+ retain(page, release) {
31
+ this.pages.delete(page);
32
+ this.pages.set(page, release);
33
+ }
34
+ touch(page) {
35
+ const release = this.pages.get(page);
36
+ this.pages.delete(page);
37
+ this.pages.set(page, release);
38
+ }
39
+ forget(page) { this.pages.delete(page); }
40
+ }
4
41
  /** A bounded page cache backed only by the caller filesystem. The default is
5
42
  * one MiB; callers own close() on every outcome. Memory filesystems still retain
6
43
  * spilled data in RAM, so large workloads require an external backing provider. */
7
44
  export class PagedStorage {
8
45
  context;
9
46
  maxPages;
47
+ cache;
10
48
  pages = new Map();
11
49
  controller = new AbortController();
12
50
  signal;
@@ -19,9 +57,10 @@ export class PagedStorage {
19
57
  end = 8; // Zero is the absent-pointer sentinel.
20
58
  active = Promise.resolve();
21
59
  closing;
22
- constructor(context, maxPages = 64) {
60
+ constructor(context, maxPages = 64, cache) {
23
61
  this.context = context;
24
62
  this.maxPages = maxPages;
63
+ this.cache = cache;
25
64
  if (!Number.isSafeInteger(maxPages) || maxPages < 1)
26
65
  throw new RangeError("Invalid storage page cache size");
27
66
  this.signal = AbortSignal.any([context.signal, this.controller.signal]);
@@ -36,10 +75,11 @@ export class PagedStorage {
36
75
  }
37
76
  operation(action) {
38
77
  this.signal.throwIfAborted();
39
- const work = this.active.then(() => {
78
+ const execute = () => {
40
79
  this.signal.throwIfAborted();
41
80
  return action();
42
- });
81
+ };
82
+ const work = this.active.then(() => this.cache ? this.cache.run(execute) : execute());
43
83
  this.active = work.then(() => undefined, () => undefined);
44
84
  return work;
45
85
  }
@@ -101,6 +141,7 @@ export class PagedStorage {
101
141
  if (cached) {
102
142
  this.pages.delete(number);
103
143
  this.pages.set(number, cached);
144
+ this.cache?.touch(cached);
104
145
  return cached;
105
146
  }
106
147
  let reusable;
@@ -110,11 +151,14 @@ export class PagedStorage {
110
151
  const [oldNumber, oldPage] = this.pages.entries().next().value;
111
152
  await this.flush(oldNumber, oldPage);
112
153
  this.pages.delete(oldNumber);
154
+ this.cache?.forget(oldPage);
113
155
  reusable = oldPage;
114
156
  }
115
157
  // Flush owns the bytes until its awaited write completes. Reuse only then;
116
158
  // zero new/unwritten ranges so the previous page cannot leak into them.
117
- const page = reusable ?? { bytes: new Uint8Array(pageBytes), dirty: false };
159
+ const page = reusable ?? (this.cache ? await this.cache.acquire() : { bytes: new Uint8Array(pageBytes), dirty: false });
160
+ // Shared eviction may await IO owned by a different, still-live signal.
161
+ this.signal.throwIfAborted();
118
162
  if (reusable)
119
163
  page.bytes.fill(0);
120
164
  if (number * pageBytes < this.diskLength) {
@@ -128,6 +172,12 @@ export class PagedStorage {
128
172
  }
129
173
  }
130
174
  this.pages.set(number, page);
175
+ this.cache?.retain(page, async () => {
176
+ if (!this.descriptor)
177
+ await this.spill();
178
+ await this.flush(number, page);
179
+ this.pages.delete(number);
180
+ });
131
181
  return page;
132
182
  }
133
183
  write(position, bytes) {
@@ -168,28 +218,33 @@ export class PagedStorage {
168
218
  this.controller.abort(new FsError("ECANCELED"));
169
219
  return this.closing ??= (async () => {
170
220
  await this.active.catch(() => { });
171
- this.pages.clear();
172
- const descriptor = this.descriptor;
173
- if (!descriptor)
174
- return;
175
- const cancellation = new FsError("ECANCELED");
176
- try {
177
- if (!this.unlinked) {
178
- const expected = this.initial ?? await descriptor.stat();
179
- await this.context.fs.removeFileConditional(this.path, { parent: this.parent, expected }).catch(error => {
180
- if (!isFsError(error) || error.code !== "ENOENT")
221
+ const retire = async () => {
222
+ for (const page of this.pages.values())
223
+ this.cache?.forget(page);
224
+ this.pages.clear();
225
+ const descriptor = this.descriptor;
226
+ if (!descriptor)
227
+ return;
228
+ const cancellation = new FsError("ECANCELED");
229
+ try {
230
+ if (!this.unlinked) {
231
+ const expected = this.initial ?? await descriptor.stat();
232
+ await this.context.fs.removeFileConditional(this.path, { parent: this.parent, expected }).catch(error => {
233
+ if (!isFsError(error) || error.code !== "ENOENT")
234
+ throw error;
235
+ });
236
+ }
237
+ }
238
+ finally {
239
+ // Conditional descriptors must discard their private staged writes, not
240
+ // publish the scratch data when the command retires its handle.
241
+ await descriptor.close({ signal: AbortSignal.abort(cancellation) }).catch(error => {
242
+ if (error !== cancellation)
181
243
  throw error;
182
244
  });
183
245
  }
184
- }
185
- finally {
186
- // Conditional descriptors must discard their private staged writes, not
187
- // publish the scratch data when the command retires its handle.
188
- await descriptor.close({ signal: AbortSignal.abort(cancellation) }).catch(error => {
189
- if (error !== cancellation)
190
- throw error;
191
- });
192
- }
246
+ };
247
+ await (this.cache ? this.cache.run(retire) : retire());
193
248
  })();
194
249
  }
195
250
  }
@@ -0,0 +1,3 @@
1
+ export declare function validCharacter(point: number): boolean;
2
+ /** Validate scalar values and normalize XML line endings in fixed windows. */
3
+ export declare function normalizeXmlChunks(source: AsyncIterable<string> | Iterable<string>, flushChunks?: boolean): AsyncGenerator<string>;
@@ -0,0 +1,53 @@
1
+ export function validCharacter(point) {
2
+ return point === 9 || point === 10 || point === 13
3
+ || (point >= 0x20 && point <= 0xd7ff)
4
+ || (point >= 0xe000 && point <= 0xfffd)
5
+ || (point >= 0x10000 && point <= 0x10ffff);
6
+ }
7
+ /** Validate scalar values and normalize XML line endings in fixed windows. */
8
+ export async function* normalizeXmlChunks(source, flushChunks = false) {
9
+ let first = true, afterCR = false, high = 0, output = '';
10
+ for await (const chunk of source) {
11
+ for (let index = 0; index < chunk.length; index++) {
12
+ const code = chunk.charCodeAt(index);
13
+ if (first) {
14
+ first = false;
15
+ if (code === 0xfeff)
16
+ continue;
17
+ }
18
+ if (high) {
19
+ if (code < 0xdc00 || code > 0xdfff)
20
+ throw new SyntaxError('Invalid XML: invalid character');
21
+ output += String.fromCharCode(high, code);
22
+ high = 0;
23
+ }
24
+ else {
25
+ if (afterCR) {
26
+ afterCR = false;
27
+ if (code === 10)
28
+ continue;
29
+ }
30
+ if (code >= 0xd800 && code <= 0xdbff) {
31
+ high = code;
32
+ continue;
33
+ }
34
+ if (!validCharacter(code))
35
+ throw new SyntaxError('Invalid XML: invalid character');
36
+ afterCR = code === 13;
37
+ output += code === 13 ? '\n' : chunk[index];
38
+ }
39
+ if (output.length >= 512) {
40
+ yield output;
41
+ output = '';
42
+ }
43
+ }
44
+ if (flushChunks && output) {
45
+ yield output;
46
+ output = '';
47
+ }
48
+ }
49
+ if (high)
50
+ throw new SyntaxError('Invalid XML: invalid character');
51
+ if (output)
52
+ yield output;
53
+ }
@@ -0,0 +1,28 @@
1
+ import type { XmlContent, XmlElement } from './index.js';
2
+ export interface XmlParserFrame {
3
+ element: XmlElement;
4
+ content: XmlContent[] | undefined;
5
+ name: string;
6
+ namespaces: Map<string, string>;
7
+ }
8
+ export type XmlFrameRequest = {
9
+ readonly frameOperation: 'push';
10
+ readonly frame: XmlParserFrame;
11
+ } | {
12
+ readonly frameOperation: 'pop';
13
+ } | {
14
+ readonly frameOperation: 'peek';
15
+ frame?: XmlParserFrame;
16
+ };
17
+ /** External mode caches only the current frame. The host owns the linked stack. */
18
+ export declare class XmlFrames {
19
+ private readonly external;
20
+ private readonly frames;
21
+ private depth;
22
+ private current;
23
+ constructor(external: boolean);
24
+ get length(): number;
25
+ peek(): Generator<XmlFrameRequest, XmlParserFrame | undefined, void>;
26
+ push(frame: XmlParserFrame): Generator<XmlFrameRequest, void, void>;
27
+ pop(): Generator<XmlFrameRequest, XmlParserFrame | undefined, void>;
28
+ }
@@ -0,0 +1,45 @@
1
+ /** External mode caches only the current frame. The host owns the linked stack. */
2
+ export class XmlFrames {
3
+ external;
4
+ frames = [];
5
+ depth = 0;
6
+ current;
7
+ constructor(external) {
8
+ this.external = external;
9
+ }
10
+ get length() { return this.external ? this.depth : this.frames.length; }
11
+ *peek() {
12
+ if (!this.external)
13
+ return this.frames.at(-1);
14
+ if (!this.depth)
15
+ return undefined;
16
+ if (!this.current) {
17
+ const request = { frameOperation: 'peek' };
18
+ yield request;
19
+ if (!request.frame)
20
+ throw new TypeError('Incomplete XML frame read');
21
+ this.current = request.frame;
22
+ }
23
+ return this.current;
24
+ }
25
+ *push(frame) {
26
+ if (!this.external) {
27
+ this.frames.push(frame);
28
+ return;
29
+ }
30
+ yield { frameOperation: 'push', frame };
31
+ this.current = frame;
32
+ this.depth++;
33
+ }
34
+ *pop() {
35
+ if (!this.external)
36
+ return this.frames.pop();
37
+ if (!this.depth)
38
+ return undefined;
39
+ const frame = yield* this.peek();
40
+ yield { frameOperation: 'pop' };
41
+ this.depth--;
42
+ this.current = undefined;
43
+ return frame;
44
+ }
45
+ }
@@ -1,3 +1,9 @@
1
+ import { type XmlFrameRequest } from "./frames.js";
2
+ export type { XmlParserFrame, XmlFrameRequest } from "./frames.js";
3
+ export { normalizeXmlChunks } from "./characters.js";
4
+ import { type XmlSourceStep } from "./source.js";
5
+ export type { XmlSourceRead, XmlSourceStep } from "./source.js";
6
+ import type { XmlStreamEvent } from "./stream.js";
1
7
  export { XmlLimitError } from "./errors.js";
2
8
  export interface XmlName {
3
9
  readonly name: string;
@@ -40,6 +46,26 @@ export interface XmlLimits {
40
46
  readonly maxTextLength?: number;
41
47
  readonly onElement?: (element: XmlName, parent: XmlName | undefined, depth: number) => void;
42
48
  }
43
- export declare function parseXmlSteps(input: string, limits?: XmlLimits): Generator<number, XmlElement, void>;
49
+ export interface XmlStepLimits extends XmlLimits {
50
+ /** Return root metadata without retaining descendants or text. */
51
+ readonly retainTree?: boolean;
52
+ /** Synchronous ordered events, including repaired closes. Requires retainTree:
53
+ * false. Drive the step iterator to await external consumers between windows. */
54
+ readonly events?: (event: XmlStreamEvent) => void;
55
+ }
56
+ export interface XmlSourceLimits extends XmlStepLimits {
57
+ /** Emit text and CDATA in bounded fragments, marking continuations of the same logical
58
+ * node. Requires retainTree: false; consumers must preserve fragment identity. */
59
+ readonly fragmentContent?: boolean;
60
+ /** Store active parser frames through host-serviced requests. Requires retainTree: false. */
61
+ readonly storeFrames?: boolean;
62
+ }
63
+ export type XmlParseStep = XmlSourceStep | XmlFrameRequest;
64
+ /** Parse a validated, BOM-free, line-normalized UTF-16 source via bounded read requests. */
65
+ export declare function parseXmlSourceSteps(length: number | undefined, limits?: XmlStepLimits & {
66
+ readonly storeFrames?: false;
67
+ }): Generator<XmlSourceStep, XmlElement, void>;
68
+ export declare function parseXmlSourceSteps(length: number | undefined, limits: XmlSourceLimits): Generator<XmlParseStep, XmlElement, void>;
69
+ export declare function parseXmlSteps(input: string, limits?: XmlStepLimits): Generator<number, XmlElement, void>;
44
70
  export declare function parseXml(input: string, limits?: XmlLimits): XmlElement;
45
71
  export { parseXmlStream, type XmlStreamLimits, type XmlStreamEvent } from "./stream.js";
@@ -1,7 +1,11 @@
1
+ import { XmlFrames } from "./frames.js";
2
+ import { validCharacter } from "./characters.js";
3
+ export { normalizeXmlChunks } from "./characters.js";
4
+ import { XmlSource } from "./source.js";
1
5
  import { XmlLimitError } from "./errors.js";
2
6
  export { XmlLimitError } from "./errors.js";
3
7
  function* find(source, needle, start) {
4
- const found = source.indexOf(needle, start);
8
+ const found = typeof source === "string" ? source.indexOf(needle, start) : yield* source.indexOf(needle, start);
5
9
  const end = found < 0 ? source.length : found;
6
10
  let remaining = end - start;
7
11
  while (remaining >= 512) {
@@ -17,12 +21,6 @@ const xmlnsNamespace = "http://www.w3.org/2000/xmlns/";
17
21
  function invalid(message) {
18
22
  throw new SyntaxError(`Invalid XML: ${message}`);
19
23
  }
20
- function validCharacter(point) {
21
- return point === 9 || point === 10 || point === 13
22
- || (point >= 0x20 && point <= 0xd7ff)
23
- || (point >= 0xe000 && point <= 0xfffd)
24
- || (point >= 0x10000 && point <= 0x10ffff);
25
- }
26
24
  function nameStart(point) {
27
25
  return point === 95 || (point >= 65 && point <= 90) || (point >= 97 && point <= 122)
28
26
  || (point >= 0xc0 && point <= 0xd6) || (point >= 0xd8 && point <= 0xf6)
@@ -69,6 +67,95 @@ function qualifiedNameSync(name, cache) {
69
67
  }
70
68
  return parts;
71
69
  }
70
+ function* entity(source, start, end, recover) {
71
+ // Inspect only a bounded prefix, even for arbitrarily long numeric references.
72
+ const prefix = typeof source === "string" ? source.slice(start, Math.min(end, start + 4))
73
+ : yield* source.slice(start, Math.min(end, start + 4));
74
+ const predefined = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
75
+ if (end - start <= 4 && Object.hasOwn(predefined, prefix))
76
+ return predefined[prefix];
77
+ const hexadecimal = prefix.startsWith("#x"), digits = start + (hexadecimal ? 2 : 1);
78
+ if (!prefix.startsWith("#") || digits >= end) {
79
+ if (!recover)
80
+ invalid("undeclared entity");
81
+ recover("undeclared entity");
82
+ return "";
83
+ }
84
+ let point = 0;
85
+ for (let index = digits; index < end; index++) {
86
+ const code = typeof source === "string" ? source.charCodeAt(index) : yield* source.charCodeAt(index);
87
+ const digit = code >= 48 && code <= 57 ? code - 48 : hexadecimal && code >= 65 && code <= 70 ? code - 55
88
+ : hexadecimal && code >= 97 && code <= 102 ? code - 87 : -1;
89
+ if (digit < 0)
90
+ invalid("undeclared entity");
91
+ point = point * (hexadecimal ? 16 : 10) + digit;
92
+ if (point > 0x10ffff)
93
+ invalid("invalid character reference");
94
+ if ((index - digits + 1) % 512 === 0)
95
+ yield 512;
96
+ }
97
+ if ((end - digits) % 512)
98
+ yield (end - digits) % 512;
99
+ if (!validCharacter(point))
100
+ invalid("invalid character reference");
101
+ return String.fromCodePoint(point);
102
+ }
103
+ /** Decoded text fragments; source read/work requests remain host-serviced. */
104
+ function* textParts(source, start, end, decode, recover) {
105
+ let cursor = start;
106
+ while (cursor < end) {
107
+ let finish = Math.min(end, cursor + 512);
108
+ if (finish < end) {
109
+ const last = yield* source.charCodeAt(finish - 1);
110
+ if (last >= 0xd800 && last <= 0xdbff)
111
+ finish--;
112
+ }
113
+ const raw = yield* source.slice(cursor, finish);
114
+ const found = decode ? raw.indexOf("&") : -1;
115
+ if (found < 0) {
116
+ yield raw;
117
+ yield raw.length;
118
+ cursor = finish;
119
+ continue;
120
+ }
121
+ const amp = cursor + found;
122
+ if (found) {
123
+ yield raw.slice(0, found);
124
+ yield found;
125
+ }
126
+ const semicolon = yield* source.indexOf(";", amp + 1, end);
127
+ if (semicolon < 0) {
128
+ if (!recover)
129
+ invalid("unterminated entity");
130
+ recover("unterminated entity");
131
+ cursor = amp + 1;
132
+ decode = false;
133
+ continue;
134
+ }
135
+ const part = yield* entity(source, amp + 1, semicolon, recover);
136
+ if (part)
137
+ yield part;
138
+ yield 1;
139
+ cursor = semicolon + 1;
140
+ }
141
+ }
142
+ function* textFragments(source, start, end, decode, recover) {
143
+ let pending = "";
144
+ for (const part of textParts(source, start, end, decode, recover)) {
145
+ if (typeof part !== "string") {
146
+ yield part;
147
+ continue;
148
+ }
149
+ pending += part;
150
+ while (pending.length >= 512) {
151
+ const last = pending.charCodeAt(511), width = last >= 0xd800 && last <= 0xdbff ? 511 : 512;
152
+ yield pending.slice(0, width);
153
+ pending = pending.slice(width);
154
+ }
155
+ }
156
+ if (pending)
157
+ yield pending;
158
+ }
72
159
  function* entities(text, recover) {
73
160
  let result = "";
74
161
  let offset = 0;
@@ -84,39 +171,7 @@ function* entities(text, recover) {
84
171
  recover("unterminated entity");
85
172
  return result + text.slice(start + 1);
86
173
  }
87
- const entity = text.slice(start + 1, end);
88
- const predefined = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
89
- if (Object.hasOwn(predefined, entity))
90
- result += predefined[entity];
91
- else {
92
- const hexadecimal = entity.startsWith("#x");
93
- const digits = entity.slice(hexadecimal ? 2 : 1);
94
- if (!entity.startsWith("#") || !digits.length) {
95
- if (!recover)
96
- invalid("undeclared entity");
97
- recover("undeclared entity");
98
- offset = end + 1;
99
- continue;
100
- }
101
- let point = 0;
102
- for (let index = 0; index < digits.length; index++) {
103
- const code = digits.charCodeAt(index);
104
- const digit = code >= 48 && code <= 57 ? code - 48 : hexadecimal && code >= 65 && code <= 70 ? code - 55
105
- : hexadecimal && code >= 97 && code <= 102 ? code - 87 : -1;
106
- if (digit < 0)
107
- invalid("undeclared entity");
108
- point = point * (hexadecimal ? 16 : 10) + digit;
109
- if (point > 0x10ffff)
110
- invalid("invalid character reference");
111
- if ((index + 1) % 512 === 0)
112
- yield 512;
113
- }
114
- if (digits.length % 512)
115
- yield digits.length % 512;
116
- if (!validCharacter(point))
117
- invalid("invalid character reference");
118
- result += String.fromCodePoint(point);
119
- }
174
+ result += yield* entity(text, start + 1, end, recover);
120
175
  offset = end + 1;
121
176
  }
122
177
  return result;
@@ -182,68 +237,43 @@ function* validDeclaration(content, expectedEncoding) {
182
237
  }
183
238
  return offset === content.length;
184
239
  }
185
- export function* parseXmlSteps(input, limits = {}) {
240
+ function validateLimits(limits) {
241
+ for (const limit of [limits.maxDepth, limits.maxNodes, limits.maxAttributes, limits.maxContentNodes, limits.maxAttributesPerElement, limits.maxNamespaces]) {
242
+ if (limit !== undefined && ((limit !== Infinity && !Number.isSafeInteger(limit)) || limit < 1))
243
+ throw new RangeError("XML limits must be positive integers");
244
+ }
245
+ if (limits.maxTextLength !== undefined && ((limits.maxTextLength !== Infinity && !Number.isSafeInteger(limits.maxTextLength)) || limits.maxTextLength < 1))
246
+ throw new RangeError("XML limits must be positive integers");
247
+ if (limits.events && limits.retainTree !== false)
248
+ throw new TypeError("XML events require retainTree: false");
249
+ }
250
+ export function* parseXmlSourceSteps(length, limits = {}) {
251
+ validateLimits(limits);
252
+ if (limits.storeFrames && limits.retainTree !== false)
253
+ throw new TypeError("Stored XML frames require retainTree: false");
254
+ if (limits.fragmentContent && limits.retainTree !== false)
255
+ throw new TypeError("XML content fragments require retainTree: false");
256
+ const source = new XmlSource(length);
186
257
  const maxDepth = limits.maxDepth ?? Infinity;
187
258
  const maxNodes = limits.maxNodes ?? Infinity;
188
259
  const maxAttributes = limits.maxAttributes ?? Infinity;
189
260
  const retainContent = limits.retainContent !== false;
261
+ const retainTree = limits.retainTree !== false;
262
+ const retain = retainTree && retainContent;
190
263
  const maxContentNodes = limits.maxContentNodes ?? Infinity;
191
264
  const emptyContent = Object.freeze([]);
192
265
  const emptyAttributes = Object.freeze([]);
193
266
  const emptyNamespaces = new Map();
194
267
  const maxAttributesPerElement = limits.maxAttributesPerElement ?? Infinity;
195
268
  const maxNamespaces = limits.maxNamespaces ?? Infinity;
196
- for (const limit of [limits.maxDepth, limits.maxNodes, limits.maxAttributes, limits.maxContentNodes, limits.maxAttributesPerElement, limits.maxNamespaces]) {
197
- if (limit !== undefined && ((limit !== Infinity && !Number.isSafeInteger(limit)) || limit < 1))
198
- throw new RangeError("XML limits must be positive integers");
199
- }
200
269
  const maxTextLength = limits.maxTextLength ?? Infinity;
201
- if (limits.maxTextLength !== undefined && ((maxTextLength !== Infinity && !Number.isSafeInteger(maxTextLength)) || maxTextLength < 1))
202
- throw new RangeError("XML limits must be positive integers");
203
270
  let textLength = 0;
204
271
  const admitText = (text) => {
205
272
  if (text.length > maxTextLength - textLength)
206
273
  throw new XmlLimitError("maxTextLength", "XML text limit exceeded");
207
274
  textLength += text.length;
208
275
  };
209
- const chunks = [];
210
- const start = input.charCodeAt(0) === 0xfeff ? 1 : 0;
211
- let chunkStart = start;
212
- let chunkLength = 0;
213
- let normalizedChunk = "";
214
- // Keep validated spans intact; only changed line endings need new strings.
215
- for (let index = start; index < input.length; index++) {
216
- const point = input.codePointAt(index);
217
- if (!validCharacter(point))
218
- invalid("invalid character");
219
- if (point === 13) {
220
- normalizedChunk += input.slice(chunkStart, index) + "\n";
221
- if (input.charCodeAt(index + 1) === 10)
222
- index++;
223
- chunkStart = index + 1;
224
- chunkLength++;
225
- }
226
- else {
227
- chunkLength += point > 0xffff ? 2 : 1;
228
- if (point > 0xffff)
229
- index++;
230
- }
231
- if (chunkLength >= 512) {
232
- if (normalizedChunk) {
233
- chunks.push(normalizedChunk + input.slice(chunkStart, index + 1));
234
- normalizedChunk = "";
235
- chunkStart = index + 1;
236
- }
237
- chunkLength = 0;
238
- yield 512;
239
- }
240
- }
241
- if (chunkLength)
242
- yield chunkLength;
243
- if (chunks.length || normalizedChunk)
244
- chunks.push(normalizedChunk + input.slice(chunkStart));
245
- const source = chunks.length ? chunks.join("") : input.slice(start);
246
- const stack = [];
276
+ const stack = new XmlFrames(limits.storeFrames === true);
247
277
  let root;
248
278
  const prolog = [];
249
279
  const epilog = [];
@@ -261,8 +291,8 @@ export function* parseXmlSteps(input, limits = {}) {
261
291
  let pendingWork = 0;
262
292
  const skipWhitespace = function* () {
263
293
  const start = offset;
264
- while (offset < source.length) {
265
- const c = source.charCodeAt(offset);
294
+ while (yield* source.has(offset)) {
295
+ const c = (yield* source.charCodeAt(offset));
266
296
  if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
267
297
  break;
268
298
  offset++;
@@ -276,8 +306,8 @@ export function* parseXmlSteps(input, limits = {}) {
276
306
  };
277
307
  const scanName = function* () {
278
308
  const start = offset;
279
- while (offset < source.length) {
280
- const c = source.charCodeAt(offset);
309
+ while (yield* source.has(offset)) {
310
+ const c = (yield* source.charCodeAt(offset));
281
311
  if (c === 32 || c === 9 || c === 13 || c === 10 || c === 47 || c === 61 || c === 62 || c === 63)
282
312
  break;
283
313
  offset++;
@@ -287,7 +317,7 @@ export function* parseXmlSteps(input, limits = {}) {
287
317
  pendingWork -= 512;
288
318
  }
289
319
  }
290
- const name = source.slice(start, offset);
320
+ const name = (yield* source.slice(start, offset));
291
321
  pendingWork += offset - start;
292
322
  while (pendingWork >= 512) {
293
323
  yield 512;
@@ -296,7 +326,7 @@ export function* parseXmlSteps(input, limits = {}) {
296
326
  const [prefix, localName] = qualifiedNameSync(name, qualifiedNames);
297
327
  return [name, prefix, localName];
298
328
  };
299
- while (offset < source.length) {
329
+ while (yield* source.has(offset)) {
300
330
  pendingWork += 1;
301
331
  if (pendingWork >= 512) {
302
332
  while (pendingWork >= 512) {
@@ -304,10 +334,53 @@ export function* parseXmlSteps(input, limits = {}) {
304
334
  pendingWork -= 512;
305
335
  }
306
336
  }
307
- if (source.charCodeAt(offset) !== 60) {
308
- const next = source.indexOf("<", offset);
337
+ if ((yield* source.charCodeAt(offset)) !== 60) {
338
+ const next = (yield* source.indexOf("<", offset));
309
339
  const endPos = next < 0 ? source.length : next;
310
- const text = source.slice(offset, endPos);
340
+ if (limits.fragmentContent) {
341
+ if ((yield* source.indexOf("]]>", offset, endPos)) >= 0)
342
+ invalid("CDATA terminator in text");
343
+ const parent = yield* stack.peek();
344
+ let size = 0, outsideText = false;
345
+ // Validate before emitting any part of the logical node. This preserves
346
+ // entity/recovery diagnostics and limit precedence without retaining it.
347
+ for (const part of textFragments(source, offset, endPos, !!parent, limits.recover)) {
348
+ if (typeof part !== "string") {
349
+ yield part;
350
+ continue;
351
+ }
352
+ size += part.length;
353
+ if (!parent)
354
+ for (const character of part) {
355
+ if (!" \t\r\n".includes(character))
356
+ outsideText = true;
357
+ }
358
+ }
359
+ if (size > maxTextLength - textLength)
360
+ throw new XmlLimitError("maxTextLength", "XML text limit exceeded");
361
+ textLength += size;
362
+ if (outsideText)
363
+ invalid("text outside the root");
364
+ if (size) {
365
+ admitContent();
366
+ if (limits.events) {
367
+ let continuation = false;
368
+ for (const part of textFragments(source, offset, endPos, !!parent, limits.recover ? () => { } : undefined)) {
369
+ if (typeof part !== "string") {
370
+ yield part;
371
+ continue;
372
+ }
373
+ limits.events({ type: "content", content: { kind: "text", text: part }, parent: parent?.element,
374
+ ...(continuation ? { continuation: true } : {}) });
375
+ continuation = true;
376
+ yield 1;
377
+ }
378
+ }
379
+ }
380
+ offset = endPos;
381
+ continue;
382
+ }
383
+ const text = (yield* source.slice(offset, endPos));
311
384
  pendingWork += (endPos - offset) + text.length;
312
385
  while (pendingWork >= 512) {
313
386
  yield 512;
@@ -321,13 +394,15 @@ export function* parseXmlSteps(input, limits = {}) {
321
394
  pendingWork -= 512;
322
395
  }
323
396
  admitText(resolved);
324
- const parent = stack.at(-1);
397
+ const parent = (yield* stack.peek());
325
398
  if (parent) {
326
- parent.element.text += resolved;
399
+ if (retainTree)
400
+ parent.element.text += resolved;
327
401
  if (resolved.length) {
328
402
  admitContent();
329
- if (retainContent)
403
+ if (retain)
330
404
  parent.content.push({ kind: "text", text: resolved });
405
+ limits.events?.({ type: "content", content: { kind: "text", text: resolved }, parent: parent.element });
331
406
  }
332
407
  }
333
408
  else {
@@ -347,42 +422,63 @@ export function* parseXmlSteps(input, limits = {}) {
347
422
  }
348
423
  if (resolved.length) {
349
424
  admitContent();
350
- if (retainContent)
425
+ if (retain)
351
426
  (root ? epilog : prolog).push({ kind: "text", text: resolved });
427
+ limits.events?.({ type: "content", content: { kind: "text", text: resolved }, parent: undefined });
352
428
  }
353
429
  }
354
430
  offset = endPos;
355
431
  }
356
- else if (source.startsWith("<!--", offset)) {
432
+ else if ((yield* source.startsWith("<!--", offset))) {
357
433
  const end = yield* find(source, "-->", offset + 4);
358
- if (end < 0 || (yield* find(source.slice(offset + 4, end), "--", 0)) >= 0 || source.slice(offset + 4, end).endsWith("-")) {
434
+ const text = end < 0 ? "" : yield* source.slice(offset + 4, end);
435
+ if (end < 0 || (yield* find(text, "--", 0)) >= 0 || text.endsWith("-")) {
359
436
  invalid("malformed comment");
360
437
  }
361
- const parent = stack.at(-1);
362
- const text = source.slice(offset + 4, end);
438
+ const parent = (yield* stack.peek());
363
439
  admitText(text);
364
440
  admitContent();
365
- if (retainContent) {
441
+ if (retain) {
366
442
  (parent?.content ?? (root ? epilog : prolog)).push({ kind: "comment", text });
367
443
  }
444
+ limits.events?.({ type: "content", content: { kind: "comment", text }, parent: parent?.element });
368
445
  offset = end + 3;
369
446
  }
370
- else if (source.startsWith("<![CDATA[", offset)) {
447
+ else if ((yield* source.startsWith("<![CDATA[", offset))) {
371
448
  if (!stack.length)
372
449
  invalid("CDATA outside root");
373
450
  const end = yield* find(source, "]]>", offset + 9);
374
451
  if (end < 0)
375
452
  invalid("unterminated CDATA");
376
- const cdataText = source.slice(offset + 9, end);
377
- admitText(cdataText);
378
- const parent = stack.at(-1);
379
- parent.element.text += cdataText;
453
+ if (end - offset - 9 > maxTextLength - textLength)
454
+ throw new XmlLimitError("maxTextLength", "XML text limit exceeded");
455
+ const parent = (yield* stack.peek());
380
456
  admitContent();
381
- if (retainContent)
382
- parent.content.push({ kind: "cdata", text: cdataText });
457
+ let cursor = offset + 9, continuation = false;
458
+ do {
459
+ let finish = limits.fragmentContent ? Math.min(end, cursor + 512) : end;
460
+ if (finish < end) {
461
+ const last = yield* source.charCodeAt(finish - 1);
462
+ if (last >= 0xd800 && last <= 0xdbff)
463
+ finish--;
464
+ }
465
+ const text = yield* source.slice(cursor, finish);
466
+ admitText(text);
467
+ if (retainTree)
468
+ parent.element.text += text;
469
+ if (retain)
470
+ parent.content.push({ kind: "cdata", text });
471
+ limits.events?.({ type: "content", content: { kind: "cdata", text }, parent: parent.element,
472
+ ...(continuation ? { continuation: true } : {}) });
473
+ // Let external consumers persist each fragment before producing another.
474
+ if (limits.fragmentContent)
475
+ yield Math.max(1, text.length);
476
+ continuation = true;
477
+ cursor = finish;
478
+ } while (cursor < end);
383
479
  offset = end + 3;
384
480
  }
385
- else if (source.startsWith("<?", offset)) {
481
+ else if ((yield* source.startsWith("<?", offset))) {
386
482
  const start = offset;
387
483
  offset += 2;
388
484
  const [target] = yield* scanName();
@@ -393,19 +489,19 @@ export function* parseXmlSteps(input, limits = {}) {
393
489
  const end = yield* find(source, "?>", offset);
394
490
  if (end < 0)
395
491
  invalid("unterminated processing instruction");
396
- const content = source.slice(offset, end);
492
+ const content = (yield* source.slice(offset, end));
397
493
  if (target.length === 3 && target.toLowerCase() === "xml") {
398
494
  if (start !== 0 || target !== "xml"
399
495
  || !(yield* validDeclaration(content, limits.expectedEncoding))) {
400
496
  invalid("unsupported XML declaration");
401
497
  }
402
498
  if (retainContent)
403
- declaration = source.slice(start, end + 2);
499
+ declaration = (yield* source.slice(start, end + 2));
404
500
  }
405
501
  else if (content && !" \t\n\r".includes(content[0]))
406
502
  invalid("invalid processing instruction");
407
503
  if (!(target.length === 3 && target.toLowerCase() === "xml")) {
408
- const parent = stack.at(-1);
504
+ const parent = (yield* stack.peek());
409
505
  let wsStart = 0;
410
506
  while (wsStart < content.length && " \t\n\r".includes(content[wsStart]))
411
507
  wsStart++;
@@ -417,16 +513,17 @@ export function* parseXmlSteps(input, limits = {}) {
417
513
  const text = content.slice(wsStart);
418
514
  admitText(text);
419
515
  admitContent();
420
- if (retainContent) {
516
+ if (retain) {
421
517
  (parent?.content ?? (root ? epilog : prolog)).push({ kind: "processing-instruction", target, text });
422
518
  }
519
+ limits.events?.({ type: "content", content: { kind: "processing-instruction", target, text }, parent: parent?.element });
423
520
  }
424
521
  offset = end + 2;
425
522
  }
426
- else if (source.startsWith("<!", offset)) {
523
+ else if ((yield* source.startsWith("<!", offset))) {
427
524
  invalid("DTD and entity declarations are forbidden");
428
525
  }
429
- else if (source.charCodeAt(offset + 1) === 47) {
526
+ else if ((yield* source.charCodeAt(offset + 1)) === 47) {
430
527
  offset += 2;
431
528
  const [name] = yield* scanName();
432
529
  yield* skipWhitespace();
@@ -434,15 +531,18 @@ export function* parseXmlSteps(input, limits = {}) {
434
531
  yield 512;
435
532
  pendingWork -= 512;
436
533
  }
437
- if (source[offset++] !== ">" || stack.pop()?.name !== name) {
534
+ const closed = (yield* source.slice(offset++, offset)) === ">" ? (yield* stack.pop()) : undefined;
535
+ if (closed?.name !== name) {
438
536
  if (!limits.recover)
439
537
  invalid("mismatched closing tag");
440
538
  limits.recover("mismatched closing tag");
441
539
  }
540
+ if (closed)
541
+ limits.events?.({ type: "close", element: closed.element, parent: (yield* stack.peek())?.element });
442
542
  }
443
543
  else {
444
544
  offset++;
445
- const repeated = previousEmpty && source.startsWith(previousEmpty.suffix, offset) ? previousEmpty : undefined;
545
+ const repeated = previousEmpty && (yield* source.startsWith(previousEmpty.suffix, offset)) ? previousEmpty : undefined;
446
546
  const [name, prefix, localName] = repeated
447
547
  ? [repeated.name, repeated.prefix, repeated.localName]
448
548
  : (yield* scanName());
@@ -457,7 +557,7 @@ export function* parseXmlSteps(input, limits = {}) {
457
557
  }
458
558
  let attributes;
459
559
  const attrMeta = [];
460
- let namespaces = stack.at(-1)?.namespaces ?? new Map([["xml", xmlNamespace]]);
560
+ let namespaces = (yield* stack.peek())?.namespaces ?? new Map([["xml", xmlNamespace]]);
461
561
  let ownsNamespaces = stack.length === 0;
462
562
  while (!repeated) {
463
563
  const ws = (yield* skipWhitespace());
@@ -465,7 +565,7 @@ export function* parseXmlSteps(input, limits = {}) {
465
565
  yield 512;
466
566
  pendingWork -= 512;
467
567
  }
468
- const ch = source.charCodeAt(offset);
568
+ const ch = (yield* source.charCodeAt(offset));
469
569
  if (ch === 47 || ch === 62 || limits.recover && offset === source.length)
470
570
  break;
471
571
  if (ws === 0)
@@ -482,13 +582,13 @@ export function* parseXmlSteps(input, limits = {}) {
482
582
  if ((attributes?.size ?? 0) >= maxAttributesPerElement)
483
583
  throw new XmlLimitError("maxAttributesPerElement", "XML attribute limit exceeded");
484
584
  yield* skipWhitespace();
485
- if (source[offset++] !== "=")
585
+ if ((yield* source.slice(offset++, offset)) !== "=")
486
586
  invalid("missing attribute equals");
487
587
  yield* skipWhitespace();
488
- const quote = source[offset++];
588
+ const quote = (yield* source.slice(offset++, offset));
489
589
  if (quote !== '"' && quote !== "'")
490
590
  invalid("unquoted attribute");
491
- const end = source.indexOf(quote, offset);
591
+ const end = (yield* source.indexOf(quote, offset));
492
592
  const scanLen = (end < 0 ? source.length : end) - offset;
493
593
  pendingWork += scanLen;
494
594
  while (pendingWork >= 512) {
@@ -497,7 +597,7 @@ export function* parseXmlSteps(input, limits = {}) {
497
597
  }
498
598
  if (end < 0)
499
599
  invalid("unterminated attribute");
500
- const raw = source.slice(offset, end);
600
+ const raw = (yield* source.slice(offset, end));
501
601
  pendingWork += raw.length * 2;
502
602
  while (pendingWork >= 512) {
503
603
  yield 512;
@@ -564,7 +664,7 @@ export function* parseXmlSteps(input, limits = {}) {
564
664
  if (stack.length + 1 > maxDepth)
565
665
  throw new XmlLimitError("maxDepth", "XML resource limit exceeded");
566
666
  const namespace = namespaces.get(prefix) ?? "";
567
- limits.onElement?.({ name, namespace, localName }, stack.at(-1)?.element, stack.length + 1);
667
+ limits.onElement?.({ name, namespace, localName }, (yield* stack.peek())?.element, stack.length + 1);
568
668
  admitContent();
569
669
  const retainedAttributes = [];
570
670
  if (retainContent && attributes) {
@@ -581,31 +681,36 @@ export function* parseXmlSteps(input, limits = {}) {
581
681
  yield 512;
582
682
  pendingWork -= 512;
583
683
  }
584
- const content = retainContent ? [] : undefined;
684
+ const content = retain ? [] : undefined;
585
685
  const element = { kind: "element", name, namespace, localName, children: [], text: "", content: content ?? emptyContent, attributes: retainContent ? retainedAttributes : emptyAttributes, namespaces: retainContent ? namespaces : emptyNamespaces, ...(root === undefined && retainContent ? { prolog, epilog, ...(declaration === undefined ? {} : { declaration }) } : {}) };
586
- const parent = stack.at(-1);
686
+ const parent = (yield* stack.peek());
587
687
  if (parent) {
588
- parent.element.children.push(element);
589
- parent.content?.push(element);
688
+ if (retainTree) {
689
+ parent.element.children.push(element);
690
+ parent.content?.push(element);
691
+ }
590
692
  }
591
693
  else if (root)
592
694
  invalid("multiple root elements");
593
695
  else
594
696
  root = element;
595
- const empty = source[offset] === "/";
697
+ const empty = (yield* source.slice(offset, offset + 1)) === "/";
596
698
  if (empty)
597
699
  offset++;
598
- if (source[offset++] !== ">") {
700
+ if ((yield* source.slice(offset++, offset)) !== ">") {
599
701
  if (!limits.recover)
600
702
  invalid("unterminated start tag");
601
703
  limits.recover("unterminated start tag");
602
704
  }
603
705
  // Only cache compact, attribute-free spellings. Namespace resolution and
604
706
  // every admission counter still run for each distinct physical element.
605
- if (empty && !attributes && name.length <= 512 && source.slice(offset - name.length - 3, offset) === `<${name}/>`)
707
+ if (empty && !attributes && name.length <= 512 && (yield* source.slice(offset - name.length - 3, offset)) === `<${name}/>`)
606
708
  previousEmpty = { suffix: name + "/>", name, prefix, localName };
709
+ limits.events?.({ type: "open", element, parent: parent?.element });
607
710
  if (!empty)
608
- stack.push({ element, content, name, namespaces });
711
+ yield* stack.push({ element, content, name, namespaces });
712
+ else
713
+ limits.events?.({ type: "close", element, parent: parent?.element });
609
714
  }
610
715
  }
611
716
  if (pendingWork > 0) {
@@ -618,9 +723,73 @@ export function* parseXmlSteps(input, limits = {}) {
618
723
  if (!limits.recover)
619
724
  invalid("incomplete document");
620
725
  limits.recover("incomplete document");
726
+ while (stack.length) {
727
+ const closed = (yield* stack.pop());
728
+ limits.events?.({ type: "close", element: closed.element, parent: (yield* stack.peek())?.element });
729
+ yield 1;
730
+ }
621
731
  }
622
732
  return root;
623
733
  }
734
+ function* normalizeXmlSteps(input) {
735
+ const chunks = [];
736
+ const start = input.charCodeAt(0) === 0xfeff ? 1 : 0;
737
+ let chunkStart = start;
738
+ let chunkLength = 0;
739
+ let normalizedChunk = "";
740
+ // Keep validated spans intact; only changed line endings need new strings.
741
+ for (let index = start; index < input.length; index++) {
742
+ const point = input.codePointAt(index);
743
+ if (!validCharacter(point))
744
+ invalid("invalid character");
745
+ if (point === 13) {
746
+ normalizedChunk += input.slice(chunkStart, index) + "\n";
747
+ if (input.charCodeAt(index + 1) === 10)
748
+ index++;
749
+ chunkStart = index + 1;
750
+ chunkLength++;
751
+ }
752
+ else {
753
+ chunkLength += point > 0xffff ? 2 : 1;
754
+ if (point > 0xffff)
755
+ index++;
756
+ }
757
+ if (chunkLength >= 512) {
758
+ if (normalizedChunk) {
759
+ chunks.push(normalizedChunk + input.slice(chunkStart, index + 1));
760
+ normalizedChunk = "";
761
+ chunkStart = index + 1;
762
+ }
763
+ chunkLength = 0;
764
+ yield 512;
765
+ }
766
+ }
767
+ if (chunkLength)
768
+ yield chunkLength;
769
+ if (chunks.length || normalizedChunk)
770
+ chunks.push(normalizedChunk + input.slice(chunkStart));
771
+ return chunks.length ? chunks.join("") : input.slice(start);
772
+ }
773
+ export function* parseXmlSteps(input, limits = {}) {
774
+ validateLimits(limits);
775
+ const source = yield* normalizeXmlSteps(input);
776
+ const parser = parseXmlSourceSteps(source.length, limits);
777
+ let step = parser.next();
778
+ try {
779
+ while (!step.done) {
780
+ if (typeof step.value === "number")
781
+ yield step.value;
782
+ else
783
+ step.value.value = source.slice(step.value.offset, step.value.offset + step.value.length);
784
+ step = parser.next();
785
+ }
786
+ return step.value;
787
+ }
788
+ finally {
789
+ if (!step.done)
790
+ parser.return(undefined);
791
+ }
792
+ }
624
793
  export function parseXml(input, limits = {}) {
625
794
  const parser = parseXmlSteps(input, limits);
626
795
  let result = parser.next();
@@ -0,0 +1,24 @@
1
+ /** Fill value before advancing. Streaming reads may be short; mark complete at EOF. */
2
+ export interface XmlSourceRead {
3
+ readonly offset: number;
4
+ readonly length: number;
5
+ readonly streaming?: boolean;
6
+ value?: string;
7
+ complete?: boolean;
8
+ }
9
+ export type XmlSourceStep = number | XmlSourceRead;
10
+ /** One fixed window; individual returned tokens still have their own size cost. */
11
+ export declare class XmlSource {
12
+ private window;
13
+ private start;
14
+ private end;
15
+ private readonly streaming;
16
+ constructor(length: number | undefined);
17
+ get length(): number;
18
+ private load;
19
+ has(offset: number): Generator<XmlSourceStep, boolean, void>;
20
+ slice(start: number, end?: number): Generator<XmlSourceStep, string, void>;
21
+ charCodeAt(offset: number): Generator<XmlSourceStep, number, void>;
22
+ startsWith(value: string, offset: number): Generator<XmlSourceStep, boolean, void>;
23
+ indexOf(value: string, offset: number, end?: number): Generator<XmlSourceStep, number, void>;
24
+ }
@@ -0,0 +1,71 @@
1
+ /** One fixed window; individual returned tokens still have their own size cost. */
2
+ export class XmlSource {
3
+ window = '';
4
+ start = -1;
5
+ end;
6
+ streaming;
7
+ constructor(length) {
8
+ if (length !== undefined && (!Number.isSafeInteger(length) || length < 0))
9
+ throw new RangeError('Invalid XML source length');
10
+ this.streaming = length === undefined;
11
+ this.end = length ?? Infinity;
12
+ }
13
+ get length() { return this.end; }
14
+ *load(offset) {
15
+ if (offset >= this.end || offset >= this.start && offset < this.start + this.window.length)
16
+ return;
17
+ const start = this.streaming ? offset : Math.floor(offset / 4096) * 4096;
18
+ const request = { offset: start, length: Math.min(this.streaming ? 512 : 4096, this.end - start),
19
+ ...(this.streaming ? { streaming: true } : {}) };
20
+ yield request;
21
+ if (typeof request.value !== 'string' || request.value.length > request.length ||
22
+ (!this.streaming && request.value.length !== request.length) ||
23
+ (!request.value.length && !request.complete && this.streaming))
24
+ throw new TypeError('Incomplete XML source read');
25
+ if (this.streaming && request.complete)
26
+ this.end = start + request.value.length;
27
+ this.window = request.value;
28
+ this.start = start;
29
+ }
30
+ *has(offset) {
31
+ yield* this.load(offset);
32
+ return offset < this.end;
33
+ }
34
+ *slice(start, end = this.length) {
35
+ start = start < 0 ? Math.max(0, this.length + start) : Math.min(start, this.length);
36
+ end = end < 0 ? Math.max(0, this.length + end) : Math.min(end, this.length);
37
+ let result = '';
38
+ while (start < Math.min(end, this.length)) {
39
+ yield* this.load(start);
40
+ if (start >= this.end)
41
+ break;
42
+ const stop = Math.min(end, this.start + this.window.length);
43
+ result += this.window.slice(start - this.start, stop - this.start);
44
+ start = stop;
45
+ }
46
+ return result;
47
+ }
48
+ *charCodeAt(offset) {
49
+ return (yield* this.slice(offset, offset + 1)).charCodeAt(0);
50
+ }
51
+ *startsWith(value, offset) {
52
+ for (let index = 0; index < value.length; index++) {
53
+ if ((yield* this.charCodeAt(offset + index)) !== value.charCodeAt(index))
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+ *indexOf(value, offset, end = Infinity) {
59
+ let tail = '';
60
+ while (offset < end && (yield* this.has(offset))) {
61
+ const stop = Math.min(end, this.start + this.window.length);
62
+ const part = tail + this.window.slice(offset - this.start, stop - this.start);
63
+ const found = part.indexOf(value);
64
+ if (found >= 0)
65
+ return offset - tail.length + found;
66
+ tail = value.length > 1 ? part.slice(-(value.length - 1)) : '';
67
+ offset = stop;
68
+ }
69
+ return -1;
70
+ }
71
+ }
@@ -5,6 +5,7 @@ export type XmlStreamEvent = {
5
5
  readonly parent: XmlElement | undefined;
6
6
  } | {
7
7
  readonly type: "content";
8
+ readonly continuation?: boolean;
8
9
  readonly content: Exclude<XmlContent, XmlElement>;
9
10
  readonly parent: XmlElement | undefined;
10
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.811",
3
+ "version": "0.1.813",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",