@shipfox/client-shell 1.0.0 → 3.0.1
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/assets/apple-touch-icon.png +0 -0
- package/assets/favicon-96x96.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +1 -0
- package/assets/site.webmanifest +22 -0
- package/assets/web-app-manifest-192x192.png +0 -0
- package/assets/web-app-manifest-512x512.png +0 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +1 -0
- package/dist/vite/index.js.map +1 -1
- package/dist/vite/manifest.d.ts +3 -0
- package/dist/vite/manifest.d.ts.map +1 -0
- package/dist/vite/manifest.js +140 -0
- package/dist/vite/manifest.js.map +1 -0
- package/package.json +15 -9
- package/.turbo/turbo-build.log +0 -2
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +0 -41
- package/src/components/main-layout.tsx +0 -45
- package/src/components/nav-bar.tsx +0 -41
- package/src/components/nav-tabs.test.tsx +0 -60
- package/src/components/nav-tabs.tsx +0 -47
- package/src/components/not-found-page.tsx +0 -14
- package/src/components/settings-nav.tsx +0 -35
- package/src/components/user-menu.tsx +0 -59
- package/src/components/workspace-crumb.tsx +0 -43
- package/src/components/workspace-switcher.tsx +0 -75
- package/src/compose/compose-routes.test.ts +0 -112
- package/src/compose/compose-routes.ts +0 -60
- package/src/compose/errors.ts +0 -54
- package/src/compose/merge-config.test.ts +0 -26
- package/src/compose/merge-config.ts +0 -27
- package/src/compose/normalize-route-path.ts +0 -6
- package/src/compose/validate-providers.test.ts +0 -31
- package/src/compose/validate-providers.ts +0 -36
- package/src/compose/validate-registries.test.ts +0 -92
- package/src/compose/validate-registries.ts +0 -61
- package/src/config/config-screen.test.tsx +0 -37
- package/src/config/shell-config.ts +0 -10
- package/src/contract.ts +0 -47
- package/src/index.ts +0 -1
- package/src/runtime/active-workspace.ts +0 -13
- package/src/runtime/anchor-paths.ts +0 -18
- package/src/runtime/anchors.tsx +0 -106
- package/src/runtime/assemble-route-tree.ts +0 -42
- package/src/runtime/auth.tsx +0 -213
- package/src/runtime/chrome-context.tsx +0 -22
- package/src/runtime/compose-client-app.tsx +0 -122
- package/src/runtime/define-route.test.ts +0 -15
- package/src/runtime/define-route.ts +0 -33
- package/src/runtime/index.ts +0 -19
- package/src/runtime/last-workspace.ts +0 -22
- package/src/runtime/nav-order.ts +0 -1
- package/src/runtime/provider-stack.tsx +0 -39
- package/src/runtime/providers.test.tsx +0 -51
- package/src/runtime/registries.test.tsx +0 -100
- package/src/runtime/registries.ts +0 -42
- package/src/runtime/router-context.ts +0 -9
- package/src/runtime/routes.test.tsx +0 -48
- package/src/runtime/workspace-setup.tsx +0 -61
- package/src/testing/index.test.tsx +0 -57
- package/src/testing/index.tsx +0 -51
- package/src/vite/evaluate-features.test.ts +0 -89
- package/src/vite/evaluate-features.ts +0 -114
- package/src/vite/generate.test.ts +0 -22
- package/src/vite/generate.ts +0 -99
- package/src/vite/generated-router.test.tsx +0 -58
- package/src/vite/index.ts +0 -1
- package/src/vite/plugin.test.ts +0 -231
- package/src/vite/plugin.ts +0 -101
- package/src/vite/validate.test.ts +0 -166
- package/test/default-route-impl.tsx +0 -3
- package/test/external/FINDINGS.md +0 -41
- package/test/external/README.md +0 -37
- package/test/external/fixture/index.html +0 -12
- package/test/external/fixture/package.json +0 -31
- package/test/external/fixture/src/app.fixture.tsx +0 -104
- package/test/external/fixture/src/features/external-settings.tsx +0 -19
- package/test/external/fixture/src/features/login-override.tsx +0 -9
- package/test/external/fixture/src/features.collision.ts +0 -15
- package/test/external/fixture/src/features.ts +0 -50
- package/test/external/fixture/src/link-typecheck.tsx +0 -10
- package/test/external/fixture/src/main.tsx +0 -86
- package/test/external/fixture/src/provider.ts +0 -45
- package/test/external/fixture/tsconfig.json +0 -15
- package/test/external/fixture/vite.config.ts +0 -21
- package/test/external/fixture/vitest.config.ts +0 -17
- package/test/external/verify.mjs +0 -504
- package/test/fixtures/features.ts +0 -56
- package/test/named-route-impl.tsx +0 -3
- package/test/render.tsx +0 -67
- package/test/search-route-impl.tsx +0 -7
- package/test/setup.ts +0 -3
- package/test/typecheck/shipfox-app.gen.ts +0 -85
- package/test/typecheck/types.tsx +0 -23
- package/tsconfig.build.json +0 -10
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -1
- package/tsconfig.test.json +0 -12
- package/vitest.config.ts +0 -22
package/src/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from './contract.js';
|
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
package/src/runtime/anchors.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
}
|
package/src/runtime/auth.tsx
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
-
import {
|
|
3
|
-
ConfigErrorScreen,
|
|
4
|
-
getWindowRuntimeConfig,
|
|
5
|
-
loadConfig,
|
|
6
|
-
setLoadedConfig,
|
|
7
|
-
} from '@shipfox/client-config';
|
|
8
|
-
import {ThemeProvider} from '@shipfox/react-ui/theme';
|
|
9
|
-
import {Toaster} from '@shipfox/react-ui/toast';
|
|
10
|
-
import {TooltipProvider} from '@shipfox/react-ui/tooltip';
|
|
11
|
-
import {QueryClient} from '@tanstack/react-query';
|
|
12
|
-
import {type AnyRouter, RouterProvider} from '@tanstack/react-router';
|
|
13
|
-
import {createStore} from 'jotai';
|
|
14
|
-
import {StrictMode, useEffect} from 'react';
|
|
15
|
-
import {createRoot} from 'react-dom/client';
|
|
16
|
-
import {composeRoutes} from '#compose/compose-routes.js';
|
|
17
|
-
import {mergeConfigShapes} from '#compose/merge-config.js';
|
|
18
|
-
import {validateProviderIds} from '#compose/validate-providers.js';
|
|
19
|
-
import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
|
|
20
|
-
import type {ClientFeature} from '#contract.js';
|
|
21
|
-
import {useAuthState} from './auth.js';
|
|
22
|
-
import {ChromeProvider, type ChromeSlots} from './chrome-context.js';
|
|
23
|
-
import {ShellProviderStack} from './provider-stack.js';
|
|
24
|
-
import type {WorkspaceSetupGate} from './workspace-setup.js';
|
|
25
|
-
|
|
26
|
-
export function composeClientApp({
|
|
27
|
-
features,
|
|
28
|
-
router,
|
|
29
|
-
chrome,
|
|
30
|
-
workspaceSetup,
|
|
31
|
-
}: {
|
|
32
|
-
features: readonly ClientFeature[];
|
|
33
|
-
router: AnyRouter;
|
|
34
|
-
chrome?: ChromeSlots;
|
|
35
|
-
workspaceSetup?: WorkspaceSetupGate;
|
|
36
|
-
}) {
|
|
37
|
-
const routes = composeRoutes(features);
|
|
38
|
-
validateProviderIds(features);
|
|
39
|
-
validateNavigation(
|
|
40
|
-
features,
|
|
41
|
-
routes.map((route) => route.path),
|
|
42
|
-
);
|
|
43
|
-
validateSettingsSections(
|
|
44
|
-
features,
|
|
45
|
-
routes.map((route) => route.path),
|
|
46
|
-
);
|
|
47
|
-
const config = loadConfig(mergeConfigShapes(features), {
|
|
48
|
-
runtime: getWindowRuntimeConfig(),
|
|
49
|
-
build: (import.meta as ImportMeta & {env?: Record<string, unknown>}).env,
|
|
50
|
-
});
|
|
51
|
-
if (config.ok) setLoadedConfig(config.config);
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
mount(element: HTMLElement): void {
|
|
55
|
-
const root = createRoot(element);
|
|
56
|
-
if (!config.ok) {
|
|
57
|
-
root.render(
|
|
58
|
-
<StrictMode>
|
|
59
|
-
<ThemeProvider>
|
|
60
|
-
<TooltipProvider>
|
|
61
|
-
<ConfigErrorScreen errors={config.errors} />
|
|
62
|
-
</TooltipProvider>
|
|
63
|
-
</ThemeProvider>
|
|
64
|
-
</StrictMode>,
|
|
65
|
-
);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
configureApiClient({baseUrl: configApiUrl(config.config)});
|
|
70
|
-
const queryClient = new QueryClient();
|
|
71
|
-
root.render(
|
|
72
|
-
<StrictMode>
|
|
73
|
-
<ChromeProvider chrome={chrome}>
|
|
74
|
-
<ShellProviderStack features={features} queryClient={queryClient} store={createStore()}>
|
|
75
|
-
<RoutedApp
|
|
76
|
-
router={router}
|
|
77
|
-
queryClient={queryClient}
|
|
78
|
-
workspaceSetup={workspaceSetup}
|
|
79
|
-
/>
|
|
80
|
-
<Toaster />
|
|
81
|
-
</ShellProviderStack>
|
|
82
|
-
</ChromeProvider>
|
|
83
|
-
</StrictMode>,
|
|
84
|
-
);
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function RoutedApp({
|
|
90
|
-
router,
|
|
91
|
-
queryClient,
|
|
92
|
-
workspaceSetup,
|
|
93
|
-
}: {
|
|
94
|
-
router: AnyRouter;
|
|
95
|
-
queryClient: QueryClient;
|
|
96
|
-
workspaceSetup: WorkspaceSetupGate | undefined;
|
|
97
|
-
}) {
|
|
98
|
-
const auth = useAuthState();
|
|
99
|
-
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if (!auth.isLoading) router.invalidate();
|
|
102
|
-
}, [auth.isLoading, router]);
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<RouterProvider
|
|
106
|
-
router={router as never}
|
|
107
|
-
context={{auth, queryClient, workspaceSetup} as never}
|
|
108
|
-
/>
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function configApiUrl(config: unknown): string {
|
|
113
|
-
if (
|
|
114
|
-
typeof config !== 'object' ||
|
|
115
|
-
config === null ||
|
|
116
|
-
!('apiUrl' in config) ||
|
|
117
|
-
typeof config.apiUrl !== 'string'
|
|
118
|
-
) {
|
|
119
|
-
throw new Error('Composed client configuration must include a string apiUrl.');
|
|
120
|
-
}
|
|
121
|
-
return config.apiUrl;
|
|
122
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import {defineRoute, isRouteImpl} from './define-route.js';
|
|
2
|
-
|
|
3
|
-
describe('isRouteImpl', () => {
|
|
4
|
-
test('accepts implementations created by defineRoute', () => {
|
|
5
|
-
const route = defineRoute({component: () => null});
|
|
6
|
-
|
|
7
|
-
expect(isRouteImpl(route)).toBe(true);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test('rejects a default component without a route implementation', () => {
|
|
11
|
-
const component = () => null;
|
|
12
|
-
|
|
13
|
-
expect(isRouteImpl(component)).toBe(false);
|
|
14
|
-
});
|
|
15
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type {ComponentType} from 'react';
|
|
2
|
-
|
|
3
|
-
export interface RouteImplOptions {
|
|
4
|
-
component?: ComponentType;
|
|
5
|
-
loader?: unknown;
|
|
6
|
-
validateSearch?: unknown;
|
|
7
|
-
beforeLoad?: unknown;
|
|
8
|
-
staticData?: unknown;
|
|
9
|
-
pendingComponent?: ComponentType;
|
|
10
|
-
errorComponent?: ComponentType;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const routeImplBrand = Symbol('routeImpl');
|
|
14
|
-
|
|
15
|
-
export interface RouteImpl<O extends RouteImplOptions = RouteImplOptions> {
|
|
16
|
-
readonly options: O;
|
|
17
|
-
readonly [routeImplBrand]: true;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function defineRoute<const O extends RouteImplOptions>(options: O): RouteImpl<O> {
|
|
21
|
-
return {options, [routeImplBrand]: true};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function isRouteImpl(value: unknown): value is RouteImpl {
|
|
25
|
-
return (
|
|
26
|
-
typeof value === 'object' &&
|
|
27
|
-
value !== null &&
|
|
28
|
-
routeImplBrand in value &&
|
|
29
|
-
'options' in value &&
|
|
30
|
-
typeof value.options === 'object' &&
|
|
31
|
-
value.options !== null
|
|
32
|
-
);
|
|
33
|
-
}
|
package/src/runtime/index.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export {WorkspaceCrumb, type WorkspaceCrumbProps} from '#components/workspace-crumb.js';
|
|
2
|
-
export {WorkspaceSwitcher} from '#components/workspace-switcher.js';
|
|
3
|
-
export * from '../compose/compose-routes.js';
|
|
4
|
-
export * from '../compose/errors.js';
|
|
5
|
-
export * from '../compose/merge-config.js';
|
|
6
|
-
export * from '../compose/normalize-route-path.js';
|
|
7
|
-
export * from '../compose/validate-providers.js';
|
|
8
|
-
export * from '../compose/validate-registries.js';
|
|
9
|
-
export * from './active-workspace.js';
|
|
10
|
-
export * from './anchor-paths.js';
|
|
11
|
-
export * from './anchors.js';
|
|
12
|
-
export * from './auth.js';
|
|
13
|
-
export * from './chrome-context.js';
|
|
14
|
-
export * from './compose-client-app.js';
|
|
15
|
-
export * from './define-route.js';
|
|
16
|
-
export * from './last-workspace.js';
|
|
17
|
-
export * from './nav-order.js';
|
|
18
|
-
export * from './router-context.js';
|
|
19
|
-
export * from './workspace-setup.js';
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import {atomWithStorage} from 'jotai/utils';
|
|
2
|
-
|
|
3
|
-
const storageKey = 'shipfox.lastWorkspaceId';
|
|
4
|
-
|
|
5
|
-
export const lastWorkspaceIdAtom = atomWithStorage<string | undefined>(storageKey, undefined);
|
|
6
|
-
|
|
7
|
-
export function getLastWorkspaceId(): string | undefined {
|
|
8
|
-
if (typeof window === 'undefined') return undefined;
|
|
9
|
-
try {
|
|
10
|
-
const raw = window.localStorage.getItem(storageKey);
|
|
11
|
-
if (!raw) return undefined;
|
|
12
|
-
const parsed: unknown = JSON.parse(raw);
|
|
13
|
-
return typeof parsed === 'string' ? parsed : undefined;
|
|
14
|
-
} catch {
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function rememberLastWorkspaceId(workspaceId: string): void {
|
|
20
|
-
if (typeof window === 'undefined') return;
|
|
21
|
-
window.localStorage.setItem(storageKey, JSON.stringify(workspaceId));
|
|
22
|
-
}
|
package/src/runtime/nav-order.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const NAV_ORDER = {projects: 100, settings: 900, runs: 100, workflows: 200} as const;
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import {ThemeProvider} from '@shipfox/react-ui/theme';
|
|
2
|
-
import {TooltipProvider} from '@shipfox/react-ui/tooltip';
|
|
3
|
-
import {type QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
4
|
-
import {type createStore, Provider as JotaiProvider} from 'jotai';
|
|
5
|
-
import type {PropsWithChildren, ReactNode} from 'react';
|
|
6
|
-
import type {ClientFeature} from '#contract.js';
|
|
7
|
-
import {AuthRuntime, type AuthRuntimeProps} from './auth.js';
|
|
8
|
-
|
|
9
|
-
type Store = ReturnType<typeof createStore>;
|
|
10
|
-
|
|
11
|
-
export function ShellProviderStack({
|
|
12
|
-
features,
|
|
13
|
-
queryClient,
|
|
14
|
-
store,
|
|
15
|
-
auth,
|
|
16
|
-
children,
|
|
17
|
-
}: PropsWithChildren<{
|
|
18
|
-
features: readonly ClientFeature[];
|
|
19
|
-
queryClient: QueryClient;
|
|
20
|
-
store: Store;
|
|
21
|
-
auth?: Pick<AuthRuntimeProps, 'effects'>;
|
|
22
|
-
}>) {
|
|
23
|
-
const featureProviders = features.flatMap((feature) => feature.providers ?? []);
|
|
24
|
-
const nestedProviders = featureProviders.reduceRight<ReactNode>(
|
|
25
|
-
(content, provider) => <provider.Component key={provider.id}>{content}</provider.Component>,
|
|
26
|
-
children,
|
|
27
|
-
);
|
|
28
|
-
return (
|
|
29
|
-
<ThemeProvider>
|
|
30
|
-
<TooltipProvider>
|
|
31
|
-
<QueryClientProvider client={queryClient}>
|
|
32
|
-
<JotaiProvider store={store}>
|
|
33
|
-
<AuthRuntime {...auth}>{nestedProviders}</AuthRuntime>
|
|
34
|
-
</JotaiProvider>
|
|
35
|
-
</QueryClientProvider>
|
|
36
|
-
</TooltipProvider>
|
|
37
|
-
</ThemeProvider>
|
|
38
|
-
);
|
|
39
|
-
}
|