tempest-react-sdk 0.81.0 → 0.82.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 (179) hide show
  1. package/README.md +5 -6
  2. package/bin/create-tempest-app.e2e.test.mjs +31 -0
  3. package/bin/create-tempest-app.mjs +11 -1
  4. package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
  5. package/bin/lib/doctor/vitest.mjs +120 -0
  6. package/bin/lib/doctor/vitest.test.mjs +139 -0
  7. package/bin/lib/fs/contain.mjs +7 -0
  8. package/bin/lib/openapi/escape.mjs +68 -4
  9. package/bin/lib/openapi/generate.hostile.test.mjs +137 -0
  10. package/bin/lib/openapi/generate.mjs +78 -13
  11. package/bin/lib/openapi/pattern-risk.mjs +134 -0
  12. package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
  13. package/bin/tempest-gen.test.mjs +100 -0
  14. package/bin/tempest.mjs +24 -8
  15. package/dist/access/use-can.cjs +1 -1
  16. package/dist/access/use-can.cjs.map +1 -1
  17. package/dist/access/use-can.js +25 -28
  18. package/dist/access/use-can.js.map +1 -1
  19. package/dist/auth/create-tempest-auth.cjs +1 -1
  20. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  21. package/dist/auth/create-tempest-auth.js +192 -149
  22. package/dist/auth/create-tempest-auth.js.map +1 -1
  23. package/dist/auth/session-identity.cjs +2 -0
  24. package/dist/auth/session-identity.cjs.map +1 -0
  25. package/dist/auth/session-identity.js +12 -0
  26. package/dist/auth/session-identity.js.map +1 -0
  27. package/dist/br/boleto.cjs.map +1 -1
  28. package/dist/br/boleto.js.map +1 -1
  29. package/dist/components/AuthForm/AuthLinks.cjs +1 -1
  30. package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
  31. package/dist/components/AuthForm/AuthLinks.js +18 -15
  32. package/dist/components/AuthForm/AuthLinks.js.map +1 -1
  33. package/dist/components/AuthPage/AuthPage.cjs +1 -1
  34. package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
  35. package/dist/components/AuthPage/AuthPage.js +58 -52
  36. package/dist/components/AuthPage/AuthPage.js.map +1 -1
  37. package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
  38. package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
  39. package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
  40. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  41. package/dist/components/Button/Button.cjs +1 -1
  42. package/dist/components/Button/Button.cjs.map +1 -1
  43. package/dist/components/Button/Button.js +41 -40
  44. package/dist/components/Button/Button.js.map +1 -1
  45. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  46. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  47. package/dist/components/Chat/ChatAttachments.js +38 -38
  48. package/dist/components/Form/Form.cjs +1 -1
  49. package/dist/components/Form/Form.cjs.map +1 -1
  50. package/dist/components/Form/Form.js +30 -32
  51. package/dist/components/Form/Form.js.map +1 -1
  52. package/dist/components/Layout/Layout.cjs +1 -1
  53. package/dist/components/Layout/Layout.cjs.map +1 -1
  54. package/dist/components/Layout/Layout.js +25 -24
  55. package/dist/components/Layout/Layout.js.map +1 -1
  56. package/dist/components/Layout/gap.cjs +2 -0
  57. package/dist/components/Layout/gap.cjs.map +1 -0
  58. package/dist/components/Layout/gap.js +15 -0
  59. package/dist/components/Layout/gap.js.map +1 -0
  60. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  61. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  62. package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
  63. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  64. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  65. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  66. package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
  67. package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
  68. package/dist/components/Sidebar/SidebarEntry.js +30 -29
  69. package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
  70. package/dist/data/create-data-provider.cjs +1 -1
  71. package/dist/data/create-data-provider.cjs.map +1 -1
  72. package/dist/data/create-data-provider.js +1 -2
  73. package/dist/data/create-data-provider.js.map +1 -1
  74. package/dist/forms/validate-form.cjs +1 -1
  75. package/dist/forms/validate-form.cjs.map +1 -1
  76. package/dist/forms/validate-form.js +6 -1
  77. package/dist/forms/validate-form.js.map +1 -1
  78. package/dist/forms/zod-resolver.cjs +1 -1
  79. package/dist/forms/zod-resolver.cjs.map +1 -1
  80. package/dist/forms/zod-resolver.js +8 -3
  81. package/dist/forms/zod-resolver.js.map +1 -1
  82. package/dist/http/api-client.cjs +1 -1
  83. package/dist/http/api-client.cjs.map +1 -1
  84. package/dist/http/api-client.js +90 -72
  85. package/dist/http/api-client.js.map +1 -1
  86. package/dist/http/credential-scope.cjs +1 -1
  87. package/dist/http/credential-scope.cjs.map +1 -1
  88. package/dist/http/credential-scope.js +33 -9
  89. package/dist/http/credential-scope.js.map +1 -1
  90. package/dist/http/presigned-post.cjs.map +1 -1
  91. package/dist/http/presigned-post.js.map +1 -1
  92. package/dist/http/resumable-upload.cjs +1 -1
  93. package/dist/http/resumable-upload.cjs.map +1 -1
  94. package/dist/http/resumable-upload.js +90 -84
  95. package/dist/http/resumable-upload.js.map +1 -1
  96. package/dist/http/upload-with-progress.cjs +1 -1
  97. package/dist/http/upload-with-progress.cjs.map +1 -1
  98. package/dist/http/upload-with-progress.js +8 -8
  99. package/dist/http/upload-with-progress.js.map +1 -1
  100. package/dist/oauth/GoogleSignIn.cjs +1 -1
  101. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  102. package/dist/oauth/GoogleSignIn.js +5 -4
  103. package/dist/oauth/GoogleSignIn.js.map +1 -1
  104. package/dist/offline/create-offline-sync.cjs +1 -1
  105. package/dist/offline/create-offline-sync.cjs.map +1 -1
  106. package/dist/offline/create-offline-sync.js +87 -69
  107. package/dist/offline/create-offline-sync.js.map +1 -1
  108. package/dist/query/offline-persistence.cjs +1 -1
  109. package/dist/query/offline-persistence.cjs.map +1 -1
  110. package/dist/query/offline-persistence.js +54 -44
  111. package/dist/query/offline-persistence.js.map +1 -1
  112. package/dist/sse/create-event-stream.cjs +1 -1
  113. package/dist/sse/create-event-stream.cjs.map +1 -1
  114. package/dist/sse/create-event-stream.js +48 -41
  115. package/dist/sse/create-event-stream.js.map +1 -1
  116. package/dist/sse/ticket-event-stream.cjs +1 -1
  117. package/dist/sse/ticket-event-stream.cjs.map +1 -1
  118. package/dist/sse/ticket-event-stream.js +48 -19
  119. package/dist/sse/ticket-event-stream.js.map +1 -1
  120. package/dist/sse/use-event-stream.cjs +1 -1
  121. package/dist/sse/use-event-stream.cjs.map +1 -1
  122. package/dist/sse/use-event-stream.js +19 -14
  123. package/dist/sse/use-event-stream.js.map +1 -1
  124. package/dist/sw/background-sync.cjs +1 -1
  125. package/dist/sw/background-sync.cjs.map +1 -1
  126. package/dist/sw/background-sync.js +10 -8
  127. package/dist/sw/background-sync.js.map +1 -1
  128. package/dist/sw/cache.cjs +1 -1
  129. package/dist/sw/cache.cjs.map +1 -1
  130. package/dist/sw/cache.js +36 -30
  131. package/dist/sw/cache.js.map +1 -1
  132. package/dist/sw/create-push-handler.cjs +1 -1
  133. package/dist/sw/create-push-handler.cjs.map +1 -1
  134. package/dist/sw/create-push-handler.js +20 -10
  135. package/dist/sw/create-push-handler.js.map +1 -1
  136. package/dist/sw.d.ts +44 -13
  137. package/dist/tabular/compact.cjs +1 -1
  138. package/dist/tabular/compact.cjs.map +1 -1
  139. package/dist/tabular/compact.js +35 -16
  140. package/dist/tabular/compact.js.map +1 -1
  141. package/dist/tabular/manifest.cjs +1 -1
  142. package/dist/tabular/manifest.cjs.map +1 -1
  143. package/dist/tabular/manifest.js +33 -15
  144. package/dist/tabular/manifest.js.map +1 -1
  145. package/dist/tabular.d.ts +11 -1
  146. package/dist/tempest-react-sdk.cjs +1 -1
  147. package/dist/tempest-react-sdk.d.ts +457 -68
  148. package/dist/tempest-react-sdk.js +224 -224
  149. package/dist/theme/initial-theme.cjs +4 -4
  150. package/dist/theme/initial-theme.cjs.map +1 -1
  151. package/dist/theme/initial-theme.js +8 -5
  152. package/dist/theme/initial-theme.js.map +1 -1
  153. package/dist/utils/compressed-storage.cjs +1 -1
  154. package/dist/utils/compressed-storage.cjs.map +1 -1
  155. package/dist/utils/compressed-storage.js +13 -7
  156. package/dist/utils/compressed-storage.js.map +1 -1
  157. package/dist/utils/objects.cjs +1 -1
  158. package/dist/utils/objects.cjs.map +1 -1
  159. package/dist/utils/objects.js +13 -7
  160. package/dist/utils/objects.js.map +1 -1
  161. package/dist/utils/safe-url.cjs +1 -1
  162. package/dist/utils/safe-url.cjs.map +1 -1
  163. package/dist/utils/safe-url.js +4 -1
  164. package/dist/utils/safe-url.js.map +1 -1
  165. package/dist/webrtc/peer-mesh.cjs +1 -1
  166. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  167. package/dist/webrtc/peer-mesh.js +7 -1
  168. package/dist/webrtc/peer-mesh.js.map +1 -1
  169. package/dist/ws/create-web-socket.cjs +1 -1
  170. package/dist/ws/create-web-socket.cjs.map +1 -1
  171. package/dist/ws/create-web-socket.js +77 -61
  172. package/dist/ws/create-web-socket.js.map +1 -1
  173. package/dist/ws/use-web-socket.cjs +1 -1
  174. package/dist/ws/use-web-socket.cjs.map +1 -1
  175. package/dist/ws/use-web-socket.js +4 -3
  176. package/dist/ws/use-web-socket.js.map +1 -1
  177. package/package.json +1 -1
  178. package/template-pwa/src/pwa.tsx +42 -0
  179. package/template-pwa/src/sw.ts +5 -4
@@ -1,6 +1,13 @@
1
1
  // OpenAPI 3.x → per-tag { schemas.ts (Zod), types.ts, service.ts (class) }.
2
2
  // Pure: takes the parsed spec object, returns a { path: contents } map. No I/O.
3
- import { assertNoCollision, commentText, toIdentifier, urlTemplate } from "./escape.mjs";
3
+ import {
4
+ assertNoCollision,
5
+ assertSafePath,
6
+ commentText,
7
+ toIdentifier,
8
+ urlTemplate,
9
+ } from "./escape.mjs";
10
+ import { hasNestedQuantifier } from "./pattern-risk.mjs";
4
11
  import { refName, schemaToZod, typeName, zodName } from "./schema-to-zod.mjs";
5
12
 
6
13
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
@@ -157,30 +164,77 @@ function isSegmentScalar(schema, schemas, depth = 0) {
157
164
  }
158
165
 
159
166
  /**
160
- * Check every `pattern` in a schema compiles as a JavaScript regex.
167
+ * Whether a JSON value has an own key `__proto__` at any depth.
161
168
  *
162
- * The generated Zod schema builds it with `new RegExp(...)` when the module is
163
- * imported, so an invalid one would only surface in the app at runtime — a blank
164
- * screen. Failing here names the schema and where in it the pattern sits.
169
+ * @param {unknown} value - A value parsed from the spec.
170
+ * @returns {boolean}
171
+ */
172
+ function hasProtoKey(value) {
173
+ if (!value || typeof value !== "object") return false;
174
+ if (Object.hasOwn(value, "__proto__")) return true;
175
+ return Object.values(value).some(hasProtoKey);
176
+ }
177
+
178
+ /**
179
+ * Check a component schema before it is turned into Zod source.
180
+ *
181
+ * - Every `pattern` must compile as a JavaScript regex. The generated schema
182
+ * builds it with `new RegExp(...)` when the module is imported, so an invalid
183
+ * one would only surface in the app at runtime — a blank screen.
184
+ * - A `pattern` with the nested-quantifier shape ({@link hasNestedQuantifier})
185
+ * is pushed to `warnings`: it compiles, and it can freeze the tab on one
186
+ * crafted value, but a heuristic does not get to fail the run.
187
+ * - A property, or a key inside a `default`, named `__proto__` is refused. The
188
+ * generated source is an object literal, where `"__proto__": ...` sets the
189
+ * prototype instead of adding a key: the field vanished from the Zod shape and
190
+ * the value went unvalidated, and Zod 4 drops the key even when it is written
191
+ * as a computed `["__proto__"]` (measured on 4.6.7: a required `__proto__:
192
+ * z.string()` accepted `{}` and `5`). There is no source that validates it, so
193
+ * the spec is refused (#533).
194
+ *
195
+ * Every failure names the schema and where in it the problem sits.
165
196
  *
166
197
  * @param {unknown} node - The schema (sub)node.
167
- * @param {string} schema - The component schema name, for the error.
198
+ * @param {string} schema - The component schema name, for the messages.
199
+ * @param {string[]} warnings - Collects the non-fatal findings.
168
200
  * @param {string[]} [at] - Keys from the schema root to `node`.
169
- * @throws {Error} When a pattern does not compile.
201
+ * @throws {Error} When a pattern does not compile, or a `__proto__` key would be
202
+ * emitted.
170
203
  */
171
- function assertPatterns(node, schema, at = []) {
204
+ function checkSchema(node, schema, warnings, at = []) {
172
205
  if (!node || typeof node !== "object") return;
206
+ const where = `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}`;
173
207
  if (typeof node.pattern === "string") {
174
208
  try {
175
209
  new RegExp(node.pattern);
176
210
  } catch (err) {
177
211
  throw new Error(
178
- `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
212
+ `${where}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
179
213
  );
180
214
  }
215
+ if (hasNestedQuantifier(node.pattern)) {
216
+ warnings.push(
217
+ `${where}: pattern ${JSON.stringify(node.pattern)} repeats a group that ends in a repetition; it can backtrack exponentially on a near-miss value and freeze the tab that validates it.`,
218
+ );
219
+ }
220
+ }
221
+ if (
222
+ node.properties &&
223
+ typeof node.properties === "object" &&
224
+ Object.hasOwn(node.properties, "__proto__")
225
+ ) {
226
+ throw new Error(
227
+ `${where}: a property named "__proto__" cannot be validated — in the generated object literal it sets the prototype instead of declaring the field. Rename it in the spec.`,
228
+ );
229
+ }
230
+ if (hasProtoKey(node.default)) {
231
+ throw new Error(
232
+ `${where}: the default value has a "__proto__" key, which the generated object literal would turn into its prototype. Remove it from the spec.`,
233
+ );
181
234
  }
182
235
  for (const [key, value] of Object.entries(node)) {
183
- if (key !== "pattern") assertPatterns(value, schema, [...at, key]);
236
+ if (key !== "pattern" && key !== "default")
237
+ checkSchema(value, schema, warnings, [...at, key]);
184
238
  }
185
239
  }
186
240
 
@@ -432,8 +486,15 @@ function compareNames(a, b) {
432
486
  * because a browser `fetch` cannot set `Cookie` — the browser sends its own
433
487
  * cookies with `credentials`, which is how a cookie parameter reaches the server.
434
488
  *
489
+ * Every path key that has an operation goes through {@link assertSafePath}, and
490
+ * every emitted component schema through {@link checkSchema}: spec text that
491
+ * would turn into a call to another origin, or into an object literal whose
492
+ * prototype the spec picks, fails the run before anything is written (#533).
493
+ *
435
494
  * @param {object} doc - The parsed OpenAPI 3.x spec.
436
- * @returns {{ files: Record<string, string>, tags: string[] }}
495
+ * @returns {{ files: Record<string, string>, tags: string[], services: { slug: string, className: string }[], warnings: string[] }}
496
+ * The files by relative path; each group's spec tag, folder and service class
497
+ * (in the same order); and the non-fatal findings the CLI prints.
437
498
  */
438
499
  export function generate(doc) {
439
500
  const schemas = doc.components?.schemas ?? {};
@@ -444,6 +505,7 @@ export function generate(doc) {
444
505
  for (const method of HTTP_METHODS) {
445
506
  const op = item[method];
446
507
  if (!op) continue;
508
+ assertSafePath(path);
447
509
  const tag = op.tags?.[0] ?? "default";
448
510
  const slug = tagSlug(tag);
449
511
  if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
@@ -459,10 +521,13 @@ export function generate(doc) {
459
521
 
460
522
  const files = {};
461
523
  const tags = [];
524
+ const services = [];
525
+ const warnings = [];
462
526
 
463
527
  for (const { tag, slug, ops } of groups.values()) {
464
528
  tags.push(tag);
465
529
  const Class = `${slugIdent(pascal(slug))}Service`;
530
+ services.push({ slug, className: Class });
466
531
 
467
532
  const usedNames = new Set(CLASS_MEMBERS);
468
533
  const signature = { refs: new Set(), validates: false, sendsHeaders: false };
@@ -483,7 +548,7 @@ export function generate(doc) {
483
548
  sorted.map((n) => [n, typeName(n)]),
484
549
  "schema names",
485
550
  );
486
- for (const name of sorted) assertPatterns(schemas[name], name);
551
+ for (const name of sorted) checkSchema(schemas[name], name, warnings);
487
552
 
488
553
  const schemaLines = sorted.map((name) => {
489
554
  const expr = schemaToZod(schemas[name], (ref) => {
@@ -542,7 +607,7 @@ export function generate(doc) {
542
607
  .map((slug) => `export * as ${slugIdent(slug)} from "./${slug}";`)
543
608
  .join("\n") + "\n";
544
609
 
545
- return { files, tags };
610
+ return { files, tags, services, warnings };
546
611
  }
547
612
 
548
613
  /**
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Static check for the regular expressions a spec's `pattern` keywords become.
3
+ *
4
+ * The generated Zod schema compiles each `pattern` with `new RegExp(...)` and
5
+ * runs it on every value the app validates. V8 backtracks, so a pattern with a
6
+ * repeated group whose body can itself match a variable amount — `^(a+)+$` — is
7
+ * exponential on a near-miss input: 28 characters took 873 ms and every extra
8
+ * one doubles it (Node 24.15, `/^(a+)+$/.test("a".repeat(28) + "!")`). The app
9
+ * tab freezes on its own data. A spec is input the developer does not control,
10
+ * so `tempest gen api` flags such a pattern when it generates the schema (#533).
11
+ */
12
+
13
+ /**
14
+ * Parse the quantifier starting at `pattern[i]`, if any.
15
+ *
16
+ * @param {string} pattern - The regex source.
17
+ * @param {number} i - Index right after an atom.
18
+ * @returns {{ min: number, max: number, length: number } | null} The bounds
19
+ * (`max` is `Infinity` when unbounded) and how many characters the quantifier
20
+ * spans, its lazy `?` included; `null` when no quantifier starts there.
21
+ */
22
+ function quantifierAt(pattern, i) {
23
+ const ch = pattern[i];
24
+ let q = null;
25
+ if (ch === "*") q = { min: 0, max: Infinity, length: 1 };
26
+ else if (ch === "+") q = { min: 1, max: Infinity, length: 1 };
27
+ else if (ch === "?") q = { min: 0, max: 1, length: 1 };
28
+ else if (ch === "{") {
29
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(pattern.slice(i));
30
+ if (m) {
31
+ const min = Number(m[1]);
32
+ const max = m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
33
+ q = { min, max, length: m[0].length };
34
+ }
35
+ }
36
+ if (q && pattern[i + q.length] === "?") q.length += 1;
37
+ return q;
38
+ }
39
+
40
+ /**
41
+ * Index right after the character class that opens at `pattern[i]` (`[`).
42
+ *
43
+ * @param {string} pattern - The regex source.
44
+ * @param {number} i - Index of the `[`.
45
+ * @returns {number} Index after the closing `]`, or the pattern's length.
46
+ */
47
+ function classEnd(pattern, i) {
48
+ let j = i + 1;
49
+ while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1;
50
+ return j + 1;
51
+ }
52
+
53
+ /**
54
+ * Length of a group's opening syntax at `pattern[i]` (`(`, `(?:`, `(?=`,
55
+ * `(?<name>`, ...), so its `?` is not read as a quantifier.
56
+ *
57
+ * @param {string} pattern - The regex source.
58
+ * @param {number} i - Index of the `(`.
59
+ * @returns {number} Characters to skip.
60
+ */
61
+ function groupOpenLength(pattern, i) {
62
+ if (pattern[i + 1] !== "?") return 1;
63
+ if (pattern[i + 2] === "<" && pattern[i + 3] !== "=" && pattern[i + 3] !== "!") {
64
+ const close = pattern.indexOf(">", i);
65
+ return close === -1 ? 1 : close - i + 1;
66
+ }
67
+ return pattern[i + 2] === "<" ? 4 : 3;
68
+ }
69
+
70
+ /**
71
+ * Whether a regex source has a repeated group whose body ends in a
72
+ * variable-length repetition — the nested-quantifier shape behind most
73
+ * catastrophic backtracking (`(a+)+`, `(\w+\s?)*`, `((x*))+`).
74
+ *
75
+ * "Ends in" is what keeps the common safe patterns quiet: in
76
+ * `^([a-z0-9]+-)*[a-z0-9]+$` every iteration must end on the mandatory `-`, so
77
+ * the inner `+` cannot trade characters with the next iteration and the group is
78
+ * not flagged; only optional atoms (`?`, `*`, `{0,n}`) may follow the inner
79
+ * repetition for the group to count. Fixed counts (`(\d{3})+`) are not variable
80
+ * and do not count.
81
+ *
82
+ * This is a heuristic for one shape, not a proof of safety: overlapping
83
+ * alternation (`(a|aa)+`) and backreferences are not analysed. A flag is a
84
+ * warning to read the pattern, not a verdict.
85
+ *
86
+ * @param {string} pattern - The regex source from the spec.
87
+ * @returns {boolean} `true` when the nested-quantifier shape is present.
88
+ */
89
+ export function hasNestedQuantifier(pattern) {
90
+ const frames = [{ tail: false, anyAlternative: false }];
91
+ let i = 0;
92
+ while (i < pattern.length) {
93
+ const ch = pattern[i];
94
+ const frame = frames[frames.length - 1];
95
+ if (ch === "|") {
96
+ frame.anyAlternative ||= frame.tail;
97
+ frame.tail = false;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (ch === "(") {
102
+ frames.push({ tail: false, anyAlternative: false });
103
+ i += groupOpenLength(pattern, i);
104
+ continue;
105
+ }
106
+ let groupTail = false;
107
+ let isGroup = false;
108
+ if (ch === ")") {
109
+ if (frames.length === 1) {
110
+ i += 1;
111
+ continue;
112
+ }
113
+ const closed = frames.pop();
114
+ groupTail = closed.anyAlternative || closed.tail;
115
+ isGroup = true;
116
+ i += 1;
117
+ } else if (ch === "[") {
118
+ i = classEnd(pattern, i);
119
+ } else if (ch === "\\") {
120
+ i += 2;
121
+ } else {
122
+ i += 1;
123
+ }
124
+ const outer = frames[frames.length - 1];
125
+ const q = quantifierAt(pattern, i);
126
+ if (q) i += q.length;
127
+ const repeats = q !== null && q.max > 1;
128
+ if (isGroup && groupTail && repeats) return true;
129
+ const variable = repeats && q.max > q.min;
130
+ if (variable || (isGroup && groupTail && !repeats)) outer.tail = true;
131
+ else if (!q || q.min > 0) outer.tail = false;
132
+ }
133
+ return false;
134
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { hasNestedQuantifier } from "./pattern-risk.mjs";
4
+
5
+ describe("hasNestedQuantifier — flags the repeated-repetition shape (#533)", () => {
6
+ it.each([
7
+ "^(a+)+$",
8
+ "^(a*)*$",
9
+ "(\\w+\\s?)*",
10
+ "^((x+))+$",
11
+ "(?:a+){2,}",
12
+ "(?<word>\\d+)+",
13
+ "^(a|b+)*$",
14
+ "(a{1,5})+",
15
+ ])("%s is flagged", (pattern) => {
16
+ expect(hasNestedQuantifier(pattern)).toBe(true);
17
+ });
18
+
19
+ it.each([
20
+ "^[a-z]+$",
21
+ "^([a-z0-9]+-)*[a-z0-9]+$",
22
+ "^(\\d{3})+$",
23
+ "^(ab)+$",
24
+ "^(a+)$",
25
+ "^(a+)?$",
26
+ "[(a+)+]",
27
+ "\\(a+\\)+",
28
+ "^(?=a+)b$",
29
+ "^\\d{5}-?\\d{3}$",
30
+ "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
31
+ ")a+(",
32
+ ])("%s is not flagged", (pattern) => {
33
+ expect(hasNestedQuantifier(pattern)).toBe(false);
34
+ });
35
+
36
+ it("the flagged shape really is exponential in V8, so the warning is not theoretical", () => {
37
+ const time = (n) => {
38
+ const start = performance.now();
39
+ /^(a+)+$/.test(`${"a".repeat(n)}!`);
40
+ return performance.now() - start;
41
+ };
42
+ time(14);
43
+ expect(time(22)).toBeGreaterThan(time(14) * 4);
44
+ });
45
+ });
@@ -186,3 +186,103 @@ describe("tempest gen api / gen icons — write containment (#484)", () => {
186
186
  expect(filesUnder(outside)).toEqual([]);
187
187
  });
188
188
  });
189
+
190
+ describe("tempest gen api — spec text never drives the terminal (#533)", () => {
191
+ const TERMINAL_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
192
+ const SGR = /\u001b\[\d+(?:;\d+)*m/g;
193
+ let cwd;
194
+
195
+ beforeEach(() => {
196
+ cwd = mkdtempSync(join(tmpdir(), "tempest-gen-tty-"));
197
+ mkdirSync(join(cwd, "src"));
198
+ });
199
+
200
+ afterEach(() => {
201
+ rmSync(cwd, { recursive: true, force: true });
202
+ });
203
+
204
+ /**
205
+ * Write `doc` as the project's spec and run `tempest gen api` on it.
206
+ *
207
+ * @param {object} doc - The OpenAPI document.
208
+ * @returns {{ status: number | null, out: string }} Exit code and the
209
+ * combined output with the CLI's own color codes removed.
210
+ */
211
+ function genApi(doc) {
212
+ writeFileSync(join(cwd, "spec.json"), JSON.stringify(doc));
213
+ const result = spawnSync(process.execPath, [CLI, "gen", "api", "spec.json"], {
214
+ cwd,
215
+ encoding: "utf8",
216
+ });
217
+ return { status: result.status, out: (result.stdout + result.stderr).replace(SGR, "") };
218
+ }
219
+
220
+ it("a tag carrying OSC, CSI and carriage-return sequences prints without them", () => {
221
+ const tag = "users\u001b]0;PWNED\u0007\u001b]8;;https://evil/\u001b\\x\u009b2J\r";
222
+ const { status, out } = genApi({
223
+ ...SPEC,
224
+ paths: {
225
+ "/users": { get: { tags: [tag], responses: { 200: { description: "ok" } } } },
226
+ },
227
+ });
228
+
229
+ expect(status).toBe(0);
230
+ expect(out).not.toMatch(TERMINAL_CONTROL);
231
+ expect(out).not.toContain("\u001b]");
232
+ expect(out).toContain("users]0;PWNED]8;;https://evil/\\x2J");
233
+ });
234
+
235
+ it("the usage hint names the folder and class that were generated", () => {
236
+ const { out } = genApi({
237
+ ...SPEC,
238
+ paths: {
239
+ "/users": {
240
+ get: { tags: ["User Profiles"], responses: { 200: { description: "ok" } } },
241
+ },
242
+ },
243
+ });
244
+
245
+ expect(out).toContain('import { UserProfilesService } from "@/api/user-profiles";');
246
+ });
247
+
248
+ it("a refusal that quotes a C1 control from the spec prints without it", () => {
249
+ const { status, out } = genApi({
250
+ ...SPEC,
251
+ paths: { "https://x\u009b2J/": { get: { responses: { 200: { description: "ok" } } } } },
252
+ });
253
+
254
+ expect(status).toBe(1);
255
+ expect(out).toMatch(/does not start with "\/"/);
256
+ expect(out).not.toMatch(TERMINAL_CONTROL);
257
+ });
258
+
259
+ it("a nested-quantifier pattern is printed as a warning and the files are still written", () => {
260
+ const { status, out } = genApi({
261
+ ...SPEC,
262
+ components: {
263
+ schemas: {
264
+ Q: { type: "object", properties: { s: { type: "string", pattern: "(a+)+" } } },
265
+ },
266
+ },
267
+ paths: {
268
+ "/users": {
269
+ post: {
270
+ tags: ["users"],
271
+ requestBody: {
272
+ content: {
273
+ "application/json": { schema: { $ref: "#/components/schemas/Q" } },
274
+ },
275
+ },
276
+ responses: { 200: { description: "ok" } },
277
+ },
278
+ },
279
+ },
280
+ });
281
+
282
+ expect(status).toBe(0);
283
+ expect(out).toMatch(
284
+ /! OpenAPI schema "Q" at properties\.s: pattern "\(a\+\)\+" repeats a group/,
285
+ );
286
+ expect(existsSync(join(cwd, "src", "api", "users", "schemas.ts"))).toBe(true);
287
+ });
288
+ });
package/bin/tempest.mjs CHANGED
@@ -27,7 +27,9 @@ import {
27
27
  } from "./lib/css/index.mjs";
28
28
  import { analyzeDesign } from "./lib/design/index.mjs";
29
29
  import { checkLucide } from "./lib/doctor/lucide.mjs";
30
+ import { checkVitest, VITEST_CONFIG_FILES } from "./lib/doctor/vitest.mjs";
30
31
  import { generateRegistry, loadIconTables } from "./lib/icons/generate.mjs";
32
+ import { terminalText } from "./lib/openapi/escape.mjs";
31
33
  import { generate } from "./lib/openapi/generate.mjs";
32
34
  import { loadSpec } from "./lib/openapi/load.mjs";
33
35
 
@@ -789,6 +791,18 @@ function doctor(args = []) {
789
791
  ],
790
792
  );
791
793
  }
794
+ if (usesSdk) {
795
+ checks.push(
796
+ ...checkVitest({
797
+ vitestInstalled: Boolean(installedVersion("vitest")),
798
+ configs: Object.fromEntries(
799
+ VITEST_CONFIG_FILES.filter((name) => existsSync(join(ROOT, name))).map(
800
+ (name) => [name, readFileSync(join(ROOT, name), "utf8")],
801
+ ),
802
+ ),
803
+ }),
804
+ );
805
+ }
792
806
  const entry = firstExisting(["src/main.tsx", "src/main.ts", "src/index.tsx", "src/index.ts"]);
793
807
  if (usesSdk) {
794
808
  // Without the stylesheet every component renders unstyled, so for an SDK
@@ -1330,7 +1344,7 @@ async function genApi(args) {
1330
1344
  }
1331
1345
  console.log(`${c.dim}→ loading ${source}${c.reset}`);
1332
1346
  const doc = await loadSpec(source);
1333
- const { files, tags } = generate(doc);
1347
+ const { files, tags, services, warnings } = generate(doc);
1334
1348
 
1335
1349
  const targets = Object.entries(files).map(([rel, contents]) => ({
1336
1350
  dest: join(outDir, rel),
@@ -1349,8 +1363,11 @@ async function genApi(args) {
1349
1363
  }
1350
1364
 
1351
1365
  console.log(
1352
- `\n${c.green}✓ Generated${c.reset} ${Object.keys(files).length} files for ${tags.length} route group(s): ${c.bold}${tags.join(", ")}${c.reset}`,
1366
+ `\n${c.green}✓ Generated${c.reset} ${Object.keys(files).length} files for ${tags.length} route group(s): ${c.bold}${terminalText(tags.join(", "))}${c.reset}`,
1353
1367
  );
1368
+ for (const warning of warnings) {
1369
+ console.log(`${c.yellow}! ${terminalText(warning)}${c.reset}`);
1370
+ }
1354
1371
  console.log(` ${c.dim}out: ${outDir}${c.reset}`);
1355
1372
  const prettier = localBin("prettier");
1356
1373
  if (prettier) {
@@ -1359,12 +1376,11 @@ async function genApi(args) {
1359
1376
  }
1360
1377
  console.log(`\n${c.dim}Inject an ApiClient into a service:${c.reset}`);
1361
1378
  console.log(` import { createApiClient } from "tempest-react-sdk";`);
1362
- if (tags[0]) {
1363
- const cls = tags[0].replace(/[^a-zA-Z0-9]+(.)?/g, (_, ch) => (ch ? ch.toUpperCase() : ""));
1364
- const Cls = cls.charAt(0).toUpperCase() + cls.slice(1) + "Service";
1365
- console.log(` import { ${Cls} } from "@/api/${tags[0].toLowerCase()}";`);
1379
+ if (services[0]) {
1380
+ const { slug, className } = services[0];
1381
+ console.log(` import { ${className} } from "@/api/${slug}";`);
1366
1382
  console.log(
1367
- ` const svc = new ${Cls}(createApiClient({ baseURL: import.meta.env.VITE_API_URL }));`,
1383
+ ` const svc = new ${className}(createApiClient({ baseURL: import.meta.env.VITE_API_URL }));`,
1368
1384
  );
1369
1385
  }
1370
1386
  return 0;
@@ -1721,7 +1737,7 @@ if (result instanceof Promise) {
1721
1737
  .then((code) => process.exit(code))
1722
1738
  .catch((err) => {
1723
1739
  console.error(
1724
- `${c.red}✗ ${err instanceof Error ? err.message : String(err)}${c.reset}`,
1740
+ `${c.red}✗ ${terminalText(err instanceof Error ? err.message : String(err))}${c.reset}`,
1725
1741
  );
1726
1742
  process.exit(1);
1727
1743
  });
@@ -1,2 +1,2 @@
1
- const e=require("./access-control-context.cjs");let t=require("react");function n(e){return typeof e==`boolean`?{can:e}:e}function r(r){let i=e.useAccessControl(),a=JSON.stringify(r),[o,s]=(0,t.useState)(()=>i?{allowed:!1,isLoading:!0}:{allowed:!0,isLoading:!1});return(0,t.useEffect)(()=>{if(!i){s({allowed:!0,isLoading:!1});return}let e=!1;return s(e=>({...e,isLoading:!0})),Promise.resolve(i.can(r)).then(t=>{if(e)return;let r=n(t);s({allowed:r.can,isLoading:!1,reason:r.reason})}).catch(t=>{e||s({allowed:!1,isLoading:!1,reason:t instanceof Error?t.message:`access check failed`})}),()=>{e=!0}},[i,a]),o}exports.useCan=r;
1
+ const e=require("./access-control-context.cjs");let t=require("react");var n={allowed:!1,isLoading:!0},r={allowed:!0,isLoading:!1};function i(e){return typeof e==`boolean`?{can:e}:e}function a(a){let o=e.useAccessControl(),s=JSON.stringify(a),[c,l]=(0,t.useState)(null);return(0,t.useEffect)(()=>{if(!o)return;let e=!1,t=t=>{e||l({control:o,key:s,result:t})};return Promise.resolve(o.can(a)).then(e=>{let n=i(e);t({allowed:n.can,isLoading:!1,reason:n.reason})}).catch(e=>{t({allowed:!1,isLoading:!1,reason:e instanceof Error?e.message:`access check failed`})}),()=>{e=!0}},[o,s]),o?c===null||c.control!==o||c.key!==s?n:c.result:r}exports.useCan=a;
2
2
  //# sourceMappingURL=use-can.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-can.cjs","names":["useAccessControl","useState","require_access_control_context","react","normalize","result","useCan","control","paramsKey","params","state","setState","cancelled","prev","raw","error"],"sources":["../../src/access/use-can.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { useAccessControl } from \"./access-control-context\";\nimport type { CanParams, CanResult } from \"./types\";\n\nexport interface UseCanResult {\n /** Whether the action is permitted. Defaults to `true` while loading is not the concern of the caller. */\n allowed: boolean;\n /** `true` while an async `can` check is pending. */\n isLoading: boolean;\n /** Optional explanation, typically present when `allowed` is `false`. */\n reason?: string;\n}\n\nfunction normalize(result: boolean | CanResult): CanResult {\n return typeof result === \"boolean\" ? { can: result } : result;\n}\n\n/**\n * Resolve an access check against the {@link AccessControl} in context.\n *\n * Handles sync booleans, sync {@link CanResult}, and promises of either. When\n * no provider is present, access is allowed (see `useAccessControl`). The check\n * re-runs whenever the params change.\n *\n * @param params - The action/resource being checked.\n */\nexport function useCan(params: CanParams): UseCanResult {\n const control = useAccessControl();\n const paramsKey = JSON.stringify(params);\n\n const [state, setState] = useState<UseCanResult>(() =>\n control ? { allowed: false, isLoading: true } : { allowed: true, isLoading: false },\n );\n\n useEffect(() => {\n if (!control) {\n setState({ allowed: true, isLoading: false });\n return;\n }\n\n let cancelled = false;\n setState((prev) => ({ ...prev, isLoading: true }));\n\n Promise.resolve(control.can(params))\n .then((raw) => {\n if (cancelled) return;\n const result = normalize(raw);\n setState({ allowed: result.can, isLoading: false, reason: result.reason });\n })\n .catch((error: unknown) => {\n if (cancelled) return;\n setState({\n allowed: false,\n isLoading: false,\n reason: error instanceof Error ? error.message : \"access check failed\",\n });\n });\n\n return () => {\n cancelled = true;\n };\n // `paramsKey` is the stable stringified form of `params`.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [control, paramsKey]);\n\n return state;\n}\n"],"mappings":"A,uEAaA,SAASI,EAAU,EAAwC,CACvD,OAAO,OAAOC,GAAW,UAAY,CAAE,IAAKA,CAAO,EAAIA,CAC3D,CAWA,SAAgBC,EAAO,EAAiC,CACpD,IAAMC,EAAUP,EAAAA,iBAAiB,EAC3BQ,EAAY,KAAK,UAAUC,CAAM,EAEjC,CAACC,EAAOC,IAAA,EAAYV,EAAAA,SAAAA,KACtBM,EAAU,CAAE,QAAS,GAAO,UAAW,EAAK,EAAI,CAAE,QAAS,GAAM,UAAW,EAAM,CACtF,EAiCA,OA/BA,EAAAJ,EAAA,UAAA,KAAgB,CACZ,GAAI,CAACI,EAAS,CACVI,EAAS,CAAE,QAAS,GAAM,UAAW,EAAM,CAAC,EAC5C,MACJ,CAEA,IAAIC,EAAY,GAkBhB,OAjBAD,EAAU,IAAU,CAAE,GAAGE,EAAM,UAAW,EAAK,EAAE,EAEjD,QAAQ,QAAQN,EAAQ,IAAIE,CAAM,CAAC,CAAC,CAC/B,KAAM,GAAQ,CACX,GAAIG,EAAW,OACf,IAAMP,EAASD,EAAUU,CAAG,EAC5BH,EAAS,CAAE,QAASN,EAAO,IAAK,UAAW,GAAO,OAAQA,EAAO,MAAO,CAAC,CAC7E,CAAC,CAAC,CACD,MAAO,GAAmB,CACnBO,GACJD,EAAS,CACL,QAAS,GACT,UAAW,GACX,OAAQI,aAAiB,MAAQA,EAAM,QAAU,qBACrD,CAAC,CACL,CAAC,MAEQ,CACT,EAAY,EAChB,CAGJ,EAAG,CAACR,EAASC,CAAS,CAAC,EAEhBE,CACX,C"}
1
+ {"version":3,"file":"use-can.cjs","names":["useAccessControl","useState","require_access_control_context","react","PENDING","ALLOW_ALL","normalize","result","useCan","control","paramsKey","params","resolved","setResolved","cancelled","settle","raw","error"],"sources":["../../src/access/use-can.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { useAccessControl } from \"./access-control-context\";\nimport type { AccessControl, CanParams, CanResult } from \"./types\";\n\nexport interface UseCanResult {\n /** Whether the action is permitted. Defaults to `true` while loading is not the concern of the caller. */\n allowed: boolean;\n /** `true` while an async `can` check is pending. */\n isLoading: boolean;\n /** Optional explanation, typically present when `allowed` is `false`. */\n reason?: string;\n}\n\n/** The answer for a check that has not resolved yet. */\nconst PENDING: UseCanResult = { allowed: false, isLoading: true };\n\n/** The answer when no provider is mounted. */\nconst ALLOW_ALL: UseCanResult = { allowed: true, isLoading: false };\n\n/** A resolved answer together with the check it answers. */\ninterface ResolvedCheck {\n control: AccessControl;\n key: string;\n result: UseCanResult;\n}\n\nfunction normalize(result: boolean | CanResult): CanResult {\n return typeof result === \"boolean\" ? { can: result } : result;\n}\n\n/**\n * Resolve an access check against the {@link AccessControl} in context.\n *\n * Handles sync booleans, sync {@link CanResult}, and promises of either. When\n * no provider is present, access is allowed (see `useAccessControl`). The check\n * re-runs whenever the params change; they are compared by their\n * `JSON.stringify` form, so an inline object literal does not re-run it on\n * every render.\n *\n * A resolved answer is stored with the control and the params it answers, and\n * is only returned while both still match. Until 0.81.0 the previous answer was\n * returned with `isLoading: false` for the render between a params change and\n * the effect that re-ran the check, so `<Can>` showed the children for the new\n * resource for one frame on the strength of the old resource's permission. The\n * first render for new params now reads `{ allowed: false, isLoading: true }`.\n *\n * @param params - The action/resource being checked.\n * @returns The answer for exactly these params, or the pending state.\n */\nexport function useCan(params: CanParams): UseCanResult {\n const control = useAccessControl();\n const paramsKey = JSON.stringify(params);\n const [resolved, setResolved] = useState<ResolvedCheck | null>(null);\n\n useEffect(() => {\n if (!control) return;\n let cancelled = false;\n const settle = (result: UseCanResult): void => {\n if (!cancelled) setResolved({ control, key: paramsKey, result });\n };\n\n Promise.resolve(control.can(params))\n .then((raw) => {\n const result = normalize(raw);\n settle({ allowed: result.can, isLoading: false, reason: result.reason });\n })\n .catch((error: unknown) => {\n settle({\n allowed: false,\n isLoading: false,\n reason: error instanceof Error ? error.message : \"access check failed\",\n });\n });\n\n return () => {\n cancelled = true;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [control, paramsKey]);\n\n if (!control) return ALLOW_ALL;\n if (resolved === null || resolved.control !== control || resolved.key !== paramsKey) {\n return PENDING;\n }\n return resolved.result;\n}\n"],"mappings":"A,uEAcA,IAAMI,EAAwB,CAAE,QAAS,GAAO,UAAW,EAAK,EAG1DC,EAA0B,CAAE,QAAS,GAAM,UAAW,EAAM,EASlE,SAASC,EAAU,EAAwC,CACvD,OAAO,OAAOC,GAAW,UAAY,CAAE,IAAKA,CAAO,EAAIA,CAC3D,CAqBA,SAAgBC,EAAO,EAAiC,CACpD,IAAMC,EAAUT,EAAAA,iBAAiB,EAC3BU,EAAY,KAAK,UAAUC,CAAM,EACjC,CAACC,EAAUC,IAAA,EAAeZ,EAAAA,SAAAA,CAA+B,IAAI,EAgCnE,OA9BA,EAAAE,EAAA,UAAA,KAAgB,CACZ,GAAI,CAACM,EAAS,OACd,IAAIK,EAAY,GACVC,EAAU,GAA+B,CACtCD,GAAWD,EAAY,CAAE,UAAS,IAAKH,EAAW,QAAO,CAAC,CACnE,EAeA,OAbA,QAAQ,QAAQD,EAAQ,IAAIE,CAAM,CAAC,CAAC,CAC/B,KAAM,GAAQ,CACX,IAAMJ,EAASD,EAAUU,CAAG,EAC5BD,EAAO,CAAE,QAASR,EAAO,IAAK,UAAW,GAAO,OAAQA,EAAO,MAAO,CAAC,CAC3E,CAAC,CAAC,CACD,MAAO,GAAmB,CACvBQ,EAAO,CACH,QAAS,GACT,UAAW,GACX,OAAQE,aAAiB,MAAQA,EAAM,QAAU,qBACrD,CAAC,CACL,CAAC,MAEQ,CACT,EAAY,EAChB,CAEJ,EAAG,CAACR,EAASC,CAAS,CAAC,EAElBD,EACDG,IAAa,MAAQA,EAAS,UAAYH,GAAWG,EAAS,MAAQF,EAC/DN,EAEJQ,EAAS,OAJKP,CAKzB,C"}
@@ -1,49 +1,46 @@
1
1
  import { useAccessControl as e } from "./access-control-context.js";
2
2
  import { useEffect as t, useState as n } from "react";
3
3
  //#region src/access/use-can.ts
4
- function r(e) {
4
+ var r = {
5
+ allowed: !1,
6
+ isLoading: !0
7
+ }, i = {
8
+ allowed: !0,
9
+ isLoading: !1
10
+ };
11
+ function a(e) {
5
12
  return typeof e == "boolean" ? { can: e } : e;
6
13
  }
7
- function i(i) {
8
- let a = e(), o = JSON.stringify(i), [s, c] = n(() => a ? {
9
- allowed: !1,
10
- isLoading: !0
11
- } : {
12
- allowed: !0,
13
- isLoading: !1
14
- });
14
+ function o(o) {
15
+ let s = e(), c = JSON.stringify(o), [l, u] = n(null);
15
16
  return t(() => {
16
- if (!a) {
17
- c({
18
- allowed: !0,
19
- isLoading: !1
17
+ if (!s) return;
18
+ let e = !1, t = (t) => {
19
+ e || u({
20
+ control: s,
21
+ key: c,
22
+ result: t
20
23
  });
21
- return;
22
- }
23
- let e = !1;
24
- return c((e) => ({
25
- ...e,
26
- isLoading: !0
27
- })), Promise.resolve(a.can(i)).then((t) => {
28
- if (e) return;
29
- let n = r(t);
30
- c({
24
+ };
25
+ return Promise.resolve(s.can(o)).then((e) => {
26
+ let n = a(e);
27
+ t({
31
28
  allowed: n.can,
32
29
  isLoading: !1,
33
30
  reason: n.reason
34
31
  });
35
- }).catch((t) => {
36
- e || c({
32
+ }).catch((e) => {
33
+ t({
37
34
  allowed: !1,
38
35
  isLoading: !1,
39
- reason: t instanceof Error ? t.message : "access check failed"
36
+ reason: e instanceof Error ? e.message : "access check failed"
40
37
  });
41
38
  }), () => {
42
39
  e = !0;
43
40
  };
44
- }, [a, o]), s;
41
+ }, [s, c]), s ? l === null || l.control !== s || l.key !== c ? r : l.result : i;
45
42
  }
46
43
  //#endregion
47
- export { i as useCan };
44
+ export { o as useCan };
48
45
 
49
46
  //# sourceMappingURL=use-can.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-can.js","names":["normalize","result","useCan","control","useAccessControl","paramsKey","params","state","setState","useState","useEffect","cancelled","prev","raw","error"],"sources":["../../src/access/use-can.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { useAccessControl } from \"./access-control-context\";\nimport type { CanParams, CanResult } from \"./types\";\n\nexport interface UseCanResult {\n /** Whether the action is permitted. Defaults to `true` while loading is not the concern of the caller. */\n allowed: boolean;\n /** `true` while an async `can` check is pending. */\n isLoading: boolean;\n /** Optional explanation, typically present when `allowed` is `false`. */\n reason?: string;\n}\n\nfunction normalize(result: boolean | CanResult): CanResult {\n return typeof result === \"boolean\" ? { can: result } : result;\n}\n\n/**\n * Resolve an access check against the {@link AccessControl} in context.\n *\n * Handles sync booleans, sync {@link CanResult}, and promises of either. When\n * no provider is present, access is allowed (see `useAccessControl`). The check\n * re-runs whenever the params change.\n *\n * @param params - The action/resource being checked.\n */\nexport function useCan(params: CanParams): UseCanResult {\n const control = useAccessControl();\n const paramsKey = JSON.stringify(params);\n\n const [state, setState] = useState<UseCanResult>(() =>\n control ? { allowed: false, isLoading: true } : { allowed: true, isLoading: false },\n );\n\n useEffect(() => {\n if (!control) {\n setState({ allowed: true, isLoading: false });\n return;\n }\n\n let cancelled = false;\n setState((prev) => ({ ...prev, isLoading: true }));\n\n Promise.resolve(control.can(params))\n .then((raw) => {\n if (cancelled) return;\n const result = normalize(raw);\n setState({ allowed: result.can, isLoading: false, reason: result.reason });\n })\n .catch((error: unknown) => {\n if (cancelled) return;\n setState({\n allowed: false,\n isLoading: false,\n reason: error instanceof Error ? error.message : \"access check failed\",\n });\n });\n\n return () => {\n cancelled = true;\n };\n // `paramsKey` is the stable stringified form of `params`.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [control, paramsKey]);\n\n return state;\n}\n"],"mappings":";;;AAaA,SAASA,EAAU,GAAwC;CACvD,OAAO,OAAOC,KAAW,YAAY,EAAE,KAAKA,EAAO,IAAIA;AAC3D;AAWA,SAAgBC,EAAO,GAAiC;CACpD,IAAMC,IAAUC,EAAiB,GAC3BC,IAAY,KAAK,UAAUC,CAAM,GAEjC,CAACC,GAAOC,KAAYC,QACtBN,IAAU;EAAE,SAAS;EAAO,WAAW;CAAK,IAAI;EAAE,SAAS;EAAM,WAAW;CAAM,CACtF;CAiCA,OA/BAO,QAAgB;EACZ,IAAI,CAACP,GAAS;GACV,EAAS;IAAE,SAAS;IAAM,WAAW;GAAM,CAAC;GAC5C;EACJ;EAEA,IAAIQ,IAAY;EAkBhB,OAjBAH,GAAU,OAAU;GAAE,GAAGI;GAAM,WAAW;EAAK,EAAE,GAEjD,QAAQ,QAAQT,EAAQ,IAAIG,CAAM,CAAC,CAAC,CAC/B,MAAM,MAAQ;GACX,IAAIK,GAAW;GACf,IAAMV,IAASD,EAAUa,CAAG;GAC5B,EAAS;IAAE,SAASZ,EAAO;IAAK,WAAW;IAAO,QAAQA,EAAO;GAAO,CAAC;EAC7E,CAAC,CAAC,CACD,OAAO,MAAmB;GACvB,AAAIU,KACJH,EAAS;IACL,SAAS;IACT,WAAW;IACX,QAAQM,aAAiB,QAAQA,EAAM,UAAU;GACrD,CAAC;EACL,CAAC,SAEQ;GACT,IAAY;EAChB;CAGJ,GAAG,CAACX,GAASE,CAAS,CAAC,GAEhBE;AACX"}
1
+ {"version":3,"file":"use-can.js","names":["PENDING","ALLOW_ALL","normalize","result","useCan","control","useAccessControl","paramsKey","params","resolved","setResolved","useState","useEffect","cancelled","settle","raw","error"],"sources":["../../src/access/use-can.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { useAccessControl } from \"./access-control-context\";\nimport type { AccessControl, CanParams, CanResult } from \"./types\";\n\nexport interface UseCanResult {\n /** Whether the action is permitted. Defaults to `true` while loading is not the concern of the caller. */\n allowed: boolean;\n /** `true` while an async `can` check is pending. */\n isLoading: boolean;\n /** Optional explanation, typically present when `allowed` is `false`. */\n reason?: string;\n}\n\n/** The answer for a check that has not resolved yet. */\nconst PENDING: UseCanResult = { allowed: false, isLoading: true };\n\n/** The answer when no provider is mounted. */\nconst ALLOW_ALL: UseCanResult = { allowed: true, isLoading: false };\n\n/** A resolved answer together with the check it answers. */\ninterface ResolvedCheck {\n control: AccessControl;\n key: string;\n result: UseCanResult;\n}\n\nfunction normalize(result: boolean | CanResult): CanResult {\n return typeof result === \"boolean\" ? { can: result } : result;\n}\n\n/**\n * Resolve an access check against the {@link AccessControl} in context.\n *\n * Handles sync booleans, sync {@link CanResult}, and promises of either. When\n * no provider is present, access is allowed (see `useAccessControl`). The check\n * re-runs whenever the params change; they are compared by their\n * `JSON.stringify` form, so an inline object literal does not re-run it on\n * every render.\n *\n * A resolved answer is stored with the control and the params it answers, and\n * is only returned while both still match. Until 0.81.0 the previous answer was\n * returned with `isLoading: false` for the render between a params change and\n * the effect that re-ran the check, so `<Can>` showed the children for the new\n * resource for one frame on the strength of the old resource's permission. The\n * first render for new params now reads `{ allowed: false, isLoading: true }`.\n *\n * @param params - The action/resource being checked.\n * @returns The answer for exactly these params, or the pending state.\n */\nexport function useCan(params: CanParams): UseCanResult {\n const control = useAccessControl();\n const paramsKey = JSON.stringify(params);\n const [resolved, setResolved] = useState<ResolvedCheck | null>(null);\n\n useEffect(() => {\n if (!control) return;\n let cancelled = false;\n const settle = (result: UseCanResult): void => {\n if (!cancelled) setResolved({ control, key: paramsKey, result });\n };\n\n Promise.resolve(control.can(params))\n .then((raw) => {\n const result = normalize(raw);\n settle({ allowed: result.can, isLoading: false, reason: result.reason });\n })\n .catch((error: unknown) => {\n settle({\n allowed: false,\n isLoading: false,\n reason: error instanceof Error ? error.message : \"access check failed\",\n });\n });\n\n return () => {\n cancelled = true;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [control, paramsKey]);\n\n if (!control) return ALLOW_ALL;\n if (resolved === null || resolved.control !== control || resolved.key !== paramsKey) {\n return PENDING;\n }\n return resolved.result;\n}\n"],"mappings":";;;AAcA,IAAMA,IAAwB;CAAE,SAAS;CAAO,WAAW;AAAK,GAG1DC,IAA0B;CAAE,SAAS;CAAM,WAAW;AAAM;AASlE,SAASC,EAAU,GAAwC;CACvD,OAAO,OAAOC,KAAW,YAAY,EAAE,KAAKA,EAAO,IAAIA;AAC3D;AAqBA,SAAgBC,EAAO,GAAiC;CACpD,IAAMC,IAAUC,EAAiB,GAC3BC,IAAY,KAAK,UAAUC,CAAM,GACjC,CAACC,GAAUC,KAAeC,EAA+B,IAAI;CAgCnE,OA9BAC,QAAgB;EACZ,IAAI,CAACP,GAAS;EACd,IAAIQ,IAAY,IACVC,KAAU,MAA+B;GAC3C,AAAKD,KAAWH,EAAY;IAAE;IAAS,KAAKH;IAAW;GAAO,CAAC;EACnE;EAeA,OAbA,QAAQ,QAAQF,EAAQ,IAAIG,CAAM,CAAC,CAAC,CAC/B,MAAM,MAAQ;GACX,IAAML,IAASD,EAAUa,CAAG;GAC5B,EAAO;IAAE,SAASZ,EAAO;IAAK,WAAW;IAAO,QAAQA,EAAO;GAAO,CAAC;EAC3E,CAAC,CAAC,CACD,OAAO,MAAmB;GACvB,EAAO;IACH,SAAS;IACT,WAAW;IACX,QAAQa,aAAiB,QAAQA,EAAM,UAAU;GACrD,CAAC;EACL,CAAC,SAEQ;GACT,IAAY;EAChB;CAEJ,GAAG,CAACX,GAASE,CAAS,CAAC,GAElBF,IACDI,MAAa,QAAQA,EAAS,YAAYJ,KAAWI,EAAS,QAAQF,IAC/DP,IAEJS,EAAS,SAJKR;AAKzB"}