@shipfox/client-shell 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 (252) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +26 -0
  5. package/LICENSE +21 -0
  6. package/dist/components/main-layout.d.ts +11 -0
  7. package/dist/components/main-layout.d.ts.map +1 -0
  8. package/dist/components/main-layout.js +39 -0
  9. package/dist/components/main-layout.js.map +1 -0
  10. package/dist/components/nav-bar.d.ts +4 -0
  11. package/dist/components/nav-bar.d.ts.map +1 -0
  12. package/dist/components/nav-bar.js +62 -0
  13. package/dist/components/nav-bar.js.map +1 -0
  14. package/dist/components/nav-tabs.d.ts +6 -0
  15. package/dist/components/nav-tabs.d.ts.map +1 -0
  16. package/dist/components/nav-tabs.js +38 -0
  17. package/dist/components/nav-tabs.js.map +1 -0
  18. package/dist/components/not-found-page.d.ts +2 -0
  19. package/dist/components/not-found-page.d.ts.map +1 -0
  20. package/dist/components/not-found-page.js +25 -0
  21. package/dist/components/not-found-page.js.map +1 -0
  22. package/dist/components/settings-nav.d.ts +5 -0
  23. package/dist/components/settings-nav.d.ts.map +1 -0
  24. package/dist/components/settings-nav.js +45 -0
  25. package/dist/components/settings-nav.js.map +1 -0
  26. package/dist/components/user-menu.d.ts +2 -0
  27. package/dist/components/user-menu.d.ts.map +1 -0
  28. package/dist/components/user-menu.js +76 -0
  29. package/dist/components/user-menu.js.map +1 -0
  30. package/dist/components/workspace-crumb.d.ts +7 -0
  31. package/dist/components/workspace-crumb.d.ts.map +1 -0
  32. package/dist/components/workspace-crumb.js +54 -0
  33. package/dist/components/workspace-crumb.js.map +1 -0
  34. package/dist/components/workspace-switcher.d.ts +5 -0
  35. package/dist/components/workspace-switcher.d.ts.map +1 -0
  36. package/dist/components/workspace-switcher.js +90 -0
  37. package/dist/components/workspace-switcher.js.map +1 -0
  38. package/dist/compose/compose-routes.d.ts +6 -0
  39. package/dist/compose/compose-routes.d.ts.map +1 -0
  40. package/dist/compose/compose-routes.js +53 -0
  41. package/dist/compose/compose-routes.js.map +1 -0
  42. package/dist/compose/errors.d.ts +26 -0
  43. package/dist/compose/errors.d.ts.map +1 -0
  44. package/dist/compose/errors.js +39 -0
  45. package/dist/compose/errors.js.map +1 -0
  46. package/dist/compose/merge-config.d.ts +4 -0
  47. package/dist/compose/merge-config.d.ts.map +1 -0
  48. package/dist/compose/merge-config.js +28 -0
  49. package/dist/compose/merge-config.js.map +1 -0
  50. package/dist/compose/normalize-route-path.d.ts +2 -0
  51. package/dist/compose/normalize-route-path.d.ts.map +1 -0
  52. package/dist/compose/normalize-route-path.js +7 -0
  53. package/dist/compose/normalize-route-path.js.map +1 -0
  54. package/dist/compose/validate-providers.d.ts +3 -0
  55. package/dist/compose/validate-providers.d.ts.map +1 -0
  56. package/dist/compose/validate-providers.js +32 -0
  57. package/dist/compose/validate-providers.js.map +1 -0
  58. package/dist/compose/validate-registries.d.ts +4 -0
  59. package/dist/compose/validate-registries.d.ts.map +1 -0
  60. package/dist/compose/validate-registries.js +50 -0
  61. package/dist/compose/validate-registries.js.map +1 -0
  62. package/dist/config/shell-config.d.ts +10 -0
  63. package/dist/config/shell-config.d.ts.map +1 -0
  64. package/dist/config/shell-config.js +12 -0
  65. package/dist/config/shell-config.js.map +1 -0
  66. package/dist/contract.d.ts +39 -0
  67. package/dist/contract.d.ts.map +1 -0
  68. package/dist/contract.js +5 -0
  69. package/dist/contract.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/runtime/active-workspace.d.ts +4 -0
  75. package/dist/runtime/active-workspace.d.ts.map +1 -0
  76. package/dist/runtime/active-workspace.js +15 -0
  77. package/dist/runtime/active-workspace.js.map +1 -0
  78. package/dist/runtime/anchor-paths.d.ts +9 -0
  79. package/dist/runtime/anchor-paths.d.ts.map +1 -0
  80. package/dist/runtime/anchor-paths.js +18 -0
  81. package/dist/runtime/anchor-paths.js.map +1 -0
  82. package/dist/runtime/anchors.d.ts +19 -0
  83. package/dist/runtime/anchors.d.ts.map +1 -0
  84. package/dist/runtime/anchors.js +125 -0
  85. package/dist/runtime/anchors.js.map +1 -0
  86. package/dist/runtime/assemble-route-tree.d.ts +10 -0
  87. package/dist/runtime/assemble-route-tree.d.ts.map +1 -0
  88. package/dist/runtime/assemble-route-tree.js +30 -0
  89. package/dist/runtime/assemble-route-tree.js.map +1 -0
  90. package/dist/runtime/auth.d.ts +50 -0
  91. package/dist/runtime/auth.d.ts.map +1 -0
  92. package/dist/runtime/auth.js +194 -0
  93. package/dist/runtime/auth.js.map +1 -0
  94. package/dist/runtime/chrome-context.d.ts +10 -0
  95. package/dist/runtime/chrome-context.d.ts.map +1 -0
  96. package/dist/runtime/chrome-context.js +16 -0
  97. package/dist/runtime/chrome-context.js.map +1 -0
  98. package/dist/runtime/compose-client-app.d.ts +13 -0
  99. package/dist/runtime/compose-client-app.d.ts.map +1 -0
  100. package/dist/runtime/compose-client-app.js +93 -0
  101. package/dist/runtime/compose-client-app.js.map +1 -0
  102. package/dist/runtime/define-route.d.ts +19 -0
  103. package/dist/runtime/define-route.d.ts.map +1 -0
  104. package/dist/runtime/define-route.js +12 -0
  105. package/dist/runtime/define-route.js.map +1 -0
  106. package/dist/runtime/index.d.ts +20 -0
  107. package/dist/runtime/index.d.ts.map +1 -0
  108. package/dist/runtime/index.js +21 -0
  109. package/dist/runtime/index.js.map +1 -0
  110. package/dist/runtime/last-workspace.d.ts +4 -0
  111. package/dist/runtime/last-workspace.d.ts.map +1 -0
  112. package/dist/runtime/last-workspace.js +20 -0
  113. package/dist/runtime/last-workspace.js.map +1 -0
  114. package/dist/runtime/nav-order.d.ts +7 -0
  115. package/dist/runtime/nav-order.d.ts.map +1 -0
  116. package/dist/runtime/nav-order.js +8 -0
  117. package/dist/runtime/nav-order.js.map +1 -0
  118. package/dist/runtime/provider-stack.d.ts +14 -0
  119. package/dist/runtime/provider-stack.d.ts.map +1 -0
  120. package/dist/runtime/provider-stack.js +28 -0
  121. package/dist/runtime/provider-stack.js.map +1 -0
  122. package/dist/runtime/registries.d.ts +4 -0
  123. package/dist/runtime/registries.d.ts.map +1 -0
  124. package/dist/runtime/registries.js +23 -0
  125. package/dist/runtime/registries.js.map +1 -0
  126. package/dist/runtime/router-context.d.ts +9 -0
  127. package/dist/runtime/router-context.d.ts.map +1 -0
  128. package/dist/runtime/router-context.js +3 -0
  129. package/dist/runtime/router-context.js.map +1 -0
  130. package/dist/runtime/workspace-setup.d.ts +18 -0
  131. package/dist/runtime/workspace-setup.d.ts.map +1 -0
  132. package/dist/runtime/workspace-setup.js +64 -0
  133. package/dist/runtime/workspace-setup.js.map +1 -0
  134. package/dist/testing/index.d.ts +17 -0
  135. package/dist/testing/index.d.ts.map +1 -0
  136. package/dist/testing/index.js +35 -0
  137. package/dist/testing/index.js.map +1 -0
  138. package/dist/tsconfig.test.tsbuildinfo +1 -0
  139. package/dist/vite/evaluate-features.d.ts +8 -0
  140. package/dist/vite/evaluate-features.d.ts.map +1 -0
  141. package/dist/vite/evaluate-features.js +80 -0
  142. package/dist/vite/evaluate-features.js.map +1 -0
  143. package/dist/vite/generate.d.ts +9 -0
  144. package/dist/vite/generate.d.ts.map +1 -0
  145. package/dist/vite/generate.js +80 -0
  146. package/dist/vite/generate.js.map +1 -0
  147. package/dist/vite/index.d.ts +2 -0
  148. package/dist/vite/index.d.ts.map +1 -0
  149. package/dist/vite/index.js +3 -0
  150. package/dist/vite/index.js.map +1 -0
  151. package/dist/vite/plugin.d.ts +7 -0
  152. package/dist/vite/plugin.d.ts.map +1 -0
  153. package/dist/vite/plugin.js +77 -0
  154. package/dist/vite/plugin.js.map +1 -0
  155. package/package.json +113 -0
  156. package/src/components/main-layout.tsx +45 -0
  157. package/src/components/nav-bar.tsx +41 -0
  158. package/src/components/nav-tabs.test.tsx +60 -0
  159. package/src/components/nav-tabs.tsx +47 -0
  160. package/src/components/not-found-page.tsx +14 -0
  161. package/src/components/settings-nav.tsx +35 -0
  162. package/src/components/user-menu.tsx +59 -0
  163. package/src/components/workspace-crumb.tsx +43 -0
  164. package/src/components/workspace-switcher.tsx +75 -0
  165. package/src/compose/compose-routes.test.ts +112 -0
  166. package/src/compose/compose-routes.ts +60 -0
  167. package/src/compose/errors.ts +54 -0
  168. package/src/compose/merge-config.test.ts +26 -0
  169. package/src/compose/merge-config.ts +27 -0
  170. package/src/compose/normalize-route-path.ts +6 -0
  171. package/src/compose/validate-providers.test.ts +31 -0
  172. package/src/compose/validate-providers.ts +36 -0
  173. package/src/compose/validate-registries.test.ts +92 -0
  174. package/src/compose/validate-registries.ts +61 -0
  175. package/src/config/config-screen.test.tsx +37 -0
  176. package/src/config/shell-config.ts +10 -0
  177. package/src/contract.ts +47 -0
  178. package/src/index.ts +1 -0
  179. package/src/runtime/active-workspace.ts +13 -0
  180. package/src/runtime/anchor-paths.ts +18 -0
  181. package/src/runtime/anchors.tsx +106 -0
  182. package/src/runtime/assemble-route-tree.ts +42 -0
  183. package/src/runtime/auth.tsx +213 -0
  184. package/src/runtime/chrome-context.tsx +22 -0
  185. package/src/runtime/compose-client-app.tsx +122 -0
  186. package/src/runtime/define-route.test.ts +15 -0
  187. package/src/runtime/define-route.ts +33 -0
  188. package/src/runtime/index.ts +19 -0
  189. package/src/runtime/last-workspace.ts +22 -0
  190. package/src/runtime/nav-order.ts +1 -0
  191. package/src/runtime/provider-stack.tsx +39 -0
  192. package/src/runtime/providers.test.tsx +51 -0
  193. package/src/runtime/registries.test.tsx +100 -0
  194. package/src/runtime/registries.ts +42 -0
  195. package/src/runtime/router-context.ts +9 -0
  196. package/src/runtime/routes.test.tsx +48 -0
  197. package/src/runtime/workspace-setup.tsx +61 -0
  198. package/src/testing/index.test.tsx +57 -0
  199. package/src/testing/index.tsx +51 -0
  200. package/src/vite/evaluate-features.test.ts +34 -0
  201. package/src/vite/evaluate-features.ts +93 -0
  202. package/src/vite/generate.test.ts +22 -0
  203. package/src/vite/generate.ts +99 -0
  204. package/src/vite/generated-router.test.tsx +58 -0
  205. package/src/vite/index.ts +1 -0
  206. package/src/vite/plugin.test.ts +181 -0
  207. package/src/vite/plugin.ts +114 -0
  208. package/src/vite/validate.test.ts +166 -0
  209. package/test/default-route-impl.tsx +3 -0
  210. package/test/external/FINDINGS.md +53 -0
  211. package/test/external/README.md +39 -0
  212. package/test/external/fixture/index.html +12 -0
  213. package/test/external/fixture/package.json +32 -0
  214. package/test/external/fixture/src/app.fixture.tsx +72 -0
  215. package/test/external/fixture/src/features/override-impl.tsx +12 -0
  216. package/test/external/fixture/src/features/override.tsx +20 -0
  217. package/test/external/fixture/src/features.collision.ts +15 -0
  218. package/test/external/fixture/src/features.ts +4 -0
  219. package/test/external/fixture/src/link-typecheck.tsx +10 -0
  220. package/test/external/fixture/src/main.tsx +4 -0
  221. package/test/external/fixture/tsconfig.json +15 -0
  222. package/test/external/fixture/vite.config.ts +10 -0
  223. package/test/external/fixture/vitest.config.ts +15 -0
  224. package/test/external/toy-feature/CHANGELOG.md +15 -0
  225. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +43 -0
  226. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +43 -0
  227. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +43 -0
  228. package/test/external/toy-feature/node_modules/.bin/shipfox-swc +43 -0
  229. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +43 -0
  230. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +43 -0
  231. package/test/external/toy-feature/package.json +57 -0
  232. package/test/external/toy-feature/src/config.ts +5 -0
  233. package/test/external/toy-feature/src/index.ts +64 -0
  234. package/test/external/toy-feature/src/provider.tsx +33 -0
  235. package/test/external/toy-feature/src/routes/insights.tsx +7 -0
  236. package/test/external/toy-feature/src/routes/settings.tsx +7 -0
  237. package/test/external/toy-feature/tsconfig.build.json +5 -0
  238. package/test/external/toy-feature/tsconfig.json +1 -0
  239. package/test/external/verify.mjs +318 -0
  240. package/test/fixtures/features.ts +56 -0
  241. package/test/named-route-impl.tsx +3 -0
  242. package/test/not-route-impl.ts +1 -0
  243. package/test/render.tsx +67 -0
  244. package/test/search-route-impl.tsx +7 -0
  245. package/test/setup.ts +3 -0
  246. package/test/typecheck/shipfox-app.gen.ts +85 -0
  247. package/test/typecheck/types.tsx +18 -0
  248. package/tsconfig.build.json +10 -0
  249. package/tsconfig.build.tsbuildinfo +1 -0
  250. package/tsconfig.json +1 -0
  251. package/tsconfig.test.json +12 -0
  252. package/vitest.config.ts +22 -0
@@ -0,0 +1,92 @@
1
+ import {validateNavigation, validateSettingsSections} from './validate-registries.js';
2
+
3
+ const duplicateNavigationMessage = /projects.*shipfox\.one.*acme\.two/u;
4
+ const missingNavigationMessage = /insights.*acme\.two.*\/insights/u;
5
+ const duplicateSettingsMessage = /members.*shipfox\.one.*acme\.two/u;
6
+ const missingSettingsMessage = /sso.*acme\.two.*\/workspaces\/\$wid\/settings\/sso/u;
7
+
8
+ describe('registry validation', () => {
9
+ test('names the id and both features for a duplicate navigation entry', () => {
10
+ expect(() =>
11
+ validateNavigation(
12
+ [
13
+ {
14
+ id: 'shipfox.one',
15
+ navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
16
+ },
17
+ {
18
+ id: 'acme.two',
19
+ navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
20
+ },
21
+ ],
22
+ ['/projects'],
23
+ ),
24
+ ).toThrow(duplicateNavigationMessage);
25
+ });
26
+
27
+ test('names the id, target, and feature for a missing navigation route', () => {
28
+ expect(() =>
29
+ validateNavigation(
30
+ [
31
+ {
32
+ id: 'acme.two',
33
+ navigation: [{id: 'insights', scope: 'workspace', label: 'Insights', to: '/insights'}],
34
+ },
35
+ ],
36
+ [],
37
+ ),
38
+ ).toThrow(missingNavigationMessage);
39
+ });
40
+
41
+ test('normalizes navigation targets before checking route existence', () => {
42
+ expect(() =>
43
+ validateNavigation(
44
+ [
45
+ {
46
+ id: 'acme.two',
47
+ navigation: [{id: 'insights', scope: 'workspace', label: 'Insights', to: '/insights/'}],
48
+ },
49
+ ],
50
+ ['/insights'],
51
+ ),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test('names the id and both features for a duplicate settings section', () => {
56
+ expect(() =>
57
+ validateSettingsSections(
58
+ [
59
+ {
60
+ id: 'shipfox.one',
61
+ settingsSections: [
62
+ {id: 'members', pathSegment: 'members', label: 'Members', icon: 'userLine'},
63
+ ],
64
+ },
65
+ {
66
+ id: 'acme.two',
67
+ settingsSections: [
68
+ {id: 'members', pathSegment: 'members', label: 'Members', icon: 'userLine'},
69
+ ],
70
+ },
71
+ ],
72
+ ['/workspaces/$wid/settings/members'],
73
+ ),
74
+ ).toThrow(duplicateSettingsMessage);
75
+ });
76
+
77
+ test('names the id, expected path, and feature for a missing settings route', () => {
78
+ expect(() =>
79
+ validateSettingsSections(
80
+ [
81
+ {
82
+ id: 'acme.two',
83
+ settingsSections: [
84
+ {id: 'sso', pathSegment: 'sso', label: 'Single sign-on', icon: 'userLine'},
85
+ ],
86
+ },
87
+ ],
88
+ [],
89
+ ),
90
+ ).toThrow(missingSettingsMessage);
91
+ });
92
+ });
@@ -0,0 +1,61 @@
1
+ import type {ClientFeature} from '#contract.js';
2
+ import {NavCompositionError, SettingsCompositionError} from './errors.js';
3
+ import {normalizeRoutePath} from './normalize-route-path.js';
4
+
5
+ export function validateNavigation(
6
+ features: readonly ClientFeature[],
7
+ routePaths: Iterable<string>,
8
+ ): void {
9
+ const routes = new Set([...routePaths].map(normalizeRoutePath));
10
+ const entries = new Map<string, string>();
11
+ for (const feature of features) {
12
+ for (const entry of feature.navigation ?? []) {
13
+ const existingFeatureId = entries.get(entry.id);
14
+ if (existingFeatureId) {
15
+ throw new NavCompositionError(
16
+ entry.id,
17
+ `Navigation entry "${entry.id}" is contributed by both features "${existingFeatureId}" and "${feature.id}".`,
18
+ [existingFeatureId, feature.id],
19
+ );
20
+ }
21
+ const target = normalizeRoutePath(entry.to);
22
+ if (!routes.has(target)) {
23
+ throw new NavCompositionError(
24
+ entry.id,
25
+ `Navigation entry "${entry.id}" in feature "${feature.id}" targets missing route "${target}".`,
26
+ [feature.id],
27
+ );
28
+ }
29
+ entries.set(entry.id, feature.id);
30
+ }
31
+ }
32
+ }
33
+
34
+ export function validateSettingsSections(
35
+ features: readonly ClientFeature[],
36
+ routePaths: Iterable<string>,
37
+ ): void {
38
+ const routes = new Set(routePaths);
39
+ const sections = new Map<string, string>();
40
+ for (const feature of features) {
41
+ for (const section of feature.settingsSections ?? []) {
42
+ const existingFeatureId = sections.get(section.id);
43
+ if (existingFeatureId) {
44
+ throw new SettingsCompositionError(
45
+ section.id,
46
+ `Settings section "${section.id}" is contributed by both features "${existingFeatureId}" and "${feature.id}".`,
47
+ [existingFeatureId, feature.id],
48
+ );
49
+ }
50
+ const path = `/workspaces/$wid/settings/${section.pathSegment}`;
51
+ if (!routes.has(path)) {
52
+ throw new SettingsCompositionError(
53
+ section.id,
54
+ `Settings section "${section.id}" in feature "${feature.id}" requires route "${path}".`,
55
+ [feature.id],
56
+ );
57
+ }
58
+ sections.set(section.id, feature.id);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,37 @@
1
+ import {getLoadedConfig} from '@shipfox/client-config';
2
+ import {act, screen} from '@testing-library/react';
3
+ import {z} from 'zod';
4
+ import {defineClientFeature} from '#contract.js';
5
+ import {composeClientApp} from '#runtime/compose-client-app.js';
6
+
7
+ describe('composed config', () => {
8
+ test('renders ConfigErrorScreen when a feature-required key is missing', async () => {
9
+ window.__SHIPFOX_CONFIG__ = undefined;
10
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
11
+ const element = document.createElement('div');
12
+ document.body.append(element);
13
+ const app = composeClientApp({
14
+ features: [defineClientFeature({id: 'acme.config', configShape: {ssoIssuer: z.url()}})],
15
+ router: {} as never,
16
+ });
17
+
18
+ await act(async () => app.mount(element));
19
+
20
+ expect(await screen.findByRole('heading', {name: 'Configuration error'})).toBeVisible();
21
+ expect(fetchSpy).not.toHaveBeenCalled();
22
+ element.remove();
23
+ });
24
+
25
+ test('loads a present feature config value into the shared config', () => {
26
+ const shape = z.string();
27
+ window.__SHIPFOX_CONFIG__ = {SSO_ISSUER: 'https://id.example.test'};
28
+ const app = composeClientApp({
29
+ features: [defineClientFeature({id: 'acme.config', configShape: {ssoIssuer: shape}})],
30
+ router: {} as never,
31
+ });
32
+ expect(app).toBeDefined();
33
+ expect(getLoadedConfig<{ssoIssuer: string}>()).toMatchObject({
34
+ ssoIssuer: 'https://id.example.test',
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,10 @@
1
+ import {apiConfigShape} from '@shipfox/client-api';
2
+ import {z} from 'zod';
3
+
4
+ export const shellConfigShape = {
5
+ ...apiConfigShape,
6
+ environment: z
7
+ .enum(['development', 'staging', 'production'])
8
+ .default('production')
9
+ .describe('Deployment environment name shown in diagnostics and reported to monitoring.'),
10
+ };
@@ -0,0 +1,47 @@
1
+ import type {IconName} from '@shipfox/react-ui/icon';
2
+ import type {ComponentType, PropsWithChildren} from 'react';
3
+ import type {z} from 'zod';
4
+
5
+ export type AnchorId = 'root' | 'workspaceLayout' | 'projectLayout' | 'workspaceSettings';
6
+
7
+ export interface RouteContribution {
8
+ path: string;
9
+ parent: AnchorId;
10
+ override?: boolean;
11
+ impl: string;
12
+ }
13
+
14
+ export interface FeatureProvider {
15
+ id: string;
16
+ Component: ComponentType<PropsWithChildren>;
17
+ }
18
+
19
+ export interface NavTabEntry {
20
+ id: string;
21
+ scope: 'workspace' | 'project';
22
+ label: string;
23
+ to: string;
24
+ exact?: boolean;
25
+ order?: number;
26
+ }
27
+
28
+ export interface SettingsSectionEntry {
29
+ id: string;
30
+ pathSegment: string;
31
+ label: string;
32
+ icon: IconName;
33
+ order?: number;
34
+ }
35
+
36
+ export interface ClientFeature<S extends z.ZodRawShape = z.ZodRawShape> {
37
+ id: string;
38
+ routes?: readonly RouteContribution[];
39
+ providers?: readonly FeatureProvider[];
40
+ navigation?: readonly NavTabEntry[];
41
+ settingsSections?: readonly SettingsSectionEntry[];
42
+ configShape?: S;
43
+ }
44
+
45
+ export function defineClientFeature<const T extends ClientFeature>(feature: T): T {
46
+ return feature;
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './contract.js';
@@ -0,0 +1,13 @@
1
+ import {useParams} from '@tanstack/react-router';
2
+ import {useAuthState, type Workspace} from './auth.js';
3
+
4
+ export function useActiveWorkspace(): Workspace {
5
+ const workspace = useMaybeActiveWorkspace();
6
+ if (!workspace) throw new Error('useActiveWorkspace called outside a /workspaces/$wid route');
7
+ return workspace;
8
+ }
9
+
10
+ export function useMaybeActiveWorkspace(): Workspace | undefined {
11
+ const {wid} = useParams({strict: false}) as {wid?: string};
12
+ return useAuthState().workspaces.find((workspace) => workspace.id === wid);
13
+ }
@@ -0,0 +1,18 @@
1
+ const anchorPaths = {
2
+ root: '/',
3
+ workspaceLayout: '/workspaces/$wid',
4
+ projectLayout: '/workspaces/$wid/projects/$pid',
5
+ workspaceSettings: '/workspaces/$wid/settings',
6
+ } as const;
7
+
8
+ export {anchorPaths};
9
+
10
+ export function routePathForAnchor(anchor: keyof typeof anchorPaths, fullPath: string): string {
11
+ const anchorPath = anchorPaths[anchor];
12
+ if (anchor === 'root') return fullPath;
13
+ if (fullPath === anchorPath) return '/';
14
+ if (!fullPath.startsWith(`${anchorPath}/`)) {
15
+ throw new Error(`Route "${fullPath}" must be nested under anchor "${anchor}" (${anchorPath}).`);
16
+ }
17
+ return fullPath.slice(anchorPath.length);
18
+ }
@@ -0,0 +1,106 @@
1
+ import {ShipfoxLoader} from '@shipfox/react-ui/loader';
2
+ import {Header, Text} from '@shipfox/react-ui/typography';
3
+ import {createRootRouteWithContext, createRoute, Outlet, redirect} from '@tanstack/react-router';
4
+ import {MainLayout} from '#components/main-layout.js';
5
+ import {NotFoundPage} from '#components/not-found-page.js';
6
+ import {SettingsNav} from '#components/settings-nav.js';
7
+ import type {NavTabEntry, SettingsSectionEntry} from '#contract.js';
8
+ import {useActiveWorkspace} from './active-workspace.js';
9
+ import {anchorPaths} from './anchor-paths.js';
10
+ import {useChrome} from './chrome-context.js';
11
+ import {rememberLastWorkspaceId} from './last-workspace.js';
12
+ import type {RouterContext} from './router-context.js';
13
+ import {WorkspaceLayoutErrorRoute, WorkspaceSetupPending} from './workspace-setup.js';
14
+
15
+ export {routePathForAnchor} from './anchor-paths.js';
16
+
17
+ export function buildAnchorSkeleton({
18
+ navigation,
19
+ settingsSections,
20
+ }: {
21
+ navigation: readonly NavTabEntry[];
22
+ settingsSections: readonly SettingsSectionEntry[];
23
+ }) {
24
+ const rootRoute = createRootRouteWithContext<RouterContext>()({
25
+ component: Outlet,
26
+ notFoundComponent: NotFoundPage,
27
+ });
28
+ const workspaceLayout = createRoute({
29
+ getParentRoute: () => rootRoute,
30
+ path: anchorPaths.workspaceLayout,
31
+ beforeLoad: async ({context, params, location}) => {
32
+ try {
33
+ rememberLastWorkspaceId(params.wid);
34
+ } catch {
35
+ // Local storage is best effort.
36
+ }
37
+ const auth = context.auth;
38
+ if (!auth || auth.isLoading || !context.queryClient) return;
39
+ if (!auth.isAuthenticated)
40
+ throw redirect({to: '/auth/login' as never, search: {redirect: location.href} as never});
41
+ if (!auth.workspaces.some((workspace) => workspace.id === params.wid))
42
+ throw redirect({to: '/'});
43
+ if (!context.workspaceSetup)
44
+ throw new Error('Client composition includes workspace routes but no workspaceSetup gate.');
45
+ return await context.workspaceSetup({
46
+ queryClient: context.queryClient,
47
+ workspaceId: params.wid,
48
+ pathname: location.pathname,
49
+ });
50
+ },
51
+ pendingComponent: () => (
52
+ <div className="flex h-screen items-center justify-center">
53
+ <ShipfoxLoader size={64} animation="circular" color="orange" background="dark" />
54
+ </div>
55
+ ),
56
+ errorComponent: WorkspaceLayoutErrorRoute,
57
+ component: () => {
58
+ const setupState = workspaceLayout.useRouteContext() as {hideProjectNavigation?: boolean};
59
+ return setupState.hideProjectNavigation === undefined ? (
60
+ <WorkspaceSetupPending />
61
+ ) : (
62
+ <MainLayout
63
+ navigation={navigation}
64
+ hideProjectNavigation={setupState.hideProjectNavigation}
65
+ />
66
+ );
67
+ },
68
+ });
69
+ const projectLayout = createRoute({
70
+ getParentRoute: () => workspaceLayout,
71
+ path: '/projects/$pid',
72
+ component: () => {
73
+ const {ProjectLayoutGuard} = useChrome();
74
+ return <ProjectLayoutGuard />;
75
+ },
76
+ });
77
+ const workspaceSettings = createRoute({
78
+ getParentRoute: () => workspaceLayout,
79
+ path: '/settings',
80
+ component: () => {
81
+ const workspace = useActiveWorkspace();
82
+ return (
83
+ <div className="flex w-full flex-col gap-24">
84
+ <header className="flex flex-col gap-6">
85
+ <Header variant="h2">Workspace settings</Header>
86
+ <Text size="sm" className="text-foreground-neutral-muted">
87
+ Configure {workspace.name}.
88
+ </Text>
89
+ </header>
90
+
91
+ <div className="grid grid-cols-[180px_minmax(0,1fr)] gap-32 max-[760px]:grid-cols-1">
92
+ <SettingsNav entries={settingsSections} />
93
+ <Outlet />
94
+ </div>
95
+ </div>
96
+ );
97
+ },
98
+ });
99
+ return {
100
+ anchors: {root: rootRoute, workspaceLayout, projectLayout, workspaceSettings},
101
+ rootRoute,
102
+ workspaceLayout,
103
+ projectLayout,
104
+ workspaceSettings,
105
+ };
106
+ }
@@ -0,0 +1,42 @@
1
+ import {createRoute} from '@tanstack/react-router';
2
+ import type {ComposedRoute} from '#compose/compose-routes.js';
3
+ import {buildAnchorSkeleton, routePathForAnchor} from '#runtime/anchors.js';
4
+ import type {RouteImpl} from '#runtime/define-route.js';
5
+
6
+ export type ResolveRouteImpl = (specifier: string) => RouteImpl | Promise<RouteImpl>;
7
+
8
+ export async function assembleRouteTree(
9
+ routes: readonly ComposedRoute[],
10
+ options: {
11
+ resolveImpl: ResolveRouteImpl;
12
+ navigation: Parameters<typeof buildAnchorSkeleton>[0]['navigation'];
13
+ settingsSections: Parameters<typeof buildAnchorSkeleton>[0]['settingsSections'];
14
+ },
15
+ ) {
16
+ const skeleton = buildAnchorSkeleton(options);
17
+ const children = await Promise.all(
18
+ routes.map(async (route) => {
19
+ const impl = await options.resolveImpl(route.impl);
20
+ return {
21
+ parent: route.parent,
22
+ route: createRoute({
23
+ getParentRoute: () => skeleton.anchors[route.parent] as never,
24
+ path: routePathForAnchor(route.parent, route.path),
25
+ ...impl.options,
26
+ } as never),
27
+ };
28
+ }),
29
+ );
30
+ const childrenFor = (anchor: keyof typeof skeleton.anchors) =>
31
+ children.filter((child) => child.parent === anchor).map((child) => child.route);
32
+ const projectLayout = skeleton.projectLayout.addChildren(childrenFor('projectLayout'));
33
+ const workspaceSettings = skeleton.workspaceSettings.addChildren(
34
+ childrenFor('workspaceSettings'),
35
+ );
36
+ const workspaceLayout = skeleton.workspaceLayout.addChildren([
37
+ ...childrenFor('workspaceLayout'),
38
+ projectLayout,
39
+ workspaceSettings,
40
+ ]);
41
+ return skeleton.rootRoute.addChildren([...childrenFor('root'), workspaceLayout]);
42
+ }
@@ -0,0 +1,213 @@
1
+ import type {LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';
2
+ import type {MembershipWithWorkspaceDto} from '@shipfox/api-workspaces-dto';
3
+ import {ApiError, apiRequest, configureApiClient} from '@shipfox/client-api';
4
+ import {useQueryClient} from '@tanstack/react-query';
5
+ import {atom, useAtomValue, useSetAtom, useStore} from 'jotai';
6
+ import type {PropsWithChildren} from 'react';
7
+ import {useCallback, useEffect, useMemo} from 'react';
8
+
9
+ const REFRESH_EARLY_MS = 5 * 60 * 1000;
10
+ const REFRESH_RETRY_DELAY_MS = 60_000;
11
+ const BASE64_URL_REPLACEMENTS = {dash: /-/g, underscore: /_/g} as const;
12
+
13
+ export type AuthStatus = 'loading' | 'authenticated' | 'guest';
14
+
15
+ export interface Workspace {
16
+ id: string;
17
+ name: string;
18
+ membershipId: string;
19
+ }
20
+
21
+ export interface AuthState {
22
+ status: AuthStatus;
23
+ token?: string;
24
+ user?: UserDto;
25
+ workspaces?: Workspace[];
26
+ }
27
+
28
+ export interface AuthStateValue extends AuthState {
29
+ isLoading: boolean;
30
+ isAuthenticated: boolean;
31
+ workspaces: Workspace[];
32
+ hasWorkspace: boolean;
33
+ }
34
+
35
+ export const initialAuthState: AuthState = {status: 'loading'};
36
+ export const authStateAtom = atom<AuthState>(initialAuthState);
37
+ export const authRefreshQueryKey = ['auth', 'refresh'] as const;
38
+ export const userWorkspacesQueryKey = ['workspaces', 'mine'] as const;
39
+
40
+ export function toAuthenticatedState(
41
+ result: LoginResponseDto,
42
+ memberships: MembershipWithWorkspaceDto[] = [],
43
+ ): AuthState {
44
+ return {
45
+ status: 'authenticated',
46
+ token: result.token,
47
+ user: result.user,
48
+ workspaces: memberships.map((membership) => ({
49
+ id: membership.workspace_id,
50
+ name: membership.workspace_name,
51
+ membershipId: membership.id,
52
+ })),
53
+ };
54
+ }
55
+
56
+ export function useAuthState(): AuthStateValue {
57
+ const state = useAtomValue(authStateAtom);
58
+ return useMemo(
59
+ () => ({
60
+ ...state,
61
+ workspaces: state.workspaces ?? [],
62
+ isLoading: state.status === 'loading',
63
+ isAuthenticated: state.status === 'authenticated',
64
+ hasWorkspace: (state.workspaces ?? []).length > 0,
65
+ }),
66
+ [state],
67
+ );
68
+ }
69
+
70
+ export async function listUserWorkspaces(token?: string) {
71
+ return await apiRequest<{memberships: MembershipWithWorkspaceDto[]}>(
72
+ '/workspaces',
73
+ token ? {headers: {authorization: `Bearer ${token}`}} : {},
74
+ );
75
+ }
76
+
77
+ async function refreshAuthRequest() {
78
+ return await apiRequest<LoginResponseDto>('/auth/refresh', {method: 'POST'});
79
+ }
80
+
81
+ function decodeBase64Url(value: string): string {
82
+ const base64 = value
83
+ .replace(BASE64_URL_REPLACEMENTS.dash, '+')
84
+ .replace(BASE64_URL_REPLACEMENTS.underscore, '/');
85
+ return atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
86
+ }
87
+
88
+ function readJwtExp(token: string): number | undefined {
89
+ const [, payload] = token.split('.');
90
+ if (!payload) return undefined;
91
+ try {
92
+ const parsed = JSON.parse(decodeBase64Url(payload)) as {exp?: unknown};
93
+ return typeof parsed.exp === 'number' && Number.isFinite(parsed.exp) ? parsed.exp : undefined;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ export function getAuthRefreshDelayMs(token: string, nowMs = Date.now()): number | undefined {
100
+ const exp = readJwtExp(token);
101
+ return exp === undefined ? undefined : exp * 1000 - nowMs - REFRESH_EARLY_MS;
102
+ }
103
+
104
+ export function useRefreshAuth() {
105
+ const queryClient = useQueryClient();
106
+ const setState = useSetAtom(authStateAtom);
107
+ return useCallback(async () => {
108
+ try {
109
+ const result = await queryClient.fetchQuery({
110
+ queryKey: authRefreshQueryKey,
111
+ queryFn: refreshAuthRequest,
112
+ retry: false,
113
+ staleTime: 0,
114
+ });
115
+ let memberships: MembershipWithWorkspaceDto[] = [];
116
+ try {
117
+ const workspaces = await queryClient.fetchQuery({
118
+ queryKey: userWorkspacesQueryKey,
119
+ queryFn: () => listUserWorkspaces(result.token),
120
+ retry: false,
121
+ staleTime: 0,
122
+ });
123
+ memberships = workspaces.memberships;
124
+ } catch {
125
+ // A valid session remains usable while workspace hydration retries on the next route load.
126
+ }
127
+ setState(toAuthenticatedState(result, memberships));
128
+ return result;
129
+ } catch (error) {
130
+ if (error instanceof ApiError && error.status === 401) setState({status: 'guest'});
131
+ throw error;
132
+ }
133
+ }, [queryClient, setState]);
134
+ }
135
+
136
+ export interface AuthRuntimeProps extends PropsWithChildren {
137
+ effects?: boolean;
138
+ }
139
+
140
+ export function AuthRuntime({children, effects = true}: AuthRuntimeProps) {
141
+ const store = useStore();
142
+ const authState = useAtomValue(authStateAtom);
143
+ const refreshAuth = useRefreshAuth();
144
+
145
+ useEffect(() => {
146
+ if (!effects) return;
147
+ configureApiClient({
148
+ getAccessToken: () => store.get(authStateAtom).token,
149
+ refreshAccessToken: async () => (await refreshAuth()).token,
150
+ });
151
+ }, [effects, refreshAuth, store]);
152
+
153
+ useEffect(() => {
154
+ if (!effects) return;
155
+ refreshAuth().catch(() => undefined);
156
+ }, [effects, refreshAuth]);
157
+
158
+ useEffect(() => {
159
+ if (!effects || authState.status !== 'authenticated' || !authState.token) return;
160
+
161
+ let timeout: ReturnType<typeof setTimeout> | undefined;
162
+ let disposed = false;
163
+ let refreshing = false;
164
+ const clearRefreshTimer = () => {
165
+ if (timeout !== undefined) clearTimeout(timeout);
166
+ timeout = undefined;
167
+ };
168
+ const scheduleRefresh = (delayMs: number) => {
169
+ clearRefreshTimer();
170
+ timeout = setTimeout(runRefresh, Math.max(0, delayMs));
171
+ };
172
+ const retryIfStillDue = () => {
173
+ const current = store.get(authStateAtom);
174
+ if (current.status !== 'authenticated' || !current.token) return;
175
+ const delay = getAuthRefreshDelayMs(current.token);
176
+ if (delay !== undefined && delay <= 0) scheduleRefresh(REFRESH_RETRY_DELAY_MS);
177
+ };
178
+ function runRefresh() {
179
+ if (disposed || refreshing) return;
180
+ refreshing = true;
181
+ clearRefreshTimer();
182
+ refreshAuth()
183
+ .catch(() => undefined)
184
+ .finally(() => {
185
+ refreshing = false;
186
+ if (!disposed) retryIfStillDue();
187
+ });
188
+ }
189
+ const refreshIfDue = () => {
190
+ const current = store.get(authStateAtom);
191
+ if (current.status !== 'authenticated' || !current.token) return;
192
+ const delay = getAuthRefreshDelayMs(current.token);
193
+ if (delay !== undefined && delay <= 0) runRefresh();
194
+ };
195
+ const refreshIfVisible = () => {
196
+ if (document.visibilityState === 'visible') refreshIfDue();
197
+ };
198
+ const delay = getAuthRefreshDelayMs(authState.token);
199
+ if (delay !== undefined) scheduleRefresh(delay);
200
+ window.addEventListener('focus', refreshIfDue);
201
+ window.addEventListener('online', refreshIfDue);
202
+ document.addEventListener('visibilitychange', refreshIfVisible);
203
+ return () => {
204
+ disposed = true;
205
+ clearRefreshTimer();
206
+ window.removeEventListener('focus', refreshIfDue);
207
+ window.removeEventListener('online', refreshIfDue);
208
+ document.removeEventListener('visibilitychange', refreshIfVisible);
209
+ };
210
+ }, [authState.status, authState.token, effects, refreshAuth, store]);
211
+
212
+ return children;
213
+ }
@@ -0,0 +1,22 @@
1
+ import type {ComponentType, PropsWithChildren} from 'react';
2
+ import {createContext, useContext} from 'react';
3
+
4
+ export interface ChromeSlots {
5
+ ProjectBreadcrumb: ComponentType;
6
+ ProjectLayoutGuard: ComponentType;
7
+ }
8
+
9
+ const ChromeContext = createContext<ChromeSlots | undefined>(undefined);
10
+
11
+ export function ChromeProvider({
12
+ chrome,
13
+ children,
14
+ }: PropsWithChildren<{chrome: ChromeSlots | undefined}>) {
15
+ return <ChromeContext.Provider value={chrome}>{children}</ChromeContext.Provider>;
16
+ }
17
+
18
+ export function useChrome(): ChromeSlots {
19
+ const chrome = useContext(ChromeContext);
20
+ if (!chrome) throw new Error('Client composition must provide browser chrome slots.');
21
+ return chrome;
22
+ }