tempest-react-sdk 0.76.0 → 0.77.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.
- package/README.md +2 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- package/bin/lib/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -0
- package/bin/lib/openapi/escape.mjs +210 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
- package/bin/lib/openapi/generate.mjs +93 -17
- package/bin/lib/openapi/schema-to-zod.mjs +41 -4
- package/bin/tempest.mjs +184 -3
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +127 -55
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +72 -62
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +12 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +151 -87
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.js.map +1 -1
- package/dist/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +271 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +271 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-owner.cjs +2 -0
- package/dist/telemetry/app-error-owner.cjs.map +1 -0
- package/dist/telemetry/app-error-owner.js +18 -0
- package/dist/telemetry/app-error-owner.js.map +1 -0
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +979 -62
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +16 -13
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +118 -63
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +21 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
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 {
|
|
3
|
+
import { assertNoCollision, commentText, toIdentifier, urlTemplate } from "./escape.mjs";
|
|
4
|
+
import { refName, schemaToZod, typeName, zodName } from "./schema-to-zod.mjs";
|
|
4
5
|
|
|
5
6
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
6
7
|
|
|
@@ -20,19 +21,68 @@ function pascal(s) {
|
|
|
20
21
|
return s.replace(/(^|[^a-zA-Z0-9])([a-zA-Z0-9])/g, (_, _b, ch) => ch.toUpperCase());
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* camelCase method name from operationId or method+path.
|
|
26
|
+
*
|
|
27
|
+
* The result only ever holds `[a-zA-Z0-9]`, so it cannot carry code; an
|
|
28
|
+
* operationId that sanitizes to nothing, or to a leading digit, falls back to
|
|
29
|
+
* the method+path name and is otherwise prefixed with `_` to stay an identifier.
|
|
30
|
+
*/
|
|
24
31
|
function methodName(op, method, path) {
|
|
25
|
-
if (op.operationId) {
|
|
32
|
+
if (typeof op.operationId === "string") {
|
|
26
33
|
const id = op.operationId.replace(/[^a-zA-Z0-9]+(.)?/g, (_, ch) =>
|
|
27
34
|
ch ? ch.toUpperCase() : "",
|
|
28
35
|
);
|
|
29
|
-
return id.charAt(0).toLowerCase() + id.slice(1);
|
|
36
|
+
if (/^[a-zA-Z]/.test(id)) return id.charAt(0).toLowerCase() + id.slice(1);
|
|
30
37
|
}
|
|
31
38
|
const parts = path
|
|
32
39
|
.split("/")
|
|
33
40
|
.filter(Boolean)
|
|
34
|
-
.map((p) => p.replace(/[
|
|
35
|
-
return method + parts.
|
|
41
|
+
.map((p) => pascal(p).replace(/[^a-zA-Z0-9]/g, ""));
|
|
42
|
+
return method + parts.join("");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Member names every generated service class already has: the constructor and the
|
|
47
|
+
* injected `api` property. An operation whose method name lands on one of them is
|
|
48
|
+
* deduplicated like any other collision (`constructor2`, `api2`).
|
|
49
|
+
*/
|
|
50
|
+
const CLASS_MEMBERS = ["constructor", "api"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Names a generated method body uses besides its path parameters: the `body` and
|
|
54
|
+
* `params` arguments and the `S` schemas import. A path parameter that maps to one
|
|
55
|
+
* of them becomes `<name>Param` (`{body}` → `bodyParam`), so it neither duplicates
|
|
56
|
+
* an argument nor shadows the import.
|
|
57
|
+
*/
|
|
58
|
+
const METHOD_LOCALS = new Set(["body", "params", "S"]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check every `pattern` in a schema compiles as a JavaScript regex.
|
|
62
|
+
*
|
|
63
|
+
* The generated Zod schema builds it with `new RegExp(...)` when the module is
|
|
64
|
+
* imported, so an invalid one would only surface in the app at runtime — a blank
|
|
65
|
+
* screen. Failing here names the schema and where in it the pattern sits.
|
|
66
|
+
*
|
|
67
|
+
* @param {unknown} node - The schema (sub)node.
|
|
68
|
+
* @param {string} schema - The component schema name, for the error.
|
|
69
|
+
* @param {string[]} [at] - Keys from the schema root to `node`.
|
|
70
|
+
* @throws {Error} When a pattern does not compile.
|
|
71
|
+
*/
|
|
72
|
+
function assertPatterns(node, schema, at = []) {
|
|
73
|
+
if (!node || typeof node !== "object") return;
|
|
74
|
+
if (typeof node.pattern === "string") {
|
|
75
|
+
try {
|
|
76
|
+
new RegExp(node.pattern);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [key, value] of Object.entries(node)) {
|
|
84
|
+
if (key !== "pattern") assertPatterns(value, schema, [...at, key]);
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
/** Collect component-schema names referenced (transitively) by a schema node. */
|
|
@@ -58,7 +108,7 @@ function collectRefs(node, schemas, acc = new Set(), seen = new Set()) {
|
|
|
58
108
|
/** TS type expression for a schema node (uses generated type names for $refs). */
|
|
59
109
|
function tsType(schema) {
|
|
60
110
|
if (!schema || typeof schema !== "object") return "unknown";
|
|
61
|
-
if (schema.$ref) return refName(schema.$ref);
|
|
111
|
+
if (schema.$ref) return typeName(refName(schema.$ref));
|
|
62
112
|
if (schema.allOf) return schema.allOf.map(tsType).join(" & ");
|
|
63
113
|
if (schema.anyOf || schema.oneOf) {
|
|
64
114
|
const parts = (schema.anyOf ?? schema.oneOf).map(tsType);
|
|
@@ -189,6 +239,10 @@ function bodySchema(op) {
|
|
|
189
239
|
*/
|
|
190
240
|
export function generate(doc) {
|
|
191
241
|
const schemas = doc.components?.schemas ?? {};
|
|
242
|
+
assertNoCollision(
|
|
243
|
+
Object.keys(schemas).map((n) => [n, typeName(n)]),
|
|
244
|
+
"schema names",
|
|
245
|
+
);
|
|
192
246
|
const groups = new Map(); // slug → { tag, ops: [...] }
|
|
193
247
|
|
|
194
248
|
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
@@ -202,12 +256,17 @@ export function generate(doc) {
|
|
|
202
256
|
}
|
|
203
257
|
}
|
|
204
258
|
|
|
259
|
+
assertNoCollision(
|
|
260
|
+
[...groups.keys()].map((slug) => [slug, slugIdent(slug)]),
|
|
261
|
+
"tags (as barrel names)",
|
|
262
|
+
);
|
|
263
|
+
|
|
205
264
|
const files = {};
|
|
206
265
|
const tags = [];
|
|
207
266
|
|
|
208
267
|
for (const { tag, slug, ops } of groups.values()) {
|
|
209
268
|
tags.push(tag);
|
|
210
|
-
const Class = `${pascal(slug)}Service`;
|
|
269
|
+
const Class = `${slugIdent(pascal(slug))}Service`;
|
|
211
270
|
|
|
212
271
|
// 1. Which component schemas does this group touch (transitively)?
|
|
213
272
|
const used = new Set();
|
|
@@ -218,6 +277,7 @@ export function generate(doc) {
|
|
|
218
277
|
if (r) collectRefs(r, schemas, used);
|
|
219
278
|
}
|
|
220
279
|
const { sorted, cyclic } = topoSort(used, schemas);
|
|
280
|
+
for (const name of sorted) assertPatterns(schemas[name], name);
|
|
221
281
|
|
|
222
282
|
// 2. schemas.ts
|
|
223
283
|
const schemaLines = sorted.map((name) => {
|
|
@@ -231,17 +291,17 @@ export function generate(doc) {
|
|
|
231
291
|
|
|
232
292
|
// 3. types.ts
|
|
233
293
|
const typeLines = sorted.map(
|
|
234
|
-
(name) => `export type ${name} = z.infer<typeof S.${zodName(name)}>;`,
|
|
294
|
+
(name) => `export type ${typeName(name)} = z.infer<typeof S.${zodName(name)}>;`,
|
|
235
295
|
);
|
|
236
296
|
files[`${slug}/types.ts`] =
|
|
237
297
|
`import type { z } from "zod";\n\nimport * as S from "./schemas";\n\n${typeLines.join("\n")}\n`;
|
|
238
298
|
|
|
239
299
|
// 4. service.ts
|
|
240
|
-
const usedNames = new Set();
|
|
300
|
+
const usedNames = new Set(CLASS_MEMBERS);
|
|
241
301
|
const methods = ops.map(({ method, path, op }) =>
|
|
242
302
|
emitMethod(method, path, op, schemas, usedNames),
|
|
243
303
|
);
|
|
244
|
-
const usedTypeNames = [...used].sort();
|
|
304
|
+
const usedTypeNames = [...used].map(typeName).sort();
|
|
245
305
|
const typeImport = usedTypeNames.length
|
|
246
306
|
? `import type { ${usedTypeNames.join(", ")} } from "./types";\n`
|
|
247
307
|
: "";
|
|
@@ -255,7 +315,7 @@ export function generate(doc) {
|
|
|
255
315
|
`import type { ${sdkTypes.join(", ")} } from "tempest-react-sdk";\n\n` +
|
|
256
316
|
schemaImport +
|
|
257
317
|
typeImport +
|
|
258
|
-
`\n/** Generated service for the "${tag}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
318
|
+
`\n/** Generated service for the "${commentText(tag)}" routes. Inject an ApiClient (createApiClient). */\n` +
|
|
259
319
|
`export class ${Class} {\n` +
|
|
260
320
|
` constructor(private readonly api: ApiClient) {}\n\n` +
|
|
261
321
|
body +
|
|
@@ -287,6 +347,22 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
287
347
|
}
|
|
288
348
|
usedNames.add(name);
|
|
289
349
|
const pathParams = (op.parameters ?? []).filter((p) => p.in === "path");
|
|
350
|
+
const where = `${method.toUpperCase()} ${path}`;
|
|
351
|
+
const paramId = (name) => {
|
|
352
|
+
const id = toIdentifier(name, "camel", `path parameter in ${where}`);
|
|
353
|
+
return METHOD_LOCALS.has(id) ? `${id}Param` : id;
|
|
354
|
+
};
|
|
355
|
+
const paramIds = new Map(pathParams.map((p) => [p.name, paramId(p.name)]));
|
|
356
|
+
assertNoCollision(
|
|
357
|
+
[
|
|
358
|
+
...[...paramIds],
|
|
359
|
+
...(bodySchema(op) ? [["the request body argument", "body"]] : []),
|
|
360
|
+
...((op.parameters ?? []).some((p) => p.in === "query")
|
|
361
|
+
? [["the query argument", "params"]]
|
|
362
|
+
: []),
|
|
363
|
+
],
|
|
364
|
+
`method arguments of ${where}`,
|
|
365
|
+
);
|
|
290
366
|
const queryParams = (op.parameters ?? []).filter((p) => p.in === "query");
|
|
291
367
|
const body = bodySchema(op);
|
|
292
368
|
const resp = successSchema(op);
|
|
@@ -299,7 +375,9 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
299
375
|
: "void";
|
|
300
376
|
|
|
301
377
|
const args = [];
|
|
302
|
-
for (const p of pathParams)
|
|
378
|
+
for (const p of pathParams) {
|
|
379
|
+
args.push(`${paramIds.get(p.name)}: ${tsType(p.schema ?? { type: "string" })}`);
|
|
380
|
+
}
|
|
303
381
|
if (body) args.push(`body: ${tsType(body)}`);
|
|
304
382
|
if (queryParams.length) {
|
|
305
383
|
const q = queryParams
|
|
@@ -311,9 +389,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
311
389
|
args.push(`params: { ${q} }`);
|
|
312
390
|
}
|
|
313
391
|
|
|
314
|
-
|
|
315
|
-
const tpl = path.replace(/{([^}]+)}/g, (_, n) => "${" + n + "}");
|
|
316
|
-
const url = pathParams.length ? `\`${tpl}\`` : JSON.stringify(path);
|
|
392
|
+
const url = urlTemplate(path, paramIds);
|
|
317
393
|
|
|
318
394
|
const callOpts = [];
|
|
319
395
|
if (body) callOpts.push("body");
|
|
@@ -330,7 +406,7 @@ function emitMethod(method, path, op, schemas, usedNames = new Set()) {
|
|
|
330
406
|
const ret = retType === "void" ? "Promise<void>" : `Promise<${retType}>`;
|
|
331
407
|
const generic = retType === "void" ? "" : `<${retType}>`;
|
|
332
408
|
return (
|
|
333
|
-
` /** \`${method.toUpperCase()} ${path}\`${op.summary ? ` — ${op.summary}` : ""} */\n` +
|
|
409
|
+
` /** \`${method.toUpperCase()} ${commentText(path)}\`${op.summary ? ` — ${commentText(op.summary)}` : ""} */\n` +
|
|
334
410
|
` async ${name}(${args.join(", ")}): ${ret} {\n` +
|
|
335
411
|
validation +
|
|
336
412
|
` return this.api.${method}${generic}(${url}${optsArg});\n` +
|
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
// JSON Schema (OpenAPI 3.0/3.1 subset) → Zod expression source string.
|
|
2
2
|
// Pure + deterministic so the generated output is stable and testable.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { toIdentifier } from "./escape.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a $ref like "#/components/schemas/User" to its spec schema name
|
|
8
|
+
* ("User"). This is the lookup key into `components.schemas`, not yet an
|
|
9
|
+
* identifier: pass it through {@link typeName} before emitting it.
|
|
10
|
+
*/
|
|
5
11
|
export function refName(ref) {
|
|
6
12
|
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
7
13
|
}
|
|
8
14
|
|
|
9
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Names the generated files declare, import or rely on as globals next to the
|
|
17
|
+
* schema types: the SDK types `service.ts` imports, the `z` and `S` imports, and
|
|
18
|
+
* the `Promise` / `Record` globals the emitted signatures use. A schema with one
|
|
19
|
+
* of these names would collide with the import or shadow the global.
|
|
20
|
+
*/
|
|
21
|
+
export const GENERATED_NAMES = new Set([
|
|
22
|
+
"ApiClient",
|
|
23
|
+
"CursorPage",
|
|
24
|
+
"OffsetPage",
|
|
25
|
+
"Promise",
|
|
26
|
+
"Record",
|
|
27
|
+
"S",
|
|
28
|
+
"z",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The TypeScript type name for a spec schema name (`user.Response` →
|
|
33
|
+
* `UserResponse`; a valid identifier is kept as is). A name in
|
|
34
|
+
* {@link GENERATED_NAMES} gets a `_` prefix (`Promise` → `_Promise`).
|
|
35
|
+
*
|
|
36
|
+
* @throws {Error} When the name has nothing to build an identifier from.
|
|
37
|
+
*/
|
|
38
|
+
export function typeName(name) {
|
|
39
|
+
const id = toIdentifier(name, "pascal", "schema name");
|
|
40
|
+
return GENERATED_NAMES.has(id) ? `_${id}` : id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The generated Zod const name for a spec schema name
|
|
45
|
+
* (`User` → `UserSchema`, `user.Response` → `UserResponseSchema`).
|
|
46
|
+
*/
|
|
10
47
|
export function zodName(name) {
|
|
11
|
-
return `${name}Schema`;
|
|
48
|
+
return `${typeName(name)}Schema`;
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
function isNullable(schema) {
|
|
@@ -77,7 +114,7 @@ export function schemaToZod(schema, resolveRef = (ref) => zodName(refName(ref)))
|
|
|
77
114
|
if (typeof schema.minLength === "number") e += `.min(${schema.minLength})`;
|
|
78
115
|
if (typeof schema.maxLength === "number") e += `.max(${schema.maxLength})`;
|
|
79
116
|
if (typeof schema.pattern === "string")
|
|
80
|
-
e += `.regex(
|
|
117
|
+
e += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`;
|
|
81
118
|
return withModifiers(e, schema);
|
|
82
119
|
}
|
|
83
120
|
case "integer":
|
package/bin/tempest.mjs
CHANGED
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
// tempest lint [paths…] run ESLint (report only)
|
|
7
7
|
// tempest fix [paths…] alias imports (../ → @/) + ESLint --fix + Prettier
|
|
8
8
|
// tempest format [paths…] Prettier --write
|
|
9
|
+
// tempest gen <api|icons|component> … code generators (`new component` = alias)
|
|
9
10
|
// tempest --help | --version
|
|
10
11
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
12
13
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
-
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { aliasImports } from "./lib/alias/index.mjs";
|
|
16
17
|
import { describeTypeScript, loadTypeScript } from "./lib/alias/typescript.mjs";
|
|
17
18
|
import { readTsconfig } from "./lib/alias/tsconfig.mjs";
|
|
19
|
+
import { componentFiles } from "./lib/component/generate.mjs";
|
|
18
20
|
import { analyzeCss, applyCssFixes, applyExtraction, planExtraction } from "./lib/css/index.mjs";
|
|
19
21
|
import { analyzeDesign } from "./lib/design/index.mjs";
|
|
20
22
|
import { checkLucide } from "./lib/doctor/lucide.mjs";
|
|
@@ -1375,7 +1377,164 @@ async function genIcons(args) {
|
|
|
1375
1377
|
return 0;
|
|
1376
1378
|
}
|
|
1377
1379
|
|
|
1378
|
-
|
|
1380
|
+
// ------------------------------------------------------- gen component ----
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* `lstat` that answers `null` for a missing path instead of throwing.
|
|
1384
|
+
*
|
|
1385
|
+
* @param {string} path - Absolute path.
|
|
1386
|
+
* @returns {import("node:fs").Stats | null}
|
|
1387
|
+
*/
|
|
1388
|
+
function lstatOrNull(path) {
|
|
1389
|
+
try {
|
|
1390
|
+
return lstatSync(path);
|
|
1391
|
+
} catch {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Whether `path` would be created inside the project.
|
|
1398
|
+
*
|
|
1399
|
+
* Resolves the nearest ancestor that exists through `realpath`, so `../x`, an
|
|
1400
|
+
* absolute path and an ancestor that is a symlink to somewhere else
|
|
1401
|
+
* (`src/components -> /elsewhere`) are all seen where they really land.
|
|
1402
|
+
*
|
|
1403
|
+
* @param {string} path - Absolute path about to be created.
|
|
1404
|
+
* @returns {boolean}
|
|
1405
|
+
*/
|
|
1406
|
+
function landsInsideProject(path) {
|
|
1407
|
+
let existing = path;
|
|
1408
|
+
while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
1409
|
+
const rel = relative(realpathSync(ROOT), realpathSync(existing));
|
|
1410
|
+
return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Parse the `gen component` argv tail.
|
|
1415
|
+
*
|
|
1416
|
+
* Unlike the shared {@link parseOut}, a `--out` with no value, an empty one or
|
|
1417
|
+
* one that is another flag (`--out --force`) is an error rather than a silent
|
|
1418
|
+
* fallback to the default or a folder named `--force`.
|
|
1419
|
+
*
|
|
1420
|
+
* @param {string[]} args - Arguments after `component`.
|
|
1421
|
+
* @returns {{ error: string } | { name: string | undefined, out: string, force: boolean }}
|
|
1422
|
+
*/
|
|
1423
|
+
function parseComponentArgs(args) {
|
|
1424
|
+
let out = "src/components";
|
|
1425
|
+
let force = false;
|
|
1426
|
+
const positionals = [];
|
|
1427
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1428
|
+
const arg = args[i];
|
|
1429
|
+
if (arg === "--force") {
|
|
1430
|
+
force = true;
|
|
1431
|
+
} else if (arg === "--out") {
|
|
1432
|
+
const value = args[i + 1];
|
|
1433
|
+
if (value === undefined || value === "" || value.startsWith("--")) {
|
|
1434
|
+
return { error: "--out needs a folder, e.g. --out src/components" };
|
|
1435
|
+
}
|
|
1436
|
+
out = value;
|
|
1437
|
+
i += 1;
|
|
1438
|
+
} else if (arg.startsWith("--")) {
|
|
1439
|
+
return { error: `Unknown option: ${arg}` };
|
|
1440
|
+
} else {
|
|
1441
|
+
positionals.push(arg);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (positionals.length > 1) {
|
|
1445
|
+
return {
|
|
1446
|
+
error: `Expected one component name, got ${positionals.length}. Quote a name with spaces: tempest gen component "User Card"`,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
return { name: positionals[0], out, force };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* `tempest gen component <Name> [--out src/components] [--force]` — create
|
|
1454
|
+
* `<out>/<Name>/` with `index.tsx`, `styles.module.css`, `constants.ts`,
|
|
1455
|
+
* `utils.ts` and `hooks.ts`.
|
|
1456
|
+
*
|
|
1457
|
+
* The name is normalized to PascalCase (`"user card"` → `UserCard`). Every check
|
|
1458
|
+
* runs before the first write, so a refusal leaves the disk untouched:
|
|
1459
|
+
*
|
|
1460
|
+
* - the folder must land inside the project once symlinks are resolved;
|
|
1461
|
+
* - an existing folder is refused unless `--force`;
|
|
1462
|
+
* - the folder must not be a symlink, and each of the five files must be a
|
|
1463
|
+
* regular file or missing — never a symlink or a directory — even with
|
|
1464
|
+
* `--force`, which then rewrites only those five and leaves the rest alone.
|
|
1465
|
+
*
|
|
1466
|
+
* The closing hint prints an `@/` import when the folder is under `src/`, the
|
|
1467
|
+
* only place the template's alias reaches; elsewhere it prints the path.
|
|
1468
|
+
*/
|
|
1469
|
+
async function genComponent(args) {
|
|
1470
|
+
const parsed = parseComponentArgs(args);
|
|
1471
|
+
if ("error" in parsed) {
|
|
1472
|
+
console.error(`${c.red}✗ ${parsed.error}${c.reset}`);
|
|
1473
|
+
return 1;
|
|
1474
|
+
}
|
|
1475
|
+
let generated;
|
|
1476
|
+
try {
|
|
1477
|
+
generated = componentFiles(parsed.name);
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
console.error(`${c.red}✗ ${err.message}${c.reset}`);
|
|
1480
|
+
return 1;
|
|
1481
|
+
}
|
|
1482
|
+
const dir = resolve(ROOT, parsed.out, generated.name);
|
|
1483
|
+
const shown = relative(ROOT, dir).split(sep).join("/") || ".";
|
|
1484
|
+
|
|
1485
|
+
if (!landsInsideProject(dir)) {
|
|
1486
|
+
console.error(
|
|
1487
|
+
`${c.red}✗ ${dir} is outside the project${c.reset} (${ROOT}), once symlinks are resolved. Pick an --out inside it.`,
|
|
1488
|
+
);
|
|
1489
|
+
return 1;
|
|
1490
|
+
}
|
|
1491
|
+
const dirStat = lstatOrNull(dir);
|
|
1492
|
+
if (dirStat?.isSymbolicLink()) {
|
|
1493
|
+
console.error(`${c.red}✗ ${shown} is a symlink; refusing to write through it.${c.reset}`);
|
|
1494
|
+
return 1;
|
|
1495
|
+
}
|
|
1496
|
+
if (dirStat && !parsed.force) {
|
|
1497
|
+
console.error(
|
|
1498
|
+
`${c.red}✗ ${shown} already exists.${c.reset} Pass --force to rewrite its five generated files.`,
|
|
1499
|
+
);
|
|
1500
|
+
return 1;
|
|
1501
|
+
}
|
|
1502
|
+
if (dirStat && !dirStat.isDirectory()) {
|
|
1503
|
+
console.error(`${c.red}✗ ${shown} exists and is not a folder.${c.reset}`);
|
|
1504
|
+
return 1;
|
|
1505
|
+
}
|
|
1506
|
+
const blocked = Object.keys(generated.files).find((file) => {
|
|
1507
|
+
const stat = lstatOrNull(join(dir, file));
|
|
1508
|
+
return stat !== null && !stat.isFile();
|
|
1509
|
+
});
|
|
1510
|
+
if (blocked) {
|
|
1511
|
+
console.error(
|
|
1512
|
+
`${c.red}✗ ${shown}/${blocked} is not a regular file${c.reset} (a folder or a symlink); nothing was written.`,
|
|
1513
|
+
);
|
|
1514
|
+
return 1;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
await mkdir(dir, { recursive: true });
|
|
1518
|
+
for (const [file, contents] of Object.entries(generated.files)) {
|
|
1519
|
+
await writeFile(join(dir, file), contents);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
console.log(`${c.green}✓ ${generated.name}${c.reset} → ${c.bold}${shown}/${c.reset}`);
|
|
1523
|
+
for (const file of Object.keys(generated.files)) console.log(` ${c.dim}· ${file}${c.reset}`);
|
|
1524
|
+
const fromSrc = relative(join(ROOT, "src"), dir);
|
|
1525
|
+
const outsideSrc = fromSrc === ".." || fromSrc.startsWith(`..${sep}`) || isAbsolute(fromSrc);
|
|
1526
|
+
if (outsideSrc) {
|
|
1527
|
+
console.log(
|
|
1528
|
+
`\n${c.yellow}! ${shown} is outside src/${c.reset}, where the template's @/ alias does not reach — import it by a relative path.`,
|
|
1529
|
+
);
|
|
1530
|
+
} else {
|
|
1531
|
+
console.log(`\n${c.dim}Use it:${c.reset}`);
|
|
1532
|
+
console.log(` import { ${generated.name} } from "@/${fromSrc.split(sep).join("/")}";`);
|
|
1533
|
+
}
|
|
1534
|
+
return 0;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const GEN_TARGETS = { api: genApi, icons: genIcons, component: genComponent };
|
|
1379
1538
|
|
|
1380
1539
|
function gen(args) {
|
|
1381
1540
|
const what = args[0];
|
|
@@ -1389,6 +1548,21 @@ function gen(args) {
|
|
|
1389
1548
|
return target(args.slice(1));
|
|
1390
1549
|
}
|
|
1391
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* `tempest new component <Name>` — the spelling from issue #454, kept as an
|
|
1553
|
+
* alias of `tempest gen component`. Only `component` is accepted, so `new` does
|
|
1554
|
+
* not become a second name for every generator.
|
|
1555
|
+
*/
|
|
1556
|
+
function newCommand(args) {
|
|
1557
|
+
if (args[0] !== "component") {
|
|
1558
|
+
console.error(
|
|
1559
|
+
`${c.red}✗ Unknown new target: ${args[0] ?? "(none)"}${c.reset} — supported: component (alias of tempest gen component).`,
|
|
1560
|
+
);
|
|
1561
|
+
return 1;
|
|
1562
|
+
}
|
|
1563
|
+
return genComponent(args.slice(1));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1392
1566
|
// ------------------------------------------------------------------ main ----
|
|
1393
1567
|
|
|
1394
1568
|
function usage() {
|
|
@@ -1408,6 +1582,12 @@ ${c.bold}Commands${c.reset}
|
|
|
1408
1582
|
(e.g. tempest gen api http://127.0.0.1:8000/openapi.json --out src/api)
|
|
1409
1583
|
${c.bold}gen icons${c.reset} Write a static icon registry from the slugs your source uses
|
|
1410
1584
|
(e.g. tempest gen icons --out src/icons.generated.ts --dir src)
|
|
1585
|
+
${c.bold}gen component${c.reset} <Name>
|
|
1586
|
+
Create <out>/<Name>/ with index.tsx, styles.module.css,
|
|
1587
|
+
constants.ts, utils.ts and hooks.ts (name → PascalCase)
|
|
1588
|
+
(e.g. tempest gen component "user card" --out src/components)
|
|
1589
|
+
--force rewrites the five files of an existing folder.
|
|
1590
|
+
Alias: tempest new component <Name>
|
|
1411
1591
|
|
|
1412
1592
|
${c.bold}doctor options${c.reset}
|
|
1413
1593
|
--no-css Skip the CSS analysis
|
|
@@ -1450,6 +1630,7 @@ const commands = {
|
|
|
1450
1630
|
fix: () => fix(rest),
|
|
1451
1631
|
format: () => format(rest),
|
|
1452
1632
|
gen: () => gen(rest),
|
|
1633
|
+
new: () => newCommand(rest),
|
|
1453
1634
|
};
|
|
1454
1635
|
|
|
1455
1636
|
if (!commands[cmd]) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../http/api-client.cjs"),
|
|
1
|
+
const e=require("../http/errors.cjs"),t=require("../http/api-client.cjs"),n=require("./create-auth-store.cjs"),r=require("./refresh-queue.cjs");function i(e){let t=e??{};return{token:t.access_token,refreshToken:t.refresh_token}}function a(a){let{baseURL:o,prefix:s,loginPath:c=`/api/auth/login`,refreshPath:l=`/api/auth/refresh`,mePath:u,storeName:d=`tempest-auth`,storage:f=`local`,withCredentials:p=!1,csrf:m,fetcher:h,parseTokens:g=i,parseUser:_,refreshBody:v=e=>e?{refresh_token:e}:void 0,retry:y,redirectTo:b,onSessionEnd:x}=a,S=n.createAuthStore({name:d,storage:f}),C=`${d}-refresh`;function w(){return typeof window>`u`?null:f===`session`?window.sessionStorage:window.localStorage}function T(){return w()?.getItem(C)??null}function E(e){let t=w();t&&(e?t.setItem(C,e):t.removeItem(C))}let D=()=>S.getState(),O=()=>D().token;async function k(){if(!u)return D().user;let e=await U.get(u,{skipAuthRetry:!0});return D().setUser(e),e}let A=0,j=Promise.resolve(),M=0;function N(){A+=1,F=r.createRefreshQueue(P)}async function P(){let t=A,n;try{n=await U.post(l,{body:v(T()),skipAuth:!0})}catch(n){throw A===t?n:new e.SessionSupersededError}if(A!==t)throw new e.SessionSupersededError;let{token:r,refreshToken:i}=g(n);D().setToken(r),i&&E(i)}let F=r.createRefreshQueue(P);function I(e){let t=O();return t===null?!1:e.token===null||e.token!==t}async function L(t){if(t.epoch!==null&&t.epoch!==A)throw new e.SessionSupersededError;if(I(t)){if(t.token===null)throw Error(`anonymous 401 under a live session`);return}return F()}async function R(t,n){if(n.epoch!==null&&n.epoch!==A)throw new e.SessionSupersededError;I(n)||await H()}async function z(t){let n=A,r=await U.post(c,{body:t,skipAuth:!0});if(A!==n)throw new e.SessionSupersededError;let{token:i,refreshToken:a}=g(r),{done:o}=B(`switch`),s=A;if(await o.catch(()=>void 0),A!==s)throw new e.SessionSupersededError;D().setToken(i),E(a??null);let l=_?.(r);return l==null?k():(D().setUser(l),l)}function B(e){N();let{user:n,token:r}=D(),i=r!==null||n!==null||T()!==null;if(D().logout(),E(null),!i)return{live:i,done:e===`switch`||M===0?j:Promise.resolve()};if(!x)return{live:i,done:Promise.resolve()};let a=t.createApiClient({baseURL:o,prefix:s,withCredentials:p,csrf:m,fetcher:h,getToken:()=>r});M+=1;let c=(async()=>{try{await x({reason:e,user:n,token:r,api:a})}finally{--M}})();return j=c.then(()=>void 0,()=>void 0),{live:i,done:c.then(()=>void 0)}}function V(){return B(`logout`).done}async function H(){let{live:e,done:t}=B(`expired`);await t.catch(()=>void 0),e&&b&&typeof window<`u`&&window.location.assign(b)}let U=t.createApiClient({baseURL:o,prefix:s,withCredentials:p,csrf:m,fetcher:h,getToken:O,getSessionEpoch:()=>A,refresh:L,retry:y,onUnauthorized:R});return{useAuthStore:S,api:U,login:z,logout:V,refresh:()=>F(),getToken:O}}exports.createTempestAuth=a;
|
|
2
2
|
//# sourceMappingURL=create-tempest-auth.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-tempest-auth.cjs","names":[],"sources":["../../src/auth/create-tempest-auth.ts"],"sourcesContent":["import { createApiClient } from \"../http\";\nimport type { ApiClient, CsrfOptions, RetryOptions } from \"../http\";\nimport { createAuthStore } from \"./create-auth-store\";\nimport type { AuthState } from \"./create-auth-store\";\nimport { createRefreshQueue } from \"./refresh-queue\";\n\n/** The token envelope returned by a Tempest FastAPI SDK login/refresh route. */\nexport interface TempestTokenResponse {\n /** The bearer access token. */\n access_token: string;\n /** Token type — always `\"bearer\"` for the SDK. */\n token_type?: string;\n /** Optional refresh token (when the API returns it in the body, not a cookie). */\n refresh_token?: string;\n}\n\nexport interface CreateTempestAuthOptions<TUser> {\n /** Base URL of the API. Required. */\n baseURL: string;\n /**\n * Path segment the API is mounted under, such as `\"/api\"`, forwarded to\n * every client this preset builds — see the `prefix` option of\n * `createApiClient`.\n *\n * Safe to combine with the default paths below: the prefix is applied at\n * most once, so `\"/api/auth/login\"` under a `\"/api\"` prefix stays\n * `\"/api/auth/login\"`.\n */\n prefix?: string;\n /** Login route (`POST`). Default: `\"/api/auth/login\"`. */\n loginPath?: string;\n /** Refresh route (`POST`). Default: `\"/api/auth/refresh\"`. */\n refreshPath?: string;\n /** Optional current-user route (`GET`) called after login/refresh. */\n mePath?: string;\n /** Persist key for the store. Default: `\"tempest-auth\"`. */\n storeName?: string;\n /** Storage backend. Default: `\"local\"`. */\n storage?: \"local\" | \"session\";\n /** Send cookies (needed when the refresh token lives in an httpOnly cookie). */\n withCredentials?: boolean;\n /**\n * CSRF token for every write, forwarded to `createApiClient`'s `csrf`. Off\n * by default; `true` echoes the `csrf_token` cookie as `X-CSRF-Token`.\n *\n * The pair to `withCredentials`: once the refresh token lives in a cookie,\n * the browser attaches it to a `POST /auth/refresh` that another site\n * triggers. Login and refresh carry the header too — they go out with\n * `skipAuth`, and the CSRF header does not follow auth.\n */\n csrf?: boolean | CsrfOptions;\n /** Custom fetch implementation (testing / SSR). Defaults to `globalThis.fetch`. */\n fetcher?: typeof fetch;\n /**\n * Extract tokens from a login/refresh response. Default reads\n * `access_token` + `refresh_token`.\n */\n parseTokens?: (data: unknown) => { token: string; refreshToken?: string };\n /** Pull the user out of the login response, when the API embeds it. */\n parseUser?: (data: unknown) => TUser | null;\n /**\n * Build the refresh request body. Default sends `{ refresh_token }` when a\n * refresh token is stored, else `undefined` (cookie-based refresh).\n */\n refreshBody?: (refreshToken: string | null) => unknown;\n /**\n * Retry policy for `api`, forwarded to {@link createApiClient}. Off by\n * default; `true` enables the conservative built-in policy, which never\n * replays a write.\n */\n retry?: boolean | RetryOptions;\n /**\n * Where to send the browser when the session ends — a hard navigation via\n * `window.location.assign`, after the store is cleared.\n *\n * **Prefer leaving this unset.** `logout()` already clears the store, so a\n * `<RouteGuard when={isAuthenticated} redirectTo=\"/login\">` wrapped around\n * the protected area navigates on its own, which keeps the SPA alive and the\n * router history intact. Reach for this only when the expiry can happen\n * outside any guarded subtree and a full reload is acceptable.\n */\n redirectTo?: string;\n}\n\nexport interface TempestAuth<TUser, TCredentials> {\n /** The persisted Zustand auth store hook (compatible with `<AuthGuard>`). */\n useAuthStore: ReturnType<typeof createAuthStore<TUser>>;\n /** A `createApiClient` wired with bearer auth + 401 → refresh → retry. */\n api: ApiClient;\n /** Authenticate, store the session, and resolve the user (or null). */\n login: (credentials: TCredentials) => Promise<TUser | null>;\n /** Clear the session (and the stored refresh token). */\n logout: () => void;\n /** Refresh the access token (deduplicated across concurrent callers). */\n refresh: () => Promise<void>;\n /** The current access token, or null. */\n getToken: () => string | null;\n}\n\nfunction defaultParseTokens(data: unknown): { token: string; refreshToken?: string } {\n const d = (data ?? {}) as TempestTokenResponse;\n return { token: d.access_token, refreshToken: d.refresh_token };\n}\n\n/**\n * Turn-key auth preset wiring `createAuthStore` + `createRefreshQueue` +\n * `createApiClient` to the Tempest FastAPI SDK auth contract: login returns\n * `{ access_token, token_type }`, requests carry `Authorization: Bearer`, and a\n * `401` triggers a single deduplicated refresh + replay.\n *\n * The session is cleared whenever that path ends unauthorized anyway — the\n * refresh threw, or the replay came back `401` — so a refresh token that the\n * backend has revoked cannot leave the app holding a dead session. With\n * `redirectTo` set, the browser also leaves the page; without it, clearing the\n * store is enough for a `<RouteGuard>` to navigate on its own.\n *\n * @example\n * const auth = createTempestAuth<User, { email: string; password: string }>({\n * baseURL: import.meta.env.VITE_API_URL,\n * mePath: \"/api/auth/me\",\n * });\n *\n * await auth.login({ email, password }); // stores session, returns the user\n * const orders = await auth.api.get(\"/api/orders\"); // sends the bearer token\n * auth.logout();\n *\n * @param options - The auth configuration.\n * @returns The store hook, a wired API client, and login/logout/refresh helpers.\n */\nexport function createTempestAuth<TUser, TCredentials = { email: string; password: string }>(\n options: CreateTempestAuthOptions<TUser>,\n): TempestAuth<TUser, TCredentials> {\n const {\n baseURL,\n prefix,\n loginPath = \"/api/auth/login\",\n refreshPath = \"/api/auth/refresh\",\n mePath,\n storeName = \"tempest-auth\",\n storage = \"local\",\n withCredentials = false,\n csrf,\n fetcher,\n parseTokens = defaultParseTokens,\n parseUser,\n refreshBody = (rt) => (rt ? { refresh_token: rt } : undefined),\n retry,\n redirectTo,\n } = options;\n\n const useAuthStore = createAuthStore<TUser>({ name: storeName, storage });\n const refreshKey = `${storeName}-refresh`;\n\n function storageImpl(): Storage | null {\n if (typeof window === \"undefined\") return null;\n return storage === \"session\" ? window.sessionStorage : window.localStorage;\n }\n function readRefreshToken(): string | null {\n return storageImpl()?.getItem(refreshKey) ?? null;\n }\n function writeRefreshToken(token: string | null): void {\n const s = storageImpl();\n if (!s) return;\n if (token) s.setItem(refreshKey, token);\n else s.removeItem(refreshKey);\n }\n\n const state = (): AuthState<TUser> => useAuthStore.getState();\n const getToken = (): string | null => state().token;\n\n /**\n * Load the current user, without letting a `401` end the session.\n *\n * `skipAuthRetry` is what keeps this one client honest: the call is\n * authenticated, but a `401` here surfaces instead of triggering a refresh\n * and `onUnauthorized`. That was the behaviour of the throwaway client this\n * used to build per invocation, and it is the reason the preset needed\n * three clients before 0.66.0.\n */\n async function fetchUser(): Promise<TUser | null> {\n if (!mePath) return state().user;\n const user = await api.get<TUser>(mePath, { skipAuthRetry: true });\n state().setUser(user);\n return user;\n }\n\n async function login(credentials: TCredentials): Promise<TUser | null> {\n const data = await api.post<unknown>(loginPath, {\n body: credentials,\n skipAuth: true,\n });\n const { token, refreshToken } = parseTokens(data);\n state().setToken(token);\n writeRefreshToken(refreshToken ?? null);\n const embedded = parseUser?.(data);\n if (embedded != null) {\n state().setUser(embedded);\n return embedded;\n }\n return fetchUser();\n }\n\n function logout(): void {\n state().logout();\n writeRefreshToken(null);\n }\n\n /**\n * Clear the session and, when `redirectTo` is set, leave the page.\n *\n * Separate from `logout` so an explicit sign-out stays a pure state change:\n * a caller that already navigates itself would otherwise get a second,\n * competing navigation.\n */\n function endSession(): void {\n logout();\n if (redirectTo && typeof window !== \"undefined\") {\n window.location.assign(redirectTo);\n }\n }\n\n const refresh = createRefreshQueue(async () => {\n const data = await api.post<unknown>(refreshPath, {\n body: refreshBody(readRefreshToken()),\n skipAuth: true,\n });\n const { token, refreshToken } = parseTokens(data);\n state().setToken(token);\n if (refreshToken) writeRefreshToken(refreshToken);\n });\n\n const api = createApiClient({\n baseURL,\n prefix,\n withCredentials,\n csrf,\n fetcher,\n getToken,\n refresh,\n retry,\n onUnauthorized: () => endSession(),\n });\n\n return { useAuthStore, api, login, logout, refresh, getToken };\n}\n"],"mappings":"gHAmGA,SAAS,EAAmB,EAAyD,CACjF,IAAM,EAAK,GAAQ,CAAC,EACpB,MAAO,CAAE,MAAO,EAAE,aAAc,aAAc,EAAE,aAAc,CAClE,CA2BA,SAAgB,EACZ,EACgC,CAChC,GAAM,CACF,UACA,SACA,YAAY,kBACZ,cAAc,oBACd,SACA,YAAY,eACZ,UAAU,QACV,kBAAkB,GAClB,OACA,UACA,cAAc,EACd,YACA,cAAe,GAAQ,EAAK,CAAE,cAAe,CAAG,EAAI,IAAA,GACpD,QACA,cACA,EAEE,EAAe,EAAA,gBAAuB,CAAE,KAAM,EAAW,SAAQ,CAAC,EAClE,EAAa,GAAG,EAAU,UAEhC,SAAS,GAA8B,CAEnC,OADI,OAAO,OAAW,IAAoB,KACnC,IAAY,UAAY,OAAO,eAAiB,OAAO,YAClE,CACA,SAAS,GAAkC,CACvC,OAAO,EAAY,CAAC,EAAE,QAAQ,CAAU,GAAK,IACjD,CACA,SAAS,EAAkB,EAA4B,CACnD,IAAM,EAAI,EAAY,EACjB,IACD,EAAO,EAAE,QAAQ,EAAY,CAAK,EACjC,EAAE,WAAW,CAAU,EAChC,CAEA,IAAM,MAAgC,EAAa,SAAS,EACtD,MAAgC,EAAM,CAAC,CAAC,MAW9C,eAAe,GAAmC,CAC9C,GAAI,CAAC,EAAQ,OAAO,EAAM,CAAC,CAAC,KAC5B,IAAM,EAAO,MAAM,EAAI,IAAW,EAAQ,CAAE,cAAe,EAAK,CAAC,EAEjE,OADA,EAAM,CAAC,CAAC,QAAQ,CAAI,EACb,CACX,CAEA,eAAe,EAAM,EAAkD,CACnE,IAAM,EAAO,MAAM,EAAI,KAAc,EAAW,CAC5C,KAAM,EACN,SAAU,EACd,CAAC,EACK,CAAE,QAAO,gBAAiB,EAAY,CAAI,EAChD,EAAM,CAAC,CAAC,SAAS,CAAK,EACtB,EAAkB,GAAgB,IAAI,EACtC,IAAM,EAAW,IAAY,CAAI,EAKjC,OAJI,GAAY,KAIT,EAAU,GAHb,EAAM,CAAC,CAAC,QAAQ,CAAQ,EACjB,EAGf,CAEA,SAAS,GAAe,CACpB,EAAM,CAAC,CAAC,OAAO,EACf,EAAkB,IAAI,CAC1B,CASA,SAAS,GAAmB,CACxB,EAAO,EACH,GAAc,OAAO,OAAW,KAChC,OAAO,SAAS,OAAO,CAAU,CAEzC,CAEA,IAAM,EAAU,EAAA,mBAAmB,SAAY,CAC3C,IAAM,EAAO,MAAM,EAAI,KAAc,EAAa,CAC9C,KAAM,EAAY,EAAiB,CAAC,EACpC,SAAU,EACd,CAAC,EACK,CAAE,QAAO,gBAAiB,EAAY,CAAI,EAChD,EAAM,CAAC,CAAC,SAAS,CAAK,EAClB,GAAc,EAAkB,CAAY,CACpD,CAAC,EAEK,EAAM,EAAA,gBAAgB,CACxB,UACA,SACA,kBACA,OACA,UACA,WACA,UACA,QACA,mBAAsB,EAAW,CACrC,CAAC,EAED,MAAO,CAAE,eAAc,MAAK,QAAO,SAAQ,UAAS,UAAS,CACjE"}
|
|
1
|
+
{"version":3,"file":"create-tempest-auth.cjs","names":[],"sources":["../../src/auth/create-tempest-auth.ts"],"sourcesContent":["import { createApiClient, SessionSupersededError } from \"../http\";\nimport type { ApiClient, CsrfOptions, RequestAuthContext, RetryOptions } from \"../http\";\nimport { createAuthStore } from \"./create-auth-store\";\nimport type { AuthState } from \"./create-auth-store\";\nimport { createRefreshQueue } from \"./refresh-queue\";\n\n/** The token envelope returned by a Tempest FastAPI SDK login/refresh route. */\nexport interface TempestTokenResponse {\n /** The bearer access token. */\n access_token: string;\n /** Token type — always `\"bearer\"` for the SDK. */\n token_type?: string;\n /** Optional refresh token (when the API returns it in the body, not a cookie). */\n refresh_token?: string;\n}\n\n/**\n * What {@link CreateTempestAuthOptions.onSessionEnd} receives about the session\n * that just ended.\n */\nexport interface TempestSessionEnd<TUser> {\n /**\n * Why it ended: `\"logout\"` for an explicit `logout()`, `\"expired\"` when the\n * client ended it on its own — the refresh failed, or the request replayed\n * after a refresh still came back `401` — and `\"switch\"` when `login()`\n * succeeded for another set of credentials while this session was live.\n */\n reason: \"logout\" | \"expired\" | \"switch\";\n /** The user of the ended session, as the store held it. */\n user: TUser | null;\n /** The access token of the ended session, or `null` when it had none. */\n token: string | null;\n /**\n * A client that sends the ended session's access token — the store has\n * already been cleared, so `auth.api` no longer does.\n *\n * Use it for the server-side half of the teardown, such as deleting this\n * browser's push subscription. It never refreshes and never ends a session:\n * a `401` here just rejects. On `\"logout\"` the token is usually still\n * valid; on `\"expired\"` the server has already refused it, so expect the\n * call to fail and keep the local half of the teardown independent of it.\n */\n api: ApiClient;\n}\n\nexport interface CreateTempestAuthOptions<TUser> {\n /** Base URL of the API. Required. */\n baseURL: string;\n /**\n * Path segment the API is mounted under, such as `\"/api\"`, forwarded to\n * every client this preset builds — see the `prefix` option of\n * `createApiClient`.\n *\n * Safe to combine with the default paths below: the prefix is applied at\n * most once, so `\"/api/auth/login\"` under a `\"/api\"` prefix stays\n * `\"/api/auth/login\"`.\n */\n prefix?: string;\n /** Login route (`POST`). Default: `\"/api/auth/login\"`. */\n loginPath?: string;\n /** Refresh route (`POST`). Default: `\"/api/auth/refresh\"`. */\n refreshPath?: string;\n /** Optional current-user route (`GET`) called after login/refresh. */\n mePath?: string;\n /** Persist key for the store. Default: `\"tempest-auth\"`. */\n storeName?: string;\n /** Storage backend. Default: `\"local\"`. */\n storage?: \"local\" | \"session\";\n /** Send cookies (needed when the refresh token lives in an httpOnly cookie). */\n withCredentials?: boolean;\n /**\n * CSRF token for every write, forwarded to `createApiClient`'s `csrf`. Off\n * by default; `true` echoes the `csrf_token` cookie as `X-CSRF-Token`.\n *\n * The pair to `withCredentials`: once the refresh token lives in a cookie,\n * the browser attaches it to a `POST /auth/refresh` that another site\n * triggers. Login and refresh carry the header too — they go out with\n * `skipAuth`, and the CSRF header does not follow auth.\n */\n csrf?: boolean | CsrfOptions;\n /** Custom fetch implementation (testing / SSR). Defaults to `globalThis.fetch`. */\n fetcher?: typeof fetch;\n /**\n * Extract tokens from a login/refresh response. Default reads\n * `access_token` + `refresh_token`.\n */\n parseTokens?: (data: unknown) => { token: string; refreshToken?: string };\n /** Pull the user out of the login response, when the API embeds it. */\n parseUser?: (data: unknown) => TUser | null;\n /**\n * Build the refresh request body. Default sends `{ refresh_token }` when a\n * refresh token is stored, else `undefined` (cookie-based refresh).\n */\n refreshBody?: (refreshToken: string | null) => unknown;\n /**\n * Retry policy for `api`, forwarded to {@link createApiClient}. Off by\n * default; `true` enables the conservative built-in policy, which never\n * replays a write.\n */\n retry?: boolean | RetryOptions;\n /**\n * Where to send the browser when the session ends — a hard navigation via\n * `window.location.assign`, after the store is cleared.\n *\n * **Prefer leaving this unset.** `logout()` already clears the store, so a\n * `<RouteGuard when={isAuthenticated} redirectTo=\"/login\">` wrapped around\n * the protected area navigates on its own, which keeps the SPA alive and the\n * router history intact. Reach for this only when the expiry can happen\n * outside any guarded subtree and a full reload is acceptable.\n */\n redirectTo?: string;\n /**\n * Tear down per-user state when a session ends — on an explicit `logout()`,\n * when the client ends the session on its own after a failed refresh, and\n * when a successful `login()` replaces a live session. Without it, the automatic path had no place to hook into, and\n * state derived from the user (a push subscription, a persisted query\n * cache, a background-sync queue) survived into the next account.\n *\n * Order, on every path:\n *\n * 1. any refresh still in flight is superseded, so it cannot reinstall the\n * ending session's tokens;\n * 2. the store and the stored refresh token are cleared — synchronously, so\n * `isAuthenticated` is already `false` when `logout()` returns;\n * 3. this hook runs with a {@link TempestSessionEnd} whose `api` still sends\n * the ended session's access token;\n * 4. on `\"expired\"` with `redirectTo` set, the browser navigates — after the\n * hook settled, even if it threw; on `\"switch\"`, the new session is\n * installed — after the hook settled, even if it threw.\n *\n * It may be async. `logout()` resolves when it settles and rejects with what\n * it threw; on the automatic path the error is swallowed, because the\n * request that ended the session already rejects with its own `401`. It\n * runs once per session: a second `logout()`, or the other requests of a\n * burst of `401`s, find nothing left to end and skip it.\n *\n * On `\"switch\"` the hook runs only **after** the new credentials were\n * accepted, so a failed `login()` leaves the live session untouched. It runs\n * **before** the new session is installed: teardown of the previous\n * account's state (clearing a persisted cache, a background-sync queue) has\n * to finish before the new account's requests start filling them, or it\n * would wipe — or race with — the new account's data. The cost is that\n * `isAuthenticated` is `false` while the hook runs, which a route guard may\n * act on; keep the hook short. Its error is swallowed there, since the\n * login itself succeeded.\n *\n * Inside the hook, reach the backend only through `session.api`. `auth.api`\n * goes out with no token; `auth.login()` waits on the teardown in progress —\n * this hook — and never settles. Telling the hook apart from an outside\n * caller would need an async context the browser does not have, so this is\n * a documented rule, not a runtime check.\n */\n onSessionEnd?: (session: TempestSessionEnd<TUser>) => void | Promise<void>;\n}\n\nexport interface TempestAuth<TUser, TCredentials> {\n /** The persisted Zustand auth store hook (compatible with `<AuthGuard>`). */\n useAuthStore: ReturnType<typeof createAuthStore<TUser>>;\n /** A `createApiClient` wired with bearer auth + 401 → refresh → retry. */\n api: ApiClient;\n /** Authenticate, store the session, and resolve the user (or null). */\n login: (credentials: TCredentials) => Promise<TUser | null>;\n /**\n * End the session: supersede any refresh in flight, clear the store and the\n * stored refresh token, then run `onSessionEnd`.\n *\n * The store is cleared synchronously, before the first `await`, so callers\n * that ignore the promise see the signed-out state at once. The promise\n * resolves when `onSessionEnd` settled and rejects with its error.\n */\n logout: () => Promise<void>;\n /**\n * Refresh the access token (deduplicated across concurrent callers).\n *\n * @throws {SessionSupersededError} When the session ended or changed while\n * the refresh was in flight; its tokens were discarded.\n */\n refresh: () => Promise<void>;\n /** The current access token, or null. */\n getToken: () => string | null;\n}\n\nfunction defaultParseTokens(data: unknown): { token: string; refreshToken?: string } {\n const d = (data ?? {}) as TempestTokenResponse;\n return { token: d.access_token, refreshToken: d.refresh_token };\n}\n\n/**\n * Turn-key auth preset wiring `createAuthStore` + `createRefreshQueue` +\n * `createApiClient` to the Tempest FastAPI SDK auth contract: login returns\n * `{ access_token, token_type }`, requests carry `Authorization: Bearer`, and a\n * `401` triggers a single deduplicated refresh + replay.\n *\n * The session is cleared whenever that path ends unauthorized anyway — the\n * refresh threw, or the replay came back `401` — so a refresh token that the\n * backend has revoked cannot leave the app holding a dead session. With\n * `redirectTo` set, the browser also leaves the page; without it, clearing the\n * store is enough for a `<RouteGuard>` to navigate on its own.\n *\n * Every refresh is bound to a **session epoch**, bumped by `logout()` and by a\n * `login()` whose credentials were accepted. A refresh that resolves after its epoch ended discards\n * what it brought back and rejects with {@link SessionSupersededError}, which\n * the client passes through without ending the new session. Before 0.77.0 a\n * refresh that was in flight during `logout()` reinstalled the old tokens —\n * and after `logout()` + `login(B)`, replaced B's token with A's while the UI\n * showed B (#435). A `401` from a request sent under an ended session is\n * refused the same way, instead of being replayed as the account signed in now.\n *\n * Refreshes are collapsed per rotation, not just per overlap: a `401` whose\n * request left with a token that has since been replaced in the same session\n * replays with the new token and refreshes nothing. Three expired requests take\n * one refresh whether their `401`s arrive together or one after another.\n *\n * @example\n * const auth = createTempestAuth<User, { email: string; password: string }>({\n * baseURL: import.meta.env.VITE_API_URL,\n * mePath: \"/api/auth/me\",\n * });\n *\n * await auth.login({ email, password }); // stores session, returns the user\n * const orders = await auth.api.get(\"/api/orders\"); // sends the bearer token\n * await auth.logout(); // clears, then runs onSessionEnd\n *\n * @param options - The auth configuration.\n * @returns The store hook, a wired API client, and login/logout/refresh helpers.\n */\nexport function createTempestAuth<TUser, TCredentials = { email: string; password: string }>(\n options: CreateTempestAuthOptions<TUser>,\n): TempestAuth<TUser, TCredentials> {\n const {\n baseURL,\n prefix,\n loginPath = \"/api/auth/login\",\n refreshPath = \"/api/auth/refresh\",\n mePath,\n storeName = \"tempest-auth\",\n storage = \"local\",\n withCredentials = false,\n csrf,\n fetcher,\n parseTokens = defaultParseTokens,\n parseUser,\n refreshBody = (rt) => (rt ? { refresh_token: rt } : undefined),\n retry,\n redirectTo,\n onSessionEnd,\n } = options;\n\n const useAuthStore = createAuthStore<TUser>({ name: storeName, storage });\n const refreshKey = `${storeName}-refresh`;\n\n function storageImpl(): Storage | null {\n if (typeof window === \"undefined\") return null;\n return storage === \"session\" ? window.sessionStorage : window.localStorage;\n }\n function readRefreshToken(): string | null {\n return storageImpl()?.getItem(refreshKey) ?? null;\n }\n function writeRefreshToken(token: string | null): void {\n const s = storageImpl();\n if (!s) return;\n if (token) s.setItem(refreshKey, token);\n else s.removeItem(refreshKey);\n }\n\n const state = (): AuthState<TUser> => useAuthStore.getState();\n const getToken = (): string | null => state().token;\n\n /**\n * Load the current user, without letting a `401` end the session.\n *\n * `skipAuthRetry` is what keeps this one client honest: the call is\n * authenticated, but a `401` here surfaces instead of triggering a refresh\n * and `onUnauthorized`. That was the behaviour of the throwaway client this\n * used to build per invocation, and it is the reason the preset needed\n * three clients before 0.66.0.\n */\n async function fetchUser(): Promise<TUser | null> {\n if (!mePath) return state().user;\n const user = await api.get<TUser>(mePath, { skipAuthRetry: true });\n state().setUser(user);\n return user;\n }\n\n let epoch = 0;\n let ending: Promise<void> = Promise.resolve();\n let hooksRunning = 0;\n\n /**\n * Start a new session epoch: supersede the refresh in flight and give the\n * new epoch a refresh queue of its own, so a `401` under the new session\n * never joins the old session's refresh.\n *\n * The client reads the epoch when each attempt leaves (`getSessionEpoch`),\n * which is what ties a `401` to the session that sent it — no set of\n * tokens to keep, and nothing that grows with the session's length.\n */\n function nextEpoch(): void {\n epoch += 1;\n queue = createRefreshQueue(rotate);\n }\n\n /**\n * Perform one refresh for the epoch that is current when it starts.\n *\n * Checked again after the round trip, on success and on failure alike: a\n * refresh that fails after `logout()` + `login(B)` must not reach\n * `onUnauthorized` either, or it would end B's session.\n *\n * @throws {SessionSupersededError} When the epoch changed during the call.\n */\n async function rotate(): Promise<void> {\n const started = epoch;\n let data: unknown;\n try {\n data = await api.post<unknown>(refreshPath, {\n body: refreshBody(readRefreshToken()),\n skipAuth: true,\n });\n } catch (error) {\n if (epoch !== started) throw new SessionSupersededError();\n throw error;\n }\n if (epoch !== started) throw new SessionSupersededError();\n const { token, refreshToken } = parseTokens(data);\n state().setToken(token);\n if (refreshToken) writeRefreshToken(refreshToken);\n }\n\n let queue = createRefreshQueue(rotate);\n\n /**\n * Whether a `401` must be left alone: no refresh, no ending the session.\n *\n * Two cases, both of the current epoch: an attempt that carried no token\n * while a session is now installed (it left before the sign-in, and\n * refreshing or ending the new session for it would punish the wrong\n * session); and an attempt whose token the session has since rotated past\n * (the rotation it would trigger already happened).\n *\n * @param context - The attempt's credential.\n * @returns Whether the attempt is stale within the current session.\n */\n function staleWithinSession(context: RequestAuthContext): boolean {\n const current = getToken();\n if (current === null) return false;\n return context.token === null || context.token !== current;\n }\n\n /**\n * The `refresh` the client calls on a `401`.\n *\n * - An attempt from an ended epoch is refused rather than replayed as\n * whoever is signed in now.\n * - A token this session already rotated past: the replay just picks up the\n * new one — no second refresh.\n * - No token, while a session is now installed: the `401` surfaces as is.\n * - Otherwise: join or start this epoch's refresh.\n *\n * @param context - The credential and epoch the rejected attempt left with.\n * @throws {SessionSupersededError} When the attempt belongs to an ended\n * session, or the session changed during the refresh.\n */\n async function refreshFor(context: RequestAuthContext): Promise<void> {\n if (context.epoch !== null && context.epoch !== epoch) throw new SessionSupersededError();\n if (staleWithinSession(context)) {\n if (context.token === null) throw new Error(\"anonymous 401 under a live session\");\n return;\n }\n return queue();\n }\n\n /**\n * The client's `onUnauthorized`: end the session only when the `401` is the\n * current session's own.\n *\n * The replay after a refresh is checked here too — it can come back `401`\n * after the user signed out and another account signed in, and ending the\n * session then would sign the new account out.\n *\n * @param context - The credential and epoch of the attempt that failed.\n * @throws {SessionSupersededError} When that attempt belongs to an ended\n * session.\n */\n async function onUnauthorized(_response: Response, context: RequestAuthContext): Promise<void> {\n if (context.epoch !== null && context.epoch !== epoch) throw new SessionSupersededError();\n if (staleWithinSession(context)) return;\n await endSession();\n }\n\n /**\n * Authenticate and install the new session.\n *\n * The previous session ends only once the new credentials are accepted: a\n * login that fails leaves it, and the refresh it may have in flight,\n * untouched. When one was live, `onSessionEnd` runs with `\"switch\"` before\n * the new tokens are installed — see the option for why that order. A\n * `logout()` still tearing down is awaited the same way, so the new\n * account's state never starts before the previous one's teardown ended.\n * Never call it from inside `onSessionEnd`: it would wait on that hook.\n *\n * @param credentials - Sent as the login body.\n * @returns The user of the new session, or null.\n * @throws {SessionSupersededError} When a `logout()` or another `login()`\n * moved the session while this one was in flight — during its request or\n * during the teardown; the new tokens are discarded.\n */\n async function login(credentials: TCredentials): Promise<TUser | null> {\n const started = epoch;\n const data = await api.post<unknown>(loginPath, {\n body: credentials,\n skipAuth: true,\n });\n if (epoch !== started) throw new SessionSupersededError();\n const { token, refreshToken } = parseTokens(data);\n const { done } = finish(\"switch\");\n const mine = epoch;\n await done.catch(() => undefined);\n if (epoch !== mine) throw new SessionSupersededError();\n state().setToken(token);\n writeRefreshToken(refreshToken ?? null);\n const embedded = parseUser?.(data);\n if (embedded != null) {\n state().setUser(embedded);\n return embedded;\n }\n return fetchUser();\n }\n\n /**\n * End the current session, synchronously up to the hook.\n *\n * The epoch moves first, so a refresh resolving between here and the hook\n * is already superseded. The hook only runs when there was a session to\n * end — a token, a stored refresh token or a user — which is what keeps a\n * burst of `401`s from running it once per request.\n *\n * With nothing left to end, the caller waits on the teardown already under\n * way (`ending`, which never rejects, so a later no-op does not inherit an\n * old error). Two callers skip that wait while a hook is running, because\n * they may be the hook itself and would wait on their own teardown forever:\n * `endSession` — a hook awaiting `auth.api` goes out tokenless, gets a\n * `401`, and lands here — and a `logout()` with no session left. `login()`\n * (`\"switch\"`) always waits: the new account's state must not start while\n * the previous one's teardown is still clearing it. A hook awaiting\n * `auth.login()` therefore never settles — misuse the option documents.\n *\n * The returned `done` is a fresh promise derived from the hook, not the one\n * `ending` holds: a `logout()` nobody awaits still surfaces the hook's error\n * as an unhandled rejection instead of losing it.\n *\n * @param reason - Whether the caller signed out, the client gave up, or a\n * login replaced the session.\n * @returns Whether a live session ended here, and a promise settling with\n * the hook.\n */\n function finish(reason: TempestSessionEnd<TUser>[\"reason\"]): {\n live: boolean;\n done: Promise<void>;\n } {\n nextEpoch();\n const { user, token } = state();\n const live = token !== null || user !== null || readRefreshToken() !== null;\n state().logout();\n writeRefreshToken(null);\n if (!live) {\n const wait = reason === \"switch\" || hooksRunning === 0;\n return { live, done: wait ? ending : Promise.resolve() };\n }\n if (!onSessionEnd) return { live, done: Promise.resolve() };\n const sessionApi = createApiClient({\n baseURL,\n prefix,\n withCredentials,\n csrf,\n fetcher,\n getToken: () => token,\n });\n hooksRunning += 1;\n const run = (async () => {\n try {\n await onSessionEnd({ reason, user, token, api: sessionApi });\n } finally {\n hooksRunning -= 1;\n }\n })();\n ending = run.then(\n () => undefined,\n () => undefined,\n );\n return { live, done: run.then(() => undefined) };\n }\n\n function logout(): Promise<void> {\n return finish(\"logout\").done;\n }\n\n /**\n * End the session after the client gave up on it and, when `redirectTo`\n * is set, leave the page once `onSessionEnd` settled.\n *\n * Separate from `logout` so an explicit sign-out stays a pure state change:\n * a caller that already navigates itself would otherwise get a second,\n * competing navigation. The hook is awaited before navigating because a\n * hard navigation aborts whatever request it still had in flight. Only the\n * call that ended a live session navigates; the rest of a burst of `401`s\n * waits on the same teardown, or returns at once inside a hook.\n */\n async function endSession(): Promise<void> {\n const { live, done } = finish(\"expired\");\n await done.catch(() => undefined);\n if (live && redirectTo && typeof window !== \"undefined\") {\n window.location.assign(redirectTo);\n }\n }\n\n const api = createApiClient({\n baseURL,\n prefix,\n withCredentials,\n csrf,\n fetcher,\n getToken,\n getSessionEpoch: () => epoch,\n refresh: refreshFor,\n retry,\n onUnauthorized,\n });\n\n const refresh = (): Promise<void> => queue();\n\n return { useAuthStore, api, login, logout, refresh, getToken };\n}\n"],"mappings":"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"}
|