@jarenjs/josl 0.49.2 → 0.66.1

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.
@@ -48,8 +48,10 @@ import {
48
48
  } from '@jarenjs/core/scan';
49
49
 
50
50
  import { CsvSyntaxError } from './errors.js';
51
- import { columnOf, feedMachine, beginParseAll } from './util.js';
51
+ import { JoslLimitError, limitOption } from './limits.js';
52
+ import { columnOf, feedMachine, beginParseAll, offsetDateTime } from './util.js';
52
53
  import { setObjectMember } from '@jarenjs/core/object';
54
+ import { utf8ByteLength } from '@jarenjs/core/string';
53
55
  import {
54
56
  LocalDate,
55
57
  LocalTime,
@@ -182,9 +184,10 @@ export function coerceCsvValue(s) {
182
184
  const frac = m[7] === undefined ? '' : '.' + m[7];
183
185
  if (m[8] !== undefined || m[9] !== undefined) {
184
186
  // an offset date-time is an instant, which `Date` holds faithfully
185
- const offset = m[8] !== undefined ? 0 : (m[9] === '-' ? -1 : 1) * (+m[10] * 60 + +m[11]);
186
- const ms = frac === '' ? 0 : Math.round(Number('0' + frac) * 1000);
187
- return new Date(Date.UTC(year, month - 1, day, hour, min, sec, ms) - offset * 60000);
187
+ const offset = m[8] ?? `${m[9]}${m[10]}:${m[11]}`;
188
+ const instant = offsetDateTime(new LocalDate(year, month, day),
189
+ new LocalTime(hour, min, sec, frac), offset);
190
+ return Number.isNaN(instant.getTime()) ? s : instant;
188
191
  }
189
192
  return new LocalDateTime(
190
193
  new LocalDate(year, month, day),
@@ -241,6 +244,16 @@ export class CsvMachine {
241
244
  this.onEvent = options.onEvent ?? null;
242
245
  this.onRepair = options.onRepair ?? null;
243
246
 
247
+ // the hostile-input limits: Infinity unless asked for, checked while
248
+ // the text is still in cutter or cell state (limits.js)
249
+ this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
250
+ this.maxRecordBytes = limitOption(options, 'maxRecordBytes');
251
+ this.maxFieldBytes = limitOption(options, 'maxFieldBytes');
252
+ this.maxColumns = limitOption(options, 'maxColumns');
253
+ this.limited = this.maxTotalBytes !== Infinity || this.maxRecordBytes !== Infinity
254
+ || this.maxFieldBytes !== Infinity || this.maxColumns !== Infinity;
255
+ this.totalBytes = 0;
256
+
244
257
  const headers = options.headers;
245
258
  this.wantHeader = headers === true;
246
259
  this.headerFields = Array.isArray(headers)
@@ -294,9 +307,38 @@ export class CsvMachine {
294
307
  this.nextLf = -2;
295
308
  if (this.nextCr === -1)
296
309
  this.nextCr = -2;
310
+ if (this.limited)
311
+ this.count(chunk);
297
312
  return feedMachine(this, chunk);
298
313
  }
299
314
 
315
+ // The byte limits, checked BEFORE the chunk is buffered: a chunk that
316
+ // takes the document past maxTotalBytes is refused whole, and a record
317
+ // still being cut — the unconsumed tail plus this chunk — that would
318
+ // pass maxRecordBytes is refused before the concatenation that would
319
+ // hold it. `parseAll` counts its one text the same way.
320
+ count(text) {
321
+ if (this.maxTotalBytes !== Infinity) {
322
+ this.totalBytes += utf8ByteLength(text);
323
+ if (this.totalBytes > this.maxTotalBytes)
324
+ throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.line);
325
+ }
326
+ if (this.maxRecordBytes !== Infinity) {
327
+ // the pending record is what the cutter has not handed off yet;
328
+ // a fresh chunk extends it
329
+ const pending = utf8ByteLength(this.buf) + utf8ByteLength(text);
330
+ if (pending > this.maxRecordBytes && !this.endsRecordWithin(text))
331
+ throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
332
+ }
333
+ }
334
+
335
+ // Whether a chunk can end the pending record within the bound: cheap
336
+ // and permissive — a terminator anywhere in the chunk means the cutter
337
+ // gets its chance; the span check in readSpan is the exact judge.
338
+ endsRecordWithin(text) {
339
+ return text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0;
340
+ }
341
+
300
342
  /**
301
343
  * Finish the document, flushing any pending record.
302
344
  * @returns {Array} The completed rows
@@ -326,6 +368,8 @@ export class CsvMachine {
326
368
  */
327
369
  parseAll(text) {
328
370
  text = beginParseAll(this, text);
371
+ if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
372
+ throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
329
373
  this.readSpan(text, 0, text.length);
330
374
  return this.outRows;
331
375
  }
@@ -586,13 +630,29 @@ export class CsvMachine {
586
630
  }
587
631
  this.recordOrigin = this.line;
588
632
  this.dropRecord = false;
633
+ const start = pos;
589
634
  pos = this.parseRecord(text, pos, end, cells);
635
+ // the record's source bytes, terminator included — judged before
636
+ // the record is kept (the streaming path also refused it at every
637
+ // chunk boundary while it was still being cut)
638
+ if (this.maxRecordBytes !== Infinity && utf8ByteLength(text, start, pos) > this.maxRecordBytes)
639
+ throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
590
640
  if (!this.dropRecord)
591
641
  this.emitRecord(cells);
592
642
  }
593
643
  return pos;
594
644
  }
595
645
 
646
+ // One cell about to be kept: its source bytes (quotes included, as
647
+ // written) and the column count are judged before the slice or the
648
+ // push that would hold it.
649
+ admitCell(cells, text, start, stop) {
650
+ if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, start, stop) > this.maxFieldBytes)
651
+ throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
652
+ if (cells.length >= this.maxColumns)
653
+ throw new JoslLimitError('CSV2004', 'a record has more than maxColumns fields', this.maxColumns, this.recordOrigin);
654
+ }
655
+
596
656
  // The single grammar path: fill `cells` with one record's fields and
597
657
  // return the offset just past the record, including its terminator.
598
658
  parseRecord(text, pos, end, cells) {
@@ -668,6 +728,8 @@ export class CsvMachine {
668
728
  stop = nlf;
669
729
  if (ncr >= 0 && ncr < stop)
670
730
  stop = ncr;
731
+ if (this.limited)
732
+ this.admitCell(cells, text, pos, stop);
671
733
  const raw = text.slice(pos, stop);
672
734
  cells.push(this.plainCells ? raw : this.finish(raw));
673
735
  return stop;
@@ -677,6 +739,7 @@ export class CsvMachine {
677
739
  parseQuoted(text, pos, end, cells) {
678
740
  const quote = this.quote;
679
741
  const delim = this.delimiter;
742
+ const opened = pos;
680
743
  pos++; // opening quote
681
744
  let start = pos;
682
745
  let out = null;
@@ -685,6 +748,8 @@ export class CsvMachine {
685
748
  // Out of input with the field still open. Closing it here is the
686
749
  // only reading that keeps the text.
687
750
  this.heal('CSV1001', this.recordOrigin, columnOf(text, pos));
751
+ if (this.limited)
752
+ this.admitCell(cells, text, opened, pos);
688
753
  cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
689
754
  return pos;
690
755
  }
@@ -697,12 +762,16 @@ export class CsvMachine {
697
762
  }
698
763
  const n = pos + 1 < end ? text.charCodeAt(pos + 1) : -1;
699
764
  if (n === quote) { // "" — one literal quote
765
+ if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, opened, pos + 2) > this.maxFieldBytes)
766
+ throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
700
767
  out = (out === null ? '' : out) + text.slice(start, pos + 1);
701
768
  pos += 2;
702
769
  start = pos;
703
770
  continue;
704
771
  }
705
772
  if (n === delim || n === CC_LF || n === CC_CR || n === -1) {
773
+ if (this.limited)
774
+ this.admitCell(cells, text, opened, pos + 1);
706
775
  cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
707
776
  return pos + 1; // past the closing quote
708
777
  }
@@ -733,6 +802,8 @@ export class CsvMachine {
733
802
  break;
734
803
  pos++;
735
804
  }
805
+ if (this.limited)
806
+ this.admitCell(cells, text, opened, pos);
736
807
  cells.push(this.finishQuoted(closed + text.slice(stray, pos)));
737
808
  return pos;
738
809
  }
package/src/csv-stream.js CHANGED
@@ -9,7 +9,8 @@
9
9
  // test suite asserts exactly that at every chunk size.
10
10
 
11
11
  import { CsvMachine } from './csv-machine.js';
12
- import { stringifyCsvChunks, formatCsvValue } from './csv.js';
12
+ import { stringifyCsvChunks, formatCsvValue, createCsvRowFormatter } from './csv.js';
13
+ import { closeIterator, abortedError } from './pull.js';
13
14
 
14
15
  //#region reading
15
16
 
@@ -83,22 +84,82 @@ export async function parseCsvStream(chunks, options = undefined) {
83
84
  */
84
85
  export async function* iterateCsvStream(chunks, options = undefined) {
85
86
  const machine = new CsvMachine(options);
87
+ const signal = options?.signal ?? null;
86
88
  const pending = machine.rows();
87
- for await (const chunk of chunks) {
88
- machine.feed(chunk);
89
- if (pending.length !== 0) {
90
- // hand over the completed rows and drop them, so the machine never
91
- // accumulates the document it is streaming
92
- const batch = pending.splice(0, pending.length);
93
- for (const row of batch)
94
- yield row;
89
+ const iterator = chunks[Symbol.asyncIterator]?.() ?? chunks[Symbol.iterator]();
90
+ let finished = false;
91
+ try {
92
+ for (;;) {
93
+ if (signal !== null && signal.aborted)
94
+ throw abortedError(signal);
95
+ const step = await iterator.next();
96
+ if (step.done) {
97
+ finished = true;
98
+ break;
99
+ }
100
+ machine.feed(step.value);
101
+ if (pending.length !== 0) {
102
+ // hand over the completed rows and drop them, so the machine never
103
+ // accumulates the document it is streaming
104
+ const batch = pending.splice(0, pending.length);
105
+ for (const row of batch)
106
+ yield row;
107
+ }
95
108
  }
96
109
  }
110
+ finally {
111
+ // a consumer that stops early, an abort, or a throw: the source is
112
+ // closed exactly once; a source read to its end needs no close
113
+ if (!finished)
114
+ await closeIterator(iterator);
115
+ }
97
116
  machine.end();
98
117
  for (const row of pending.splice(0, pending.length))
99
118
  yield row;
100
119
  }
101
120
 
121
+ /**
122
+ * Serialize an async iterable of records as an async iterable of CSV
123
+ * text chunks — the pull form of `stringifyCsvChunks`, byte-identical
124
+ * to it for the same records: the header exactly once before the first
125
+ * object row, one line per chunk. Pull is the backpressure: the next
126
+ * record is requested only when the consumer asks for the next chunk,
127
+ * so a database cursor behind it never runs ahead of the socket in
128
+ * front of it. `options.signal` aborts between pulls (the rejection is
129
+ * the signal's reason); an abort, a consumer that stops early or a throw
130
+ * closes the record source exactly once.
131
+ * @param {AsyncIterable<Array|object>|Iterable<Array|object>} rows - Records
132
+ * @param {object} [options] - Writer options; see `stringifyCsv`, plus `signal`
133
+ * @yields {string} One line at a time
134
+ * @example
135
+ * response.body = stringifyCsvStream(store.collection('rows').query(doc), { signal });
136
+ */
137
+ export async function* stringifyCsvStream(rows, options = {}) {
138
+ const formatter = createCsvRowFormatter(options);
139
+ const signal = options.signal ?? null;
140
+ const iterator = rows[Symbol.asyncIterator]?.() ?? rows[Symbol.iterator]();
141
+ let finished = false;
142
+ try {
143
+ for (;;) {
144
+ if (signal !== null && signal.aborted)
145
+ throw abortedError(signal);
146
+ const step = await iterator.next();
147
+ if (step.done) {
148
+ finished = true;
149
+ break;
150
+ }
151
+ yield* formatter.lines(step.value);
152
+ }
153
+ }
154
+ finally {
155
+ if (!finished)
156
+ await closeIterator(iterator);
157
+ }
158
+ const tail = formatter.tail();
159
+ if (tail.length !== 0)
160
+ yield tail;
161
+ }
162
+
102
163
  //#endregion
103
164
 
104
165
  //#region writing
@@ -114,8 +175,7 @@ export class CsvStreamWriter {
114
175
  this.options = options;
115
176
  this.chunks = [];
116
177
  this.onChunk = options.onChunk ?? null;
117
- this.fields = options.fields ?? null;
118
- this.wroteHeader = options.header === false;
178
+ this.formatter = createCsvRowFormatter(options);
119
179
  }
120
180
 
121
181
  #emit(text) {
@@ -126,23 +186,19 @@ export class CsvStreamWriter {
126
186
  return this;
127
187
  }
128
188
 
189
+ /** @returns {string[]|null} The columns as decided, once known. */
190
+ get fields() {
191
+ return this.formatter.fields();
192
+ }
193
+
129
194
  /**
130
195
  * Write one record.
131
196
  * @param {Array|object} row - An array, or an object keyed by column
132
197
  * @returns {this} The writer, for chaining
133
198
  */
134
199
  write(row) {
135
- if (!Array.isArray(row) && this.fields === null)
136
- this.fields = Object.keys(row);
137
- for (const chunk of stringifyCsvChunks([row], {
138
- ...this.options,
139
- fields: this.fields,
140
- header: !this.wroteHeader && this.options.header !== false,
141
- })) {
200
+ for (const chunk of this.formatter.lines(row))
142
201
  this.#emit(chunk);
143
- }
144
- if (!Array.isArray(row))
145
- this.wroteHeader = true;
146
202
  return this;
147
203
  }
148
204
 
@@ -166,10 +222,14 @@ export class CsvStreamWriter {
166
222
  }
167
223
 
168
224
  /**
169
- * Finish writing.
225
+ * Finish writing: the header an explicit field list is still owed
226
+ * goes out when no record was written.
170
227
  * @returns {string} The complete document, or `''` with an `onChunk` sink
171
228
  */
172
229
  end() {
230
+ const tail = this.formatter.tail();
231
+ if (tail.length !== 0)
232
+ this.#emit(tail);
173
233
  return this.toString();
174
234
  }
175
235
  }
@@ -192,5 +252,6 @@ export function createCsvStreamWriter(options = undefined) {
192
252
  //#endregion
193
253
 
194
254
  export { stringifyCsvChunks, formatCsvValue };
255
+ export { JoslLimitError, CSV_LIMIT_CODES } from './limits.js';
195
256
 
196
257
  //#endregion
package/src/csv.js CHANGED
@@ -250,8 +250,10 @@ function needsQuoteTester(delimiter, quote) {
250
250
 
251
251
  /**
252
252
  * Render one value as CSV cell text. `null`/`undefined` become empty,
253
- * bigints lose the `n` suffix JOSL uses (CSV has no type marks), and the
254
- * JOSL date classes and `Date` render as ISO-8601.
253
+ * bigints lose the `n` suffix JOSL uses (CSV has no type marks), the
254
+ * JOSL date classes and `Date` render as ISO-8601, and a plain object or
255
+ * an array — a nested document in a flat format — is its JSON text, so
256
+ * a reader gets the value back instead of `[object Object]`.
255
257
  * @param {*} value - The value
256
258
  * @returns {string} Cell text, unquoted
257
259
  */
@@ -267,6 +269,8 @@ export function formatCsvValue(value) {
267
269
  }
268
270
  if (value instanceof Date)
269
271
  return Number.isNaN(value.getTime()) ? '' : value.toISOString();
272
+ if (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
273
+ return JSON.stringify(value);
270
274
  // LocalDate / LocalTime / LocalDateTime, and anything else that knows
271
275
  // how to render itself
272
276
  return String(value);
@@ -301,13 +305,19 @@ export function stringifyCsv(rows, options = {}) {
301
305
  }
302
306
 
303
307
  /**
304
- * Serialize records as an iterable of chunks, one record at a time, so a
305
- * large table never exists as a single string.
306
- * @param {Iterable<Array|object>} rows - Records
308
+ * The one row formatter behind every CSV writer: the same delimiter,
309
+ * quote and terminator rules and the same header-once rule, whether
310
+ * rows arrive as an array, an iterable, an async iterable or one at a
311
+ * time through the stream writer — so all of them produce byte-identical
312
+ * text for the same records by construction.
307
313
  * @param {object} [options] - Writer options; see `stringifyCsv`
308
- * @yields {string} One record (or the header) at a time
314
+ * @returns {{ lines: (row: Array|object) => string[], tail: () => string, fields: () => string[] | null }}
315
+ * `lines` formats one record as its line — preceded by the header
316
+ * line exactly once, before the first object row — `tail` answers the
317
+ * header an explicit field list is still owed when no record was
318
+ * ever written, `fields` the columns as decided
309
319
  */
310
- export function* stringifyCsvChunks(rows, options = {}) {
320
+ export function createCsvRowFormatter(options = {}) {
311
321
  const delimiter = options.delimiter ?? ',';
312
322
  const quote = options.quote ?? '"';
313
323
  const newline = options.newline ?? '\r\n';
@@ -321,39 +331,58 @@ export function* stringifyCsvChunks(rows, options = {}) {
321
331
  return s;
322
332
  return quote + (s.includes(quote) ? s.replaceAll(quote, escaped) : s) + quote;
323
333
  };
334
+ const line = (values) => {
335
+ let out = '';
336
+ for (let i = 0; i < values.length; i++)
337
+ out += (i === 0 ? '' : delimiter) + cell(values[i]);
338
+ return out + newline;
339
+ };
324
340
 
325
341
  let fields = options.fields ?? null;
326
342
  let emittedHeader = false;
327
- for (const row of rows) {
328
- if (Array.isArray(row)) {
329
- let line = '';
330
- for (let i = 0; i < row.length; i++)
331
- line += (i === 0 ? '' : delimiter) + cell(row[i]);
332
- yield line + newline;
333
- continue;
334
- }
335
- if (fields === null)
336
- fields = Object.keys(row);
337
- if (wantHeader && !emittedHeader) {
338
- emittedHeader = true;
339
- let head = '';
343
+ return {
344
+ lines(row) {
345
+ if (Array.isArray(row))
346
+ return [line(row)];
347
+ if (fields === null)
348
+ fields = Object.keys(row);
349
+ const values = new Array(fields.length);
340
350
  for (let i = 0; i < fields.length; i++)
341
- head += (i === 0 ? '' : delimiter) + cell(fields[i]);
342
- yield head + newline;
343
- }
344
- let line = '';
345
- for (let i = 0; i < fields.length; i++)
346
- line += (i === 0 ? '' : delimiter) + cell(row[fields[i]]);
347
- yield line + newline;
348
- }
349
- // an explicit field list still deserves its header when there were no
350
- // records to infer one from
351
- if (wantHeader && !emittedHeader && fields !== null && options.fields !== undefined) {
352
- let head = '';
353
- for (let i = 0; i < fields.length; i++)
354
- head += (i === 0 ? '' : delimiter) + cell(fields[i]);
355
- yield head + newline;
356
- }
351
+ values[i] = row[fields[i]];
352
+ if (wantHeader && !emittedHeader) {
353
+ emittedHeader = true;
354
+ return [line(fields), line(values)];
355
+ }
356
+ return [line(values)];
357
+ },
358
+ tail() {
359
+ // an explicit field list still deserves its header when there
360
+ // were no records to infer one from
361
+ if (wantHeader && !emittedHeader && fields !== null && options.fields !== undefined) {
362
+ emittedHeader = true;
363
+ return line(fields);
364
+ }
365
+ return '';
366
+ },
367
+ fields: () => fields,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Serialize records as an iterable of chunks, one record at a time, so a
373
+ * large table never exists as a single string. The header precedes the
374
+ * first object row; a chunk is one line.
375
+ * @param {Iterable<Array|object>} rows - Records
376
+ * @param {object} [options] - Writer options; see `stringifyCsv`
377
+ * @yields {string} One record (or the header) at a time
378
+ */
379
+ export function* stringifyCsvChunks(rows, options = {}) {
380
+ const formatter = createCsvRowFormatter(options);
381
+ for (const row of rows)
382
+ yield* formatter.lines(row);
383
+ const tail = formatter.tail();
384
+ if (tail.length !== 0)
385
+ yield tail;
357
386
  }
358
387
 
359
388
  //#endregion
package/src/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  export { parseJosl, parseToml } from './parse.js';
10
10
  export { parseJoslCst, parseTomlCst, JoslCstDocument } from './cst.js';
11
- export { createStreamReader, parseJoslStream } from './stream.js';
11
+ export { createStreamReader, parseJoslStream, iterateJoslStream } from './stream.js';
12
12
  export {
13
13
  stringifyJosl,
14
14
  stringifyToml,
@@ -17,7 +17,7 @@ export {
17
17
  formatValue,
18
18
  formatSection,
19
19
  } from './stringify.js';
20
- export { createStreamWriter, stringifyJoslChunks } from './write.js';
20
+ export { createStreamWriter, stringifyJoslChunks, stringifyJoslStream } from './write.js';
21
21
  export { toGbnf, tomlToGbnf } from './gbnf.js';
22
22
  export { parseJsonx, stringifyJsonx } from './jsonx.js';
23
23
  export { createJsonxStreamReader, parseJsonxStream } from './jsonx-stream.js';
@@ -35,10 +35,12 @@ export {
35
35
  createCsvStreamReader,
36
36
  parseCsvStream,
37
37
  iterateCsvStream,
38
+ stringifyCsvStream,
38
39
  createCsvStreamWriter,
39
40
  CsvStreamWriter,
40
41
  } from './csv-stream.js';
41
42
  export { JoslSyntaxError, JoslStringifyError, JsonxSyntaxError, CsvSyntaxError } from './errors.js';
43
+ export { JoslLimitError, CSV_LIMIT_CODES, JOSL_LIMIT_CODES, JSONX_LIMIT_CODES } from './limits.js';
42
44
  export {
43
45
  LocalDate,
44
46
  LocalTime,
@@ -79,7 +79,9 @@ import {
79
79
  } from '@jarenjs/core/scan';
80
80
 
81
81
  import { JsonxSyntaxError } from './errors.js';
82
+ import { JoslLimitError, limitOption } from './limits.js';
82
83
  import { setObjectMember } from '@jarenjs/core/object';
84
+ import { utf8ByteLength } from '@jarenjs/core/string';
83
85
  import {
84
86
  isValueEndCode,
85
87
  decodeString,
@@ -154,6 +156,13 @@ export class JsonxMachine {
154
156
  this.onEvent = options.onEvent ?? null;
155
157
  this.partialText = options.partialText === true;
156
158
  this.detach = options.detach === undefined ? null : detachPattern(options.detach);
159
+ // the hostile-input limits: Infinity unless asked for (limits.js)
160
+ this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
161
+ this.maxTokenBytes = limitOption(options, 'maxTokenBytes');
162
+ this.maxDepth = limitOption(options, 'maxDepth');
163
+ this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
164
+ this.totalBytes = 0;
165
+ this.retained = 0; // values linked into the tree
157
166
  this.partialFrom = -1; // body offset the next text-partial delta starts at
158
167
  this.partialHold = ''; // lone high surrogate held back for the next delta
159
168
  this.buf = '';
@@ -188,12 +197,38 @@ export class JsonxMachine {
188
197
  if (this.ended)
189
198
  throw new Error('cannot feed after end()');
190
199
  if (chunk.length !== 0) {
200
+ if (this.maxTotalBytes !== Infinity) {
201
+ this.totalBytes += utf8ByteLength(chunk);
202
+ if (this.totalBytes > this.maxTotalBytes)
203
+ throw new JoslLimitError('JSONX2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.curLine);
204
+ }
205
+ if (this.maxTokenBytes !== Infinity && this.scanPos >= 0) {
206
+ // a token still being scanned grows by this chunk: refused
207
+ // before the concatenation that would hold it
208
+ const pending = utf8ByteLength(this.buf, this.pos, this.buf.length) + utf8ByteLength(chunk);
209
+ if (pending > this.maxTokenBytes)
210
+ throw new JoslLimitError('JSONX2002', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.curLine);
211
+ }
191
212
  this.buf += chunk;
192
213
  this.pump();
193
214
  }
194
215
  return this;
195
216
  }
196
217
 
218
+ // A token whose extent is known, judged before it is decoded.
219
+ token(buf, start, end) {
220
+ if (this.maxTokenBytes !== Infinity && utf8ByteLength(buf, start, end) > this.maxTokenBytes)
221
+ throw new JoslLimitError('JSONX2002', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.curLine);
222
+ }
223
+
224
+ // One more value linked into the tree, judged before the link. A
225
+ // detached value is never linked and never counted: that is what
226
+ // detaching means.
227
+ retain() {
228
+ if (++this.retained > this.maxRetainedValues)
229
+ throw new JoslLimitError('JSONX2004', 'the root retains more than maxRetainedValues values', this.maxRetainedValues, this.curLine);
230
+ }
231
+
197
232
  /**
198
233
  * Finish the document, flushing any pending token.
199
234
  * @returns {*} The completed root value
@@ -301,6 +336,7 @@ export class JsonxMachine {
301
336
  const end = this.scanString(buf, pos);
302
337
  if (end < 0)
303
338
  break pumping;
339
+ this.token(buf, pos, end);
304
340
  this.stack[this.stack.length - 1].key = decodeString(buf, pos, this.errCb)[0];
305
341
  this.state = ST_COLON;
306
342
  pos = end;
@@ -394,6 +430,7 @@ export class JsonxMachine {
394
430
  this.emitPartialText(buf, pos, buf.length, true);
395
431
  return -1;
396
432
  }
433
+ this.token(buf, pos, end);
397
434
  const value = decodeString(buf, pos, this.errCb)[0];
398
435
  if (this.partialText) {
399
436
  // the closing delta completes the run, so the deltas for a string
@@ -408,8 +445,10 @@ export class JsonxMachine {
408
445
  if (c === CC_SLASH) {
409
446
  if (this.mode === 'json')
410
447
  this.errAt(pos, 'a regexp literal is a JSONX extension', 'quote the pattern as a string');
411
- if (this.scanRegExp(buf, pos) < 0)
448
+ const scanned = this.scanRegExp(buf, pos);
449
+ if (scanned < 0)
412
450
  return -1;
451
+ this.token(buf, pos, scanned);
413
452
  const [re, end] = matchRegExp(buf, pos, this.errCb);
414
453
  this.checkValueEnd(buf, end);
415
454
  this.completeScalar(re);
@@ -420,6 +459,7 @@ export class JsonxMachine {
420
459
  const end = this.scanScalar(buf, pos);
421
460
  if (end < 0)
422
461
  return -1;
462
+ this.token(buf, pos, end);
423
463
  return this.decodeScalarToken(buf, pos, c);
424
464
  }
425
465
 
@@ -525,9 +565,13 @@ export class JsonxMachine {
525
565
  const index = frame.count++;
526
566
  // A detached scalar is not stored: the `item` event below already
527
567
  // carries it, so linking it in would retain exactly what the
528
- // caller asked not to retain.
529
- if (!this.detaches(index))
568
+ // caller asked not to retain. A value inside a detached subtree is
569
+ // linked into that subtree, which the root never holds: not counted.
570
+ if (!this.detaches(index)) {
571
+ if (!frame.detached)
572
+ this.retain();
530
573
  frame.value[index] = value;
574
+ }
531
575
  if (this.onEvent !== null)
532
576
  this.onEvent({
533
577
  type: 'item',
@@ -539,8 +583,11 @@ export class JsonxMachine {
539
583
  this.state = ST_ARR_NEXT;
540
584
  }
541
585
  else {
542
- if (!this.detaches(frame.key))
586
+ if (!this.detaches(frame.key)) {
587
+ if (!frame.detached)
588
+ this.retain();
543
589
  setObjectMember(frame.value, frame.key, value);
590
+ }
544
591
  if (this.onEvent !== null)
545
592
  this.onEvent({
546
593
  type: 'pair',
@@ -561,7 +608,12 @@ export class JsonxMachine {
561
608
  openContainer(isArray) {
562
609
  const container = isArray ? [] : {};
563
610
  const stack = this.stack;
611
+ if (stack.length + 1 > this.maxDepth)
612
+ throw new JoslLimitError('JSONX2003', 'the document nests deeper than maxDepth', this.maxDepth, this.curLine);
564
613
  const parent = stack.length !== 0 ? stack[stack.length - 1] : null;
614
+ // whether this container lives outside the root: detached itself, or
615
+ // inside a detached subtree — nothing linked into it counts as retained
616
+ let detached = parent !== null && parent.detached;
565
617
  if (parent === null)
566
618
  this.rootValue = container;
567
619
  else if (parent.array) {
@@ -571,13 +623,23 @@ export class JsonxMachine {
571
623
  // so the memory is not freed later — it is not held in the first
572
624
  // place, and a document with a million records never builds a
573
625
  // million-slot array either.
574
- if (!this.detaches(index))
626
+ if (this.detaches(index))
627
+ detached = true;
628
+ else {
629
+ if (!detached)
630
+ this.retain();
575
631
  parent.value[index] = container;
632
+ }
576
633
  this.path.push(index);
577
634
  }
578
635
  else {
579
- if (!this.detaches(parent.key))
636
+ if (this.detaches(parent.key))
637
+ detached = true;
638
+ else {
639
+ if (!detached)
640
+ this.retain();
580
641
  setObjectMember(parent.value, parent.key, container);
642
+ }
581
643
  this.path.push(parent.key);
582
644
  }
583
645
  stack.push({
@@ -586,6 +648,7 @@ export class JsonxMachine {
586
648
  key: undefined,
587
649
  count: 0,
588
650
  pathed: parent !== null,
651
+ detached,
589
652
  });
590
653
  if (this.onEvent !== null)
591
654
  this.onEvent({
@@ -802,5 +865,6 @@ export async function parseJsonxStream(chunks, options = undefined) {
802
865
  }
803
866
 
804
867
  export { JsonxSyntaxError } from './errors.js';
868
+ export { JoslLimitError, JSONX_LIMIT_CODES } from './limits.js';
805
869
 
806
870
  //#endregion