@bpmnkit/core 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +30 -1
  2. package/dist/bpmn/bpmn-builder.d.ts +209 -3
  3. package/dist/bpmn/bpmn-builder.js +456 -16
  4. package/dist/bpmn/bpmn-model.d.ts +110 -0
  5. package/dist/bpmn/bpmn-parser.js +1413 -528
  6. package/dist/bpmn/bpmn-serializer.js +101 -19
  7. package/dist/bpmn/compact.d.ts +17 -2
  8. package/dist/bpmn/compact.js +3 -3
  9. package/dist/bpmn/full-operations.d.ts +89 -0
  10. package/dist/bpmn/full-operations.js +478 -0
  11. package/dist/bpmn/index.d.ts +19 -0
  12. package/dist/bpmn/index.js +21 -0
  13. package/dist/bpmn/optimize/feel.js +2 -2
  14. package/dist/bpmn/optimize/patterns.js +23 -16
  15. package/dist/bpmn/optimize/tasks.js +30 -7
  16. package/dist/bpmn/optimize/utils.js +2 -4
  17. package/dist/bpmn/optimize/variable-flow.js +58 -67
  18. package/dist/bpmn/semantic-hash.d.ts +93 -0
  19. package/dist/bpmn/semantic-hash.js +155 -0
  20. package/dist/bpmn/sha256.d.ts +17 -0
  21. package/dist/bpmn/sha256.js +95 -0
  22. package/dist/bpmn/zeebe-extensions.d.ts +56 -0
  23. package/dist/bpmn/zeebe-extensions.js +79 -0
  24. package/dist/bpmn/zeebe-placement.d.ts +12 -0
  25. package/dist/bpmn/zeebe-placement.js +140 -0
  26. package/dist/errors.d.ts +40 -1
  27. package/dist/errors.js +41 -0
  28. package/dist/index.d.ts +10 -4
  29. package/dist/index.js +7 -3
  30. package/dist/layout/semantic/graph.d.ts +9 -1
  31. package/dist/layout/semantic/graph.js +42 -17
  32. package/dist/layout/semantic/route.js +102 -42
  33. package/dist/node/index.d.ts +10 -0
  34. package/dist/node/index.js +9 -0
  35. package/dist/node/write.d.ts +81 -0
  36. package/dist/node/write.js +167 -0
  37. package/dist/types/id-generator.js +11 -3
  38. package/dist/xml/index.d.ts +3 -1
  39. package/dist/xml/index.js +2 -1
  40. package/dist/xml/xml-parser.d.ts +32 -0
  41. package/dist/xml/xml-parser.js +394 -143
  42. package/package.json +8 -1
@@ -13,6 +13,49 @@ const DETOUR_PENALTY = 0;
13
13
  const CORRIDOR_SPACING = 20;
14
14
  /** How many lanes deep a corridor may stack before routes are allowed to share. */
15
15
  const CORRIDOR_LANES = 4;
16
+ /** Width of one bucket in the x-axis indexes below. */
17
+ const INDEX_CELL = 128;
18
+ /**
19
+ * Items bucketed by the x-range they cover, so a query for a segment's x-span
20
+ * visits only the shapes and routes near it instead of every one in the
21
+ * diagram. Orthogonal routes are mostly short, so this turns the per-edge
22
+ * obstacle and crossing checks from O(n) into near-constant work.
23
+ */
24
+ class XIndex {
25
+ cells = new Map();
26
+ stamp = 0;
27
+ insert(minX, maxX, item) {
28
+ const entry = { item, stamp: 0 };
29
+ const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
30
+ const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
31
+ for (let c = lo; c <= hi; c++) {
32
+ const cell = this.cells.get(c);
33
+ if (cell)
34
+ cell.push(entry);
35
+ else
36
+ this.cells.set(c, [entry]);
37
+ }
38
+ }
39
+ /** Visit every item whose x-range may overlap [minX, maxX]; stop early when `visit` returns true. */
40
+ query(minX, maxX, visit) {
41
+ const stamp = ++this.stamp;
42
+ const lo = Math.floor(Math.min(minX, maxX) / INDEX_CELL);
43
+ const hi = Math.floor(Math.max(minX, maxX) / INDEX_CELL);
44
+ for (let c = lo; c <= hi; c++) {
45
+ const cell = this.cells.get(c);
46
+ if (!cell)
47
+ continue;
48
+ for (const entry of cell) {
49
+ if (entry.stamp === stamp)
50
+ continue;
51
+ entry.stamp = stamp;
52
+ if (visit(entry.item))
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ }
16
59
  function centre(b) {
17
60
  return { x: b.x + b.width / 2, y: b.y + b.height / 2 };
18
61
  }
@@ -25,7 +68,17 @@ function centre(b) {
25
68
  * the empty gutter in front of the target's column as the general fallback.
26
69
  */
27
70
  export function routeFlows(graph, flows, bounds, bandLayout, gutterX) {
28
- const ctx = { graph, bounds, gutterX, reserved: [], routed: [] };
71
+ const obstacles = new XIndex();
72
+ for (const [id, b] of bounds)
73
+ obstacles.insert(b.x, b.x + b.width, { id, b });
74
+ const ctx = {
75
+ graph,
76
+ bounds,
77
+ obstacles,
78
+ gutterX,
79
+ reserved: [],
80
+ routed: new XIndex(),
81
+ };
29
82
  const routed = new Map();
30
83
  const pending = [];
31
84
  for (const flow of flows) {
@@ -65,7 +118,7 @@ export function routeFlows(graph, flows, bounds, bandLayout, gutterX) {
65
118
  const around = detour(ctx, item.source, item.target, item.sourceRank, item.targetRank, item.below, item.flow.sourceRef, item.flow.targetRef);
66
119
  // Going around costs bends and length, so it has to save more than one
67
120
  // crossing to be worth taking.
68
- const cost = crossingCount(around, ctx.routed) + DETOUR_PENALTY;
121
+ const cost = crossingCount(around, ctx) + DETOUR_PENALTY;
69
122
  const waypoints = item.direct && item.direct.crossings <= cost ? item.direct.waypoints : around;
70
123
  commit(ctx, routed, item.flow.id, waypoints);
71
124
  }
@@ -94,7 +147,7 @@ function commit(ctx, routed, id, waypoints) {
94
147
  const a = waypoints[i];
95
148
  const b = waypoints[i + 1];
96
149
  if (a && b)
97
- ctx.routed.push([a, b]);
150
+ ctx.routed.insert(Math.min(a.x, b.x), Math.max(a.x, b.x), [a, b]);
98
151
  }
99
152
  }
100
153
  function hostOf(graph, eventId) {
@@ -106,18 +159,13 @@ function hostOf(graph, eventId) {
106
159
  }
107
160
  /** First candidate that crosses no shape other than its own endpoints. */
108
161
  function pick(candidates, ctx, sourceId, targetId) {
109
- const obstacles = [];
110
- for (const [id, b] of ctx.bounds) {
111
- if (id === sourceId || id === targetId)
112
- continue;
113
- // An expanded container legitimately holds its children's routes.
114
- obstacles.push(b);
115
- }
116
162
  let best = null;
117
163
  for (const candidate of candidates) {
118
- if (blocked(candidate, obstacles))
164
+ // An expanded container legitimately holds its children's routes, so only
165
+ // the two endpoints are exempt from the obstacle check.
166
+ if (blocked(ctx, candidate, sourceId, targetId))
119
167
  continue;
120
- const crossings = crossingCount(candidate, ctx.routed);
168
+ const crossings = crossingCount(candidate, ctx);
121
169
  if (crossings === 0)
122
170
  return { waypoints: candidate, crossings };
123
171
  if (!best || crossings < best.crossings)
@@ -131,44 +179,59 @@ function intersects(p, q, r, t) {
131
179
  return side(p, q, r) !== side(p, q, t) && side(r, t, p) !== side(r, t, q);
132
180
  }
133
181
  /** How many already-routed edges a candidate would cut across. */
134
- function crossingCount(waypoints, routed) {
182
+ function crossingCount(waypoints, ctx) {
135
183
  let count = 0;
136
184
  for (let i = 0; i + 1 < waypoints.length; i++) {
137
185
  const a = waypoints[i];
138
186
  const b = waypoints[i + 1];
139
187
  if (!a || !b)
140
188
  continue;
141
- for (const [c, d] of routed)
189
+ ctx.routed.query(Math.min(a.x, b.x), Math.max(a.x, b.x), ([c, d]) => {
142
190
  if (intersects(a, b, c, d))
143
191
  count++;
192
+ return false;
193
+ });
144
194
  }
145
195
  return count;
146
196
  }
147
- /** How many shapes a route passes through. */
148
- function hitCount(waypoints, obstacles) {
149
- let count = 0;
150
- for (const o of obstacles)
151
- if (blocked(waypoints, [o]))
152
- count++;
153
- return count;
197
+ /** True when the segment a→b passes through `o`, grazing allowed up to HIT_TOLERANCE. */
198
+ function segmentHits(a, b, o) {
199
+ const minX = Math.min(a.x, b.x) + HIT_TOLERANCE;
200
+ const maxX = Math.max(a.x, b.x) - HIT_TOLERANCE;
201
+ const minY = Math.min(a.y, b.y) + HIT_TOLERANCE;
202
+ const maxY = Math.max(a.y, b.y) - HIT_TOLERANCE;
203
+ if (maxX <= o.x || o.x + o.width <= minX)
204
+ return false;
205
+ if (maxY <= o.y || o.y + o.height <= minY)
206
+ return false;
207
+ return true;
154
208
  }
155
- function blocked(waypoints, obstacles) {
209
+ /** How many shapes a route passes through, its own endpoints excepted. */
210
+ function hitCount(ctx, waypoints, sourceId, targetId) {
211
+ const hit = new Set();
156
212
  for (let i = 0; i + 1 < waypoints.length; i++) {
157
213
  const a = waypoints[i];
158
214
  const b = waypoints[i + 1];
159
215
  if (!a || !b)
160
216
  continue;
161
- const minX = Math.min(a.x, b.x) + HIT_TOLERANCE;
162
- const maxX = Math.max(a.x, b.x) - HIT_TOLERANCE;
163
- const minY = Math.min(a.y, b.y) + HIT_TOLERANCE;
164
- const maxY = Math.max(a.y, b.y) - HIT_TOLERANCE;
165
- for (const o of obstacles) {
166
- if (maxX <= o.x || o.x + o.width <= minX)
167
- continue;
168
- if (maxY <= o.y || o.y + o.height <= minY)
169
- continue;
217
+ ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => {
218
+ if (o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b))
219
+ hit.add(o);
220
+ return false;
221
+ });
222
+ }
223
+ return hit.size;
224
+ }
225
+ /** True when any segment of the route passes through a shape other than its endpoints. */
226
+ function blocked(ctx, waypoints, sourceId, targetId) {
227
+ for (let i = 0; i + 1 < waypoints.length; i++) {
228
+ const a = waypoints[i];
229
+ const b = waypoints[i + 1];
230
+ if (!a || !b)
231
+ continue;
232
+ const hit = ctx.obstacles.query(Math.min(a.x, b.x), Math.max(a.x, b.x), (o) => o.id !== sourceId && o.id !== targetId && segmentHits(a, b, o.b));
233
+ if (hit)
170
234
  return true;
171
- }
172
235
  }
173
236
  return false;
174
237
  }
@@ -310,7 +373,7 @@ isLoop = false, sourceId = "", targetId = "") {
310
373
  const crossings = crossingCount([
311
374
  { x: left, y: option },
312
375
  { x: right, y: option },
313
- ], ctx.routed);
376
+ ], ctx);
314
377
  if (crossings < fewest) {
315
378
  fewest = crossings;
316
379
  base = option;
@@ -342,12 +405,8 @@ isLoop = false, sourceId = "", targetId = "") {
342
405
  if (chosen)
343
406
  return chosen.waypoints;
344
407
  // Neither is clear: keep whichever grazes fewer shapes.
345
- const obstacles = [];
346
- for (const [id, b] of ctx.bounds) {
347
- if (id !== sourceId && id !== targetId)
348
- obstacles.push(b);
349
- }
350
- return hitCount(straightOut, obstacles) <= hitCount(viaGutter, obstacles)
408
+ return hitCount(ctx, straightOut, sourceId, targetId) <=
409
+ hitCount(ctx, viaGutter, sourceId, targetId)
351
410
  ? straightOut
352
411
  : viaGutter;
353
412
  }
@@ -356,7 +415,7 @@ isLoop = false, sourceId = "", targetId = "") {
356
415
  * corridor is free, so two long routes stack instead of merging into one line.
357
416
  */
358
417
  function reserve(ctx, left, right, base, direction) {
359
- const hitsShape = (candidate) => [...ctx.bounds.values()].some((b) => b.x + b.width > left &&
418
+ const hitsShape = (candidate) => ctx.obstacles.query(left, right, ({ b }) => b.x + b.width > left &&
360
419
  b.x < right &&
361
420
  b.y - HIT_TOLERANCE < candidate &&
362
421
  candidate < b.y + b.height + HIT_TOLERANCE);
@@ -389,11 +448,12 @@ floor,
389
448
  /** Only consider corridors above this line. */
390
449
  ceiling) {
391
450
  const spans = [];
392
- for (const b of ctx.bounds.values()) {
451
+ ctx.obstacles.query(left, right, ({ b }) => {
393
452
  if (b.x + b.width > left && b.x < right) {
394
453
  spans.push([b.y - ROUTING_MARGIN, b.y + b.height + ROUTING_MARGIN]);
395
454
  }
396
- }
455
+ return false;
456
+ });
397
457
  if (spans.length === 0)
398
458
  return preferredY;
399
459
  spans.sort((a, b) => a[0] - b[0]);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Node-only entry point.
3
+ *
4
+ * Everything here touches the filesystem, so it lives behind the
5
+ * `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
6
+ * `node:` builtins and keeps working in browsers, workers and edge runtimes.
7
+ */
8
+ export { writeBpmn } from "./write.js";
9
+ export type { WriteBpmnOptions, WriteBpmnResult } from "./write.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Node-only entry point.
3
+ *
4
+ * Everything here touches the filesystem, so it lives behind the
5
+ * `@bpmnkit/core/node` subpath. Importing `@bpmnkit/core` itself stays free of
6
+ * `node:` builtins and keeps working in browsers, workers and edge runtimes.
7
+ */
8
+ export { writeBpmn } from "./write.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,81 @@
1
+ import type { BpmnDefinitions } from "../bpmn/bpmn-model.js";
2
+ import { type SemanticDiff } from "../bpmn/semantic-hash.js";
3
+ /**
4
+ * The only place in this package that writes a BPMN file, and the only one that
5
+ * checks what it wrote.
6
+ *
7
+ * Node-only — it is reached through the `@bpmnkit/core/node` subpath so that
8
+ * importing `@bpmnkit/core` in a browser never pulls `node:fs` in.
9
+ *
10
+ * **What the verification does and does not cover.** Before anything reaches
11
+ * disk, the model is serialised, parsed back, and the two semantic hashes are
12
+ * compared. That catches the serialiser dropping or mangling something. It
13
+ * cannot catch the *parser* dropping something on the way in: content the
14
+ * parser never saw is absent from both sides and the hashes agree. Guarding
15
+ * that is the round-trip corpus gate's job (`tests/roundtrip-corpus.test.ts`),
16
+ * not this function's.
17
+ *
18
+ * There is deliberately no option to skip verification. Turning it off would
19
+ * only ever be used to get past the bug it exists to report; callers who want
20
+ * unchecked serialisation can still use `Bpmn.export()` and write it
21
+ * themselves.
22
+ */
23
+ export interface WriteBpmnOptions {
24
+ /** Path to write to. */
25
+ output: string;
26
+ /**
27
+ * Replace `output` if it already exists. Default `false`, which refuses
28
+ * rather than overwrite.
29
+ */
30
+ force?: boolean;
31
+ /**
32
+ * `"preserve"` (default) writes the diagram the model already carries.
33
+ * `"auto"` regenerates it first — the model is unchanged either way, which
34
+ * the verification step proves.
35
+ */
36
+ layout?: "preserve" | "auto";
37
+ }
38
+ export interface WriteBpmnResult {
39
+ /** Absolute path written. */
40
+ destination: string;
41
+ /** Size of the written file in bytes. */
42
+ bytes: number;
43
+ /** SHA-256 of the exact bytes written. */
44
+ outputSha256: string;
45
+ /** Semantic hash of the model, as verified after reading it back. */
46
+ semanticHash: string;
47
+ /**
48
+ * What this write changed about the file that was already there, or
49
+ * `undefined` when the destination was newly created or the previous
50
+ * contents could not be parsed.
51
+ */
52
+ changes?: SemanticDiff;
53
+ }
54
+ /**
55
+ * Serialises a model, verifies it survives a round trip, and writes it
56
+ * atomically.
57
+ *
58
+ * The file appears complete or not at all: the contents go to a temporary file
59
+ * in the destination's own directory and are then renamed into place, so an
60
+ * interrupted write cannot leave a half-written model behind. Without `force`
61
+ * the final step is a hard link, which fails if the destination appeared in the
62
+ * meantime rather than silently replacing it.
63
+ *
64
+ * @param definitions - The model to write.
65
+ * @param options - Destination and write behaviour.
66
+ * @returns Where it went, what it hashes to, and what it changed.
67
+ * @throws {WriteVerificationError} If reading the output back does not
68
+ * reproduce the model. Nothing is written.
69
+ * @throws {WriteError} If the destination exists and `force` was not given, or
70
+ * the filesystem refused the write.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * import { writeBpmn } from "@bpmnkit/core/node"
75
+ *
76
+ * const result = await writeBpmn(definitions, { output: "flow.bpmn" })
77
+ * console.log(result.semanticHash, result.changes?.changed.length ?? 0)
78
+ * ```
79
+ */
80
+ export declare function writeBpmn(definitions: BpmnDefinitions, options: WriteBpmnOptions): Promise<WriteBpmnResult>;
81
+ //# sourceMappingURL=write.d.ts.map
@@ -0,0 +1,167 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, link, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { applyAutoLayout } from "../bpmn/auto-layout.js";
5
+ import { parseBpmn } from "../bpmn/bpmn-parser.js";
6
+ import { serializeBpmn } from "../bpmn/bpmn-serializer.js";
7
+ import { diffSemantics, semanticHash } from "../bpmn/semantic-hash.js";
8
+ import { sha256Hex } from "../bpmn/sha256.js";
9
+ import { WriteError, WriteVerificationError } from "../errors.js";
10
+ async function pathExists(path) {
11
+ try {
12
+ await stat(path);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ if (isErrnoCode(error, "ENOENT"))
17
+ return false;
18
+ throw error;
19
+ }
20
+ }
21
+ function isErrnoCode(error, code) {
22
+ return error instanceof Error && "code" in error && error.code === code;
23
+ }
24
+ /** Codes returned by filesystems that cannot make a hard link. */
25
+ const NO_HARD_LINKS = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "EMLINK"]);
26
+ /**
27
+ * Serialises a model, verifies it survives a round trip, and writes it
28
+ * atomically.
29
+ *
30
+ * The file appears complete or not at all: the contents go to a temporary file
31
+ * in the destination's own directory and are then renamed into place, so an
32
+ * interrupted write cannot leave a half-written model behind. Without `force`
33
+ * the final step is a hard link, which fails if the destination appeared in the
34
+ * meantime rather than silently replacing it.
35
+ *
36
+ * @param definitions - The model to write.
37
+ * @param options - Destination and write behaviour.
38
+ * @returns Where it went, what it hashes to, and what it changed.
39
+ * @throws {WriteVerificationError} If reading the output back does not
40
+ * reproduce the model. Nothing is written.
41
+ * @throws {WriteError} If the destination exists and `force` was not given, or
42
+ * the filesystem refused the write.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * import { writeBpmn } from "@bpmnkit/core/node"
47
+ *
48
+ * const result = await writeBpmn(definitions, { output: "flow.bpmn" })
49
+ * console.log(result.semanticHash, result.changes?.changed.length ?? 0)
50
+ * ```
51
+ */
52
+ export async function writeBpmn(definitions, options) {
53
+ const destination = resolve(options.output);
54
+ const force = options.force === true;
55
+ const exists = await pathExists(destination);
56
+ // Fail before doing the work, not after it.
57
+ if (exists && !force) {
58
+ throw new WriteError(`Refusing to overwrite ${options.output}. Pass force: true to replace it.`);
59
+ }
60
+ // applyAutoLayout returns a new model, so the caller's stays untouched.
61
+ const model = options.layout === "auto" ? applyAutoLayout(definitions) : definitions;
62
+ const expected = semanticHash(model);
63
+ const xml = serializeBpmn(model);
64
+ let reparsed;
65
+ try {
66
+ reparsed = parseBpmn(xml);
67
+ }
68
+ catch (error) {
69
+ const reason = error instanceof Error ? error.message : String(error);
70
+ throw new WriteVerificationError(`Serialising the model produced BPMN that cannot be parsed back: ${reason}`, { added: [], removed: [], changed: [] });
71
+ }
72
+ const actual = semanticHash(reparsed);
73
+ if (actual !== expected) {
74
+ const changes = diffSemantics(model, reparsed);
75
+ throw new WriteVerificationError([
76
+ "Serialising the model did not reproduce it, so nothing was written.",
77
+ `Lost: ${changes.removed.join(", ") || "none"}.`,
78
+ `Added: ${changes.added.join(", ") || "none"}.`,
79
+ `Altered: ${changes.changed.map((entry) => entry.id).join(", ") || "none"}.`,
80
+ ].join(" "), changes);
81
+ }
82
+ const changes = exists ? await changesAgainst(destination, reparsed) : undefined;
83
+ await writeAtomically(destination, xml, force);
84
+ return {
85
+ destination,
86
+ bytes: Buffer.byteLength(xml, "utf-8"),
87
+ outputSha256: sha256Hex(xml),
88
+ semanticHash: actual,
89
+ changes,
90
+ };
91
+ }
92
+ /**
93
+ * Diffs the model about to be written against the one already on disk. A
94
+ * previous file that cannot be read or parsed yields no report rather than
95
+ * failing the write — the old contents are being replaced either way.
96
+ */
97
+ async function changesAgainst(destination, next) {
98
+ try {
99
+ return diffSemantics(parseBpmn(await readFile(destination, "utf-8")), next);
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ async function writeAtomically(destination, contents, force) {
106
+ const temporary = resolve(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`);
107
+ try {
108
+ await writeFile(temporary, contents, { encoding: "utf-8", flag: "wx" });
109
+ if (force) {
110
+ // Keep the permissions the file already had; a rename would otherwise
111
+ // hand it whatever the temporary file was created with.
112
+ const mode = await modeOf(destination);
113
+ if (mode !== undefined)
114
+ await chmod(temporary, mode);
115
+ await rename(temporary, destination);
116
+ return;
117
+ }
118
+ await linkOrCreateExclusively(temporary, destination, contents);
119
+ }
120
+ catch (error) {
121
+ if (error instanceof WriteError)
122
+ throw error;
123
+ if (isErrnoCode(error, "EEXIST")) {
124
+ throw new WriteError(`Refusing to overwrite ${destination}: it appeared while writing. Pass force: true to replace it.`);
125
+ }
126
+ const reason = error instanceof Error ? error.message : String(error);
127
+ throw new WriteError(`Unable to write ${destination}: ${reason}`);
128
+ }
129
+ finally {
130
+ await rm(temporary, { force: true });
131
+ }
132
+ }
133
+ /**
134
+ * Creates the destination without replacing anything. `link` fails with EEXIST
135
+ * if the destination is taken, which `rename` would not, and it publishes the
136
+ * already-complete temporary file in one step.
137
+ *
138
+ * Filesystems without hard links fall back to an exclusive create, which is
139
+ * still safe against replacing an existing file but writes in place rather than
140
+ * atomically — an interrupted write there can leave a partial file.
141
+ */
142
+ async function linkOrCreateExclusively(temporary, destination, contents) {
143
+ try {
144
+ await link(temporary, destination);
145
+ }
146
+ catch (error) {
147
+ if (!isErrnoCode(error, "EEXIST") && isNoHardLinkSupport(error)) {
148
+ await writeFile(destination, contents, { encoding: "utf-8", flag: "wx" });
149
+ return;
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+ function isNoHardLinkSupport(error) {
155
+ return (error instanceof Error &&
156
+ "code" in error &&
157
+ NO_HARD_LINKS.has(error.code ?? ""));
158
+ }
159
+ async function modeOf(path) {
160
+ try {
161
+ return (await stat(path)).mode;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ //# sourceMappingURL=write.js.map
@@ -1,12 +1,20 @@
1
1
  const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
2
2
  const ID_SIZE = 8;
3
+ // Random bytes are drawn in batches: one getRandomValues call per id is
4
+ // dominated by the crypto call overhead, not by the bytes it returns.
5
+ const POOL_SIZE = 1024;
6
+ const pool = new Uint8Array(POOL_SIZE);
7
+ let poolOffset = POOL_SIZE;
3
8
  function nanoId() {
4
- const bytes = new Uint8Array(ID_SIZE);
5
- crypto.getRandomValues(bytes);
9
+ if (poolOffset + ID_SIZE > POOL_SIZE) {
10
+ crypto.getRandomValues(pool);
11
+ poolOffset = 0;
12
+ }
6
13
  let id = "";
7
14
  for (let i = 0; i < ID_SIZE; i++) {
8
- id += ALPHABET[bytes[i] % ALPHABET.length];
15
+ id += ALPHABET[pool[poolOffset + i] % ALPHABET.length];
9
16
  }
17
+ poolOffset += ID_SIZE;
10
18
  return id;
11
19
  }
12
20
  // Counter used only in deterministic test mode (activated by resetIdCounter())
@@ -1,2 +1,4 @@
1
- export { parseXml, serializeXml } from "./xml-parser.js";
1
+ export { parseXml, scanXml, serializeXml } from "./xml-parser.js";
2
+ export { Visit } from "./xml-parser.js";
3
+ export type { XmlSink } from "./xml-parser.js";
2
4
  //# sourceMappingURL=index.d.ts.map
package/dist/xml/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export { parseXml, serializeXml } from "./xml-parser.js";
1
+ export { parseXml, scanXml, serializeXml } from "./xml-parser.js";
2
+ export { Visit } from "./xml-parser.js";
2
3
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,36 @@
1
1
  import type { XmlElement } from "../types/xml-element.js";
2
+ /** What a sink wants the scanner to do with an element after its start tag. */
3
+ export declare const Visit: {
4
+ /** Report child elements and character data. */
5
+ readonly All: 0;
6
+ /**
7
+ * Skip the element's content: the scanner fast-forwards past the matching
8
+ * end tag without building attributes or decoding text for anything inside,
9
+ * and `end` is not called for it.
10
+ */
11
+ readonly Skip: 1;
12
+ /** Report child elements but drop character data without decoding it. */
13
+ readonly ElementsOnly: 2;
14
+ };
15
+ export type Visit = (typeof Visit)[keyof typeof Visit];
16
+ /** Receives the events of one XML document from {@link scanXml}. */
17
+ export interface XmlSink {
18
+ /**
19
+ * @param name qualified name, e.g. "bpmn:task"
20
+ * @param local name without its prefix, e.g. "task" (same string as `name` when unprefixed)
21
+ * @param selfClosing true for `<a/>`; `end` follows immediately
22
+ */
23
+ start(name: string, local: string, attributes: Record<string, string>, selfClosing: boolean): Visit;
24
+ /** Character data or CDATA directly inside the current element, entities decoded. */
25
+ text(text: string): void;
26
+ end(name: string): void;
27
+ }
28
+ /**
29
+ * Scan an XML document, reporting the root element and everything inside it to
30
+ * `sink`. Returns false when the document has no root element. Content after
31
+ * the root element is ignored.
32
+ */
33
+ export declare function scanXml(xml: string, sink: XmlSink): boolean;
2
34
  /**
3
35
  * Parse an XML string into an XmlElement tree.
4
36
  * Returns the root element with all namespace prefixes preserved.