@codemation/next-host 0.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/README.md +25 -0
- package/app/(shell)/credentials/page.tsx +5 -0
- package/app/(shell)/dashboard/page.tsx +14 -0
- package/app/(shell)/layout.tsx +11 -0
- package/app/(shell)/page.tsx +5 -0
- package/app/(shell)/users/page.tsx +5 -0
- package/app/(shell)/workflows/[workflowId]/page.tsx +19 -0
- package/app/(shell)/workflows/page.tsx +5 -0
- package/app/api/[[...path]]/route.ts +40 -0
- package/app/api/auth/[...nextauth]/route.ts +3 -0
- package/app/globals.css +997 -0
- package/app/invite/[token]/page.tsx +10 -0
- package/app/layout.tsx +65 -0
- package/app/login/layout.tsx +25 -0
- package/app/login/page.tsx +22 -0
- package/components.json +21 -0
- package/docs/FORMS.md +46 -0
- package/docs/TAILWIND_SHADCN_MIGRATION.md +89 -0
- package/eslint.config.mjs +56 -0
- package/middleware.ts +29 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +34 -0
- package/package.json +76 -0
- package/postcss.config.mjs +7 -0
- package/public/canvas-icons/builtin/openai.svg +5 -0
- package/src/api/CodemationApiClient.ts +107 -0
- package/src/api/CodemationApiHttpError.ts +17 -0
- package/src/auth/CodemationNextAuthConfigResolver.ts +14 -0
- package/src/auth/CodemationNextAuthOAuthProviderDescriptorMapper.ts +30 -0
- package/src/auth/CodemationNextAuthOAuthProviderSnapshotResolver.ts +17 -0
- package/src/auth/CodemationNextAuthProviderCatalog.ts +107 -0
- package/src/auth/codemationEdgeAuth.ts +25 -0
- package/src/auth/codemationNextAuth.ts +32 -0
- package/src/components/Codemation.tsx +6 -0
- package/src/components/CodemationDataTable.tsx +37 -0
- package/src/components/CodemationDialog.tsx +137 -0
- package/src/components/CodemationFormattedDateTime.tsx +46 -0
- package/src/components/GoogleColorGIcon.tsx +39 -0
- package/src/components/OauthProviderIcon.tsx +33 -0
- package/src/components/PasswordStrengthMeter.tsx +59 -0
- package/src/components/forms/index.ts +28 -0
- package/src/components/json/JsonMonacoEditor.tsx +75 -0
- package/src/components/oauthProviderIconData.ts +17 -0
- package/src/components/ui/alert.tsx +56 -0
- package/src/components/ui/badge.tsx +40 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +70 -0
- package/src/components/ui/collapsible.tsx +26 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/dropdown-menu.tsx +238 -0
- package/src/components/ui/form.tsx +147 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/select.tsx +169 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/switch.tsx +28 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +76 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/toggle.tsx +41 -0
- package/src/features/credentials/components/CredentialConfirmDialog.tsx +58 -0
- package/src/features/credentials/components/CredentialDialog.tsx +252 -0
- package/src/features/credentials/components/CredentialDialogFeedback.tsx +36 -0
- package/src/features/credentials/components/CredentialDialogFieldRows.tsx +257 -0
- package/src/features/credentials/components/CredentialDialogFormSections.tsx +230 -0
- package/src/features/credentials/components/CredentialEnvFieldStatusRow.tsx +64 -0
- package/src/features/credentials/components/CredentialFieldCopyButton.tsx +48 -0
- package/src/features/credentials/components/CredentialsScreenHealthBadge.tsx +21 -0
- package/src/features/credentials/components/CredentialsScreenInstancesTable.tsx +108 -0
- package/src/features/credentials/components/CredentialsScreenTestFailureAlert.tsx +33 -0
- package/src/features/credentials/hooks/useCredentialCreateDialog.ts +33 -0
- package/src/features/credentials/hooks/useCredentialDialogSession.ts +616 -0
- package/src/features/credentials/hooks/useCredentialsScreen.ts +213 -0
- package/src/features/credentials/lib/credentialFieldHelpers.ts +35 -0
- package/src/features/credentials/lib/credentialFormTypes.ts +1 -0
- package/src/features/credentials/lib/credentialInstanceTestPayloadParser.ts +10 -0
- package/src/features/credentials/screens/CredentialsScreen.tsx +187 -0
- package/src/features/invite/screens/InviteAcceptScreen.tsx +190 -0
- package/src/features/users/components/UsersInviteDialog.tsx +121 -0
- package/src/features/users/components/UsersRegenerateDialog.tsx +81 -0
- package/src/features/users/components/UsersScreenUserStatusBadge.tsx +19 -0
- package/src/features/users/schemas/usersInviteFormSchema.ts +7 -0
- package/src/features/users/screens/UsersScreen.tsx +240 -0
- package/src/features/workflows/components/WorkflowListFolderSection.tsx +91 -0
- package/src/features/workflows/components/WorkflowListItemCard.tsx +67 -0
- package/src/features/workflows/components/WorkflowListRoot.tsx +39 -0
- package/src/features/workflows/components/WorkflowsListTree.tsx +28 -0
- package/src/features/workflows/components/canvas/CanvasNodeChromeTooltip.tsx +96 -0
- package/src/features/workflows/components/canvas/CanvasNodeIconSlot.tsx +25 -0
- package/src/features/workflows/components/canvas/VisibleNodeStatusResolver.tsx +84 -0
- package/src/features/workflows/components/canvas/WorkflowCanvas.tsx +248 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNode.tsx +182 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAccents.tsx +73 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAgentBottomSourceHandles.tsx +43 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAgentLabels.tsx +47 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeCard.tsx +202 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeHandles.tsx +77 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeLabelBelow.tsx +51 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeMainGlyph.tsx +64 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeToolbar.tsx +95 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasLoadingPlaceholder.tsx +69 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasNodeIcon.tsx +102 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasSimpleIconGlyph.tsx +21 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasStraightCountEdge.tsx +33 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasStructureSignature.tsx +7 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasSymmetricForkEdge.tsx +32 -0
- package/src/features/workflows/components/canvas/WorkflowCanvasToolbarIconButton.tsx +95 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasBuiltinIconRegistry.ts +26 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasEdgeCountResolver.ts +51 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasEdgeStyleResolver.ts +35 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasLabelLayoutEstimator.ts +42 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasOverlapResolver.ts +78 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasPortOrderResolver.ts +25 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasRoundedOrthogonalPathPlanner.ts +56 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasSiIconRegistry.ts +18 -0
- package/src/features/workflows/components/canvas/lib/WorkflowCanvasSymmetricForkPathPlanner.ts +43 -0
- package/src/features/workflows/components/canvas/lib/layoutWorkflow.ts +315 -0
- package/src/features/workflows/components/canvas/lib/workflowCanvasEdgeGeometry.ts +3 -0
- package/src/features/workflows/components/canvas/lib/workflowCanvasEmbeddedStyles.ts +62 -0
- package/src/features/workflows/components/canvas/lib/workflowCanvasFlowTypes.ts +10 -0
- package/src/features/workflows/components/canvas/lib/workflowCanvasNodeData.ts +41 -0
- package/src/features/workflows/components/canvas/lib/workflowCanvasNodeGeometry.ts +99 -0
- package/src/features/workflows/components/canvas/workflowCanvasNodeChrome.tsx +46 -0
- package/src/features/workflows/components/realtime/RealtimeContext.tsx +14 -0
- package/src/features/workflows/components/realtime/WorkflowRealtimeProvider.tsx +15 -0
- package/src/features/workflows/components/workflowDetail/NodeCredentialBindingRow.tsx +209 -0
- package/src/features/workflows/components/workflowDetail/NodeCredentialBindingsSection.tsx +227 -0
- package/src/features/workflows/components/workflowDetail/NodePropertiesConfigSection.tsx +51 -0
- package/src/features/workflows/components/workflowDetail/NodePropertiesPanelHeader.tsx +50 -0
- package/src/features/workflows/components/workflowDetail/NodePropertiesSlidePanel.tsx +134 -0
- package/src/features/workflows/components/workflowDetail/WorkflowActivationErrorDialog.tsx +71 -0
- package/src/features/workflows/components/workflowDetail/WorkflowActivationHeaderControl.tsx +64 -0
- package/src/features/workflows/components/workflowDetail/WorkflowDetailIcons.tsx +52 -0
- package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspector.tsx +110 -0
- package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorDetailBody.tsx +213 -0
- package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorPanes.tsx +239 -0
- package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorSidebarResizer.tsx +31 -0
- package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorTreePanel.tsx +133 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorAttachmentGroupingPresenter.tsx +31 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorAttachmentList.tsx +118 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorBinaryView.tsx +15 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorErrorView.tsx +107 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorJsonView.tsx +114 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyTreePresenter.tsx +132 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyTreeViewRenderer.tsx +147 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyView.tsx +65 -0
- package/src/features/workflows/components/workflowDetail/WorkflowInspectorViews.tsx +5 -0
- package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorBinaryAttachmentRow.tsx +74 -0
- package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorBinaryUploadRow.tsx +69 -0
- package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorDialog.tsx +254 -0
- package/src/features/workflows/components/workflowDetail/WorkflowRunsList.tsx +89 -0
- package/src/features/workflows/components/workflowDetail/WorkflowRunsSidebar.tsx +50 -0
- package/src/features/workflows/hooks/canvas/useWorkflowCanvasVisibleNodeStatuses.ts +14 -0
- package/src/features/workflows/hooks/realtime/realtime.tsx +271 -0
- package/src/features/workflows/hooks/realtime/runQueryPolling.ts +34 -0
- package/src/features/workflows/hooks/realtime/useWorkflowRealtimeInfrastructure.ts +541 -0
- package/src/features/workflows/hooks/realtime/useWorkflowRealtimeShowDisconnectedBadge.ts +9 -0
- package/src/features/workflows/hooks/workflowDetail/useWorkflowDetailController.tsx +1300 -0
- package/src/features/workflows/lib/realtime/realtimeApi.ts +78 -0
- package/src/features/workflows/lib/realtime/realtimeClientBridge.ts +52 -0
- package/src/features/workflows/lib/realtime/realtimeDomainTypes.ts +191 -0
- package/src/features/workflows/lib/realtime/realtimeQueryKeys.ts +15 -0
- package/src/features/workflows/lib/realtime/realtimeRunMutations.ts +167 -0
- package/src/features/workflows/lib/realtime/workflowTypes.ts +5 -0
- package/src/features/workflows/lib/workflowDetail/PersistedWorkflowSnapshotMapper.ts +205 -0
- package/src/features/workflows/lib/workflowDetail/WorkflowActivationHttpErrorFormat.ts +32 -0
- package/src/features/workflows/lib/workflowDetail/WorkflowDetailPresenter.ts +1017 -0
- package/src/features/workflows/lib/workflowDetail/WorkflowDetailUrlCodec.ts +70 -0
- package/src/features/workflows/lib/workflowDetail/workflowDetailTypes.ts +152 -0
- package/src/features/workflows/lib/workflowDetailTreeStyles.ts +65 -0
- package/src/features/workflows/screens/WorkflowDetailScreen.tsx +236 -0
- package/src/features/workflows/screens/WorkflowDetailScreenInspectorPanel.tsx +55 -0
- package/src/features/workflows/screens/WorkflowsList.tsx +35 -0
- package/src/features/workflows/screens/WorkflowsScreen.tsx +31 -0
- package/src/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
- package/src/middleware/CodemationNextHostMiddlewarePathRules.ts +31 -0
- package/src/providers/CodemationSessionProvider.tsx +23 -0
- package/src/providers/Providers.tsx +36 -0
- package/src/providers/RealtimeBoundary.tsx +17 -0
- package/src/providers/WhitelabelProvider.tsx +22 -0
- package/src/server/CodemationAuthPrismaClient.ts +21 -0
- package/src/server/CodemationNextHost.ts +379 -0
- package/src/shell/AppLayout.tsx +141 -0
- package/src/shell/AppLayoutNavItems.tsx +129 -0
- package/src/shell/AppLayoutPageHeader.tsx +79 -0
- package/src/shell/AppLayoutSidebarBrand.tsx +33 -0
- package/src/shell/AppMainContent.tsx +17 -0
- package/src/shell/AppShellHeaderActions.tsx +12 -0
- package/src/shell/AppShellHeaderActionsAuthenticated.tsx +51 -0
- package/src/shell/CodemationNextClientShell.tsx +17 -0
- package/src/shell/CredentialsSignInRedirectResolver.ts +21 -0
- package/src/shell/LoginPageClient.tsx +231 -0
- package/src/shell/WorkflowDetailChromeContext.tsx +42 -0
- package/src/shell/WorkflowFolderTreeBuilder.ts +62 -0
- package/src/shell/WorkflowFolderUi.ts +42 -0
- package/src/shell/WorkflowSidebarNavFolder.tsx +112 -0
- package/src/shell/WorkflowSidebarNavTree.tsx +68 -0
- package/src/shell/appLayoutPageTitle.ts +16 -0
- package/src/shell/appLayoutSidebarIcons.tsx +108 -0
- package/src/whitelabel/CodemationWhitelabelSnapshot.ts +4 -0
- package/src/whitelabel/CodemationWhitelabelSnapshotFactory.ts +18 -0
- package/tsconfig.json +40 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Path classification for Next.js middleware (Edge). Extracted for unit tests. */
|
|
2
|
+
export class CodemationNextHostMiddlewarePathRules {
|
|
3
|
+
static isFrameworkAuthRoute(pathname: string): boolean {
|
|
4
|
+
return pathname.startsWith("/api/auth");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
static isAnonymousApiRoute(pathname: string): boolean {
|
|
8
|
+
return (
|
|
9
|
+
pathname.startsWith("/api/webhooks") ||
|
|
10
|
+
pathname === "/api/dev/runtime" ||
|
|
11
|
+
pathname === "/api/dev/bootstrap-summary" ||
|
|
12
|
+
pathname === "/api/users/invites/verify" ||
|
|
13
|
+
pathname === "/api/users/invites/accept" ||
|
|
14
|
+
// Anonymous whitelabel logo (login page `<img>`; same path as ApiPaths.whitelabelLogo() / Hono anonymous policy).
|
|
15
|
+
pathname === "/api/whitelabel/logo"
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static isPublicUiRoute(pathname: string): boolean {
|
|
20
|
+
return pathname === "/login" || pathname.startsWith("/login/") || pathname.startsWith("/invite/");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static isNextStaticAsset(pathname: string): boolean {
|
|
24
|
+
return (
|
|
25
|
+
pathname.startsWith("/_next") ||
|
|
26
|
+
pathname === "/favicon.ico" ||
|
|
27
|
+
pathname.startsWith("/favicon.ico") ||
|
|
28
|
+
pathname.startsWith("/public")
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Session } from "next-auth";
|
|
4
|
+
import { SessionProvider } from "next-auth/react";
|
|
5
|
+
import { Component, type ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
type CodemationSessionRootProps = Readonly<{
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Server-resolved session so the first client paint matches SSR (avoids `useSession` hydration mismatches).
|
|
12
|
+
*/
|
|
13
|
+
session: Session | null;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export class CodemationSessionRoot extends Component<CodemationSessionRootProps> {
|
|
17
|
+
override render(): ReactNode {
|
|
18
|
+
if (!this.props.enabled) {
|
|
19
|
+
return this.props.children;
|
|
20
|
+
}
|
|
21
|
+
return <SessionProvider session={this.props.session}>{this.props.children}</SessionProvider>;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Logger } from "@codemation/host-src/application/logging/Logger";
|
|
2
|
+
|
|
3
|
+
import { BrowserLoggerFactory } from "@codemation/host-src/infrastructure/logging/BrowserLoggerFactory";
|
|
4
|
+
import { logLevelPolicyFactory } from "@codemation/host-src/infrastructure/logging/LogLevelPolicyFactory";
|
|
5
|
+
|
|
6
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
7
|
+
|
|
8
|
+
import { useState, type ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
import { RealtimeBoundary } from "./RealtimeBoundary";
|
|
11
|
+
|
|
12
|
+
export function Providers(args: Readonly<{ children: ReactNode; websocketPort?: string }>) {
|
|
13
|
+
const { children, websocketPort } = args;
|
|
14
|
+
const defaultQueryStaleTimeMs = process.env.NODE_ENV === "development" ? 30_000 : 0;
|
|
15
|
+
const [loggerFactory] = useState(() => new BrowserLoggerFactory(logLevelPolicyFactory.create()));
|
|
16
|
+
const [realtimeLogger] = useState<Logger>(() => loggerFactory.create("workflow-realtime.frontend"));
|
|
17
|
+
const [queryClient] = useState(
|
|
18
|
+
() =>
|
|
19
|
+
new QueryClient({
|
|
20
|
+
defaultOptions: {
|
|
21
|
+
queries: {
|
|
22
|
+
staleTime: defaultQueryStaleTimeMs,
|
|
23
|
+
refetchOnWindowFocus: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<QueryClientProvider client={queryClient}>
|
|
31
|
+
<RealtimeBoundary logger={realtimeLogger} websocketPort={websocketPort}>
|
|
32
|
+
{children}
|
|
33
|
+
</RealtimeBoundary>
|
|
34
|
+
</QueryClientProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Logger } from "@codemation/host-src/application/logging/Logger";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { WorkflowRealtimeProvider } from "../features/workflows/hooks/realtime/realtime";
|
|
6
|
+
|
|
7
|
+
export function RealtimeBoundary(args: Readonly<{ children: ReactNode; logger: Logger; websocketPort?: string }>) {
|
|
8
|
+
const { children, logger, websocketPort } = args;
|
|
9
|
+
if (typeof window === "undefined") {
|
|
10
|
+
return <>{children}</>;
|
|
11
|
+
}
|
|
12
|
+
return (
|
|
13
|
+
<WorkflowRealtimeProvider logger={logger} websocketPort={websocketPort}>
|
|
14
|
+
{children}
|
|
15
|
+
</WorkflowRealtimeProvider>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import type { CodemationWhitelabelSnapshot } from "../whitelabel/CodemationWhitelabelSnapshot";
|
|
6
|
+
|
|
7
|
+
const defaultSnapshot: CodemationWhitelabelSnapshot = {
|
|
8
|
+
productName: "Codemation",
|
|
9
|
+
logoUrl: null,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const WhitelabelContext = createContext<CodemationWhitelabelSnapshot>(defaultSnapshot);
|
|
13
|
+
|
|
14
|
+
export function WhitelabelProvider(
|
|
15
|
+
args: Readonly<{ children: ReactNode; value: CodemationWhitelabelSnapshot }>,
|
|
16
|
+
): ReactNode {
|
|
17
|
+
return <WhitelabelContext.Provider value={args.value}>{args.children}</WhitelabelContext.Provider>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useWhitelabel(): CodemationWhitelabelSnapshot {
|
|
21
|
+
return useContext(WhitelabelContext);
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PrismaClient } from "@codemation/host-src/infrastructure/persistence/generated/prisma-client/client.js";
|
|
2
|
+
|
|
3
|
+
export class CodemationAuthPrismaClient {
|
|
4
|
+
static async resolveShared(): Promise<PrismaClient> {
|
|
5
|
+
return await CodemationAuthPrismaClient.resolveFromPreparedNextHost();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
private static async resolveFromPreparedNextHost(): Promise<PrismaClient> {
|
|
9
|
+
const { CodemationNextHost } = await import("./CodemationNextHost");
|
|
10
|
+
try {
|
|
11
|
+
return await CodemationNextHost.shared.getPreparedPrismaClient();
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(
|
|
14
|
+
[
|
|
15
|
+
"Codemation authentication requires prepared runtime database persistence.",
|
|
16
|
+
"Ensure the Next host has been prepared with PostgreSQL or PGlite before creating the auth adapter.",
|
|
17
|
+
].join(" "),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type { Container } from "@codemation/core";
|
|
2
|
+
import type { CodemationAuthConfig, CodemationPlugin } from "@codemation/host";
|
|
3
|
+
import {
|
|
4
|
+
ApplicationTokens,
|
|
5
|
+
CodemationApplication,
|
|
6
|
+
CodemationBootstrapRequest,
|
|
7
|
+
CodemationFrontendBootstrapRequest,
|
|
8
|
+
CodemationHonoApiApp,
|
|
9
|
+
CodemationPluginListMerger,
|
|
10
|
+
logLevelPolicyFactory,
|
|
11
|
+
ServerLoggerFactory,
|
|
12
|
+
WorkflowWebsocketServer,
|
|
13
|
+
} from "@codemation/host/next/server";
|
|
14
|
+
import type { PrismaClient } from "@codemation/host-src/infrastructure/persistence/generated/prisma-client/client.js";
|
|
15
|
+
import { CodemationTsyringeTypeInfoRegistrar } from "@codemation/host/dev-server-sidecar";
|
|
16
|
+
import type { CodemationConsumerApp } from "@codemation/host-src/presentation/server/CodemationConsumerAppResolver";
|
|
17
|
+
import type { Hono } from "hono";
|
|
18
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { CodemationWhitelabelSnapshotFactory } from "../whitelabel/CodemationWhitelabelSnapshotFactory";
|
|
23
|
+
import type { CodemationWhitelabelSnapshot } from "../whitelabel/CodemationWhitelabelSnapshot";
|
|
24
|
+
|
|
25
|
+
export type CodemationNextHostContext = Readonly<{
|
|
26
|
+
application: CodemationApplication;
|
|
27
|
+
authConfig: CodemationAuthConfig | undefined;
|
|
28
|
+
buildVersion: string;
|
|
29
|
+
consumerRoot: string;
|
|
30
|
+
repoRoot: string;
|
|
31
|
+
workflowSources: ReadonlyArray<string>;
|
|
32
|
+
/** Derived from the loaded consumer manifest config (same source as {@link CodemationApplication.useConfig}). */
|
|
33
|
+
whitelabelSnapshot: CodemationWhitelabelSnapshot;
|
|
34
|
+
}>;
|
|
35
|
+
|
|
36
|
+
type CodemationNextHostGlobal = typeof globalThis & {
|
|
37
|
+
__codemationNextHost__?: CodemationNextHost;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type CodemationConsumerBuildManifest = Readonly<{
|
|
41
|
+
buildVersion: string;
|
|
42
|
+
consumerRoot: string;
|
|
43
|
+
entryPath: string;
|
|
44
|
+
pluginEntryPath: string;
|
|
45
|
+
workflowSourcePaths: ReadonlyArray<string>;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Next-hosted consumer runtime: one loaded context per process.
|
|
50
|
+
* In development, the consumer manifest `buildVersion` is re-read on each prepare; when it changes
|
|
51
|
+
* (e.g. after `codemation build` / dev publish), the previous {@link CodemationApplication} is torn
|
|
52
|
+
* down so whitelabel and config updates apply without restarting Next.
|
|
53
|
+
*/
|
|
54
|
+
export class CodemationNextHost {
|
|
55
|
+
private readonly pluginListMerger = new CodemationPluginListMerger();
|
|
56
|
+
private nextApiApp: Hono | null = null;
|
|
57
|
+
private nextApiAppBuildVersion: string | null = null;
|
|
58
|
+
private contextPromise: Promise<CodemationNextHostContext> | null = null;
|
|
59
|
+
/** Tracks which manifest `buildVersion` the current {@link contextPromise} was built from (dev invalidation). */
|
|
60
|
+
private loadedManifestBuildVersion: string | null = null;
|
|
61
|
+
/** Single-flight so concurrent `prepare()` calls do not create duplicate application graphs. */
|
|
62
|
+
private prepareInFlight: Promise<CodemationNextHostContext> | null = null;
|
|
63
|
+
private sharedWorkflowWebsocketServer: WorkflowWebsocketServer | null = null;
|
|
64
|
+
|
|
65
|
+
static get shared(): CodemationNextHost {
|
|
66
|
+
const globalState = globalThis as CodemationNextHostGlobal;
|
|
67
|
+
if (!globalState.__codemationNextHost__) {
|
|
68
|
+
globalState.__codemationNextHost__ = new CodemationNextHost();
|
|
69
|
+
}
|
|
70
|
+
return globalState.__codemationNextHost__;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async prepare(): Promise<CodemationNextHostContext> {
|
|
74
|
+
if (this.prepareInFlight) {
|
|
75
|
+
return this.prepareInFlight;
|
|
76
|
+
}
|
|
77
|
+
this.prepareInFlight = this.prepareInternal();
|
|
78
|
+
try {
|
|
79
|
+
return await this.prepareInFlight;
|
|
80
|
+
} finally {
|
|
81
|
+
this.prepareInFlight = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async prepareInternal(): Promise<CodemationNextHostContext> {
|
|
86
|
+
const manifest = await this.resolveBuildManifest();
|
|
87
|
+
if (this.shouldReloadContextForDev(manifest)) {
|
|
88
|
+
await this.teardownLoadedContext();
|
|
89
|
+
}
|
|
90
|
+
if (!this.contextPromise) {
|
|
91
|
+
const context = await this.createContext(manifest);
|
|
92
|
+
this.loadedManifestBuildVersion = manifest.buildVersion;
|
|
93
|
+
this.contextPromise = Promise.resolve(context);
|
|
94
|
+
}
|
|
95
|
+
return await this.contextPromise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private shouldReloadContextForDev(manifest: CodemationConsumerBuildManifest): boolean {
|
|
99
|
+
if (process.env.NODE_ENV === "production") {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (this.contextPromise === null || this.loadedManifestBuildVersion === null) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return this.loadedManifestBuildVersion !== manifest.buildVersion;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async teardownLoadedContext(): Promise<void> {
|
|
109
|
+
if (!this.contextPromise) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const ctx = await this.contextPromise;
|
|
114
|
+
await ctx.application.stop({ stopWebsocketServer: false });
|
|
115
|
+
} catch {
|
|
116
|
+
// Best-effort teardown before reloading consumer output.
|
|
117
|
+
}
|
|
118
|
+
this.contextPromise = null;
|
|
119
|
+
this.nextApiApp = null;
|
|
120
|
+
this.nextApiAppBuildVersion = null;
|
|
121
|
+
this.loadedManifestBuildVersion = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getContainer(): Promise<Container> {
|
|
125
|
+
return (await this.prepare()).application.getContainer();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getPreparedPrismaClient(): Promise<PrismaClient> {
|
|
129
|
+
const preparedContext = await this.prepare();
|
|
130
|
+
const existingPrisma = this.tryResolvePreparedPrismaClient(preparedContext);
|
|
131
|
+
if (existingPrisma) {
|
|
132
|
+
return existingPrisma;
|
|
133
|
+
}
|
|
134
|
+
await this.teardownLoadedContext();
|
|
135
|
+
const refreshedContext = await this.prepare();
|
|
136
|
+
const refreshedPrisma = this.tryResolvePreparedPrismaClient(refreshedContext);
|
|
137
|
+
if (refreshedPrisma) {
|
|
138
|
+
return refreshedPrisma;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
[
|
|
142
|
+
"Codemation authentication requires prepared runtime database persistence.",
|
|
143
|
+
"Ensure the Next host has been prepared with PostgreSQL or PGlite before creating the auth adapter.",
|
|
144
|
+
].join(" "),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolved whitelabel for Server Components (sidebar, login, metadata). Requires {@link prepare} first.
|
|
150
|
+
* Uses the snapshot captured from the built consumer config so branding matches `codemation.config` even if DI
|
|
151
|
+
* resolution were ever misaligned in a bundled server graph.
|
|
152
|
+
*/
|
|
153
|
+
async getWhitelabelSnapshot(): Promise<CodemationWhitelabelSnapshot> {
|
|
154
|
+
const context = await this.prepare();
|
|
155
|
+
return context.whitelabelSnapshot;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Entry point for all `/api/**` traffic when the App Router route is not proxying to the dev gateway.
|
|
160
|
+
*/
|
|
161
|
+
async fetchApi(request: Request): Promise<Response> {
|
|
162
|
+
const context = await this.prepare();
|
|
163
|
+
const app = this.resolveNextApiApp(context);
|
|
164
|
+
return app.fetch(request);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private resolveNextApiApp(context: CodemationNextHostContext): Hono {
|
|
168
|
+
if (this.nextApiApp && this.nextApiAppBuildVersion === context.buildVersion) {
|
|
169
|
+
return this.nextApiApp;
|
|
170
|
+
}
|
|
171
|
+
const coreApp = context.application.getContainer().resolve(CodemationHonoApiApp).getHono();
|
|
172
|
+
this.nextApiApp = coreApp;
|
|
173
|
+
this.nextApiAppBuildVersion = context.buildVersion;
|
|
174
|
+
return coreApp;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async createContext(buildManifest: CodemationConsumerBuildManifest): Promise<CodemationNextHostContext> {
|
|
178
|
+
const consumerRoot = path.resolve(buildManifest.consumerRoot);
|
|
179
|
+
const repoRoot = await this.detectWorkspaceRoot(consumerRoot);
|
|
180
|
+
const prismaCliOverride = await this.resolvePrismaCliOverride();
|
|
181
|
+
const hostPackageRoot = path.resolve(repoRoot, "packages", "host");
|
|
182
|
+
if (prismaCliOverride) {
|
|
183
|
+
process.env.CODEMATION_PRISMA_CLI_PATH = prismaCliOverride;
|
|
184
|
+
}
|
|
185
|
+
process.env.CODEMATION_HOST_PACKAGE_ROOT = hostPackageRoot;
|
|
186
|
+
process.env.CODEMATION_PRISMA_CONFIG_PATH = path.resolve(hostPackageRoot, "prisma.config.ts");
|
|
187
|
+
const resolvedConsumerApp = await this.loadBuiltConsumerApp(buildManifest.entryPath);
|
|
188
|
+
const whitelabelSnapshot = CodemationWhitelabelSnapshotFactory.fromConsumerConfig(resolvedConsumerApp.config);
|
|
189
|
+
const env = { ...process.env };
|
|
190
|
+
if (prismaCliOverride) {
|
|
191
|
+
env.CODEMATION_PRISMA_CLI_PATH = prismaCliOverride;
|
|
192
|
+
}
|
|
193
|
+
env.CODEMATION_HOST_PACKAGE_ROOT = hostPackageRoot;
|
|
194
|
+
env.CODEMATION_PRISMA_CONFIG_PATH = path.resolve(hostPackageRoot, "prisma.config.ts");
|
|
195
|
+
env.CODEMATION_CONSUMER_ROOT = consumerRoot;
|
|
196
|
+
const isRuntimeDevProxy = Boolean(process.env.CODEMATION_RUNTIME_DEV_URL?.trim());
|
|
197
|
+
const bootstrapRequest = new CodemationBootstrapRequest({
|
|
198
|
+
consumerRoot,
|
|
199
|
+
repoRoot,
|
|
200
|
+
env,
|
|
201
|
+
workflowSources: resolvedConsumerApp.workflowSources,
|
|
202
|
+
});
|
|
203
|
+
const application = new CodemationApplication();
|
|
204
|
+
application.useSharedWorkflowWebsocketServer(this.resolveSharedWorkflowWebsocketServer());
|
|
205
|
+
const discoveredPlugins = await this.loadDiscoveredPlugins(buildManifest);
|
|
206
|
+
|
|
207
|
+
application.useConfig(resolvedConsumerApp.config);
|
|
208
|
+
if (discoveredPlugins.length > 0) {
|
|
209
|
+
application.usePlugins(this.pluginListMerger.merge(resolvedConsumerApp.config.plugins ?? [], discoveredPlugins));
|
|
210
|
+
}
|
|
211
|
+
await application.applyPlugins(bootstrapRequest);
|
|
212
|
+
await application.prepareContainer(bootstrapRequest);
|
|
213
|
+
const typeInfoRegistrar = new CodemationTsyringeTypeInfoRegistrar(application.getContainer());
|
|
214
|
+
typeInfoRegistrar.registerWorkflowDefinitions(resolvedConsumerApp.config.workflows ?? []);
|
|
215
|
+
await application.bootFrontend(
|
|
216
|
+
new CodemationFrontendBootstrapRequest({
|
|
217
|
+
bootstrap: bootstrapRequest,
|
|
218
|
+
skipPresentationServers: isRuntimeDevProxy,
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
application,
|
|
223
|
+
authConfig: resolvedConsumerApp.config.auth,
|
|
224
|
+
buildVersion: buildManifest.buildVersion,
|
|
225
|
+
consumerRoot,
|
|
226
|
+
repoRoot,
|
|
227
|
+
workflowSources: resolvedConsumerApp.workflowSources,
|
|
228
|
+
whitelabelSnapshot,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private tryResolvePreparedPrismaClient(context: CodemationNextHostContext): PrismaClient | null {
|
|
233
|
+
const container = context.application.getContainer();
|
|
234
|
+
if (!container.isRegistered(ApplicationTokens.PrismaClient, true)) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return container.resolve(ApplicationTokens.PrismaClient);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async detectWorkspaceRoot(startDirectory: string): Promise<string> {
|
|
241
|
+
let currentDirectory = path.resolve(startDirectory);
|
|
242
|
+
while (true) {
|
|
243
|
+
if (await this.exists(path.resolve(currentDirectory, "pnpm-workspace.yaml"))) {
|
|
244
|
+
return currentDirectory;
|
|
245
|
+
}
|
|
246
|
+
const parentDirectory = path.dirname(currentDirectory);
|
|
247
|
+
if (parentDirectory === currentDirectory) {
|
|
248
|
+
return startDirectory;
|
|
249
|
+
}
|
|
250
|
+
currentDirectory = parentDirectory;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async resolvePrismaCliOverride(): Promise<string | null> {
|
|
255
|
+
const candidate = path.resolve(process.cwd(), "node_modules", "prisma", "build", "index.js");
|
|
256
|
+
return (await this.exists(candidate)) ? candidate : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async resolveBuildManifest(): Promise<CodemationConsumerBuildManifest> {
|
|
260
|
+
const manifestPath = await this.resolveBuildManifestPath();
|
|
261
|
+
const manifestText = await readFile(manifestPath, "utf8");
|
|
262
|
+
const parsedManifest = JSON.parse(manifestText) as Partial<CodemationConsumerBuildManifest>;
|
|
263
|
+
if (
|
|
264
|
+
typeof parsedManifest.buildVersion !== "string" ||
|
|
265
|
+
typeof parsedManifest.consumerRoot !== "string" ||
|
|
266
|
+
typeof parsedManifest.entryPath !== "string" ||
|
|
267
|
+
typeof parsedManifest.pluginEntryPath !== "string" ||
|
|
268
|
+
!Array.isArray(parsedManifest.workflowSourcePaths)
|
|
269
|
+
) {
|
|
270
|
+
throw new Error(`Invalid Codemation consumer build manifest at ${manifestPath}.`);
|
|
271
|
+
}
|
|
272
|
+
const buildManifest: CodemationConsumerBuildManifest = {
|
|
273
|
+
buildVersion: parsedManifest.buildVersion,
|
|
274
|
+
consumerRoot: path.resolve(parsedManifest.consumerRoot),
|
|
275
|
+
entryPath: path.resolve(parsedManifest.entryPath),
|
|
276
|
+
pluginEntryPath: path.resolve(parsedManifest.pluginEntryPath),
|
|
277
|
+
workflowSourcePaths: parsedManifest.workflowSourcePaths.filter(
|
|
278
|
+
(workflowSourcePath): workflowSourcePath is string => typeof workflowSourcePath === "string",
|
|
279
|
+
),
|
|
280
|
+
};
|
|
281
|
+
if (!(await this.exists(buildManifest.entryPath))) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Built consumer output not found at ${buildManifest.entryPath}. Run \`codemation build\` before starting the Next host.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (!(await this.exists(buildManifest.pluginEntryPath))) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Discovered plugins output not found at ${buildManifest.pluginEntryPath}. Run \`codemation build\` before starting the Next host.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return buildManifest;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async resolveBuildManifestPath(): Promise<string> {
|
|
295
|
+
const configuredPath = process.env.CODEMATION_CONSUMER_OUTPUT_MANIFEST_PATH;
|
|
296
|
+
if (!configuredPath || configuredPath.trim().length === 0) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"Missing CODEMATION_CONSUMER_OUTPUT_MANIFEST_PATH. Start the Next host through `codemation dev` or `codemation build`.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const resolvedPath = path.resolve(configuredPath);
|
|
302
|
+
if (!(await this.exists(resolvedPath))) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Build manifest not found at ${resolvedPath}. Run \`codemation build\` before starting the Next host.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return resolvedPath;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async loadBuiltConsumerApp(outputPath: string): Promise<CodemationConsumerApp> {
|
|
311
|
+
const importedModule = (await import(
|
|
312
|
+
/* webpackIgnore: true */ await this.createRuntimeImportSpecifier(outputPath)
|
|
313
|
+
)) as {
|
|
314
|
+
codemationConsumerApp?: CodemationConsumerApp;
|
|
315
|
+
default?: CodemationConsumerApp;
|
|
316
|
+
};
|
|
317
|
+
const consumerApp = importedModule.codemationConsumerApp ?? importedModule.default;
|
|
318
|
+
if (!consumerApp) {
|
|
319
|
+
throw new Error(`Built consumer output did not export a Codemation consumer app: ${outputPath}`);
|
|
320
|
+
}
|
|
321
|
+
return consumerApp;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private async loadDiscoveredPlugins(
|
|
325
|
+
buildManifest: CodemationConsumerBuildManifest,
|
|
326
|
+
): Promise<ReadonlyArray<CodemationPlugin>> {
|
|
327
|
+
const resolvedPath = path.resolve(buildManifest.pluginEntryPath);
|
|
328
|
+
if (!(await this.exists(resolvedPath))) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const importedModule = (await import(
|
|
332
|
+
/* webpackIgnore: true */ await this.createRuntimeImportSpecifier(resolvedPath)
|
|
333
|
+
)) as {
|
|
334
|
+
codemationDiscoveredPlugins?: ReadonlyArray<CodemationPlugin>;
|
|
335
|
+
default?: ReadonlyArray<CodemationPlugin>;
|
|
336
|
+
};
|
|
337
|
+
return importedModule.codemationDiscoveredPlugins ?? importedModule.default ?? [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async createRuntimeImportSpecifier(filePath: string): Promise<string> {
|
|
341
|
+
const fileUrl = pathToFileURL(filePath);
|
|
342
|
+
const fileStats = await stat(filePath);
|
|
343
|
+
fileUrl.searchParams.set("t", String(fileStats.mtimeMs));
|
|
344
|
+
return fileUrl.href;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private resolveSharedWorkflowWebsocketServer(): WorkflowWebsocketServer {
|
|
348
|
+
if (!this.sharedWorkflowWebsocketServer) {
|
|
349
|
+
this.sharedWorkflowWebsocketServer = new WorkflowWebsocketServer(
|
|
350
|
+
this.resolveWebSocketPort(),
|
|
351
|
+
this.resolveWebSocketBindHost(),
|
|
352
|
+
new ServerLoggerFactory(logLevelPolicyFactory).create("codemation-websocket.server"),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return this.sharedWorkflowWebsocketServer;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private resolveWebSocketPort(): number {
|
|
359
|
+
const rawPort = process.env.CODEMATION_WS_PORT ?? process.env.VITE_CODEMATION_WS_PORT;
|
|
360
|
+
const parsedPort = Number(rawPort);
|
|
361
|
+
if (Number.isInteger(parsedPort) && parsedPort > 0) {
|
|
362
|
+
return parsedPort;
|
|
363
|
+
}
|
|
364
|
+
return 3001;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private resolveWebSocketBindHost(): string {
|
|
368
|
+
return process.env.CODEMATION_WS_BIND_HOST ?? "0.0.0.0";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async exists(filePath: string): Promise<boolean> {
|
|
372
|
+
try {
|
|
373
|
+
await access(filePath);
|
|
374
|
+
return true;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
import { AppLayoutNavItems } from "./AppLayoutNavItems";
|
|
9
|
+
import { AppLayoutPageHeader } from "./AppLayoutPageHeader";
|
|
10
|
+
import { AppLayoutSidebarBrand } from "./AppLayoutSidebarBrand";
|
|
11
|
+
import { AppMainContent } from "./AppMainContent";
|
|
12
|
+
import { IconChevronLeft, IconChevronRight } from "./appLayoutSidebarIcons";
|
|
13
|
+
|
|
14
|
+
const SIDEBAR_WIDTH_KEY = "codemation-sidebar-width";
|
|
15
|
+
const SIDEBAR_COLLAPSED_KEY = "codemation-sidebar-collapsed";
|
|
16
|
+
const MIN_SIDEBAR_WIDTH = 12;
|
|
17
|
+
const MAX_SIDEBAR_WIDTH = 28;
|
|
18
|
+
const DEFAULT_SIDEBAR_WIDTH = 16;
|
|
19
|
+
|
|
20
|
+
export interface AppLayoutProps {
|
|
21
|
+
readonly children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type AppLayoutState = {
|
|
25
|
+
sidebarWidth: number;
|
|
26
|
+
sidebarCollapsed: boolean;
|
|
27
|
+
isResizing: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function loadSidebarWidth(): number {
|
|
31
|
+
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
|
32
|
+
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
33
|
+
const n = stored ? Number.parseFloat(stored) : DEFAULT_SIDEBAR_WIDTH;
|
|
34
|
+
return Number.isFinite(n) ? Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, n)) : DEFAULT_SIDEBAR_WIDTH;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadSidebarCollapsed(): boolean {
|
|
38
|
+
if (typeof window === "undefined") return false;
|
|
39
|
+
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AppLayout extends Component<AppLayoutProps, AppLayoutState> {
|
|
43
|
+
override state: AppLayoutState = {
|
|
44
|
+
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
|
45
|
+
sidebarCollapsed: false,
|
|
46
|
+
isResizing: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
override componentDidMount(): void {
|
|
50
|
+
this.setState({
|
|
51
|
+
sidebarWidth: loadSidebarWidth(),
|
|
52
|
+
sidebarCollapsed: loadSidebarCollapsed(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private handleResizeStart = (e: React.MouseEvent): void => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
this.setState({ isResizing: true });
|
|
59
|
+
const onMove = (moveEvent: MouseEvent): void => {
|
|
60
|
+
const rem = moveEvent.clientX / 16;
|
|
61
|
+
const w = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, rem));
|
|
62
|
+
this.setState({ sidebarWidth: w });
|
|
63
|
+
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w));
|
|
64
|
+
};
|
|
65
|
+
const onUp = (): void => {
|
|
66
|
+
this.setState({ isResizing: false });
|
|
67
|
+
document.removeEventListener("mousemove", onMove);
|
|
68
|
+
document.removeEventListener("mouseup", onUp);
|
|
69
|
+
document.body.style.cursor = "";
|
|
70
|
+
document.body.style.userSelect = "";
|
|
71
|
+
};
|
|
72
|
+
document.body.style.cursor = "col-resize";
|
|
73
|
+
document.body.style.userSelect = "none";
|
|
74
|
+
document.addEventListener("mousemove", onMove);
|
|
75
|
+
document.addEventListener("mouseup", onUp);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
private handleToggleCollapse = (): void => {
|
|
79
|
+
this.setState((s) => {
|
|
80
|
+
const next = !s.sidebarCollapsed;
|
|
81
|
+
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
|
|
82
|
+
return { sidebarCollapsed: next };
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
override render(): ReactNode {
|
|
87
|
+
const { children } = this.props;
|
|
88
|
+
const { sidebarWidth, sidebarCollapsed, isResizing } = this.state;
|
|
89
|
+
const widthRem = sidebarCollapsed ? 3.5 : sidebarWidth;
|
|
90
|
+
return (
|
|
91
|
+
<div className={cn("flex h-screen min-h-0 overflow-hidden", isResizing && "select-none")}>
|
|
92
|
+
<aside
|
|
93
|
+
className="relative flex flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width,min-width] duration-200 ease-in-out"
|
|
94
|
+
style={{ width: `${widthRem}rem`, minWidth: `${widthRem}rem` }}
|
|
95
|
+
data-testid="app-sidebar"
|
|
96
|
+
>
|
|
97
|
+
<div className="flex h-14 shrink-0 items-center justify-between border-b border-sidebar-border px-4">
|
|
98
|
+
<AppLayoutSidebarBrand collapsed={sidebarCollapsed} />
|
|
99
|
+
<Button
|
|
100
|
+
type="button"
|
|
101
|
+
variant="ghost"
|
|
102
|
+
size="icon-sm"
|
|
103
|
+
className="text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
|
104
|
+
onClick={this.handleToggleCollapse}
|
|
105
|
+
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
106
|
+
data-testid="sidebar-toggle"
|
|
107
|
+
>
|
|
108
|
+
{sidebarCollapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
<nav
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto p-3",
|
|
114
|
+
sidebarCollapsed && "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
115
|
+
)}
|
|
116
|
+
aria-label="Main navigation"
|
|
117
|
+
>
|
|
118
|
+
<AppLayoutNavItems collapsed={sidebarCollapsed} />
|
|
119
|
+
</nav>
|
|
120
|
+
{!sidebarCollapsed && (
|
|
121
|
+
<div
|
|
122
|
+
className={cn(
|
|
123
|
+
"absolute top-0 right-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/30",
|
|
124
|
+
isResizing && "bg-primary/30",
|
|
125
|
+
)}
|
|
126
|
+
onMouseDown={this.handleResizeStart}
|
|
127
|
+
role="separator"
|
|
128
|
+
aria-orientation="vertical"
|
|
129
|
+
aria-label="Resize sidebar"
|
|
130
|
+
data-testid="sidebar-resize-handle"
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</aside>
|
|
134
|
+
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
|
135
|
+
<AppLayoutPageHeader />
|
|
136
|
+
<AppMainContent>{children}</AppMainContent>
|
|
137
|
+
</main>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|