@stapel/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/dist/analytics/context.d.ts +9 -0
  4. package/dist/analytics/context.d.ts.map +1 -0
  5. package/dist/analytics/context.js +14 -0
  6. package/dist/analytics/context.js.map +1 -0
  7. package/dist/analytics/createAnalytics.d.ts +10 -0
  8. package/dist/analytics/createAnalytics.d.ts.map +1 -0
  9. package/dist/analytics/createAnalytics.js +273 -0
  10. package/dist/analytics/createAnalytics.js.map +1 -0
  11. package/dist/analytics/flow.d.ts +10 -0
  12. package/dist/analytics/flow.d.ts.map +1 -0
  13. package/dist/analytics/flow.js +10 -0
  14. package/dist/analytics/flow.js.map +1 -0
  15. package/dist/analytics/hash.d.ts +3 -0
  16. package/dist/analytics/hash.d.ts.map +1 -0
  17. package/dist/analytics/hash.js +12 -0
  18. package/dist/analytics/hash.js.map +1 -0
  19. package/dist/analytics/pii.d.ts +9 -0
  20. package/dist/analytics/pii.d.ts.map +1 -0
  21. package/dist/analytics/pii.js +52 -0
  22. package/dist/analytics/pii.js.map +1 -0
  23. package/dist/analytics/providers.d.ts +28 -0
  24. package/dist/analytics/providers.d.ts.map +1 -0
  25. package/dist/analytics/providers.js +82 -0
  26. package/dist/analytics/providers.js.map +1 -0
  27. package/dist/analytics/types.d.ts +94 -0
  28. package/dist/analytics/types.d.ts.map +1 -0
  29. package/dist/analytics/types.js +7 -0
  30. package/dist/analytics/types.js.map +1 -0
  31. package/dist/client.d.ts +49 -0
  32. package/dist/client.d.ts.map +1 -0
  33. package/dist/client.js +135 -0
  34. package/dist/client.js.map +1 -0
  35. package/dist/config.d.ts +32 -0
  36. package/dist/config.d.ts.map +1 -0
  37. package/dist/config.js +28 -0
  38. package/dist/config.js.map +1 -0
  39. package/dist/errors.d.ts +33 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +46 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/i18n.d.ts +51 -0
  44. package/dist/i18n.d.ts.map +1 -0
  45. package/dist/i18n.js +90 -0
  46. package/dist/i18n.js.map +1 -0
  47. package/dist/index.d.ts +22 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +19 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/query.d.ts +42 -0
  52. package/dist/query.d.ts.map +1 -0
  53. package/dist/query.js +95 -0
  54. package/dist/query.js.map +1 -0
  55. package/dist/storage.d.ts +16 -0
  56. package/dist/storage.d.ts.map +1 -0
  57. package/dist/storage.js +65 -0
  58. package/dist/storage.js.map +1 -0
  59. package/dist/useBreakpoint.d.ts +8 -0
  60. package/dist/useBreakpoint.d.ts.map +1 -0
  61. package/dist/useBreakpoint.js +22 -0
  62. package/dist/useBreakpoint.js.map +1 -0
  63. package/dist/verification.d.ts +31 -0
  64. package/dist/verification.d.ts.map +1 -0
  65. package/dist/verification.js +20 -0
  66. package/dist/verification.js.map +1 -0
  67. package/package.json +68 -0
  68. package/src/analytics/context.ts +20 -0
  69. package/src/analytics/createAnalytics.ts +310 -0
  70. package/src/analytics/flow.ts +19 -0
  71. package/src/analytics/hash.ts +16 -0
  72. package/src/analytics/pii.ts +66 -0
  73. package/src/analytics/providers.ts +108 -0
  74. package/src/analytics/types.ts +105 -0
  75. package/src/client.ts +206 -0
  76. package/src/config.tsx +62 -0
  77. package/src/errors.ts +70 -0
  78. package/src/i18n.tsx +147 -0
  79. package/src/index.ts +72 -0
  80. package/src/query.ts +151 -0
  81. package/src/storage.ts +76 -0
  82. package/src/useBreakpoint.ts +27 -0
  83. package/src/verification.ts +48 -0
  84. package/tsconfig.json +26 -0
package/src/query.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { QueryClient, dehydrate, hydrate } from "@tanstack/react-query";
2
+ import type { DehydratedState } from "@tanstack/react-query";
3
+ import { defaultPersistStorage } from "./storage.js";
4
+ import type { PersistStorage } from "./storage.js";
5
+
6
+ export type { PersistStorage } from "./storage.js";
7
+
8
+ interface PersistedRecord {
9
+ readonly version: string;
10
+ readonly state: DehydratedState;
11
+ }
12
+
13
+ export interface StapelQueryClientOptions {
14
+ /** Storage key prefix. Default `"stapel-query"`. */
15
+ readonly cacheKeyPrefix?: string;
16
+ /**
17
+ * Cache buster: persisted state written under a different version is
18
+ * discarded on restore. Convention: the consuming package's version.
19
+ */
20
+ readonly cacheVersion?: string;
21
+ /** Override the storage backend (tests, custom stores). */
22
+ readonly storage?: PersistStorage;
23
+ /** Bring your own QueryClient (defaults applied only when absent). */
24
+ readonly queryClient?: QueryClient;
25
+ /** Debounce for persist writes, ms. Default 100. */
26
+ readonly throttleMs?: number;
27
+ }
28
+
29
+ export interface StapelQueryRuntime {
30
+ readonly queryClient: QueryClient;
31
+ /**
32
+ * Switch the persistence namespace to a user (per-user cache scope,
33
+ * frontend-standard §4.6). Restores that user's persisted state, then
34
+ * mirrors cache changes back to storage. Pass `null` to stop persisting
35
+ * (anonymous / logged out).
36
+ */
37
+ setPersistUser(userId: string | null): Promise<void>;
38
+ /** Write any pending state now (also useful in tests). */
39
+ flushPersist(): Promise<void>;
40
+ /**
41
+ * Remove ALL persisted Stapel query state (every user namespace) and
42
+ * clear the in-memory cache. Call on logout and for GDPR erasure.
43
+ */
44
+ purgePersistedCache(): Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * TanStack Query v5 client wrapped with Stapel persistence: per-user
49
+ * namespaces in IndexedDB (localStorage fallback), version-busted restore,
50
+ * and a GDPR-grade purge.
51
+ */
52
+ export function createStapelQueryClient(
53
+ options: StapelQueryClientOptions = {}
54
+ ): StapelQueryRuntime {
55
+ const prefix = options.cacheKeyPrefix ?? "stapel-query";
56
+ const version = options.cacheVersion ?? "0";
57
+ const storage = options.storage ?? defaultPersistStorage();
58
+ const throttleMs = options.throttleMs ?? 100;
59
+
60
+ const queryClient =
61
+ options.queryClient ??
62
+ new QueryClient({
63
+ defaultOptions: {
64
+ queries: {
65
+ staleTime: 30_000,
66
+ gcTime: 24 * 60 * 60 * 1000,
67
+ retry: (failureCount, error) => {
68
+ // Do not retry envelope errors the app must handle (4xx).
69
+ const status = (error as { status?: number }).status;
70
+ if (status !== undefined && status >= 400 && status < 500) {
71
+ return false;
72
+ }
73
+ return failureCount < 2;
74
+ },
75
+ },
76
+ },
77
+ });
78
+
79
+ let currentKey: string | null = null;
80
+ let unsubscribe: (() => void) | null = null;
81
+ let timer: ReturnType<typeof setTimeout> | null = null;
82
+
83
+ const keyFor = (userId: string): string => `${prefix}:${userId}`;
84
+
85
+ async function persistNow(): Promise<void> {
86
+ if (currentKey === null) return;
87
+ const record: PersistedRecord = {
88
+ version,
89
+ state: dehydrate(queryClient),
90
+ };
91
+ await storage.set(currentKey, record);
92
+ }
93
+
94
+ function schedulePersist(): void {
95
+ if (currentKey === null || timer !== null) return;
96
+ timer = setTimeout(() => {
97
+ timer = null;
98
+ void persistNow();
99
+ }, throttleMs);
100
+ }
101
+
102
+ function stopPersisting(): void {
103
+ if (unsubscribe) {
104
+ unsubscribe();
105
+ unsubscribe = null;
106
+ }
107
+ if (timer !== null) {
108
+ clearTimeout(timer);
109
+ timer = null;
110
+ }
111
+ currentKey = null;
112
+ }
113
+
114
+ async function setPersistUser(userId: string | null): Promise<void> {
115
+ if (currentKey !== null) await persistNow();
116
+ stopPersisting();
117
+ if (userId === null) return;
118
+
119
+ currentKey = keyFor(userId);
120
+ const stored = (await storage.get(currentKey)) as
121
+ | PersistedRecord
122
+ | undefined;
123
+ if (stored && stored.version === version) {
124
+ hydrate(queryClient, stored.state);
125
+ } else if (stored) {
126
+ await storage.del(currentKey);
127
+ }
128
+ unsubscribe = queryClient.getQueryCache().subscribe(schedulePersist);
129
+ }
130
+
131
+ async function flushPersist(): Promise<void> {
132
+ if (timer !== null) {
133
+ clearTimeout(timer);
134
+ timer = null;
135
+ }
136
+ await persistNow();
137
+ }
138
+
139
+ async function purgePersistedCache(): Promise<void> {
140
+ stopPersisting();
141
+ const allKeys = await storage.keys();
142
+ await Promise.all(
143
+ allKeys
144
+ .filter((key) => key.startsWith(`${prefix}:`))
145
+ .map((key) => storage.del(key))
146
+ );
147
+ queryClient.clear();
148
+ }
149
+
150
+ return { queryClient, setPersistUser, flushPersist, purgePersistedCache };
151
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { get as idbGet, set as idbSet, del as idbDel, keys as idbKeys } from "idb-keyval";
2
+
3
+ /**
4
+ * Pluggable persistence backend shared by the query persist layer and the
5
+ * analytics offline queue. Default resolution order:
6
+ * IndexedDB (idb-keyval) → localStorage → in-memory (no-op across reloads).
7
+ */
8
+ export interface PersistStorage {
9
+ get(key: string): Promise<unknown>;
10
+ set(key: string, value: unknown): Promise<void>;
11
+ del(key: string): Promise<void>;
12
+ keys(): Promise<string[]>;
13
+ }
14
+
15
+ export function idbStorage(): PersistStorage {
16
+ return {
17
+ get: (key) => idbGet(key),
18
+ set: (key, value) => idbSet(key, value),
19
+ del: (key) => idbDel(key),
20
+ keys: async () => (await idbKeys()).map(String),
21
+ };
22
+ }
23
+
24
+ export function localStorageAdapter(storageArea: Storage): PersistStorage {
25
+ return {
26
+ get: (key) => {
27
+ const raw = storageArea.getItem(key);
28
+ if (raw === null) return Promise.resolve(undefined);
29
+ try {
30
+ return Promise.resolve(JSON.parse(raw) as unknown);
31
+ } catch {
32
+ return Promise.resolve(undefined);
33
+ }
34
+ },
35
+ set: (key, value) => {
36
+ storageArea.setItem(key, JSON.stringify(value));
37
+ return Promise.resolve();
38
+ },
39
+ del: (key) => {
40
+ storageArea.removeItem(key);
41
+ return Promise.resolve();
42
+ },
43
+ keys: () => {
44
+ const result: string[] = [];
45
+ for (let i = 0; i < storageArea.length; i += 1) {
46
+ const key = storageArea.key(i);
47
+ if (key !== null) result.push(key);
48
+ }
49
+ return Promise.resolve(result);
50
+ },
51
+ };
52
+ }
53
+
54
+ export function memoryStorage(): PersistStorage {
55
+ const map = new Map<string, unknown>();
56
+ return {
57
+ get: (key) => Promise.resolve(map.get(key)),
58
+ set: (key, value) => {
59
+ map.set(key, value);
60
+ return Promise.resolve();
61
+ },
62
+ del: (key) => {
63
+ map.delete(key);
64
+ return Promise.resolve();
65
+ },
66
+ keys: () => Promise.resolve([...map.keys()]),
67
+ };
68
+ }
69
+
70
+ export function defaultPersistStorage(): PersistStorage {
71
+ if (typeof indexedDB !== "undefined") return idbStorage();
72
+ if (typeof localStorage !== "undefined") {
73
+ return localStorageAdapter(localStorage);
74
+ }
75
+ return memoryStorage();
76
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from "react";
2
+ import { breakpointForWidth } from "@stapel/tokens";
3
+ import type { Breakpoint } from "@stapel/tokens";
4
+
5
+ /**
6
+ * Current viewport breakpoint from the three `@stapel/tokens` breakpoints
7
+ * (phone / tablet / desktop). SSR-safe: returns `undefined` until mounted,
8
+ * so server and first client render agree.
9
+ */
10
+ export function useBreakpoint(): Breakpoint | undefined {
11
+ const [breakpoint, setBreakpoint] = useState<Breakpoint | undefined>(
12
+ undefined
13
+ );
14
+
15
+ useEffect(() => {
16
+ const update = (): void => {
17
+ setBreakpoint(breakpointForWidth(window.innerWidth));
18
+ };
19
+ update();
20
+ window.addEventListener("resize", update);
21
+ return () => {
22
+ window.removeEventListener("resize", update);
23
+ };
24
+ }, []);
25
+
26
+ return breakpoint;
27
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Step-up verification (the flagship cross-module flow, frontend-standard §2):
3
+ * a 403 whose body carries a `verification` object is a challenge, not a
4
+ * terminal error. `@stapel/core` hands the challenge to a configurable
5
+ * handler (implemented by `@stapel/auth-react`'s factor machines or the
6
+ * host) and retries the original request once on success.
7
+ */
8
+ export interface VerificationChallenge {
9
+ readonly challenge_id: string;
10
+ /** What the challenge protects, e.g. `"billing.payout"`. */
11
+ readonly scope?: string;
12
+ /** Factors the user may satisfy, e.g. `["totp", "webauthn"]`. */
13
+ readonly factors?: readonly string[];
14
+ /** Backends may attach extra fields; kept for forward-compat. */
15
+ readonly [extra: string]: unknown;
16
+ }
17
+
18
+ export interface VerificationOutcome {
19
+ /** Retry the original request? */
20
+ readonly retry: boolean;
21
+ /** Sent as `X-Verification-Token` on the retry when present. */
22
+ readonly token?: string;
23
+ }
24
+
25
+ export type VerificationChallengeHandler = (
26
+ challenge: VerificationChallenge
27
+ ) => Promise<VerificationOutcome>;
28
+
29
+ /** Header carrying the verification proof on retried requests. */
30
+ export const VERIFICATION_TOKEN_HEADER = "X-Verification-Token";
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ /**
37
+ * Extract a verification challenge from a 403 response body, or `null` when
38
+ * the body is a plain error envelope.
39
+ */
40
+ export function extractVerificationChallenge(
41
+ body: unknown
42
+ ): VerificationChallenge | null {
43
+ if (!isRecord(body)) return null;
44
+ const verification = body["verification"];
45
+ if (!isRecord(verification)) return null;
46
+ if (typeof verification["challenge_id"] !== "string") return null;
47
+ return verification as unknown as VerificationChallenge;
48
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }