@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
package/src/locale.tsx ADDED
@@ -0,0 +1,246 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { Icon } from "./icons";
5
+ import { Menu, MenuItem } from "./ui/Menu";
6
+ import { UiTextProvider, useStrings } from "./uiText";
7
+ import type { TerpStrings } from "./uiText";
8
+
9
+ /**
10
+ * One locale's catalog: per-key overrides of the framework strings (missing keys fall
11
+ * back to the bundled English defaults) plus an optional native display name for
12
+ * language pickers. `{}` is a valid catalog — English needs no overrides.
13
+ */
14
+ export interface LocaleCatalog {
15
+ /** Native display name shown by {@link LanguageSwitcher} (default: the locale code). */
16
+ label?: string;
17
+ /** Framework-string overrides for this locale. */
18
+ strings?: Partial<TerpStrings>;
19
+ }
20
+
21
+ /** The built-in English catalog — the bundled defaults, no overrides needed. */
22
+ export const LOCALE_EN: LocaleCatalog = { label: "English" };
23
+
24
+ /**
25
+ * The built-in Dutch catalog: a complete translation of every framework string,
26
+ * so `locales: { en: LOCALE_EN, nl: LOCALE_NL }` localises the whole chrome out
27
+ * of the box (a completeness test pins it to the `TerpStrings` key set).
28
+ */
29
+ export const LOCALE_NL: LocaleCatalog = {
30
+ label: "Nederlands",
31
+ strings: {
32
+ loading: "Laden...",
33
+ emptyList: "Nog niets te zien.",
34
+ add: "Toevoegen",
35
+ signOut: "Uitloggen",
36
+ signIn: "Inloggen",
37
+ signingIn: "Bezig met inloggen…",
38
+ email: "E-mailadres",
39
+ password: "Wachtwoord",
40
+ signInFailed: "Inloggen mislukt. Controleer je gegevens.",
41
+ fillDevCredentials: "Vul dev-inloggegevens in",
42
+ continueWith: "Doorgaan met",
43
+ orSeparator: "of",
44
+ ssoFailed: "Single sign-on mislukt. Probeer het opnieuw.",
45
+ uploadFile: "Bestand uploaden",
46
+ uploading: "Uploaden…",
47
+ unauthorized: "Je hebt geen toegang tot deze pagina.",
48
+ breadcrumbsLabel: "Kruimelpad",
49
+ moduleNavigationLabel: "Modulenavigatie",
50
+ moreActions: "Meer acties",
51
+ errorTitle: "Er is iets misgegaan.",
52
+ confirm: "Bevestigen",
53
+ cancel: "Annuleren",
54
+ successTitle: "Gelukt",
55
+ warningTitle: "Let op",
56
+ dismiss: "Sluiten",
57
+ accountMenu: "Accountmenu",
58
+ settings: "Instellingen",
59
+ profile: "Profiel",
60
+ role: "Rol",
61
+ home: "Start",
62
+ primaryNavigationLabel: "Hoofdnavigatie",
63
+ collapseSidebar: "Zijbalk inklappen",
64
+ expandSidebar: "Zijbalk uitklappen",
65
+ openNavigation: "Navigatie openen",
66
+ closeNavigation: "Navigatie sluiten",
67
+ theme: "Thema",
68
+ themeLight: "Licht",
69
+ themeDark: "Donker",
70
+ themeSystem: "Systeem",
71
+ language: "Taal",
72
+ admin: "Beheer",
73
+ adminUsers: "Gebruikers",
74
+ adminUsersDescription: "Accounts aanmaken, rollen wijzigen, wachtwoorden resetten",
75
+ adminGroups: "Groepen",
76
+ adminGroupsDescription: "Bundel permissies; lidmaatschap past ze toe",
77
+ adminAudit: "Auditlog",
78
+ adminAuditDescription: "Elke wijziging: wat, wie, wanneer",
79
+ statusColumn: "Status",
80
+ createdColumn: "Aangemaakt",
81
+ statusActive: "Actief",
82
+ statusDeactivated: "Gedeactiveerd",
83
+ provisionUser: "Gebruiker aanmaken",
84
+ roleViewer: "Lezer",
85
+ roleEditor: "Redacteur",
86
+ roleAdmin: "Beheerder",
87
+ working: "Bezig…",
88
+ makeRole: "Maak {role}",
89
+ resetPassword: "Wachtwoord resetten",
90
+ newPassword: "Nieuw wachtwoord",
91
+ deactivate: "Deactiveren",
92
+ reactivate: "Heractiveren",
93
+ changeRoleConfirm: "De rol van deze gebruiker wijzigen naar {role}?",
94
+ deactivateUserConfirm: "Dit account deactiveren? Actieve sessies worden ingetrokken.",
95
+ reactivateUserConfirm: "Dit account heractiveren?",
96
+ groupName: "Naam",
97
+ description: "Omschrijving",
98
+ members: "Leden",
99
+ createGroup: "Groep aanmaken",
100
+ deleteGroup: "Groep verwijderen",
101
+ deleteGroupConfirm: "Deze groep verwijderen? Lidmaatschappen en permissies gaan mee.",
102
+ addMember: "Lid toevoegen",
103
+ removeMember: "Verwijderen",
104
+ removeMemberConfirm: "Dit lid uit de groep verwijderen?",
105
+ userField: "Gebruiker",
106
+ userNotFound: "Geen account gevonden met dat e-mailadres.",
107
+ permissions: "Permissies",
108
+ grantPermission: "Permissie toekennen",
109
+ permission: "Permissie",
110
+ revoke: "Intrekken",
111
+ revokeConfirm: "Deze permissie van de groep intrekken?",
112
+ actionColumn: "Actie",
113
+ actorColumn: "Uitvoerder",
114
+ targetColumn: "Doel",
115
+ whenColumn: "Wanneer",
116
+ details: "Details",
117
+ saved: "Opgeslagen",
118
+ requestFailed: "Het verzoek is mislukt. Probeer opnieuw.",
119
+ },
120
+ };
121
+
122
+ /** The `localStorage` key {@link LocaleProvider} persists the choice under. */
123
+ export const LOCALE_STORAGE_KEY = "terp.locale";
124
+
125
+ interface LocaleContextValue {
126
+ locale: string;
127
+ locales: readonly string[];
128
+ labelOf: (locale: string) => string;
129
+ setLocale: (locale: string) => void;
130
+ }
131
+
132
+ const LocaleContext = createContext<LocaleContextValue | null>(null);
133
+
134
+ export interface LocaleProviderProps {
135
+ /** The app's locales, keyed by BCP-47 code (e.g. `{ en: LOCALE_EN, nl: {...} }`). */
136
+ locales: Record<string, LocaleCatalog>;
137
+ /** Starting locale when the user has not chosen one; default: the first key. */
138
+ defaultLocale?: string;
139
+ children: ReactNode;
140
+ }
141
+
142
+ /**
143
+ * The language seam over {@link UiTextProvider}: owns which locale is active, persists
144
+ * the choice in `localStorage`, and feeds the active catalog's string overrides to the
145
+ * `UiText` context — so every react-core component (and every `UiText` prop) follows the
146
+ * switch with no per-component wiring. Adding a language to an app is one catalog entry.
147
+ */
148
+ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProviderProps) {
149
+ const codes = Object.keys(locales);
150
+ const fallback = defaultLocale !== undefined && codes.includes(defaultLocale)
151
+ ? defaultLocale
152
+ : codes[0];
153
+ const [locale, setLocaleState] = useState<string>(() => {
154
+ try {
155
+ const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
156
+ return stored !== null && codes.includes(stored) ? stored : (fallback ?? "en");
157
+ } catch {
158
+ return fallback ?? "en";
159
+ }
160
+ });
161
+
162
+ const setLocale = useCallback(
163
+ (next: string) => {
164
+ if (!codes.includes(next)) {
165
+ return;
166
+ }
167
+ setLocaleState(next);
168
+ try {
169
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
170
+ } catch {
171
+ // Private mode / quota: the choice still applies for this session.
172
+ }
173
+ },
174
+ [codes.join("\u0000")],
175
+ );
176
+
177
+ const value = useMemo<LocaleContextValue>(
178
+ () => ({
179
+ locale,
180
+ locales: codes,
181
+ labelOf: (code) => locales[code]?.label ?? code,
182
+ setLocale,
183
+ }),
184
+ [locale, codes.join("\u0000"), setLocale, locales],
185
+ );
186
+
187
+ return (
188
+ <LocaleContext.Provider value={value}>
189
+ <UiTextProvider strings={locales[locale]?.strings}>{children}</UiTextProvider>
190
+ </LocaleContext.Provider>
191
+ );
192
+ }
193
+
194
+ /** The active locale + the catalog codes + setter, or `null` outside a {@link LocaleProvider}. */
195
+ export function useLocale(): LocaleContextValue | null {
196
+ return useContext(LocaleContext);
197
+ }
198
+
199
+ export interface LanguageSwitcherProps {
200
+ /**
201
+ * `"stacked"` (default) renders a labelled icon menu for settings panels;
202
+ * `"inline"` renders only the compact icon trigger used by the shell header.
203
+ */
204
+ variant?: "stacked" | "inline";
205
+ }
206
+
207
+ /**
208
+ * The standard language control: a token-themed menu over the app's locale catalogs.
209
+ * Renders nothing outside a {@link LocaleProvider} or when only one locale is declared,
210
+ * so shared chrome (the shell header) can include it unconditionally.
211
+ */
212
+ export function LanguageSwitcher({ variant = "stacked" }: LanguageSwitcherProps) {
213
+ const context = useLocale();
214
+ const strings = useStrings();
215
+ if (context === null || context.locales.length < 2) {
216
+ return null;
217
+ }
218
+ const menu = (
219
+ <Menu trigger={<Icon name="globe" size="1.15rem" />} triggerLabel={strings.language}>
220
+ {({ close }) => (
221
+ <>
222
+ {context.locales.map((code) => (
223
+ <MenuItem
224
+ key={code}
225
+ label={context.labelOf(code)}
226
+ selected={code === context.locale}
227
+ onSelect={() => {
228
+ context.setLocale(code);
229
+ close(true);
230
+ }}
231
+ />
232
+ ))}
233
+ </>
234
+ )}
235
+ </Menu>
236
+ );
237
+ if (variant === "inline") {
238
+ return menu;
239
+ }
240
+ return (
241
+ <div style={{ display: "grid", justifyItems: "start", gap: "var(--space-1)", fontSize: "var(--font-size-sm)" }}>
242
+ <span style={{ color: "var(--color-neutral-600)" }}>{strings.language}</span>
243
+ {menu}
244
+ </div>
245
+ );
246
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ModuleManifest } from "@terpjs/contract";
3
+
4
+ import { visibleNav } from "./nav";
5
+
6
+ const manifests: ModuleManifest[] = [
7
+ { name: "notes", routes: [], nav: [{ label: "Notes", to: "/notes" }] },
8
+ { name: "admin", routes: [], nav: [{ label: "Users", to: "/users", role: "admin" }] },
9
+ { name: "noNav", routes: [] },
10
+ ];
11
+
12
+ describe("visibleNav", () => {
13
+ it("flattens nav across manifests when everything is visible", () => {
14
+ expect(visibleNav(manifests, () => true).map((i) => i.to)).toEqual(["/notes", "/users"]);
15
+ });
16
+
17
+ it("filters items by their required role", () => {
18
+ const onlyPublic = visibleNav(manifests, (role) => role === undefined);
19
+ expect(onlyPublic.map((i) => i.to)).toEqual(["/notes"]);
20
+ });
21
+ });
package/src/nav.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { ModuleManifest, NavItem } from "@terpjs/contract";
2
+
3
+ /**
4
+ * Flatten the nav of every module manifest into one ordered sidebar list, keeping only
5
+ * the items the current user may see. `canSeeRole(role)` decides visibility for an item's
6
+ * required role (an item with no `role` is visible to any authenticated user).
7
+ */
8
+ export function visibleNav(
9
+ manifests: readonly ModuleManifest[],
10
+ canSeeRole: (role: string | undefined) => boolean,
11
+ ): NavItem[] {
12
+ return manifests.flatMap((manifest) => manifest.nav ?? []).filter((item) => canSeeRole(item.role));
13
+ }
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * The runtime half of the "every routed view is a page archetype" control. The router's route
5
+ * wrapper provides a marker callback; {@link Page} (which `OverviewPage` / `DetailPage` /
6
+ * `HubPage` all compose) invokes it during render. After mount the wrapper checks the mark and
7
+ * fails closed on a routed view that skipped the archetypes — so every screen keeps the frame
8
+ * (breadcrumbs, one `h1`, the loading/error slots) even if a lint were bypassed.
9
+ */
10
+ export const PageMarkerContext = createContext<(() => void) | null>(null);
11
+
12
+ /** The marker callback for the current routed view, or null outside a routed view. */
13
+ export function usePageMarker(): (() => void) | null {
14
+ return useContext(PageMarkerContext);
15
+ }
package/src/raw.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Minimal Node file access for the token guard test only — the package keeps
3
+ * `"types": []` so component source never sees ambient Node globals.
4
+ */
5
+ declare module "node:fs" {
6
+ export function readFileSync(path: URL | string, encoding: "utf-8"): string;
7
+ }
@@ -0,0 +1,226 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ post: vi.fn(),
7
+ client: null as unknown as { POST: ReturnType<typeof vi.fn> },
8
+ }));
9
+ mocks.client = { POST: mocks.post };
10
+
11
+ vi.mock("./TerpProvider", () => ({
12
+ useTerpBaseUrl: () => "https://api.example.test",
13
+ useTerpClient: () => mocks.client,
14
+ }));
15
+
16
+ import { useRealtimeChannel } from "./realtime";
17
+
18
+ interface Notice {
19
+ sequence: number;
20
+ text: string;
21
+ }
22
+
23
+ const isNotice = (value: unknown): value is Notice => {
24
+ if (typeof value !== "object" || value === null) return false;
25
+ const record = value as Record<string, unknown>;
26
+ return typeof record.sequence === "number" && typeof record.text === "string";
27
+ };
28
+
29
+ class EventSourceDouble {
30
+ static instances: EventSourceDouble[] = [];
31
+
32
+ readonly url: string;
33
+ closed = false;
34
+ onopen: ((event: Event) => void) | null = null;
35
+ onmessage: ((event: MessageEvent<string>) => void) | null = null;
36
+ onerror: ((event: Event) => void) | null = null;
37
+
38
+ constructor(url: string | URL) {
39
+ this.url = String(url);
40
+ EventSourceDouble.instances.push(this);
41
+ }
42
+
43
+ close() {
44
+ this.closed = true;
45
+ }
46
+ }
47
+
48
+ class WebSocketDouble {
49
+ static readonly OPEN = 1;
50
+ static instances: WebSocketDouble[] = [];
51
+
52
+ readonly url: string;
53
+ readyState = WebSocketDouble.OPEN;
54
+ onopen: ((event: Event) => void) | null = null;
55
+ onmessage: ((event: MessageEvent<string>) => void) | null = null;
56
+ onerror: ((event: Event) => void) | null = null;
57
+ onclose: ((event: CloseEvent) => void) | null = null;
58
+
59
+ constructor(url: string | URL) {
60
+ this.url = String(url);
61
+ WebSocketDouble.instances.push(this);
62
+ }
63
+
64
+ send() {}
65
+
66
+ close() {
67
+ this.readyState = 3;
68
+ }
69
+ }
70
+
71
+ afterEach(() => {
72
+ cleanup();
73
+ mocks.post.mockReset();
74
+ EventSourceDouble.instances = [];
75
+ WebSocketDouble.instances = [];
76
+ vi.unstubAllGlobals();
77
+ vi.useRealTimers();
78
+ });
79
+
80
+ describe("useRealtimeChannel lifecycle", () => {
81
+ it("does not open a transport when closed while ticket mint is pending", async () => {
82
+ let resolveTicket: ((value: unknown) => void) | undefined;
83
+ mocks.post.mockReturnValue(
84
+ new Promise((resolve) => {
85
+ resolveTicket = resolve;
86
+ }),
87
+ );
88
+ vi.stubGlobal("EventSource", EventSourceDouble);
89
+
90
+ const { result } = renderHook(() =>
91
+ useRealtimeChannel({ channel: "system.notices", validate: isNotice }),
92
+ );
93
+ await waitFor(() => expect(mocks.post).toHaveBeenCalledTimes(1));
94
+
95
+ act(() => result.current.close());
96
+ expect(result.current.status).toBe("closed");
97
+
98
+ await act(async () => {
99
+ resolveTicket?.({
100
+ data: {
101
+ ticket: "late-ticket",
102
+ expires_in: 30,
103
+ channel: "system.notices",
104
+ transport: "sse",
105
+ },
106
+ });
107
+ await Promise.resolve();
108
+ });
109
+
110
+ expect(EventSourceDouble.instances).toHaveLength(0);
111
+ expect(result.current.status).toBe("closed");
112
+ });
113
+
114
+ it("closes a failed SSE source and remints its one-use ticket", async () => {
115
+ vi.useFakeTimers();
116
+ mocks.post
117
+ .mockResolvedValueOnce({
118
+ data: {
119
+ ticket: "ticket-one",
120
+ expires_in: 30,
121
+ channel: "system.notices",
122
+ transport: "sse",
123
+ },
124
+ })
125
+ .mockResolvedValueOnce({
126
+ data: {
127
+ ticket: "ticket-two",
128
+ expires_in: 30,
129
+ channel: "system.notices",
130
+ transport: "sse",
131
+ },
132
+ });
133
+ vi.stubGlobal("EventSource", EventSourceDouble);
134
+
135
+ const { result } = renderHook(() =>
136
+ useRealtimeChannel({ channel: "system.notices", validate: isNotice }),
137
+ );
138
+ await act(async () => {
139
+ await Promise.resolve();
140
+ });
141
+ expect(EventSourceDouble.instances).toHaveLength(1);
142
+ expect(EventSourceDouble.instances[0].url).toContain("ticket=ticket-one");
143
+
144
+ act(() => EventSourceDouble.instances[0].onerror?.(new Event("error")));
145
+ expect(EventSourceDouble.instances[0].closed).toBe(true);
146
+ expect(result.current.status).toBe("error");
147
+
148
+ await act(async () => {
149
+ await vi.advanceTimersByTimeAsync(1_000);
150
+ });
151
+
152
+ expect(mocks.post).toHaveBeenCalledTimes(2);
153
+ expect(EventSourceDouble.instances).toHaveLength(2);
154
+ expect(EventSourceDouble.instances[1].url).toContain("ticket=ticket-two");
155
+
156
+ act(() =>
157
+ EventSourceDouble.instances[0].onmessage?.(
158
+ new MessageEvent("message", {
159
+ data: '{"sequence":1,"text":"stale"}',
160
+ }),
161
+ ),
162
+ );
163
+ expect(result.current.lastMessage).toBeNull();
164
+
165
+ act(() =>
166
+ EventSourceDouble.instances[1].onmessage?.(
167
+ new MessageEvent("message", {
168
+ data: '{"sequence":2,"text":"fresh"}',
169
+ }),
170
+ ),
171
+ );
172
+ expect(result.current.lastMessage).toEqual({ sequence: 2, text: "fresh" });
173
+ });
174
+
175
+ it("remints after a remote WebSocket close until the caller closes", async () => {
176
+ vi.useFakeTimers();
177
+ mocks.post
178
+ .mockResolvedValueOnce({
179
+ data: {
180
+ ticket: "socket-one",
181
+ expires_in: 30,
182
+ channel: "personal.updates",
183
+ transport: "websocket",
184
+ },
185
+ })
186
+ .mockResolvedValueOnce({
187
+ data: {
188
+ ticket: "socket-two",
189
+ expires_in: 30,
190
+ channel: "personal.updates",
191
+ transport: "websocket",
192
+ },
193
+ });
194
+ vi.stubGlobal("WebSocket", WebSocketDouble);
195
+
196
+ const { result } = renderHook(() =>
197
+ useRealtimeChannel({
198
+ channel: "personal.updates",
199
+ transport: "websocket",
200
+ validate: isNotice,
201
+ }),
202
+ );
203
+ await act(async () => {
204
+ await Promise.resolve();
205
+ });
206
+ expect(WebSocketDouble.instances).toHaveLength(1);
207
+
208
+ act(() =>
209
+ WebSocketDouble.instances[0].onclose?.(
210
+ new CloseEvent("close", { code: 1000 }),
211
+ ),
212
+ );
213
+ await act(async () => {
214
+ await vi.advanceTimersByTimeAsync(1_000);
215
+ });
216
+ expect(mocks.post).toHaveBeenCalledTimes(2);
217
+ expect(WebSocketDouble.instances).toHaveLength(2);
218
+
219
+ act(() => result.current.close());
220
+ await act(async () => {
221
+ await vi.advanceTimersByTimeAsync(30_000);
222
+ });
223
+ expect(result.current.status).toBe("closed");
224
+ expect(mocks.post).toHaveBeenCalledTimes(2);
225
+ });
226
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseMessage, transportUrl, useRealtimeChannel } from "./realtime";
4
+
5
+ interface Notice {
6
+ sequence: number;
7
+ text: string;
8
+ }
9
+
10
+ const isNotice = (value: unknown): value is Notice => {
11
+ if (typeof value !== "object" || value === null) return false;
12
+ const record = value as Record<string, unknown>;
13
+ return typeof record.sequence === "number" && typeof record.text === "string";
14
+ };
15
+
16
+ describe("realtime transport contract", () => {
17
+ it("derives SSE and WebSocket URLs from the provider base URL with an opaque ticket", () => {
18
+ expect(
19
+ transportUrl("https://api.example.test/base", "sse", "notes.changed", "ticket value"),
20
+ ).toBe(
21
+ "https://api.example.test/api/v1/realtime/sse/notes.changed?ticket=ticket+value",
22
+ );
23
+ expect(
24
+ transportUrl("https://api.example.test", "websocket", "notes/live", "opaque"),
25
+ ).toBe(
26
+ "wss://api.example.test/api/v1/realtime/ws/notes%2Flive?ticket=opaque",
27
+ );
28
+ });
29
+
30
+ it("parses only JSON that passes the channel's runtime type guard", () => {
31
+ expect(parseMessage('{"sequence":1,"text":"hello"}', isNotice)).toEqual({
32
+ sequence: 1,
33
+ text: "hello",
34
+ });
35
+ expect(() => parseMessage("not json", isNotice)).toThrow(/invalid JSON/);
36
+ expect(() => parseMessage('{"sequence":"one"}', isNotice)).toThrow(
37
+ /outside its declared type/,
38
+ );
39
+ });
40
+
41
+ it("exports the sanctioned hook", () => {
42
+ expect(typeof useRealtimeChannel).toBe("function");
43
+ });
44
+ });