@spfn/core 0.3.0-beta.17 → 0.3.0-beta.19

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.
@@ -1,10 +1,11 @@
1
+ import { E as EventRouterDef } from '../router-CysBmgv3.js';
1
2
  import { R as Router } from '../router-BqyEARoY.js';
2
- import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-ClQVomgV.js';
3
- export { d as CompatibilityPolicy, e as ContractRequest, f as ContractViolationKind, J as JsonSchema } from '../types-ClQVomgV.js';
3
+ import { C as ContractDocument, a as ContractViolation, b as ContractOperation, c as ContractSnapshot } from '../types-B4DuGCyM.js';
4
+ export { d as CompatibilityPolicy, e as ContractEvent, f as ContractEvents, g as ContractRequest, h as ContractViolationKind, J as JsonSchema } from '../types-B4DuGCyM.js';
5
+ import '@sinclair/typebox';
4
6
  import '../define-middleware-CVKgqo8S.js';
5
7
  import 'hono';
6
8
  import '../route-builder-2ani2jEI.js';
7
- import '@sinclair/typebox';
8
9
  import 'hono/utils/http-status';
9
10
  import '../route/types.js';
10
11
 
@@ -12,7 +13,8 @@ import '../route/types.js';
12
13
  * Contract Collection
13
14
  *
14
15
  * Walks a loaded router and turns every route carrying `.contract()` into a
15
- * contract operation.
16
+ * contract operation, and every event of an event router carrying
17
+ * `.contract()` into a contract event.
16
18
  *
17
19
  * The router is loaded and walked rather than parsed from source. Real routes
18
20
  * build their schemas from imported values — `EmailSchema`, `FileSchema()`,
@@ -26,11 +28,14 @@ declare class ContractCollectionError extends Error {
26
28
  constructor(message: string);
27
29
  }
28
30
  /**
29
- * Build the contract document from a loaded router.
31
+ * Build the contract document from a loaded router and, optionally, the app's
32
+ * event router.
30
33
  *
31
- * Routes without `.contract()` are skipped — they are not part of the promise.
34
+ * Routes and events without `.contract()` are skipped — they are not part of
35
+ * the promise. A document with no contracted event carries no `events` key, so
36
+ * it is byte-identical to one collected without an event router.
32
37
  */
33
- declare function collectContractDocument(router: Router<any>): ContractDocument;
38
+ declare function collectContractDocument(router: Router<any>, eventRouter?: EventRouterDef<any>): ContractDocument;
34
39
 
35
40
  /**
36
41
  * Backward-compatibility comparison
@@ -52,6 +57,10 @@ declare function collectContractDocument(router: Router<any>): ContractDocument;
52
57
  * | required → optional | pass | refuse |
53
58
  * | optional → required | refuse | pass |
54
59
  * | type changed | refuse | refuse |
60
+ *
61
+ * An event payload flows server → client like a response, so it is compared
62
+ * under the response rules by the same walk, and only the violation kinds are
63
+ * renamed.
55
64
  */
56
65
 
57
66
  interface DocumentComparison {
@@ -63,14 +72,19 @@ interface DocumentComparison {
63
72
  * whether any released app still calls it, which is a separate check.
64
73
  */
65
74
  removedOperations: string[];
75
+ /**
76
+ * Events present in the baseline and gone from the current contract —
77
+ * decided against usage files exactly as a removed operation is.
78
+ */
79
+ removedEvents: string[];
66
80
  }
67
81
  /** Compare one operation that exists on both sides. */
68
82
  declare function compareOperation(before: ContractOperation, after: ContractOperation): ContractViolation[];
69
83
  /**
70
84
  * Compare a released contract against the one this build produced.
71
85
  *
72
- * Operations that are new in `after` need no check — nothing has been promised
73
- * about them yet.
86
+ * Operations and events that are new in `after` need no check — nothing has
87
+ * been promised about them yet.
74
88
  */
75
89
  declare function compareDocuments(before: ContractDocument, after: ContractDocument): DocumentComparison;
76
90
 
@@ -79,7 +93,7 @@ declare function compareDocuments(before: ContractDocument, after: ContractDocum
79
93
  *
80
94
  * Puts the pieces together: read the newest released snapshot, compare this
81
95
  * build's contract against it, and decide removals against what released
82
- * clients still call.
96
+ * clients still call or subscribe to.
83
97
  *
84
98
  * Nothing released yet is a pass — with a warning. "This is the first contract"
85
99
  * and "the release that should have written a snapshot didn't" produce the same
@@ -181,12 +195,12 @@ declare function readCurrentDocument(contractsDir: string): ContractDocument;
181
195
  declare function writeCurrentDocument(contractsDir: string, document: ContractDocument): boolean;
182
196
 
183
197
  /**
184
- * Usage files — who still calls an operation
198
+ * Usage files — who still calls an operation or subscribes to an event
185
199
  *
186
200
  * A released app is compiled and shipped; the server cannot ask it what it
187
- * calls. So each released client writes down the operations it uses, and those
188
- * files are what a removal is judged against:
189
- * `contracts/usage/<platform>-<appVersion>.json`.
201
+ * calls. So each released client writes down the operations it uses and the
202
+ * events it subscribes to, and those files are what a removal is judged
203
+ * against: `contracts/usage/<platform>-<appVersion>.json`.
190
204
  *
191
205
  * The one rule this file exists to hold: **an unreadable file and "nobody calls
192
206
  * it" are not the same answer.** An empty scan result reading as a pass is how
@@ -198,6 +212,12 @@ interface UsageRecord {
198
212
  platform: string;
199
213
  appVersion: string;
200
214
  operations: string[];
215
+ /**
216
+ * Contracted events the client subscribes to. Always set when read from a
217
+ * file — a file without the key subscribes to none — and optional only so a
218
+ * record built by hand before events were contracted still type-checks.
219
+ */
220
+ events?: string[];
201
221
  /** File this came from, for messages. */
202
222
  file: string;
203
223
  }
@@ -217,5 +237,7 @@ type UsageReadResult = {
217
237
  declare function readUsageRecords(usageDir: string): UsageReadResult;
218
238
  /** Which released clients still call `operation`. */
219
239
  declare function callersOf(operation: string, records: UsageRecord[]): UsageRecord[];
240
+ /** Which released clients still subscribe to `event`. */
241
+ declare function subscribersOf(event: string, records: UsageRecord[]): UsageRecord[];
220
242
 
221
- export { CURRENT_FILENAME, type ContractCheckResult, ContractCollectionError, ContractDocument, ContractOperation, ContractSnapshot, ContractSnapshotError, ContractViolation, type DocumentComparison, RELEASED_DIRNAME, type SnapshotFile, USAGE_DIRNAME, type UsageReadResult, type UsageRecord, callersOf, canonicalize, checkContract, collectContractDocument, compareDocuments, compareOperation, compareVersions, currentPath, formatViolations, listSnapshots, newestSnapshot, readCurrentDocument, readSnapshot, readUsageRecords, releasedDir, stableDigest, stableStringify, stableStringifyPretty, usageDir, writeCurrentDocument, writeSnapshot };
243
+ export { CURRENT_FILENAME, type ContractCheckResult, ContractCollectionError, ContractDocument, ContractOperation, ContractSnapshot, ContractSnapshotError, ContractViolation, type DocumentComparison, RELEASED_DIRNAME, type SnapshotFile, USAGE_DIRNAME, type UsageReadResult, type UsageRecord, callersOf, canonicalize, checkContract, collectContractDocument, compareDocuments, compareOperation, compareVersions, currentPath, formatViolations, listSnapshots, newestSnapshot, readCurrentDocument, readSnapshot, readUsageRecords, releasedDir, stableDigest, stableStringify, stableStringifyPretty, subscribersOf, usageDir, writeCurrentDocument, writeSnapshot };
@@ -2,6 +2,12 @@ import { createHash } from 'crypto';
2
2
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
 
5
+ // src/event/sse/paths.ts
6
+ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
7
+ function sseTokenPath(streamPath) {
8
+ return streamPath.replace(/\/[^/]+$/, "/token");
9
+ }
10
+
5
11
  // src/contract/collect.ts
6
12
  var ContractCollectionError = class extends Error {
7
13
  constructor(message) {
@@ -104,15 +110,65 @@ function addOperation(name, routeDef, trail, found) {
104
110
  }
105
111
  });
106
112
  }
107
- function collectContractDocument(router) {
113
+ var RESERVED_EVENT_NAMES = /* @__PURE__ */ new Set(["connected", "ping"]);
114
+ function byName(a, b) {
115
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
116
+ }
117
+ function toContractEvent(name, eventDef) {
118
+ const contract = eventDef._contract;
119
+ if (RESERVED_EVENT_NAMES.has(name)) {
120
+ throw new ContractCollectionError(
121
+ `Contracted event "${name}" uses a reserved frame name. The stream itself sends "connected" and "ping" frames, so a client could not tell this event from them. Register the event under another key.`
122
+ );
123
+ }
124
+ if (!contract.since) {
125
+ throw new ContractCollectionError(
126
+ `Contracted event "${name}" has no "since" version. The version an event first appeared in is part of the promise.`
127
+ );
128
+ }
129
+ if (!eventDef.schema) {
130
+ throw new ContractCollectionError(
131
+ `Contracted event "${name}" declares no payload schema. Its frame carries no data, so there is nothing for the contract to describe. Declare Type.Object({}) and emit {} instead.`
132
+ );
133
+ }
134
+ return {
135
+ name,
136
+ since: contract.since,
137
+ ...contract.deprecatedIn ? { deprecatedIn: contract.deprecatedIn } : {},
138
+ ...contract.removedIn ? { removedIn: contract.removedIn } : {},
139
+ payload: toJsonSchema(eventDef.schema)
140
+ };
141
+ }
142
+ function collectEvents(eventRouter) {
143
+ const contracted = Object.entries(eventRouter?.events ?? {}).filter(([, eventDef]) => eventDef._contract);
144
+ if (contracted.length === 0) {
145
+ return void 0;
146
+ }
147
+ const routerContract = eventRouter._contract;
148
+ if (!routerContract) {
149
+ throw new ContractCollectionError(
150
+ `Event router defineEventRouter({ ${eventRouter.eventNames.join(", ")} }) contracts events but declares no .contract() of its own. Without it the contract cannot say whether a client must exchange a token before opening the stream. Add .contract({ auth: 'none' }) or .contract({ auth: 'tokenExchange' }) to the router.`
151
+ );
152
+ }
153
+ const streamPath = routerContract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
154
+ return {
155
+ streamPath,
156
+ tokenPath: sseTokenPath(streamPath),
157
+ auth: routerContract.auth,
158
+ items: contracted.map(([name, eventDef]) => toContractEvent(name, eventDef)).sort(byName)
159
+ };
160
+ }
161
+ function collectContractDocument(router, eventRouter) {
108
162
  const found = /* @__PURE__ */ new Map();
109
163
  visitRouter(router, ["router"], found);
110
- const operations = [...found.values()].map((entry) => entry.operation).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
164
+ const operations = [...found.values()].map((entry) => entry.operation).sort(byName);
165
+ const events = collectEvents(eventRouter);
111
166
  return {
112
167
  documentVersion: 1,
113
168
  ...router._contractVersion ? { contractVersion: router._contractVersion } : {},
114
169
  compatibilityPolicy: "perOperation",
115
- operations
170
+ operations,
171
+ ...events ? { events } : {}
116
172
  };
117
173
  }
118
174
  function canonicalize(value) {
@@ -279,6 +335,52 @@ function compareOperation(before, after) {
279
335
  );
280
336
  return violations;
281
337
  }
338
+ var PAYLOAD_KINDS = {
339
+ "response.field-removed": "event.payload.field-removed",
340
+ "response.field-became-optional": "event.payload.field-became-optional",
341
+ "response.type-changed": "event.payload.type-changed"
342
+ };
343
+ function compareEvent(before, after) {
344
+ const found = [];
345
+ compareSchema(before.payload, after.payload, "payload", { operation: before.name, side: "response", violations: found });
346
+ return found.map(({ kind, operation, ...rest }) => ({ ...rest, kind: PAYLOAD_KINDS[kind] ?? kind, event: operation }));
347
+ }
348
+ function compareStream(before, after) {
349
+ const violations = [];
350
+ if (before.streamPath !== after.streamPath) {
351
+ violations.push({
352
+ kind: "events.stream-path-changed",
353
+ detail: `stream path moved ${before.streamPath} \u2192 ${after.streamPath}; released clients open the old one`
354
+ });
355
+ }
356
+ if (before.auth !== after.auth) {
357
+ violations.push({
358
+ kind: "events.auth-changed",
359
+ detail: `stream auth changed ${before.auth} \u2192 ${after.auth}; released clients connect the old way`
360
+ });
361
+ }
362
+ return violations;
363
+ }
364
+ function compareEvents(before, after) {
365
+ if (!before) {
366
+ return { violations: [], removedEvents: [] };
367
+ }
368
+ if (!after) {
369
+ return { violations: [], removedEvents: before.items.map((item) => item.name) };
370
+ }
371
+ const current = new Map(after.items.map((item) => [item.name, item]));
372
+ const violations = compareStream(before, after);
373
+ const removedEvents = [];
374
+ for (const item of before.items) {
375
+ const now = current.get(item.name);
376
+ if (!now) {
377
+ removedEvents.push(item.name);
378
+ continue;
379
+ }
380
+ violations.push(...compareEvent(item, now));
381
+ }
382
+ return { violations, removedEvents };
383
+ }
282
384
  function compareDocuments(before, after) {
283
385
  const current = new Map(after.operations.map((operation) => [operation.name, operation]));
284
386
  const violations = [];
@@ -291,7 +393,12 @@ function compareDocuments(before, after) {
291
393
  }
292
394
  violations.push(...compareOperation(operation, now));
293
395
  }
294
- return { violations, removedOperations };
396
+ const events = compareEvents(before.events, after.events);
397
+ return {
398
+ violations: [...violations, ...events.violations],
399
+ removedOperations,
400
+ removedEvents: events.removedEvents
401
+ };
295
402
  }
296
403
  var ContractSnapshotError = class extends Error {
297
404
  constructor(message) {
@@ -454,10 +561,15 @@ function parseRecord(file, raw) {
454
561
  if (!Array.isArray(parsed.operations) || parsed.operations.some((name) => typeof name !== "string")) {
455
562
  throw new Error('"operations" must be an array of operation names');
456
563
  }
564
+ const events = parsed.events ?? [];
565
+ if (!Array.isArray(events) || events.some((name) => typeof name !== "string")) {
566
+ throw new Error('"events" must be an array of event names');
567
+ }
457
568
  return {
458
569
  platform: parsed.platform,
459
570
  appVersion: parsed.appVersion,
460
571
  operations: parsed.operations,
572
+ events,
461
573
  file
462
574
  };
463
575
  }
@@ -489,6 +601,9 @@ function readUsageRecords(usageDir2) {
489
601
  function callersOf(operation, records) {
490
602
  return records.filter((record) => record.operations.includes(operation));
491
603
  }
604
+ function subscribersOf(event, records) {
605
+ return records.filter((record) => (record.events ?? []).includes(event));
606
+ }
492
607
 
493
608
  // src/contract/check.ts
494
609
  function checkContract(contractsDir, current) {
@@ -514,45 +629,54 @@ function checkContract(contractsDir, current) {
514
629
  }]
515
630
  };
516
631
  }
517
- const { violations, removedOperations } = compareDocuments(previous, current);
632
+ const { violations, removedOperations, removedEvents } = compareDocuments(previous, current);
518
633
  return {
519
634
  baselineVersion: baseline.version,
520
635
  warnings: [],
521
- violations: [...violations, ...judgeRemovals(contractsDir, removedOperations)]
636
+ violations: [...violations, ...judgeRemovals(contractsDir, removedOperations, removedEvents)]
522
637
  };
523
638
  }
524
- function judgeRemovals(contractsDir, removedOperations) {
525
- if (removedOperations.length === 0) {
639
+ function judgeRemovals(contractsDir, removedOperations, removedEvents) {
640
+ const removed = [...removedOperations, ...removedEvents];
641
+ if (removed.length === 0) {
526
642
  return [];
527
643
  }
528
644
  const usage = readUsageRecords(usageDir(contractsDir));
529
645
  if (!usage.decidable) {
530
646
  return [{
531
647
  kind: "usage.undecidable",
532
- detail: `${removedOperations.join(", ")} would be removed, but no released client's call list could be read (${usage.reason}). Not knowing who calls an operation is not the same as knowing nobody does.`
648
+ detail: `${removed.join(", ")} would be removed, but no released client's usage list could be read (${usage.reason}). Not knowing who uses it is not the same as knowing nobody does.`
533
649
  }];
534
650
  }
535
- const violations = [];
536
- for (const operation of removedOperations) {
537
- const callers = callersOf(operation, usage.records);
538
- if (callers.length === 0) {
539
- continue;
540
- }
541
- violations.push({
542
- kind: "usage.still-called",
543
- operation,
544
- detail: "still called by " + callers.map((caller) => `${caller.platform} ${caller.appVersion}`).join(", ")
545
- });
651
+ return [
652
+ ...removedOperations.flatMap((operation) => stillUsed(
653
+ { kind: "usage.still-called", operation },
654
+ "still called by",
655
+ callersOf(operation, usage.records)
656
+ )),
657
+ ...removedEvents.flatMap((event) => stillUsed(
658
+ { kind: "usage.still-subscribed", event },
659
+ "still subscribed to by",
660
+ subscribersOf(event, usage.records)
661
+ ))
662
+ ];
663
+ }
664
+ function stillUsed(subject, verb, users) {
665
+ if (users.length === 0) {
666
+ return [];
546
667
  }
547
- return violations;
668
+ return [{
669
+ ...subject,
670
+ detail: `${verb} ${users.map((user) => `${user.platform} ${user.appVersion}`).join(", ")}`
671
+ }];
548
672
  }
549
673
  function formatViolations(violations) {
550
674
  return violations.map((violation) => {
551
- const where = [violation.operation, violation.location].filter(Boolean).join(" ");
675
+ const where = [violation.operation ?? violation.event, violation.location].filter(Boolean).join(" ");
552
676
  return ` - [${violation.kind}]${where ? ` ${where}` : ""}: ${violation.detail}`;
553
677
  }).join("\n");
554
678
  }
555
679
 
556
- export { CURRENT_FILENAME, ContractCollectionError, ContractSnapshotError, RELEASED_DIRNAME, USAGE_DIRNAME, callersOf, canonicalize, checkContract, collectContractDocument, compareDocuments, compareOperation, compareVersions, currentPath, formatViolations, listSnapshots, newestSnapshot, readCurrentDocument, readSnapshot, readUsageRecords, releasedDir, stableDigest, stableStringify, stableStringifyPretty, usageDir, writeCurrentDocument, writeSnapshot };
680
+ export { CURRENT_FILENAME, ContractCollectionError, ContractSnapshotError, RELEASED_DIRNAME, USAGE_DIRNAME, callersOf, canonicalize, checkContract, collectContractDocument, compareDocuments, compareOperation, compareVersions, currentPath, formatViolations, listSnapshots, newestSnapshot, readCurrentDocument, readSnapshot, readUsageRecords, releasedDir, stableDigest, stableStringify, stableStringifyPretty, subscribersOf, usageDir, writeCurrentDocument, writeSnapshot };
557
681
  //# sourceMappingURL=index.js.map
558
682
  //# sourceMappingURL=index.js.map