diff --git a/apps/lifeforge--achievements b/apps/lifeforge--achievements new file mode 160000 index 000000000..c793853c9 --- /dev/null +++ b/apps/lifeforge--achievements @@ -0,0 +1 @@ +Subproject commit c793853c98513b2e899de8eff9204373146468cf diff --git a/apps/lifeforge--utility-widgets b/apps/lifeforge--utility-widgets new file mode 160000 index 000000000..76f384efc --- /dev/null +++ b/apps/lifeforge--utility-widgets @@ -0,0 +1 @@ +Subproject commit 76f384efc6cc2f28b370229682106e1885a81a9f diff --git a/apps/lifeforge--wallet b/apps/lifeforge--wallet new file mode 160000 index 000000000..843cd084d --- /dev/null +++ b/apps/lifeforge--wallet @@ -0,0 +1 @@ +Subproject commit 843cd084d9d28bb8660189aeb4c85f887d6a5be1 diff --git a/apps/melvinchia3636--modrinth b/apps/melvinchia3636--modrinth new file mode 160000 index 000000000..b2f6f64a4 --- /dev/null +++ b/apps/melvinchia3636--modrinth @@ -0,0 +1 @@ +Subproject commit b2f6f64a4f241dfd261ff222cf23d1e9b6a8ccb8 diff --git a/bun.lock b/bun.lock index c3c283178..71eca5489 100644 --- a/bun.lock +++ b/bun.lock @@ -88,6 +88,34 @@ "@lifeforge/ui": "workspace:*", }, }, + "apps/lifeforge-module-localIpWidget": { + "name": "@lifeforge/lifeforge--local-ip-widget", + "version": "0.0.1", + "dependencies": { + "@iconify/react": "^6.0.2", + "@lifeforge/shared": "workspace:*", + "@lifeforge/ui": "workspace:*", + "@tanstack/react-query": "^5.90.11", + "@uidotdev/usehooks": "^2.4.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.18", + "react": "^19.2.0", + "react-i18next": "^15.1.1", + "react-toastify": "^11.0.5", + "vite": "^7.1.9", + "zod": "^4.1.12", + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "vite": "^7.1.9", + }, + "peerDependencies": { + "@lifeforge/server-utils": "workspace:*", + "@lifeforge/shared": "workspace:*", + "@lifeforge/ui": "workspace:*", + }, + }, "apps/melvinchia3636--modrinth": { "name": "@lifeforge/melvinchia3636--modrinth", "version": "0.0.5", @@ -740,6 +768,8 @@ "@lifeforge/lifeforge--lang-en": ["@lifeforge/lifeforge--lang-en@workspace:locales/lifeforge--lang-en"], + "@lifeforge/lifeforge--local-ip-widget": ["@lifeforge/lifeforge--local-ip-widget@workspace:apps/lifeforge-module-localIpWidget"], + "@lifeforge/lifeforge--wallet": ["@lifeforge/lifeforge--wallet@workspace:apps/lifeforge--wallet"], "@lifeforge/log": ["@lifeforge/log@workspace:packages/lifeforge-log"], @@ -3826,6 +3856,8 @@ "@lifeforge/client/react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="], + "@lifeforge/lifeforge--local-ip-widget/react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="], + "@lifeforge/log/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@lifeforge/server/@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], diff --git a/client/src/contract.ts b/client/src/contract.ts index 5341520d8..528328417 100644 --- a/client/src/contract.ts +++ b/client/src/contract.ts @@ -2467,6 +2467,9 @@ export const contract = { "name": { "type": "string" }, + "moduleId": { + "type": "string" + }, "displayName": { "type": "string" }, @@ -2500,6 +2503,7 @@ export const contract = { }, "required": [ "name", + "moduleId", "displayName", "version", "description", @@ -2537,6 +2541,9 @@ export const contract = { "name": { "type": "string" }, + "moduleId": { + "type": "string" + }, "displayName": { "type": "string" }, @@ -2586,6 +2593,7 @@ export const contract = { }, "required": [ "name", + "moduleId", "displayName", "version", "description", diff --git a/client/src/federation/loaders/loadModuleConfig.ts b/client/src/federation/loaders/loadModuleConfig.ts index b4871f9b4..fa3648c9f 100644 --- a/client/src/federation/loaders/loadModuleConfig.ts +++ b/client/src/federation/loaders/loadModuleConfig.ts @@ -5,10 +5,12 @@ import { // @ts-expect-error - Virtual federation methods } from 'virtual:__federation__' -import type { - InferOutput, - ModuleCategory, - ModuleConfig +import { + type InferOutput, + type ModuleCategory, + type ModuleConfig, + globalProxyRegistry, + moduleConfigSchema } from '@lifeforge/shared' import forgeAPI from '@/forgeAPI' @@ -87,8 +89,17 @@ export async function loadModuleConfig( unwrapped = await loadFromFederation(mod) } + const validation = moduleConfigSchema.safeParse(unwrapped) + + if (!validation.success) { + throw new Error( + `Module configuration validation failed for ${mod.name}: ${validation.error.message}` + ) + } + const moduleConfig: ModuleCategory['items'][number] = { name: mod.name, + moduleId: mod.moduleId, displayName: mod.displayName, version: mod.version, description: mod.description, @@ -102,7 +113,15 @@ export async function loadModuleConfig( disabled: unwrapped.disabled, clearQueryOnUnmount: unwrapped.clearQueryOnUnmount, APIKeyAccess: mod.APIKeyAccess, - widgets: unwrapped.widgets + widgets: unwrapped.widgets, + contract: unwrapped.contract + } + + if (unwrapped.contract) { + globalProxyRegistry.set(unwrapped.contract, { + moduleId: mod.moduleId, + apiHost: import.meta.env.VITE_API_HOST + }) } return moduleConfig diff --git a/client/src/forgeAPI.tsx b/client/src/forgeAPI.tsx index 9f3c1c5bb..f03dbabf4 100644 --- a/client/src/forgeAPI.tsx +++ b/client/src/forgeAPI.tsx @@ -1,10 +1,13 @@ -import { createForgeProxy } from '@lifeforge/shared' +import { createForgeProxy, globalProxyRegistry } from '@lifeforge/shared' import contract from './contract' -const forgeAPI = createForgeProxy( - contract, - import.meta.env.VITE_API_HOST || 'https://localhost:3000' -) +globalProxyRegistry.set(contract, { + moduleId: '', + apiHost: import.meta.env.VITE_API_HOST || 'https://localhost:3000' +}) + +const forgeAPI = createForgeProxy(contract) export default forgeAPI + diff --git a/client/vite.config.ts b/client/vite.config.ts index f8703a808..f88932320 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -38,7 +38,11 @@ export const alias: Alias[] = [ importer?.includes('/client/') && !importer?.includes('/client/src/') - if (importer?.endsWith('manifest.ts')) { + const isManifest = id === '@/manifest' || id === '@/manifest.ts' + + if (isManifest) { + rootDir = importer?.split('/src/')[0] || '' + } else if (importer?.endsWith('manifest.ts')) { rootDir = importer.replace('manifest.ts', 'src/') } else if (isAppModule) { const clientMatch = importer?.match(/(.+\/client)\//) diff --git a/instructions/ui-guide.md b/instructions/ui-guide.md index 820324bd0..fcd801727 100644 --- a/instructions/ui-guide.md +++ b/instructions/ui-guide.md @@ -632,6 +632,44 @@ interface TransitionProps { --- +### K. Prose + +A rich-text wrapper component that automatically styles standard HTML/Markdown elements cleanly according to the active theme. Useful for rendering documentation, wiki pages, or comments without manual style overrides. + +```typescript +export function Prose({ + className, + style, + children +}: { + className?: string + style?: CSSProperties + children?: ReactNode +}) +``` + +#### Supported Child Element Styling: +- **Headings (`h1` - `h6`):** Automatically styled with appropriate sizing, weights, line-heights, and margins. +- **Lists (`ul`, `ol`, `li`):** Renders margins, paddings, list-style-type (`disc`/`decimal`), and markers styled with the `bg-400` token. +- **Code Blocks & Inline Code (`kbd`, `code`, `pre`):** Automatically styled with clean borders, background colors, custom SFMono fonts, rounded corners, and shadows. +- **Blockquotes:** Rendered with vertical accent borders, custom quotes, margins, and custom text colors matching light/dark modes. +- **Tables:** Fully styled table layouts with borders, headers (`thead`), zebra-striping/row boundaries, and proper cell alignments. +- **Media (`img`, `video`, `picture`):** Automatically centered with `max-width: 100%` and rounded corners. + +#### Example: +```tsx + +

Guide Title

+

Here is some text with bold styling and a link.

+ +
+``` + +--- + ## 5. Composition & Chaining Rules Primitives are designed to be composed together using the `asChild` pattern. This merges the class names, styling resolvers, and custom variables of multiple layers onto a single DOM node. @@ -707,6 +745,214 @@ type ButtonProps = ButtonOwnProps & Since `Button` extends `FlexProps` (which extends `BoxProps`), **any layout prop available on `Flex` or `Box` can be passed directly** — no wrapping `Box` needed. Only reach for `Box asChild` when you need a prop that Flex/Box doesn't support (e.g. CSS properties only available via inline `style`). +#### Notable Engineering Features: +1. **Dynamic Contrast Matching:** In `useButtonStyleProps`, when `variant="primary"` is set, the button fetches the user's active theme color (`derivedThemeColor`) and runs `getMostReadableColor()` to compute a text color with optimal contrast. +2. **Smart i18n Translation:** If the children is a string, it automatically attempts to search for translations across various namespaces (e.g., `buttons.cancel`, `common.buttons:cancel`). +3. **Loading Spinners:** Renders the pre-animated `svg-spinners:ring-resize` icon automatically. + +#### Example: +```tsx + +``` + +--- + +### B. Input Components & Infrastructure + +All raw input components share a unified visual style by extending components from the `inputs/shared` module: +- `InputWrapper`: Creates the surrounding classic/plain background field, error display, and click handlers. +- `InputLabel`: Coordinates labels, required asterisks, and floating positions. +- `InputIcon`: Renders helper icons aligned within field paddings. + +#### Available Inputs in SDK: +- **`TextInput`:** Controlled input box. Supports standard inputs, passwords with custom visibility toggles, and action buttons. +- **`NumberInput` / `CurrencyInput`:** Custom formatted fields for numbers and currencies. +- **`TextAreaInput`:** Multiline text input area. +- **`Switch` / `Checkbox`:** Toggle switches and checkboxes. +- **`FAB`:** Floating Action Button. +- **`ColorInput` / `FileInput` / `IconInput`:** Advanced pickers that open specialized modals (`ColorPickerModal`, `FilePickerModal`, `IconPickerModal`). +- **`TagsInput`:** Multi-tag editor. +- **`LocationInput`:** Address/coordinates selector. +- **`SliderInput`:** Range slider selector. +- **`ListboxInput` / `ComboboxInput`:** Select/combobox dropdowns with search, options selection, and validation styling. +- **`QRCodeScanner`:** Quick QR code input scanner dialog. + +--- + +### C. React Hook Form Integration & FormModal + +LifeForge integrates forms via `react-hook-form` controllers to provide a type-safe form state and automated validation messaging. + +#### 1. FormModal +A dialog wrapper that encapsulates form state management and submission configurations. + +```typescript +type SubmissionConfig = + | { + template: 'create' | 'update' + disabled?: boolean + handler: (data: T) => Promise | unknown + } + | { + label: string + icon: string + disabled?: boolean + handler: (data: T) => Promise | unknown + } +``` + +- **i18n Namespace Context:** Automatically provides a namespace to nested fields via `useNamespace()`. +- **Loading states:** Switches the form layout to a loading spinner during network requests. +- **Auto-Loading Submission Button:** Uses `usePromiseLoading` to display a spinner inside the button during async handler resolution. + +#### 2. Specialized Form Fields +Form Fields (e.g. `TextField`, `CheckboxField`, `ListboxField`, `DateField`, `FileField`, etc.) wrap raw inputs inside `useController`, allowing binding via `control` and `name` props: + +```tsx + { await updateProfile(data) } + }} +> + + + +``` + +#### 3. Zod Default Values Generator (`createDefaultValues`) +Before initializing a form, use `createDefaultValues(schema)` to parse the Zod validation schema and return type-safe, empty defaults: + +```typescript +import { createDefaultValues } from '@lifeforge/ui' +import { z } from 'zod' + +const userSchema = z.object({ + name: z.string(), + age: z.number().default(18), + roles: z.array(z.string()) +}) + +const defaultValues = createDefaultValues(userSchema) +// => { name: '', age: 18, roles: [] } +``` + +--- + +## 7. Overlays, Modals & Context Menus + +### A. Context Menu + +A hover/click dropdown menu built on Radix UI's Dropdown Menu primitives. + +```typescript +interface MenuProps { + children: React.ReactNode + customIcon?: string + buttonComponent?: React.ReactNode + align?: 'start' | 'center' | 'end' + side?: 'top' | 'right' | 'bottom' | 'left' +} +``` + +- **ContextMenuItem:** Represents a single button in the dropdown menu. It is wrapped in `WithDivide` to automatically render separators. It supports `checked` (renders checkmark), `loading` (shows spinner), and `dangerous` (turns red) states. +- **ContextMenuGroup:** Renders sub-sections inside the menu. + +#### Example: +```tsx + + + + + + +``` + +--- + +### B. Modal System + +LifeForge features a robust, portal-based modal stack manager. + +#### 1. ModalProvider & useModalStore +State store context that manages a stack of open modals. It provides hooks to interact programmatically: +- `open(Component, data)`: Pushes a new modal onto the stack. +- `close()`: Closes the topmost active modal. +- `remove(index)`: Destroys a modal after exit animations finish. + +#### 2. ModalManager & ModalWrapper +Renders the portal container at the application root level. Maps over the stack and places items inside a `ModalWrapper`, which manages scaling enter/leave transitions and overlays. + +#### 3. ConfirmationModal +A pre-built confirmation dialog for destructive actions: +- Supports a `confirmationPrompt` string where the user must type a specific word (e.g. "DELETE") to enable the primary button. +- Handles loader spinners during confirmation hooks. + +```tsx +const { open } = useModalStore() + +open(ConfirmationModal, { + title: 'Delete Repository', + description: 'This action cannot be undone. Please type DELETE to confirm.', + confirmationPrompt: 'DELETE', + confirmationButton: 'delete', + onConfirm: async () => { await deleteRepository() } +}) +``` + +--- + +## 8. Layout, Header & Sidebar Infrastructure + +### A. Card + +The basic block container for laying out groups of information. + +- **Inherits FlexProps:** Because `Card` extends `FlexProps`, any layout or alignment property can be passed directly to Card (e.g. `direction="row"`, `gap="md"`, `align="center"`). +- **Interactive Preset:** Passing `isInteractive` automatically sets cursor styles, shadows, transitions, and configures background state presets: + ```typescript + // isInteractive = false + bg={surface.default} // base: 'bg-50', dark: 'bg-900' + + // isInteractive = true + bg={surface.defaultInteractive} // base: 'bg-50', hover: 'bg-100', dark: 'bg-900', darkHover: 'bg-800' + ``` + > [!TIP] > **`Card` is already a `Flex` component.** Because `CardProps` extends `FlexProps`, you can pass `align`, `gap`, `justify`, `direction`, and all other layout props **directly to `Card`** — no need to wrap its children in a `` container. The only prop `Card` adds beyond `Flex` is `isInteractive`. > @@ -728,54 +974,128 @@ Since `Button` extends `FlexProps` (which extends `BoxProps`), **any layout prop > > `Card` defaults to `direction="column"`. Override it with `direction="row"` when you need a horizontal layout. -#### Notable Engineering Features: +--- -1. **Dynamic Contrast Matching:** In `useButtonStyleProps`, when `variant="primary"` is set, the button fetches the user's active theme color (`derivedThemeColor`) and runs `getMostReadableColor()` to compute a text color with optimal contrast. -2. **Smart i18n Translation:** If the children is a string, it automatically attempts to search for translations across various namespaces (e.g., `buttons.cancel`, `common.buttons:cancel`). -3. **Loading Spinners:** Renders the pre-animated `svg-spinners:ring-resize` icon automatically. +### B. Layout Shells -#### Example: - -```tsx - -``` +- **`LayoutWithSidebar`:** A flex row container that arranges sidebar layout columns and main views. +- **`ContentWrapperWithSidebar`:** The scrollable inner content block of the viewport. +- **`ModuleWrapper`:** Sets up `ModuleHeaderStateProvider` and `ModuleSidebarStateProvider` contexts, wraps layouts in scrollbars, and cleans up queries from React Query on unmount. --- -### B. Form Input Infrastructure (`TextInput`) +### C. ModuleHeader -All inputs share a unified visual style by extending components from the `inputs/shared` module: +Renders the top panel header block of a workspace module. -- `InputWrapper`: Creates the surrounding classic/plain background field, error display, and click handlers. -- `InputLabel`: Coordinates labels, required asterisks, and floating positions. -- `InputIcon`: Renders helper icons aligned within field paddings. - -#### Example: Password Input with Visibility Toggles +- **i18n Titles & Descriptions:** Automatically looks up translations based on the module's namespace and title. +- **Item Badges:** Displays `totalItems` (e.g., `(142)`) with clean numeric formatting. +- **Tips & Tricks:** Optional hover-based dropdown that displays contextual advice or instructions using a dropdown trigger. +- **SSO & Header Actions:** Supports custom elements and action buttons on the right header margin. ```tsx -Double click a task to mark it as complete. + }} + actionButton={} /> ``` --- -## 7. Real-world Codebase Walkthrough +### D. Navigation Sidebar + +- **`SidebarWrapper`:** The container drawer that handles sliding animations (`sidebar-opening` / `sidebar-closing`), sizing, and scrollbars. +- **`SidebarItem`:** Represents a clickable navigation node. Supports icons (URL/SVG/Iconify), active strips, amount badges, cancellation triggers, and subsection trees: + ```tsx + + ``` +- **`SidebarTitle` & `SidebarDivider`:** Section headers and dividers. +- **`MainSidebarItem`:** Active-tracking item for routing nodes. + +--- + +## 9. Navigation & Feedback UI + +### A. Navigation Controls + +- **`GoBackButton`:** A chevron plain button saying "Go Back". +- **`Pagination`:** Nav buttons (next/prev) and numeric buttons for table pagination. +- **`Tabs`:** Renders horizontal tab controls with active bottom borders, icons, amounts, and animated text transitions. + +--- + +### B. Feedback Screens + +- **`Alert`:** Custom callouts for notices. Types: `note` (blue), `warning` (yellow), `caution` (orange), `tip` (green), `important` (purple). + ```tsx + This action will permanently delete your records. + ``` +- **`EmptyStateScreen`:** Placeholders for empty search or blank records. +- **`ErrorScreen`:** Centered error messages with reload retry hooks. +- **`LoadingScreen`:** Centers a pre-animated `svg-spinners:ring-resize` loader. +- **`NotFoundScreen`:** Portrayal screen for 404 pages with a bug-reporting link. + +--- + +## 10. Core Utilities & Wrappers + +### A. Helper Wrappers + +- **`APIOnlineStatusWrapper`:** Wraps page routes to verify API availability, showing a connection error screen if the server is offline. +- **`EncryptionWrapper`:** Displays loader screens while E2E encryption initializes. +- **`Tooltip`:** Trigger overlay utilizing `react-tooltip` and matching theme outlines. +- **`PrintArea`:** Formats viewport sections for printing, copying global CSS variable scopes into a `@media print` style block. + +--- + +### B. Data & Query Wrappers + +- **`WithQuery`:** Binds a TanStack Query result. Automatically returns `LoadingScreen` when loading, `ErrorScreen` when failed, or renders children on success: + +```tsx +const query = useQuery(tasksQueryOptions) + +return ( + + {(tasks) => ( + + {tasks.map(t => {t.name})} + + )} + +) +``` + +- **`WithQueryData`:** Simplifies the process by resolving a LifeForge controller query directly: + +```tsx +return ( + + {(tasks) => ( + + {tasks.map(t => {t.name})} + + )} + +) +``` + +--- + +## 11. Real-world Codebase Walkthrough Here is a complete, real-world view page (`UserCreationPage.tsx`) utilizing the UI library. Observe how grid structures, text responsiveness, and inputs are combined without writing standard CSS or inline styles: @@ -861,8 +1181,7 @@ function UserCreationPage() { return ( // 1. Center layout with responsive paddings and full width bounds - // 2. Main title using asChild to combine structural Box with semantic - Heading + // 2. Main title using asChild to combine structural Box with semantic Heading @@ -874,8 +1193,7 @@ function UserCreationPage() { - // 3. Responsive typography heading (scales automatically on - tablet/desktop) + // 3. Responsive typography heading (scales automatically on tablet/desktop) > { try { const mod = await import(modulePath) - const key = modDir.includes('--') - ? modDir.startsWith('lifeforge--') - ? _.camelCase(modDir.split('--')[1]) - : `${modDir.split('--')[0]}$${_.camelCase(modDir.split('--')[1])}` - : _.camelCase(modDir) + const pkgPath = path.join(appsDir, modDir, 'package.json') + if (!fs.existsSync(pkgPath)) { + continue + } + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + const key = generateModuleId(pkg.name) if (!mod.default) { logger.warn(`Module ${modDir} has no default export`) diff --git a/server/src/core/routes/index.ts b/server/src/core/routes/index.ts index 416494f22..b345144b1 100644 --- a/server/src/core/routes/index.ts +++ b/server/src/core/routes/index.ts @@ -41,8 +41,11 @@ const listRoutes = forge .callback(async ({ response }) => response.ok(traceRouteStack(router.stack))) const mainRoutes = forgeRouter({ - ...appRoutes, ...coreRoutes, + modules: forgeRouter({ + ...coreRoutes.modules, + ...appRoutes + }), listRoutes }) diff --git a/server/src/lib/modules/routes/modules.ts b/server/src/lib/modules/routes/modules.ts index 38fab9d47..8ffc8fb91 100644 --- a/server/src/lib/modules/routes/modules.ts +++ b/server/src/lib/modules/routes/modules.ts @@ -5,6 +5,8 @@ import fs from 'fs' import path from 'path' import z from 'zod' +import { generateModuleId } from '@functions/modules/loadModuleRoutes' + import forge from '../forge' import scanFederatedModules, { type ModuleManifestEntry @@ -21,6 +23,7 @@ export const manifest = forge modules: z.array( z.object({ name: z.string(), + moduleId: z.string(), displayName: z.string(), version: z.string(), description: z.string(), @@ -62,6 +65,7 @@ export const manifest = forge export interface InstalledModule { name: string + moduleId: string displayName: string version: string description: string @@ -82,6 +86,7 @@ export const list = forge OK: z.array( z.object({ name: z.string(), + moduleId: z.string(), displayName: z.string(), version: z.string(), description: z.string(), @@ -142,6 +147,7 @@ export const list = forge modules.push({ name: pkg.name, + moduleId: generateModuleId(pkg.name), displayName: pkg.displayName || pkg.name, version: pkg.version || '0.0.0', description: pkg.description || '', diff --git a/server/src/lib/modules/utils/scanFederatedModules.ts b/server/src/lib/modules/utils/scanFederatedModules.ts index 20ec9b12a..249b11a69 100644 --- a/server/src/lib/modules/utils/scanFederatedModules.ts +++ b/server/src/lib/modules/utils/scanFederatedModules.ts @@ -3,11 +3,14 @@ import path from 'path' import { packageJSONSchema } from '@lifeforge/shared' +import { generateModuleId } from '@functions/modules/loadModuleRoutes' + /** * Module manifest entry for federated modules */ export interface ModuleManifestEntry { name: string + moduleId: string displayName: string version: string description: string @@ -83,6 +86,7 @@ export default function scanFederatedModules( modules.push({ name: dir.name, + moduleId: generateModuleId(parsed.data.name), displayName: parsed.data.displayName, version: parsed.data.version, description: parsed.data.description, diff --git a/shared/src/api/core/createForgeModuleClient.ts b/shared/src/api/core/createForgeModuleClient.ts new file mode 100644 index 000000000..9e7076edc --- /dev/null +++ b/shared/src/api/core/createForgeModuleClient.ts @@ -0,0 +1,16 @@ +import type { ModuleConfig } from '../../interfaces/module_config.types' +import type { ProxyTree } from '../typescript/forge_proxy.types' +import createForgeProxy from './createForgeProxy' + +/** + * Wraps a module configuration and automatically appends a type-safe `forgeAPI` proxy client + * resolved dynamically from the module's contract. + */ +export default function createForgeModuleClient( + config: T +): T & { forgeAPI: T['contract'] extends undefined ? undefined : ProxyTree } { + return { + ...config, + forgeAPI: config.contract ? (createForgeProxy(config.contract) as any) : undefined + } as any +} diff --git a/shared/src/api/core/createForgeProxy.ts b/shared/src/api/core/createForgeProxy.ts index f89cda147..e9b3ffbc2 100644 --- a/shared/src/api/core/createForgeProxy.ts +++ b/shared/src/api/core/createForgeProxy.ts @@ -7,6 +7,7 @@ import ForgeEndpoint from './forgeEndpoint' import CORE_HELPERS from './helpers/config' import createCoreHelper from './helpers/createCoreHelper' import createGetMediaHelper from './helpers/getMediaHelper' +import { globalProxyRegistry } from './registry' /** * Creates a type-safe, proxy-based API client that mirrors your route contract structure. @@ -14,10 +15,13 @@ import createGetMediaHelper from './helpers/getMediaHelper' * Traverses the generated routes contract statically at type-level using json-schema-to-ts, * and dynamically at runtime using Proxy traps. */ -export default function createForgeProxy( +export default function createForgeProxy(contract: T): ProxyTree { + return createForgeProxyInternal(contract, []) +} + +function createForgeProxyInternal( contract: T, - apiHost?: string, - path: string[] | string = [] + path: string[] | string ): ProxyTree { const pathArray = Array.isArray(path) ? path : [path] @@ -30,10 +34,13 @@ export default function createForgeProxy( } } + const resolvedHost = (contract && globalProxyRegistry.get(contract)?.apiHost) || '' + const endpoint = new ForgeEndpoint( - apiHost, + resolvedHost, pathArray.join('/'), - currentContract + currentContract, + contract ) return new Proxy(() => {}, { @@ -58,17 +65,19 @@ export default function createForgeProxy( if (prop === 'untyped') { return (url: string) => new ForgeEndpoint>( - apiHost, - url + resolvedHost, + url, + undefined, + contract ) } if (prop === 'getMedia') { - return createGetMediaHelper(apiHost) + return createGetMediaHelper(resolvedHost) } if (prop in CORE_HELPERS) { - return createCoreHelper(apiHost, prop as keyof typeof CORE_HELPERS) + return createCoreHelper(resolvedHost, prop as keyof typeof CORE_HELPERS) } if (prop in endpoint && typeof (endpoint as any)[prop] !== 'undefined') { @@ -77,7 +86,7 @@ export default function createForgeProxy( return typeof value === 'function' ? value.bind(endpoint) : value } - return createForgeProxy(contract, apiHost, [...pathArray, prop as string]) + return createForgeProxyInternal(contract, [...pathArray, prop as string]) }, apply: () => { diff --git a/shared/src/api/core/forgeEndpoint.ts b/shared/src/api/core/forgeEndpoint.ts index c9b105beb..6cb60819a 100644 --- a/shared/src/api/core/forgeEndpoint.ts +++ b/shared/src/api/core/forgeEndpoint.ts @@ -11,6 +11,7 @@ import { import fetchAPI from '../../utils/fetchAPI' import type { InferInput, InferOutput } from '../typescript/forge_proxy.types' import { getFormData, hasFile, joinObjectsRecursively } from './utils' +import { globalProxyRegistry } from './registry' /** * ForgeEndpoint is a chainable API endpoint handler for making type-safe @@ -50,9 +51,24 @@ export default class ForgeEndpoint< constructor( private _apiHost: string = '', private _route: string, - private _contract?: any + private _contract?: any, + private _rootContract?: any ) {} + private get _resolvedConfig() { + const ctx = this._rootContract ? globalProxyRegistry.get(this._rootContract) : null + if (ctx) { + return { + apiHost: ctx.apiHost, + prefix: ctx.moduleId ? `modules/${ctx.moduleId}` : '' + } + } + return { + apiHost: this._apiHost || (typeof window !== 'undefined' ? window.location.origin : ''), + prefix: '' + } + } + /** * Returns Zod schemas derived from the contract's JSON schemas. */ @@ -82,18 +98,19 @@ export default class ForgeEndpoint< * Returns the full endpoint URL (absolute), including query string if present. */ get endpoint() { - const path = this._getPath() + const { apiHost, prefix } = this._resolvedConfig + const path = this._getPath(prefix) // Handle relative URLs (e.g., /api) - if (this._apiHost.startsWith('/')) { + if (apiHost.startsWith('/')) { const origin = typeof window !== 'undefined' ? window.location.origin : '' const normalizedPath = path.startsWith('/') ? path : `/${path}` - return `${origin}${this._apiHost}${normalizedPath}` + return `${origin}${apiHost}${normalizedPath}` } - return new URL(path, this._apiHost).toString() + return new URL(path, apiHost).toString() } /** @@ -222,13 +239,16 @@ export default class ForgeEndpoint< * @returns Promise resolving to the decrypted API response */ async query(): Promise> { + const { apiHost, prefix } = this._resolvedConfig + const path = this._getPath(prefix) + // Create encryption session (generates AES key and encrypts it with server's public key) const { encryptedKey, session } = await createEncryptionSession() // Send GET request with encrypted AES key in header const response = await fetchAPI<{ iv: string; data: string; tag: string }>( - this._apiHost, - this._getPath(), + apiHost, + path, { method: 'GET', headers: { @@ -257,7 +277,9 @@ export default class ForgeEndpoint< raiseError?: boolean isExternal?: boolean }) { - return fetchAPI>(this._apiHost, this._getPath(), { + const { apiHost, prefix } = this._resolvedConfig + const path = this._getPath(prefix) + return fetchAPI>(apiHost, path, { method: 'GET', ...options }) @@ -272,9 +294,11 @@ export default class ForgeEndpoint< * @returns Promise resolving to the API response */ mutateRaw(data: InferInput['body'] | FormData) { + const { apiHost, prefix } = this._resolvedConfig + const path = this._getPath(prefix) const payloadData = data === undefined ? {} : data - return fetchAPI>(this._apiHost, this._getPath(), { + return fetchAPI>(apiHost, path, { method: 'POST', body: payloadData instanceof FormData @@ -323,6 +347,8 @@ export default class ForgeEndpoint< * ``` */ async mutate(data: InferInput['body']): Promise> { + const { apiHost, prefix } = this._resolvedConfig + const path = this._getPath(prefix) const payloadData = data === undefined ? {} : data // If data contains files, fall back to raw mode (server also disables encryption for media endpoints) @@ -339,8 +365,8 @@ export default class ForgeEndpoint< // Send encrypted payload const response = await fetchAPI<{ iv: string; data: string; tag: string }>( - this._apiHost, - this._getPath(), + apiHost, + path, { method: 'POST', body: payload as unknown as Record @@ -359,8 +385,8 @@ export default class ForgeEndpoint< /** * Constructs the endpoint path with query parameters if present. */ - protected _getPath() { - let endpoint = `${this._route}` + protected _getPath(prefix?: string) { + let endpoint = prefix ? `${prefix}/${this._route}` : `${this._route}` if (this._input) { const queryString = Object.entries(this._input) diff --git a/shared/src/api/core/registry.ts b/shared/src/api/core/registry.ts new file mode 100644 index 000000000..53bfea1dc --- /dev/null +++ b/shared/src/api/core/registry.ts @@ -0,0 +1,6 @@ +export interface ForgeProxyContextValue { + moduleId: string + apiHost: string +} + +export const globalProxyRegistry = new WeakMap() diff --git a/shared/src/index.ts b/shared/src/index.ts index afb7bde2d..b367f1632 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -62,6 +62,7 @@ export { packageJSONSchema } from './interfaces/module_config.types' // Forge API client and types export { default as createForgeProxy } from './api/core/createForgeProxy' +export { default as createForgeModuleClient } from './api/core/createForgeModuleClient' export { default as ForgeEndpoint } from './api/core/forgeEndpoint' export type { ProxyTree, @@ -78,6 +79,8 @@ export type { ModuleConfig, ModuleCategory } from './interfaces/module_config.types' +export { moduleConfigSchema } from './interfaces/module_config.types' export type { default as WidgetConfig } from './interfaces/widget_config.types' export { widgetConfigSchema } from './interfaces/widget_config.types' +export { globalProxyRegistry } from './api/core/registry' export { SYSTEM_CATEGORIES } from './providers/FederationProvider' diff --git a/shared/src/interfaces/module_config.types.ts b/shared/src/interfaces/module_config.types.ts index a291a033d..0465085da 100644 --- a/shared/src/interfaces/module_config.types.ts +++ b/shared/src/interfaces/module_config.types.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import z from 'zod' import type WidgetConfig from './widget_config.types' @@ -13,12 +14,46 @@ export interface ModuleConfig { hidden?: boolean disabled?: boolean | (() => Promise) clearQueryOnUnmount?: boolean + contract?: any widgets?: (() => Promise<{ default: React.ComponentType config: WidgetConfig }>)[] } +export const moduleConfigSchema: z.ZodType = z.object({ + provider: z.any().optional(), + routes: z.record(z.string(), z.any()), + subsection: z + .array( + z.object({ + label: z.string(), + icon: z.string(), + path: z.string() + }) + ) + .optional(), + hidden: z.boolean().optional(), + disabled: z + .union([ + z.boolean(), + z.custom<() => Promise>(val => typeof val === 'function') + ]) + .optional(), + clearQueryOnUnmount: z.boolean().optional(), + contract: z.any().optional(), + widgets: z + .array( + z.custom< + () => Promise<{ + default: React.ComponentType + config: WidgetConfig + }> + >(val => typeof val === 'function') + ) + .optional() +}) + export const packageJSONSchema = z.object({ name: z.string(), displayName: z.string(), @@ -51,6 +86,7 @@ export interface ModuleCategory { title: string items: (ModuleConfig & { name: string + moduleId: string displayName: string version: string author: string