diff --git a/.cursor/rules/ts-code-migration.mdc b/.cursor/rules/ts-code-migration.mdc new file mode 100644 index 000000000..6651775d7 --- /dev/null +++ b/.cursor/rules/ts-code-migration.mdc @@ -0,0 +1,491 @@ +--- +description: +globs: +alwaysApply: false +--- +# Baseplate TypeScript Generator Migration Guide + +This guide provides detailed instructions for migrating from the old TypeScript generator system to the new fragment-based approach. The new system offers improved composability, type safety, and maintainability. + +## Core Conceptual Changes + +The migration represents a shift from imperative, mutable operations to declarative, functional composition: + +| Old System | New System | +|------------|------------| +| `TypescriptCodeBlock` | `TsCodeFragment` | +| String-based imports | `tsImportBuilder` | +| Custom provider interfaces | Config providers with FieldMaps | +| Manual template rendering | Standardized template system | +| Imperative modifications | Declarative compositions | + +## 1. Migrating Provider Types + +### Old Approach +```typescript +// Define custom provider interface +export interface ReactSentryProvider { + addSentryScopeAction(block: TypescriptCodeBlock): void; +} + +// Create provider type +export const reactSentryProvider = createProviderType('react-sentry'); + +// Provider implementation in task +providers: { + reactSentry: { + addSentryScopeAction(block) { + sentryFile.addCodeBlock('SENTRY_SCOPE_ACTIONS', block); + } + } +} +``` + +### New Approach with Config Providers +```typescript +// Create a config provider task with a FieldMap +const [setupTask, reactSentryConfigProvider, reactSentryConfigValuesProvider] = + createConfigProviderTask( + (t) => ({ + sentryScopeActions: t.map(), + }), + { + prefix: 'react-sentry', + configScope: projectScope, + } + ); + +// Export the provider +export { reactSentryConfigProvider }; + +// In a consumer task +reactSentryConfig.sentryScopeActions.set('key', fragment); +``` + +### Migration Rules for Providers + +1. Identify the provider's core functionality: + - For collections, use `t.map()`, `t.array()`, etc. + - For single values, use `t.scalar(defaultValue)` + - For objects, use `t.object(defaultValue)` + +2. Replace provider methods with FieldMap operations: + - `add*()` methods → `array.push()` or `map.set()` + - `set*()` methods → `scalar.set()` or `map.set()` + - `get*()` methods → Access from `configValuesProvider` + +3. Structure your task: + - Export the `setupTask` from `createConfigProviderTask` + - Export the config provider (for mutation) + - Export the values provider (for reading) + +## 2. Migrating Code Fragments + +### Old Approach +```typescript +const headerBlock = TypescriptCodeUtils.createBlock( + `function parseData() { + // code + }`, + "import { DataType } from '@/types'" +); +``` + +### New Approach +```typescript +const headerFragment = tsCodeFragment( + `function parseData() { + // code + }`, + tsImportBuilder(['DataType']).from('@/types') +); +``` + +### Advanced Fragment With Hoisted Content +```typescript +// New approach with hoisted fragments +const fragment = tsCodeFragment( + `mainFunction() { + // Main implementation + }`, + tsImportBuilder(['utility']).from('@/utils'), + { + hoistedFragments: [ + tsHoistedFragment( + helperFragment, + 'unique-helper-key', + 'afterImports' + ) + ] + } +); +``` + +### Using TsCodeUtils + +The `TsCodeUtils` object provides utility functions for working with code fragments: + +```typescript +// Merge multiple fragments +const merged = TsCodeUtils.mergeFragments( + fragmentsMap, + '\n\n' // separator +); + +// Format as template +const formatted = TsCodeUtils.formatFragment( + 'function NAME(PARAMS) {\n BODY\n}', + { + NAME: 'processUser', + PARAMS: 'user: User', + BODY: bodyFragment + } +); + +// Create object literal +const options = TsCodeUtils.mergeFragmentsAsObject({ + timeout: '5000', + formatter: formatterFragment, + logging: 'true' +}); + +// Template literal style +const result = TsCodeUtils.template` + export function handler() { + ${logicFragment} + return ${resultFragment}; + } +`; +``` + +## 3. Migrating Import Declarations + +### Old Approach +```typescript +"import { GraphQLError } from 'graphql'" +"import React, { useState, useEffect } from 'react'" +``` + +### New Approach +```typescript +// Named imports +tsImportBuilder(['GraphQLError']).from('graphql') + +// Multiple named imports +tsImportBuilder() + .named('useState') + .named('useEffect') + .from('react') + +// Default import +tsImportBuilder() + .default('React') + .from('react') + +// Combined default and named imports +tsImportBuilder() + .default('React') + .named('useState') + .named('useEffect') + .from('react') + +// With alias +tsImportBuilder() + .named('createElement', 'h') + .from('react') + +// Type-only imports +tsImportBuilder() + .typeOnly() + .named('UserType') + .from('@/types') +``` + +## 4. Migrating Template Rendering + +### Old Approach +```typescript +const sentryFile = typescript.createTemplate( + { + SENTRY_SCOPE_ACTIONS: { + type: 'code-block', + }, + }, + { importMappers: [reactConfig] } +); + +// Add content to the template +sentryFile.addCodeBlock('SENTRY_SCOPE_ACTIONS', block); + +// Render the template +sentryFile.renderToAction('sentry.ts', sentryPath) +``` + +### New Approach +First, create a template definition file (usually in `generated/ts-templates.ts`): + +```typescript +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +const sentry = createTsTemplateFile({ + importMapProviders: { reactConfigImports: reactConfigImportsProvider }, + name: 'sentry', + source: { path: 'sentry.ts' }, + variables: { TPL_SENTRY_SCOPE_ACTIONS: {} }, +}); + +export const CORE_REACT_SENTRY_TS_TEMPLATES = { sentry }; +``` + +Then use the template: + +```typescript +// Render the template +await builder.apply( + typescriptFile.renderTemplateFile({ + template: CORE_REACT_SENTRY_TS_TEMPLATES.sentry, + destination: sentryPath, + importMapProviders: { + reactConfigImports, + }, + variables: { + TPL_SENTRY_SCOPE_ACTIONS: TsCodeUtils.mergeFragments(sentryScopeActions), + }, + }) +); +``` + +The template file should use `TPL_` prefix for variables: + +```typescript +// In the template file +TPL_SENTRY_SCOPE_ACTIONS; +``` + +## 5. Refactoring Tasks + +### Old Task Structure +```typescript +createGeneratorTask({ + dependencies: { + typescript: typescriptProvider, + reactConfig: reactConfigProvider, + }, + exports: { + reactSentry: reactSentryProvider.export(projectScope), + }, + run({ typescript, reactConfig }) { + // Initialize state + const sentryFile = typescript.createTemplate(...); + + return { + providers: { + reactSentry: { + addSentryScopeAction(block) { + sentryFile.addCodeBlock('SENTRY_SCOPE_ACTIONS', block); + }, + }, + }, + build: async (builder) => { + await builder.apply( + sentryFile.renderToAction('sentry.ts', sentryPath), + ); + }, + }; + }, +}); +``` + +### New Task Structure +```typescript +// Config setup task +const [setupTask, reactSentryConfigProvider, reactSentryConfigValuesProvider] = + createConfigProviderTask( + (t) => ({ + sentryScopeActions: t.map(), + }), + { + prefix: 'react-sentry', + configScope: projectScope, + } + ); + +// Main task +createGeneratorTask({ + dependencies: { + typescriptFile: typescriptFileProvider, + reactConfigImports: reactConfigImportsProvider, + reactSentryConfigValues: reactSentryConfigValuesProvider, + }, + run({ + typescriptFile, + reactConfigImports, + reactSentryConfigValues: { sentryScopeActions }, + }) { + return { + build: async (builder) => { + await builder.apply( + typescriptFile.renderTemplateFile({ + template: CORE_REACT_SENTRY_TS_TEMPLATES.sentry, + destination: sentryPath, + importMapProviders: { + reactConfigImports, + }, + variables: { + TPL_SENTRY_SCOPE_ACTIONS: TsCodeUtils.mergeFragments(sentryScopeActions), + }, + }), + ); + }, + }; + }, +}); +``` + +## 6. How to Choose Container Types in FieldMaps + +When creating config providers, select the appropriate container type based on the data structure: + +| Container Type | Use Case | Example | +|---------------|----------|---------| +| `t.scalar()` | Single immutable value | `port: t.number(3000)` | +| `t.array()` | Collection of items | `tags: t.array(['default'])` | +| `t.map()` | Key-value pairs | `routes: t.map()` | +| `t.object()` | Complex object | `settings: t.object({ timeout: 5000 })` | +| `t.mapOfMaps()` | Nested maps | `permissions: t.mapOfMaps()` | + +## 7. Path Handling + +### Old Approach +```typescript +const [sentryImport, sentryPath] = makeImportAndFilePath( + 'src/services/sentry.ts', +); +``` + +### New Approach +```typescript +const sentryPath = '@/src/services/sentry.ts'; +``` + +## TsCodeUtils Functions Reference + +The `TsCodeUtils` object provides a comprehensive set of utility functions to simplify working with TypeScript code fragments: + +| Function | Description | Example | +|----------|-------------|---------| +| `frag()` | Create a code fragment from a string | `TsCodeUtils.frag('const x = 1;')` | +| `importBuilder()` | Create an import builder | `TsCodeUtils.importBuilder(['User'])` | +| `importFragment()` | Create a fragment that imports from a module | `TsCodeUtils.importFragment('User', '@/types')` | +| `mergeFragments()` | Merge multiple fragments (from Map) | `TsCodeUtils.mergeFragments(fragmentsMap)` | +| `mergeFragmentsPresorted()` | Merge array of fragments | `TsCodeUtils.mergeFragmentsPresorted([f1, f2])` | +| `formatAsComment()` | Format text as a comment | `TsCodeUtils.formatAsComment('Note')` | +| `formatFragment()` | Create fragment with placeholders | `TsCodeUtils.formatFragment('func NAME() { BODY }', {...})` | +| `mergeFragmentsAsObject()` | Create object literal | `TsCodeUtils.mergeFragmentsAsObject({ prop: value })` | +| `mergeFragmentsAsInterfaceContent()` | Create interface properties | `TsCodeUtils.mergeFragmentsAsInterfaceContent({...})` | +| `mergeFragmentsAsArray()` | Create array literal | `TsCodeUtils.mergeFragmentsAsArray({...})` | +| `wrapFragment()` | Wrap fragment with template | `TsCodeUtils.wrapFragment(frag, 'try { CONTENTS }')` | +| `template` | Template literal function | ``TsCodeUtils.template`export const x = ${frag};` ``| +| `templateWithImports()` | Template with imports | `TsCodeUtils.templateWithImports(imports)`` `` | +| `mergeFragmentsAsJsxElement()` | Create JSX element | `TsCodeUtils.mergeFragmentsAsJsxElement('div', {...})` | + +### Special Format Options for mergeFragmentsAsObject + +- **Object Shorthand**: If the key matches the value, it becomes a shorthand property + ```typescript + // { value: 'value' } becomes { value, } + ``` + +- **Function Shorthand**: Functions are automatically converted to method syntax + ```typescript + // { method: 'function method() {}' } becomes { method() {}, } + ``` + +- **Spread Operator**: Keys starting with '...' are treated as spreads + ```typescript + // { '...base': baseFragment } becomes { ...base, } + ``` + +## Template Files Example + +The new template system involves three key parts: + +### 1. Template Source File +```typescript +// auth.plugin.ts (template source) +import type { AuthContext } from '%authContextImports'; +import type { UserSessionService } from '%userSessionTypesImports'; + +import { createAuthContextFromSessionInfo } from '%authContextImports'; +import { userSessionService } from '%userSessionServiceImports'; +import { requestContext } from '@fastify/request-context'; +import fp from 'fastify-plugin'; + +// ... plugin implementation ... +``` + +### 2. Template Definition File +```typescript +// generated/ts-templates.ts +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +import { userSessionServiceImportsProvider } from '../../_providers/user-session.js'; +import { authContextImportsProvider } from '../../auth-context/generated/ts-import-maps.js'; +import { userSessionTypesImportsProvider } from '../../user-session-types/generated/ts-import-maps.js'; + +const authPlugin = createTsTemplateFile({ + importMapProviders: { + authContextImports: authContextImportsProvider, + userSessionServiceImports: userSessionServiceImportsProvider, + userSessionTypesImports: userSessionTypesImportsProvider, + }, + name: 'auth-plugin', + projectExports: {}, + source: { path: 'auth.plugin.ts' }, + variables: {}, +}); + +export const AUTH_AUTH_PLUGIN_TS_TEMPLATES = { authPlugin }; +``` + +### 3. Template Usage in Generator +```typescript +await builder.apply( + typescriptFile.renderTemplateFile({ + template: AUTH_AUTH_PLUGIN_TS_TEMPLATES.authPlugin, + destination: authPluginPath, + importMapProviders: { + authContextImports, + userSessionServiceImports, + userSessionTypesImports, + }, + }) +); +``` + +## Common Pitfalls & Solutions + +1. **Missing Hoisted Fragments**: If you have helper functions that need to be accessible throughout the file, use `tsHoistedFragment` with a unique key instead of `headerBlocks`. + +2. **Import Order Issues**: Let the system handle import sorting by using `tsImportBuilder` consistently. + +3. **Template Variables**: Always use the `TPL_` prefix for variables in templates. + +4. **Provider Method Conversion**: Map imperative method calls to FieldMap operations: + - `.add*()` → `.push()` or `.set()` + - `.register*()` → `.set()` + - `.get*()` → use the values provider + +5. **Fragment Collection Merging**: Always use `TsCodeUtils.mergeFragments()` when combining multiple fragments. + +## Step-by-Step Migration Process + +1. **Identify Provider Patterns**: Analyze how your custom providers are used +2. **Create Config Provider Tasks**: Replace custom providers with FieldMap-based config providers +3. **Update Code Fragments**: Convert all `TypescriptCodeBlock` to `tsCodeFragment` +4. **Update Import Statements**: Replace string imports with `tsImportBuilder` +5. **Refactor Templates**: Create template definition files and update rendering logic +6. **Update Path Handling**: Use direct path strings instead of utility functions +7. **Test Generated Code**: Verify the output maintains the same functionality + +By following this guide, you should be able to successfully migrate your TypeScript generators to the new system while maintaining or improving functionality. \ No newline at end of file diff --git a/package.json b/package.json index 357d7a632..b387c0be8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "knip": "knip", "lint": "turbo run lint --continue", "lint:affected": "turbo run lint --affected --continue", + "lint:only": "pnpm run -r lint", "meta:test": "meta-updater --test", "meta:update": "meta-updater", "precheck": "turbo run lint prettier:check typecheck --output-logs=errors-only --affected --continue && pnpm run knip", diff --git a/packages/core-generators/src/generators/node/eslint/generate-config.ts b/packages/core-generators/src/generators/node/eslint/generate-config.ts index 0968890f5..a4b32404f 100644 --- a/packages/core-generators/src/generators/node/eslint/generate-config.ts +++ b/packages/core-generators/src/generators/node/eslint/generate-config.ts @@ -75,16 +75,16 @@ export function generateConfig({ // we should prefer logger over console 'no-console': 'error', // // ensure we alphabetize imports for easier reading - // 'import/order': [ - // 'error', - // { - // pathGroups: [ - // { pattern: 'src/**', group: 'external', position: 'after' }, - // { pattern: '@src/**', group: 'external', position: 'after' }, - // ], - // alphabetize: { order: 'asc', caseInsensitive: true }, - // }, - // ], + 'import/order': [ + 'error', + { + pathGroups: [ + { pattern: 'src/**', group: 'external', position: 'after' }, + { pattern: '@src/**', group: 'external', position: 'after' }, + ], + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], // ensure we don't have devDependencies imported in production code 'import/no-extraneous-dependencies': [ 'error', diff --git a/packages/core-generators/src/generators/node/typescript/typescript.generator.ts b/packages/core-generators/src/generators/node/typescript/typescript.generator.ts index 32bb51d2d..94ab4997c 100644 --- a/packages/core-generators/src/generators/node/typescript/typescript.generator.ts +++ b/packages/core-generators/src/generators/node/typescript/typescript.generator.ts @@ -340,6 +340,19 @@ export const typescriptGenerator = createGenerator({ }); } + const sharedRenderOptions = { + importSortOptions: { + groups: [ + 'builtin', + 'external', + 'internal', + ['parent', 'sibling'], + 'index', + ] as const, + internalPatterns, + }, + }; + function renderTemplateFile( payload: RenderTsTemplateFileActionInput, ): BuilderAction { @@ -352,9 +365,7 @@ export const typescriptGenerator = createGenerator({ resolveModule(moduleSpecifier) { return resolveModuleSpecifier(moduleSpecifier, directory); }, - importSortOptions: { - internalPatterns, - }, + ...sharedRenderOptions, }, }); } @@ -379,9 +390,7 @@ export const typescriptGenerator = createGenerator({ sourceDirectory, ); }, - importSortOptions: { - internalPatterns, - }, + ...sharedRenderOptions, }, }), }, diff --git a/packages/core-generators/src/renderers/typescript/imports/sort-imports/sort-import-declarations.ts b/packages/core-generators/src/renderers/typescript/imports/sort-imports/sort-import-declarations.ts index 3ca425f81..e51924481 100644 --- a/packages/core-generators/src/renderers/typescript/imports/sort-imports/sort-import-declarations.ts +++ b/packages/core-generators/src/renderers/typescript/imports/sort-imports/sort-import-declarations.ts @@ -14,7 +14,7 @@ export interface SortImportDeclarationsOptions { * Groups to sort imports by. Each inner array represents imports * that should be grouped together. */ - groups: (ImportSortGroup | ImportSortGroup[])[]; + groups: readonly (ImportSortGroup | readonly ImportSortGroup[])[]; /** * Whether to ignore case when sorting imports * diff --git a/packages/core-generators/src/renderers/typescript/ts-code-utils.ts b/packages/core-generators/src/renderers/typescript/ts-code-utils.ts index eb54669b3..df0c253e8 100644 --- a/packages/core-generators/src/renderers/typescript/ts-code-utils.ts +++ b/packages/core-generators/src/renderers/typescript/ts-code-utils.ts @@ -262,6 +262,27 @@ export const TsCodeUtils = { }; }, + /** + * Merge an array of code fragments into an array literal. + * + * @param fragments - The code fragments to merge. + * @returns The merged code fragment as an array literal. + */ + mergeFragmentsAsArrayPresorted( + fragments: (string | TsCodeFragment)[], + ): TsCodeFragment { + return { + contents: `[${fragments + .map((fragment) => + typeof fragment === 'string' ? fragment : fragment.contents, + ) + .join(',\n')}]`, + ...mergeFragmentImportsAndHoistedFragments( + fragments.filter(isTsCodeFragment), + ), + }; + }, + /** * Merge a map of code fragments into an array literal. The fragments are sorted by key * to ensure deterministic output. @@ -277,18 +298,9 @@ export const TsCodeUtils = { const map = objOrMap instanceof Map ? objOrMap : new Map(Object.entries(objOrMap)); const sortedFragmentEntries = sortBy([...map.entries()], [([key]) => key]); - return { - contents: `[${sortedFragmentEntries - .map(([, fragment]) => - typeof fragment === 'string' ? fragment : fragment.contents, - ) - .join(',\n')}]`, - ...mergeFragmentImportsAndHoistedFragments( - sortedFragmentEntries - .map(([, fragment]) => fragment) - .filter(isTsCodeFragment), - ), - }; + return this.mergeFragmentsAsArrayPresorted( + sortedFragmentEntries.map(([, fragment]) => fragment), + ); }, /** diff --git a/packages/project-builder-lib/src/plugins/imports/loader.ts b/packages/project-builder-lib/src/plugins/imports/loader.ts index 17b281eb5..3a647723e 100644 --- a/packages/project-builder-lib/src/plugins/imports/loader.ts +++ b/packages/project-builder-lib/src/plugins/imports/loader.ts @@ -1,4 +1,4 @@ -import { toposort } from '@halfdomelabs/utils'; +import { toposortOrdered } from '@halfdomelabs/utils'; import { keyBy, mapValues } from 'es-toolkit'; import { stripUndefinedValues } from '@src/utils/strip.js'; @@ -85,7 +85,7 @@ export function getOrderedPluginModuleInitializationSteps( const nodes = pluginModules.map((p) => p.id); - return toposort(nodes, edges); + return toposortOrdered(nodes, edges); } export function initializeOrderedPluginModules( diff --git a/packages/project-builder-server/src/compiler/admin/index.ts b/packages/project-builder-server/src/compiler/admin/index.ts index d9ba8e203..a3371da0e 100644 --- a/packages/project-builder-server/src/compiler/admin/index.ts +++ b/packages/project-builder-server/src/compiler/admin/index.ts @@ -83,7 +83,7 @@ function buildAdmin(builder: AdminAppEntryBuilder): GeneratorBundle { }) : undefined, routes: [ - compileAuthPages(builder, appConfig.allowedRoles), + compileAuthPages(builder), ...compileAdminFeatures(builder), ], }, diff --git a/packages/project-builder-server/src/compiler/lib/web-auth.ts b/packages/project-builder-server/src/compiler/lib/web-auth.ts index 901e4eaee..8f2193a7f 100644 --- a/packages/project-builder-server/src/compiler/lib/web-auth.ts +++ b/packages/project-builder-server/src/compiler/lib/web-auth.ts @@ -9,14 +9,7 @@ import { auth0CallbackGenerator, auth0ComponentsGenerator, auth0HooksGenerator, - authApolloGenerator, - authComponentsGenerator, - authHooksGenerator, authIdentifyGenerator, - authLayoutGenerator, - authLoginPageGenerator, - authPagesGenerator, - authServiceGenerator, reactAuth0Generator, reactRoutesGenerator, } from '@halfdomelabs/react-generators'; @@ -29,56 +22,31 @@ export function compileAuthFeatures( if (builder.appConfig.type === 'web' && !builder.appConfig.includeAuth) { return undefined; } - if (builder.projectDefinition.auth?.useAuth0) { - return { - auth: reactAuth0Generator({ - callbackPath: 'auth/auth0-callback', - }), - authHooks: auth0HooksGenerator({}), - authIdentify: authIdentifyGenerator({}), - auth0Apollo: auth0ApolloGenerator({}), - auth0Components: auth0ComponentsGenerator({}), - }; + if (!builder.projectDefinition.auth?.useAuth0) { + throw new Error('Auth0 is not enabled'); } return { - authService: authServiceGenerator({}), - authHooks: authHooksGenerator({}), - authIdentify: authIdentifyGenerator({}), - authApollo: authApolloGenerator({}), - authComponents: authComponentsGenerator({ - loginPath: '/auth/login', + auth: reactAuth0Generator({ + callbackPath: 'auth/auth0-callback', }), + authHooks: auth0HooksGenerator({}), + authIdentify: authIdentifyGenerator({}), + auth0Apollo: auth0ApolloGenerator({}), + auth0Components: auth0ComponentsGenerator({}), }; } export function compileAuthPages( builder: AppEntryBuilder, - allowedRoles: string[] = [], ): GeneratorBundle { - if (builder.projectDefinition.auth?.useAuth0) { - return reactRoutesGenerator({ - id: 'auth', - name: 'auth', - children: { - auth: auth0CallbackGenerator({}), - }, - }); + if (!builder.projectDefinition.auth?.useAuth0) { + throw new Error('Auth0 is not enabled'); } - return reactRoutesGenerator({ id: 'auth', name: 'auth', children: { - auth: authPagesGenerator({ - children: { - layout: authLayoutGenerator({ - name: 'AuthLayout', - }), - login: authLoginPageGenerator({ - allowedRoles: allowedRoles.map((r) => builder.nameFromId(r)), - }), - }, - }), + auth: auth0CallbackGenerator({}), }, }); } diff --git a/packages/project-builder-server/src/compiler/web/index.ts b/packages/project-builder-server/src/compiler/web/index.ts index 4b5f25b1a..ed687bdcf 100644 --- a/packages/project-builder-server/src/compiler/web/index.ts +++ b/packages/project-builder-server/src/compiler/web/index.ts @@ -42,7 +42,7 @@ function buildReact(builder: AppEntryBuilder): GeneratorBundle { children: { reactNotFoundHandler: reactNotFoundHandlerGenerator({}), auth: builder.appConfig.includeAuth - ? compileAuthPages(builder, appConfig.allowedRoles) + ? compileAuthPages(builder) : undefined, }, }), diff --git a/packages/project-builder-web/src/app/ProjectSyncModal/ProjectSyncModal.tsx b/packages/project-builder-web/src/app/ProjectSyncModal/ProjectSyncModal.tsx index 36a7c15d9..1d96ec1e6 100644 --- a/packages/project-builder-web/src/app/ProjectSyncModal/ProjectSyncModal.tsx +++ b/packages/project-builder-web/src/app/ProjectSyncModal/ProjectSyncModal.tsx @@ -13,7 +13,7 @@ import { Console } from '@src/components'; import { useProjects } from '@src/hooks/useProjects'; import { useSyncMetadata } from '@src/hooks/useSyncMetadata'; import { cancelSync, startSync } from '@src/services/api'; -import { formatError } from '@src/services/error-formatter'; +import { formatError, logAndFormatError } from '@src/services/error-formatter'; import { PackageSyncStatus } from './PackageSyncStatus'; @@ -48,7 +48,7 @@ function ProjectSyncModal({ className }: Props): React.JSX.Element { } startSync(currentProjectId).catch((error: unknown) => - toast.error(formatError(error)), + toast.error(logAndFormatError(error)), ); setIsOpen(true); }; diff --git a/packages/react-generators/src/generators/admin/admin-bull-board/admin-bull-board.generator.ts b/packages/react-generators/src/generators/admin/admin-bull-board/admin-bull-board.generator.ts index 5e2bddc9e..3aeace9f3 100644 --- a/packages/react-generators/src/generators/admin/admin-bull-board/admin-bull-board.generator.ts +++ b/packages/react-generators/src/generators/admin/admin-bull-board/admin-bull-board.generator.ts @@ -1,3 +1,5 @@ +import type { ImportMapper } from '@halfdomelabs/core-generators'; + import { projectScope, tsCodeFragment, @@ -7,13 +9,17 @@ import { import { createGenerator, createGeneratorTask, + createProviderTask, createProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; import { reactApolloProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; -import { reactConfigProvider } from '@src/generators/core/react-config/react-config.generator.js'; +import { + reactConfigImportsProvider, + reactConfigProvider, +} from '@src/generators/core/react-config/react-config.generator.js'; import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; import { reactRoutesProvider } from '@src/providers/routes.js'; @@ -31,11 +37,18 @@ export const adminBullBoardGenerator = createGenerator({ generatorFileUrl: import.meta.url, descriptorSchema, buildTasks: ({ bullBoardUrl }) => ({ + reactConfig: createProviderTask(reactConfigProvider, (reactConfig) => { + reactConfig.configEntries.set('VITE_BULL_BOARD_BASE', { + comment: 'Base path for bull-board site', + validator: 'z.string().min(1)', + devDefaultValue: bullBoardUrl, + }); + }), main: createGeneratorTask({ dependencies: { typescript: typescriptProvider, reactComponents: reactComponentsProvider, - reactConfig: reactConfigProvider, + reactConfigImports: reactConfigImportsProvider, reactError: reactErrorProvider, reactApollo: reactApolloProvider, reactRoutes: reactRoutesProvider, @@ -46,7 +59,7 @@ export const adminBullBoardGenerator = createGenerator({ run({ typescript, reactComponents, - reactConfig, + reactConfigImports, reactError, reactApollo, reactRoutes, @@ -58,9 +71,16 @@ export const adminBullBoardGenerator = createGenerator({ adminBullBoard: {}, }, build: async (builder) => { - const importMappers = [ + const importMappers: ImportMapper[] = [ reactComponents, - reactConfig, + { + getImportMap: () => ({ + '%react-config': { + path: reactConfigImports.config.source, + allowedImports: ['config'], + }, + }), + }, reactError, reactApollo, ]; @@ -77,12 +97,6 @@ export const adminBullBoardGenerator = createGenerator({ ), }); - reactConfig.configEntries.set('VITE_BULL_BOARD_BASE', { - comment: 'Base path for bull-board site', - validator: 'z.string().min(1)', - devDefaultValue: bullBoardUrl, - }); - await builder.apply( typescript.createCopyFilesAction({ paths: ['index.tsx', 'bull-board.gql'], diff --git a/packages/react-generators/src/generators/admin/admin-home/admin-home.generator.ts b/packages/react-generators/src/generators/admin/admin-home/admin-home.generator.ts index 49914d0b3..9f41ce33d 100644 --- a/packages/react-generators/src/generators/admin/admin-home/admin-home.generator.ts +++ b/packages/react-generators/src/generators/admin/admin-home/admin-home.generator.ts @@ -10,7 +10,7 @@ import { } from '@halfdomelabs/sync'; import { z } from 'zod'; -import { authHooksProvider } from '@src/generators/auth/auth-hooks/auth-hooks.generator.js'; +import { authHooksProvider } from '@src/generators/auth/_providers/auth-hooks.js'; import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; import { reactRoutesProvider } from '@src/providers/routes.js'; import { createRouteElement } from '@src/utils/routes.js'; diff --git a/packages/react-generators/src/generators/admin/admin-layout/admin-layout.generator.ts b/packages/react-generators/src/generators/admin/admin-layout/admin-layout.generator.ts index 1032828af..60c3a193e 100644 --- a/packages/react-generators/src/generators/admin/admin-layout/admin-layout.generator.ts +++ b/packages/react-generators/src/generators/admin/admin-layout/admin-layout.generator.ts @@ -14,8 +14,8 @@ import { import { quot } from '@halfdomelabs/utils'; import { z } from 'zod'; -import { authComponentsProvider } from '@src/generators/auth/auth-components/auth-components.generator.js'; -import { authHooksProvider } from '@src/generators/auth/auth-hooks/auth-hooks.generator.js'; +import { authComponentsProvider } from '@src/generators/auth/_providers/auth-components.js'; +import { authHooksProvider } from '@src/generators/auth/_providers/auth-hooks.js'; import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; import { reactTailwindProvider } from '@src/generators/core/react-tailwind/react-tailwind.generator.js'; import { reactRoutesProvider } from '@src/providers/routes.js'; diff --git a/packages/react-generators/src/generators/apollo/apollo-error-link/apollo-error-link.generator.ts b/packages/react-generators/src/generators/apollo/apollo-error-link/apollo-error-link.generator.ts index 7af0fcc64..9ff3e0c24 100644 --- a/packages/react-generators/src/generators/apollo/apollo-error-link/apollo-error-link.generator.ts +++ b/packages/react-generators/src/generators/apollo/apollo-error-link/apollo-error-link.generator.ts @@ -1,15 +1,26 @@ -import { TypescriptCodeUtils } from '@halfdomelabs/core-generators'; -import { createGenerator, createGeneratorTask } from '@halfdomelabs/sync'; +import { + projectScope, + tsCodeFragment, + tsHoistedFragment, + tsImportBuilder, +} from '@halfdomelabs/core-generators'; +import { + createGenerator, + createGeneratorTask, + createReadOnlyProviderType, +} from '@halfdomelabs/sync'; import { z } from 'zod'; -import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; -import { reactLoggerProvider } from '@src/generators/core/react-logger/react-logger.generator.js'; +import { reactErrorImportsProvider } from '@src/generators/core/react-error/react-error.generator.js'; +import { reactLoggerImportsProvider } from '@src/generators/core/react-logger/react-logger.generator.js'; -import { reactApolloSetupProvider } from '../react-apollo/react-apollo.generator.js'; +import { reactApolloConfigProvider } from '../react-apollo/react-apollo.generator.js'; -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); +const descriptorSchema = z.object({}); + +export const apolloErrorLinkProvider = createReadOnlyProviderType<{ + errorLinkName: string; +}>('apollo-error-link'); export const apolloErrorLinkGenerator = createGenerator({ name: 'apollo/apollo-error-link', @@ -18,14 +29,18 @@ export const apolloErrorLinkGenerator = createGenerator({ buildTasks: () => ({ main: createGeneratorTask({ dependencies: { - reactApolloSetup: reactApolloSetupProvider, - reactError: reactErrorProvider, - reactLogger: reactLoggerProvider, + reactApolloConfig: reactApolloConfigProvider, + reactErrorImports: reactErrorImportsProvider, + reactLoggerImports: reactLoggerImportsProvider, }, - run({ reactApolloSetup, reactError, reactLogger }) { - reactApolloSetup.addLink({ + exports: { + apolloErrorLink: apolloErrorLinkProvider.export(projectScope), + }, + run({ reactApolloConfig, reactErrorImports, reactLoggerImports }) { + reactApolloConfig.apolloLinks.add({ name: 'errorLink', - bodyExpression: TypescriptCodeUtils.createBlock( + priority: 'error', + bodyFragment: tsCodeFragment( `const errorLink = onError(({ graphQLErrors, networkError, operation }) => { // log query/subscription errors but not mutations since it should be handled by caller const definition = getMainDefinition(operation.query); @@ -65,31 +80,39 @@ export const apolloErrorLinkGenerator = createGenerator({ } });`, [ - 'import { logError } from "%react-error/logger"', - 'import { logger } from "%react-logger"', - 'import { onError } from "@apollo/client/link/error"', - 'import { getMainDefinition } from "@apollo/client/utilities"', - 'import { GraphQLError, Kind } from "graphql";', - 'import { ServerError } from "@apollo/client/link/utils";', + reactErrorImports.logError.declaration(), + reactLoggerImports.logger.declaration(), + tsImportBuilder(['onError']).from('@apollo/client/link/error'), + tsImportBuilder(['getMainDefinition']).from( + '@apollo/client/utilities', + ), + tsImportBuilder(['GraphQLError', 'Kind']).from('graphql'), + tsImportBuilder(['ServerError']).from( + '@apollo/client/link/utils', + ), ], { - importMappers: [reactError, reactLogger], - headerBlocks: [ - TypescriptCodeUtils.createBlock( + hoistedFragments: [ + tsHoistedFragment( `export interface ErrorExtensions { code?: string; statusCode?: number; extraData?: Record; reqId?: string; }`, - undefined, - { headerKey: 'ErrorExtensions' }, + 'error-extensions', ), ], }, ), }); - return {}; + return { + providers: { + apolloErrorLink: { + errorLinkName: 'errorLink', + }, + }, + }; }, }), }), diff --git a/packages/react-generators/src/generators/apollo/apollo-error/apollo-error.generator.ts b/packages/react-generators/src/generators/apollo/apollo-error/apollo-error.generator.ts index fc10f6691..3fc876b21 100644 --- a/packages/react-generators/src/generators/apollo/apollo-error/apollo-error.generator.ts +++ b/packages/react-generators/src/generators/apollo/apollo-error/apollo-error.generator.ts @@ -1,9 +1,8 @@ import type { ImportMapper } from '@halfdomelabs/core-generators'; import { - makeImportAndFilePath, projectScope, - typescriptProvider, + typescriptFileProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, @@ -12,6 +11,12 @@ import { } from '@halfdomelabs/sync'; import { z } from 'zod'; +import { + apolloErrorImportsProvider, + createApolloErrorImports, +} from './generated/ts-import-maps.js'; +import { APOLLO_APOLLO_ERROR_TS_TEMPLATES } from './generated/ts-templates.js'; + const descriptorSchema = z.object({ placeholder: z.string().optional(), }); @@ -28,31 +33,31 @@ export const apolloErrorGenerator = createGenerator({ buildTasks: () => ({ main: createGeneratorTask({ dependencies: { - typescript: typescriptProvider, + typescriptFile: typescriptFileProvider, }, exports: { apolloError: apolloErrorProvider.export(projectScope), + apolloErrorImports: apolloErrorImportsProvider.export(projectScope), }, - run({ typescript }) { - const [utilImport, utilPath] = makeImportAndFilePath( - 'src/utils/apollo-error.ts', - ); + run({ typescriptFile }) { + const utilPath = '@/src/utils/apollo-error.ts'; return { providers: { apolloError: { getImportMap: () => ({ '%apollo-error/utils': { - path: utilImport, + path: utilPath, allowedImports: ['getApolloErrorCode'], }, }), }, + apolloErrorImports: createApolloErrorImports('@/src/utils'), }, build: async (builder) => { await builder.apply( - typescript.createCopyAction({ - source: 'apollo-error.ts', + typescriptFile.renderTemplateFile({ + template: APOLLO_APOLLO_ERROR_TS_TEMPLATES.apolloError, destination: utilPath, }), ); @@ -62,3 +67,5 @@ export const apolloErrorGenerator = createGenerator({ }), }), }); + +export { apolloErrorImportsProvider } from './generated/ts-import-maps.js'; diff --git a/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-maps.ts b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-maps.ts new file mode 100644 index 000000000..f043a1e20 --- /dev/null +++ b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-import-maps.ts @@ -0,0 +1,33 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMap, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; +import path from 'node:path/posix'; + +const apolloErrorImportsSchema = createTsImportMapSchema({ + getApolloErrorCode: {}, +}); + +type ApolloErrorImportsProvider = TsImportMapProviderFromSchema< + typeof apolloErrorImportsSchema +>; + +export const apolloErrorImportsProvider = + createReadOnlyProviderType( + 'apollo-error-imports', + ); + +export function createApolloErrorImports( + importBase: string, +): ApolloErrorImportsProvider { + if (!importBase.startsWith('@/')) { + throw new Error('importBase must start with @/'); + } + + return createTsImportMap(apolloErrorImportsSchema, { + getApolloErrorCode: path.join(importBase, 'apollo-error.js'), + }); +} diff --git a/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-templates.ts b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-templates.ts new file mode 100644 index 000000000..e8eeacb10 --- /dev/null +++ b/packages/react-generators/src/generators/apollo/apollo-error/generated/ts-templates.ts @@ -0,0 +1,10 @@ +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +const apolloError = createTsTemplateFile({ + name: 'apollo-error', + projectExports: { getApolloErrorCode: {} }, + source: { path: 'apollo-error.ts' }, + variables: {}, +}); + +export const APOLLO_APOLLO_ERROR_TS_TEMPLATES = { apolloError }; diff --git a/packages/react-generators/src/generators/apollo/apollo-sentry/apollo-sentry.generator.ts b/packages/react-generators/src/generators/apollo/apollo-sentry/apollo-sentry.generator.ts index 562811816..360775a0e 100644 --- a/packages/react-generators/src/generators/apollo/apollo-sentry/apollo-sentry.generator.ts +++ b/packages/react-generators/src/generators/apollo/apollo-sentry/apollo-sentry.generator.ts @@ -1,20 +1,26 @@ import { - makeImportAndFilePath, tsCodeFragment, tsHoistedFragment, tsImportBuilder, - TypescriptCodeUtils, - typescriptProvider, + typescriptFileProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, createGeneratorTask } from '@halfdomelabs/sync'; import { z } from 'zod'; -import { reactSentryConfigProvider } from '@src/generators/core/react-sentry/react-sentry.generator.js'; +import { reactErrorImportsProvider } from '@src/generators/core/index.js'; +import { + reactSentryConfigProvider, + reactSentryImportsProvider, +} from '@src/generators/core/react-sentry/react-sentry.generator.js'; -import { reactApolloSetupProvider } from '../react-apollo/react-apollo.generator.js'; +import { apolloErrorLinkProvider } from '../apollo-error-link/apollo-error-link.generator.js'; +import { reactApolloConfigProvider } from '../react-apollo/react-apollo.generator.js'; +import { APOLLO_APOLLO_SENTRY_TS_TEMPLATES } from './generated/ts-templates.js'; const descriptorSchema = z.object({}); +const apolloSentryLinkPath = '@/src/services/apollo/apollo-sentry-link.ts'; + export const apolloSentryGenerator = createGenerator({ name: 'apollo/apollo-sentry', generatorFileUrl: import.meta.url, @@ -81,29 +87,39 @@ export const apolloSentryGenerator = createGenerator({ }), apolloSentryLink: createGeneratorTask({ dependencies: { - reactApolloSetup: reactApolloSetupProvider, - typescript: typescriptProvider, + reactApolloConfig: reactApolloConfigProvider, + apolloErrorLink: apolloErrorLinkProvider, }, - run({ reactApolloSetup, typescript }) { - const [linkImport, linkPath] = makeImportAndFilePath( - 'src/services/apollo/apollo-sentry-link.ts', - ); + run({ reactApolloConfig, apolloErrorLink }) { + reactApolloConfig.apolloLinks.add({ + name: 'apolloSentryLink', + nameImport: tsImportBuilder(['apolloSentryLink']).from( + apolloSentryLinkPath, + ), + priority: 'error', + dependencies: [apolloErrorLink.errorLinkName], + }); + }, + }), + apolloSentryLinkFile: createGeneratorTask({ + dependencies: { + typescriptFile: typescriptFileProvider, + reactSentryImports: reactSentryImportsProvider, + reactErrorImports: reactErrorImportsProvider, + }, + run({ typescriptFile, reactSentryImports, reactErrorImports }) { return { async build(builder) { await builder.apply( - typescript.createCopyAction({ - source: 'apollo-sentry-link.ts', - destination: linkPath, + typescriptFile.renderTemplateFile({ + template: APOLLO_APOLLO_SENTRY_TS_TEMPLATES.apolloSentryLink, + destination: apolloSentryLinkPath, + importMapProviders: { + reactSentryImports, + reactErrorImports, + }, }), ); - - reactApolloSetup.addLink({ - key: 'apolloSentryLink', - name: TypescriptCodeUtils.createExpression(`apolloSentryLink`, [ - `import { apolloSentryLink } from '${linkImport}'`, - ]), - dependencies: [['errorLink', 'apolloSentryLink']], - }); }, }; }, diff --git a/packages/react-generators/src/generators/apollo/apollo-sentry/generated/ts-templates.ts b/packages/react-generators/src/generators/apollo/apollo-sentry/generated/ts-templates.ts new file mode 100644 index 000000000..098bfa0bd --- /dev/null +++ b/packages/react-generators/src/generators/apollo/apollo-sentry/generated/ts-templates.ts @@ -0,0 +1,17 @@ +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +import { reactErrorImportsProvider } from '../../../core/react-error/generated/ts-import-maps.js'; +import { reactSentryImportsProvider } from '../../../core/react-sentry/generated/ts-import-maps.js'; + +const apolloSentryLink = createTsTemplateFile({ + importMapProviders: { + reactErrorImports: reactErrorImportsProvider, + reactSentryImports: reactSentryImportsProvider, + }, + name: 'apollo-sentry-link', + projectExports: {}, + source: { path: 'apollo-sentry-link.ts' }, + variables: {}, +}); + +export const APOLLO_APOLLO_SENTRY_TS_TEMPLATES = { apolloSentryLink }; diff --git a/packages/react-generators/src/generators/apollo/apollo-sentry/templates/apollo-sentry-link.ts b/packages/react-generators/src/generators/apollo/apollo-sentry/templates/apollo-sentry-link.ts index 238d91cde..321479d13 100644 --- a/packages/react-generators/src/generators/apollo/apollo-sentry/templates/apollo-sentry-link.ts +++ b/packages/react-generators/src/generators/apollo/apollo-sentry/templates/apollo-sentry-link.ts @@ -1,10 +1,10 @@ // @ts-nocheck +import { logError } from '%reactErrorImports'; +import { logBreadcrumbToSentry } from '%reactSentryImports'; import { ApolloLink } from '@apollo/client'; -import { Observable, getMainDefinition } from '@apollo/client/utilities'; +import { getMainDefinition, Observable } from '@apollo/client/utilities'; import { Kind } from 'graphql'; -import { logError } from '../error-logger'; -import { logBreadcrumbToSentry } from '../sentry'; export const apolloSentryLink = new ApolloLink((operation, forward) => { operation.setContext({ startAt: new Date().getTime() }); diff --git a/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-maps.ts b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-maps.ts new file mode 100644 index 000000000..5465d524f --- /dev/null +++ b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-import-maps.ts @@ -0,0 +1,35 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMap, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; +import path from 'node:path/posix'; + +const reactApolloImportsSchema = createTsImportMapSchema({ + createApolloCache: {}, + createApolloClient: {}, +}); + +type ReactApolloImportsProvider = TsImportMapProviderFromSchema< + typeof reactApolloImportsSchema +>; + +export const reactApolloImportsProvider = + createReadOnlyProviderType( + 'react-apollo-imports', + ); + +export function createReactApolloImports( + importBase: string, +): ReactApolloImportsProvider { + if (!importBase.startsWith('@/')) { + throw new Error('importBase must start with @/'); + } + + return createTsImportMap(reactApolloImportsSchema, { + createApolloCache: path.join(importBase, 'cache.js'), + createApolloClient: path.join(importBase, 'index.js'), + }); +} diff --git a/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-templates.ts b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-templates.ts new file mode 100644 index 000000000..d5048e351 --- /dev/null +++ b/packages/react-generators/src/generators/apollo/react-apollo/generated/ts-templates.ts @@ -0,0 +1,32 @@ +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +const appApolloProvider = createTsTemplateFile({ + name: 'app-apollo-provider', + projectExports: {}, + source: { path: 'app/AppApolloProvider.tsx' }, + variables: { + TPL_CREATE_ARGS: {}, + TPL_MEMO_DEPENDENCIES: {}, + TPL_RENDER_BODY: {}, + }, +}); + +const cache = createTsTemplateFile({ + name: 'cache', + projectExports: { createApolloCache: {} }, + source: { path: 'services/apollo/cache.ts' }, + variables: {}, +}); + +const service = createTsTemplateFile({ + name: 'service', + projectExports: { createApolloClient: {} }, + source: { path: 'services/apollo/index.ts' }, + variables: { TPL_CREATE_ARGS: {}, TPL_LINKS: {}, TPL_LINK_BODIES: {} }, +}); + +export const APOLLO_REACT_APOLLO_TS_TEMPLATES = { + appApolloProvider, + cache, + service, +}; diff --git a/packages/react-generators/src/generators/apollo/react-apollo/react-apollo.generator.ts b/packages/react-generators/src/generators/apollo/react-apollo/react-apollo.generator.ts index 9e97da65a..65f6189f1 100644 --- a/packages/react-generators/src/generators/apollo/react-apollo/react-apollo.generator.ts +++ b/packages/react-generators/src/generators/apollo/react-apollo/react-apollo.generator.ts @@ -1,75 +1,165 @@ -import type { ImportMapper } from '@halfdomelabs/core-generators'; +import type { + ImportMapper, + TsCodeFragment, + TsImportDeclaration, +} from '@halfdomelabs/core-generators'; import { createNodePackagesTask, createNodeTask, eslintProvider, extractPackageVersions, - makeImportAndFilePath, prettierProvider, projectScope, tsCodeFragment, TsCodeUtils, + tsHoistedFragment, tsImportBuilder, - TypescriptCodeBlock, - TypescriptCodeExpression, + tsTemplate, TypescriptCodeUtils, - typescriptProvider, + typescriptFileProvider, } from '@halfdomelabs/core-generators'; import { + createConfigProviderTask, createGenerator, createGeneratorTask, - createNonOverwriteableMap, + createProviderTask, createProviderType, POST_WRITE_COMMAND_PRIORITY, renderTextTemplateFileAction, } from '@halfdomelabs/sync'; -import { toposort } from '@halfdomelabs/utils'; +import { toposortOrdered } from '@halfdomelabs/utils'; import { z } from 'zod'; import { REACT_PACKAGES } from '@src/constants/react-packages.js'; import { reactAppConfigProvider } from '@src/generators/core/react-app/react-app.generator.js'; -import { reactConfigProvider } from '@src/generators/core/react-config/react-config.generator.js'; +import { + reactConfigImportsProvider, + reactConfigProvider, +} from '@src/generators/core/react-config/react-config.generator.js'; import { reactErrorConfigProvider } from '@src/generators/core/react-error/react-error.generator.js'; import { reactProxyProvider } from '@src/generators/core/react-proxy/react-proxy.generator.js'; import { notEmpty } from '../../../utils/array.js'; import { APOLLO_REACT_APOLLO_TEXT_TEMPLATES } from './generated/text-templates.js'; +import { + createReactApolloImports, + reactApolloImportsProvider, +} from './generated/ts-import-maps.js'; +import { APOLLO_REACT_APOLLO_TS_TEMPLATES } from './generated/ts-templates.js'; const descriptorSchema = z.object({ + /** + * URL for the GraphQL API endpoint for use in development, e.g. /api/graphql + */ devApiEndpoint: z.string().min(1), + /** + * Location to get the GraphQL schema relative to the app root, e.g. ../backend/schema.graphql + */ schemaLocation: z.string().min(1), + /** + * Whether to enable GraphQL subscriptions + */ enableSubscriptions: z.boolean().optional(), }); -export interface ApolloCreateArg { +/** + * Argument for the createApolloClient function with various + * hooks to get the appropriate values + * + * @example + * ```ts + * // In app/AppApolloProvider.tsx + * function AppApolloProvider() { + * REACT_RENDER_BODY; + * + * const client = useMemo( + * () => createApolloClient({ NAME }), + * [NAME], + * ); + * } + * + * // In services/apollo/index.ts + * + * function createApolloClient({ NAME }: { NAME: TYPE }) { + * ... + * } + * ``` + */ +export interface ApolloCreateArgument { + /** + * Name of the argument + */ name: string; - type: TypescriptCodeExpression; - creatorValue: TypescriptCodeExpression; - createArgs?: string[]; - hookDependency?: string; - renderBody?: TypescriptCodeBlock; + /** + * Type of the argument + */ + type: TsCodeFragment | string; + /** + * Fragment to add to the React render function for the Apollo Provider + * + * This should add the name of the argument to the scope to be used in the + * createApolloClient function. + */ + reactRenderBody: TsCodeFragment; } +const APOLLO_LINK_PRIORITY = { + error: 1, + auth: 2, + network: 3, +}; + +type ApolloLinkPriority = keyof typeof APOLLO_LINK_PRIORITY; + +/** + * Link for the ApolloClient + */ export interface ApolloLink { - key?: string; - name: string | TypescriptCodeExpression; - bodyExpression?: TypescriptCodeBlock; - dependencies?: [string, string][]; - httpOnly?: boolean; - wsOnly?: boolean; + /** + * Name of the link + */ + name: string; + /** + * Import of the link if not declared in the bodyFragment + */ + nameImport?: TsImportDeclaration; + /** + * Priority of the link + */ + priority: ApolloLinkPriority; + /** + * Fragment to add the body of the createApolloClient function. + * + * This should add the name of the link to the function scope. + */ + bodyFragment?: TsCodeFragment; + /** + * The name of any links this link depends on. + */ + dependencies?: string[]; + /** + * Which network transport this link applies to. + * + * @default 'all' + */ + transport?: 'http' | 'ws' | 'all'; } -export interface ReactApolloSetupProvider extends ImportMapper { - addCreateArg(arg: ApolloCreateArg): void; - addLink(link: ApolloLink): void; - addWebsocketOption(name: string, expression: TypescriptCodeExpression): void; - getApiEndpointExpression(): TypescriptCodeExpression; - registerGqlFile(filePath: string): void; -} +const [setupTask, reactApolloConfigProvider, reactApolloConfigValuesProvider] = + createConfigProviderTask( + (t) => ({ + createApolloClientArguments: t.namedArray(), + apolloLinks: t.namedArray(), + websocketOptions: t.map(), + }), + { + prefix: 'react-apollo', + configScope: projectScope, + }, + ); -export const reactApolloSetupProvider = - createProviderType('react-apollo-setup'); +export { reactApolloConfigProvider }; export interface ReactApolloProvider extends ImportMapper { registerGqlFile(filePath: string): void; @@ -79,11 +169,14 @@ export interface ReactApolloProvider extends ImportMapper { export const reactApolloProvider = createProviderType('react-apollo'); +const appApolloProviderPath = '@/src/app/AppApolloProvider.tsx'; + export const reactApolloGenerator = createGenerator({ name: 'apollo/react-apollo', generatorFileUrl: import.meta.url, descriptorSchema, buildTasks: ({ devApiEndpoint, schemaLocation, enableSubscriptions }) => ({ + setup: setupTask, nodePackages: createNodePackagesTask({ prod: extractPackageVersions(REACT_PACKAGES, [ '@apollo/client', @@ -111,123 +204,102 @@ export const reactApolloGenerator = createGenerator({ prod: extractPackageVersions(REACT_PACKAGES, ['graphql-ws']), }) : undefined, - main: createGeneratorTask({ - dependencies: { - reactConfig: reactConfigProvider, - typescript: typescriptProvider, - reactAppConfig: reactAppConfigProvider, - eslint: eslintProvider, - prettier: prettierProvider, - reactProxy: reactProxyProvider, - }, + eslint: createProviderTask(eslintProvider, (eslint) => { + eslint + .getConfig() + .appendUnique('eslintIgnore', ['src/generated/graphql.tsx']); + }), + prettier: createProviderTask(prettierProvider, (prettier) => { + prettier.addPrettierIgnore('src/generated/graphql.tsx'); + }), + reactProxy: createProviderTask(reactProxyProvider, (reactProxy) => { + if (enableSubscriptions) { + reactProxy.enableWebSocket(); + } + }), + reactConfig: createProviderTask(reactConfigProvider, (reactConfig) => { + reactConfig.configEntries.set('VITE_GRAPH_API_ENDPOINT', { + comment: 'URL for the GraphQL API endpoint', + validator: 'z.string().min(1)', + devDefaultValue: devApiEndpoint, + }); + + if (enableSubscriptions) { + reactConfig.configEntries.set('VITE_GRAPH_WS_API_ENDPOINT', { + comment: 'URL for the GraphQL web socket API endpoint (optional)', + validator: 'z.string()', + devDefaultValue: '', + }); + } + }), + reactApolloImports: createGeneratorTask({ exports: { - reactApolloSetup: reactApolloSetupProvider.export(projectScope), - reactApollo: reactApolloProvider.export(projectScope), + reactApolloImports: reactApolloImportsProvider.export(projectScope), }, - run({ - reactConfig, - typescript, - reactAppConfig, - eslint, - prettier, - reactProxy, - }) { - const apolloCreateArgs: ApolloCreateArg[] = []; - const links: ApolloLink[] = []; - const gqlFiles: string[] = []; - - reactConfig.configEntries.set('VITE_GRAPH_API_ENDPOINT', { - comment: 'URL for the GraphQL API endpoint', - validator: 'z.string().min(1)', - devDefaultValue: devApiEndpoint, - }); - - if (enableSubscriptions) { - reactConfig.configEntries.set('VITE_GRAPH_WS_API_ENDPOINT', { - comment: 'URL for the GraphQL web socket API endpoint (optional)', - validator: 'z.string()', - devDefaultValue: '', - }); - } - - const cacheFile = typescript.createTemplate({}); - const cachePath = 'src/services/apollo/cache.ts'; - - const [clientImport, clientPath] = makeImportAndFilePath( - 'src/services/apollo/index.ts', - ); - - const [providerImport, providerPath] = makeImportAndFilePath( - 'src/app/AppApolloProvider.tsx', - ); - + run() { + return { + providers: { + reactApolloImports: createReactApolloImports( + '@/src/services/apollo', + ), + }, + }; + }, + }), + reactAppConfig: createProviderTask( + reactAppConfigProvider, + (reactAppConfig) => { reactAppConfig.renderWrappers.set('react-apollo', { wrap: (contents) => TsCodeUtils.templateWithImports( tsImportBuilder() .default('AppApolloProvider') - .from(providerImport), + .from(appApolloProviderPath), )`${contents}`, type: 'data', }); + }, + ), + main: createGeneratorTask({ + dependencies: { + reactConfigImports: reactConfigImportsProvider, + typescriptFile: typescriptFileProvider, + reactApolloConfigValues: reactApolloConfigValuesProvider, + }, + exports: { + reactApollo: reactApolloProvider.export(projectScope), + }, + run({ + reactConfigImports, + typescriptFile, + reactApolloConfigValues: { + createApolloClientArguments, + apolloLinks, + websocketOptions, + }, + }) { + const gqlFiles: string[] = []; - const importMap = { - '%react-apollo/client': { - path: clientImport, - allowedImports: ['createApolloClient'], - }, - '%react-apollo/generated': { - path: '@/src/generated/graphql', - allowedImports: ['*'], - }, - }; - - eslint - .getConfig() - .appendUnique('eslintIgnore', ['src/generated/graphql.tsx']); - - prettier.addPrettierIgnore('src/generated/graphql.tsx'); - - const websocketOptions = createNonOverwriteableMap< - Record - >({}); - - if (enableSubscriptions) { - reactProxy.enableWebSocket(); - } + const cachePath = '@/src/services/apollo/cache.ts'; + const clientPath = '@/src/services/apollo/index.ts'; return { providers: { - reactApolloSetup: { - addCreateArg(arg) { - apolloCreateArgs.push(arg); - }, - addLink(link) { - links.push(link); - }, - getApiEndpointExpression() { - return new TypescriptCodeExpression( - 'config.VITE_GRAPH_API_ENDPOINT', - 'import { config } from "%react-config";', - { importMappers: [reactConfig] }, - ); - }, - registerGqlFile(filePath) { - gqlFiles.push(filePath); - }, - getImportMap() { - return importMap; - }, - addWebsocketOption(name, expression) { - websocketOptions.set(name, expression); - }, - }, reactApollo: { registerGqlFile(filePath) { gqlFiles.push(filePath); }, getImportMap() { - return importMap; + return { + '%react-apollo/client': { + path: clientPath, + allowedImports: ['createApolloClient'], + }, + '%react-apollo/generated': { + path: '@/src/generated/graphql', + allowedImports: ['*'], + }, + }; }, getGeneratedFilePath() { return '@/src/generated/graphql'; @@ -235,25 +307,40 @@ export const reactApolloGenerator = createGenerator({ }, }, build: async (builder) => { - const sortedLinks = toposort( - links.map((link) => link.key ?? link.name), - links.flatMap((link) => link.dependencies ?? []), - ) - .map((name) => links.find((link) => link.name === name)) - .filter(notEmpty); - // always push http link at the last one + const findLink = (name: string): ApolloLink => { + const link = apolloLinks.find((link) => link.name === name); + if (!link) { + throw new Error(`Link ${name} not found`); + } + return link; + }; + const sortedLinks = toposortOrdered( + apolloLinks, + apolloLinks.flatMap( + (link) => + link.dependencies?.map((dep): [ApolloLink, ApolloLink] => [ + findLink(dep), + link, + ]) ?? [], + ), + ).toSorted( + (a, b) => + APOLLO_LINK_PRIORITY[a.priority] - + APOLLO_LINK_PRIORITY[b.priority], + ); + sortedLinks.push({ name: 'httpLink', - httpOnly: true, - bodyExpression: new TypescriptCodeBlock( + transport: 'http', + priority: 'network', + bodyFragment: tsCodeFragment( `const httpLink = new HttpLink({ uri: config.VITE_GRAPH_API_ENDPOINT, });`, [ - `import { HttpLink } from '@apollo/client';`, - `import { config } from '%react-config';`, + tsImportBuilder(['HttpLink']).from('@apollo/client'), + reactConfigImports.config.declaration(), ], - { importMappers: [reactConfig] }, ), }); @@ -271,148 +358,167 @@ export const reactApolloGenerator = createGenerator({ 'RETRY_WAIT', ).replace(/;$/, ''); - websocketOptions.merge({ - connectionParams: - TypescriptCodeUtils.createExpression(`async () => { + // TODO: This should not live here but in auth service + // TODO: This should live in the defaults not set afterwards to prevent them from being overridden + websocketOptions.set( + 'connectionParams', + tsCodeFragment(`async () => { const accessToken = await getAccessToken(); if (!accessToken) { return {}; } return { authorization: \`Bearer \${accessToken}\` }; }`), - url: TypescriptCodeUtils.createExpression( - `getWsUrl()`, - undefined, - { - headerBlocks: [ - TypescriptCodeUtils.createBlock(getWsUrlTemplate), - ], - }, - ), - retryAttempts: - "86400 /* effectively retry forever (1 month of retries) - there's no way of disabling retry attempts */", - retryWait: retryWaitTemplate, - shouldRetry: '() => true', - }); + ); - const wsOptions = TypescriptCodeUtils.mergeExpressionsAsObject( - websocketOptions.value(), + websocketOptions.set( + 'url', + tsCodeFragment(`getWsUrl()`, [], { + hoistedFragments: [ + tsHoistedFragment( + tsCodeFragment(getWsUrlTemplate), + 'get-ws-url', + ), + ], + }), + ); + websocketOptions.set( + 'retryAttempts', + "86400 /* effectively retry forever (1 month of retries) - there's no way of disabling retry attempts */", ); + websocketOptions.set('retryWait', retryWaitTemplate); + websocketOptions.set('shouldRetry', '() => true'); + + const wsOptionsFragment = + TsCodeUtils.mergeFragmentsAsObject(websocketOptions); sortedLinks.push({ name: 'wsLink', - wsOnly: true, - bodyExpression: TypescriptCodeUtils.formatBlock( - `const wsLink = new GraphQLWsLink(createClient(WS_OPTIONS));`, - { WS_OPTIONS: wsOptions }, - { - importText: [ - `import { GraphQLWsLink } from '@apollo/client/link/subscriptions';`, - `import { createClient } from 'graphql-ws';`, - ], - }, - ), + transport: 'ws', + priority: 'network', + bodyFragment: TsCodeUtils.templateWithImports([ + tsImportBuilder(['GraphQLWsLink']).from( + '@apollo/client/link/subscriptions', + ), + tsImportBuilder(['createClient']).from('graphql-ws'), + ])`const wsLink = new GraphQLWsLink(createClient(${wsOptionsFragment}));`, }); - const splitLinkTemplate = - TypescriptCodeUtils.extractTemplateSnippet( - websocketTemplate, - 'SPLIT_LINK', - ); - - const wsLinks = sortedLinks.filter((l) => l.wsOnly); - const httpLinks = sortedLinks.filter((l) => l.httpOnly); + const wsLinks = sortedLinks.filter((l) => l.transport === 'ws'); + const httpLinks = sortedLinks.filter( + (l) => l.transport === 'http', + ); const formatLinks = ( linksToFormat: ApolloLink[], - ): TypescriptCodeExpression => { - const linkNames = linksToFormat.map((link) => - typeof link.name === 'string' - ? new TypescriptCodeExpression(link.name) - : link.name, - ); + ): TsCodeFragment | string => { + const linkNames = linksToFormat.map((link) => link.name); if (linkNames.length === 1) { return linkNames[0]; } - return TypescriptCodeUtils.mergeExpressionsAsArray( - linkNames, - ).wrap( - (contents) => `from(${contents})`, - 'import { from } from "@apollo/client";', - ); + return TsCodeUtils.templateWithImports([ + tsImportBuilder(['from']).from('@apollo/client'), + ])`from(${TsCodeUtils.mergeFragmentsPresorted(linkNames)})`; }; sortedLinks.push({ name: 'splitLink', - bodyExpression: TypescriptCodeUtils.formatBlock( - splitLinkTemplate, - { - WS_LINK: formatLinks(wsLinks), - HTTP_LINK: formatLinks(httpLinks), - }, - { - importText: [ - `import { split } from '@apollo/client';`, - `import { getMainDefinition } from '@apollo/client/utilities';`, - `import { Kind, OperationTypeNode } from 'graphql';`, - ], - }, - ), + priority: 'network', + bodyFragment: TsCodeUtils.templateWithImports([ + tsImportBuilder(['split']).from('@apollo/client'), + tsImportBuilder(['getMainDefinition']).from( + '@apollo/client/utilities', + ), + tsImportBuilder(['Kind', 'OperationTypeNode']).from( + 'graphql', + ), + ])` + const splitLink = split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === Kind.OPERATION_DEFINITION && + definition.operation === OperationTypeNode.SUBSCRIPTION + ); + }, + ${formatLinks(wsLinks)}, + ${formatLinks(httpLinks)}, +);`, }); } - await builder.apply( - cacheFile.renderToAction('services/apollo/cache.ts', cachePath), - ); - - const createArgNames = apolloCreateArgs + const createArgNames = createApolloClientArguments .map((arg) => arg.name) .join(', '); + const apolloLinkFragments = sortedLinks + .filter((apolloLink) => { + if (enableSubscriptions) { + // subscriptions use the split link from above for transport-specific links + return ( + !apolloLink.transport || apolloLink.transport === 'all' + ); + } + return true; + }) + .map((apolloLink) => + tsCodeFragment(apolloLink.name, apolloLink.nameImport), + ); - const clientFile = typescript.createTemplate({ - CREATE_ARGS: - apolloCreateArgs.length === 0 - ? new TypescriptCodeExpression('') - : TypescriptCodeUtils.createExpression( - `{${createArgNames}}: CreateApolloClientOptions`, - undefined, - { - headerBlocks: [ - TypescriptCodeUtils.mergeBlocksAsInterfaceContent( - Object.fromEntries( - apolloCreateArgs.map((arg) => [ - arg.name, - arg.type, - ]), - ), - ).wrap( - (contents) => - `interface CreateApolloClientOptions {\n${contents}\n}`, - ), - ], - }, - ), - LINK_BODIES: TypescriptCodeUtils.mergeBlocks( - sortedLinks.map((link) => link.bodyExpression).filter(notEmpty), - '\n\n', - ), - LINKS: TypescriptCodeUtils.mergeExpressionsAsArray( - sortedLinks - .filter((l) => - enableSubscriptions ? !l.httpOnly && !l.wsOnly : true, - ) - .map((link) => - typeof link.name === 'string' - ? new TypescriptCodeExpression(link.name) - : link.name, + // services/apollo/index.ts + await builder.apply( + typescriptFile.renderTemplateFile({ + template: APOLLO_REACT_APOLLO_TS_TEMPLATES.service, + destination: clientPath, + variables: { + TPL_CREATE_ARGS: + createApolloClientArguments.length === 0 + ? '' + : tsCodeFragment( + `{${createArgNames}}: CreateApolloClientOptions`, + undefined, + { + hoistedFragments: [ + tsHoistedFragment( + tsTemplate` + interface CreateApolloClientOptions { + ${TsCodeUtils.mergeFragmentsAsInterfaceContent( + Object.fromEntries( + createApolloClientArguments.map((arg) => [ + arg.name, + arg.type, + ]), + ), + )} + } + `, + 'create-apollo-client-options', + ), + ], + }, + ), + TPL_LINK_BODIES: TsCodeUtils.mergeFragmentsPresorted( + sortedLinks + .map((link) => link.bodyFragment) + .filter(notEmpty), + '\n\n', ), - ), - }); + TPL_LINKS: + TsCodeUtils.mergeFragmentsAsArrayPresorted( + apolloLinkFragments, + ), + }, + }), + ); + // services/apollo/cache.ts await builder.apply( - clientFile.renderToAction('services/apollo/index.ts', clientPath), + typescriptFile.renderTemplateFile({ + template: APOLLO_REACT_APOLLO_TS_TEMPLATES.cache, + destination: cachePath, + }), ); + // codegen.yml await builder.apply( renderTextTemplateFileAction({ template: APOLLO_REACT_APOLLO_TEXT_TEMPLATES.codegenYml, @@ -423,46 +529,30 @@ export const reactApolloGenerator = createGenerator({ }), ); - const apolloProviderFile = typescript.createTemplate( - { - RENDER_BODY: TypescriptCodeUtils.mergeBlocks( - apolloCreateArgs - .map((arg) => arg.renderBody) - .filter(notEmpty), - ), - CREATE_ARG_VALUE: - apolloCreateArgs.length === 0 - ? TypescriptCodeUtils.createExpression('') - : TypescriptCodeUtils.mergeExpressionsAsObject( - Object.fromEntries( - apolloCreateArgs.map((arg) => [ - arg.name, - arg.creatorValue, - ]), - ), - ), - CREATE_ARGS: TypescriptCodeUtils.createExpression( - apolloCreateArgs - .map((arg) => arg.hookDependency) - .filter(notEmpty) - .join(', '), - ), - }, - { - importMappers: [{ getImportMap: () => importMap }], - }, - ); - + // app/AppApolloProvider.tsx await builder.apply( - apolloProviderFile.renderToAction( - 'app/AppApolloProvider.tsx', - providerPath, - ), + typescriptFile.renderTemplateFile({ + template: APOLLO_REACT_APOLLO_TS_TEMPLATES.appApolloProvider, + destination: appApolloProviderPath, + variables: { + TPL_RENDER_BODY: TsCodeUtils.mergeFragmentsPresorted( + createApolloClientArguments.map( + (arg) => arg.reactRenderBody, + ), + '\n\n', + ), + TPL_CREATE_ARGS: `{ ${createApolloClientArguments + .map((arg) => arg.name) + .join(', ')} }`, + TPL_MEMO_DEPENDENCIES: createApolloClientArguments + .map((arg) => arg.name) + .join(', '), + }, + }), ); builder.addPostWriteCommand('pnpm generate', { - // run after prisma generate - priority: POST_WRITE_COMMAND_PRIORITY.CODEGEN + 1, + priority: POST_WRITE_COMMAND_PRIORITY.CODEGEN, onlyIfChanged: [...gqlFiles, 'codegen.yml'], }); }, @@ -554,3 +644,5 @@ export const reactApolloGenerator = createGenerator({ }), }), }); + +export { reactApolloImportsProvider } from './generated/ts-import-maps.js'; diff --git a/packages/react-generators/src/generators/apollo/react-apollo/templates/app/AppApolloProvider.tsx b/packages/react-generators/src/generators/apollo/react-apollo/templates/app/AppApolloProvider.tsx index fe9138c32..d8b1aa5d0 100644 --- a/packages/react-generators/src/generators/apollo/react-apollo/templates/app/AppApolloProvider.tsx +++ b/packages/react-generators/src/generators/apollo/react-apollo/templates/app/AppApolloProvider.tsx @@ -2,18 +2,19 @@ import { ApolloProvider } from '@apollo/client'; import { useMemo } from 'react'; -import { createApolloClient } from '%react-apollo/client'; + +import { createApolloClient } from '../services/apollo/index.js'; interface Props { children: React.ReactNode; } function AppApolloProvider({ children }: Props): JSX.Element { - RENDER_BODY; + TPL_RENDER_BODY; const client = useMemo( - () => createApolloClient(CREATE_ARG_VALUE), - [CREATE_ARGS], + () => createApolloClient(TPL_CREATE_ARGS), + [TPL_MEMO_DEPENDENCIES], ); return {children}; diff --git a/packages/react-generators/src/generators/apollo/react-apollo/templates/services/apollo/index.ts b/packages/react-generators/src/generators/apollo/react-apollo/templates/services/apollo/index.ts index aa7e14bb8..e48400ee0 100644 --- a/packages/react-generators/src/generators/apollo/react-apollo/templates/services/apollo/index.ts +++ b/packages/react-generators/src/generators/apollo/react-apollo/templates/services/apollo/index.ts @@ -1,15 +1,15 @@ // @ts-nocheck import { ApolloClient, from, NormalizedCacheObject } from '@apollo/client'; -import { createApolloCache } from './cache'; + +import { createApolloCache } from './cache.js'; export function createApolloClient( - CREATE_ARGS, + TPL_CREATE_ARGS, ): ApolloClient { - LINK_BODIES; - + TPL_LINK_BODIES; const client = new ApolloClient({ - link: from(LINKS), + link: from(TPL_LINKS), cache: createApolloCache(), }); diff --git a/packages/react-generators/src/generators/auth/_providers/auth-components.ts b/packages/react-generators/src/generators/auth/_providers/auth-components.ts new file mode 100644 index 000000000..34bd1218e --- /dev/null +++ b/packages/react-generators/src/generators/auth/_providers/auth-components.ts @@ -0,0 +1,8 @@ +import type { ImportMapper } from '@halfdomelabs/core-generators'; + +import { createProviderType } from '@halfdomelabs/sync'; + +export type AuthComponentsProvider = ImportMapper; + +export const authComponentsProvider = + createProviderType('auth-components'); diff --git a/packages/react-generators/src/generators/auth/_providers/auth-hooks.ts b/packages/react-generators/src/generators/auth/_providers/auth-hooks.ts new file mode 100644 index 000000000..b4bf23c61 --- /dev/null +++ b/packages/react-generators/src/generators/auth/_providers/auth-hooks.ts @@ -0,0 +1,10 @@ +import type { ImportMapper } from '@halfdomelabs/core-generators'; + +import { createProviderType } from '@halfdomelabs/sync'; + +export interface AuthHooksProvider extends ImportMapper { + addCurrentUserField: (field: string) => void; +} + +export const authHooksProvider = + createProviderType('auth-hooks'); diff --git a/packages/react-generators/src/generators/auth/_providers/index.ts b/packages/react-generators/src/generators/auth/_providers/index.ts new file mode 100644 index 000000000..df722a220 --- /dev/null +++ b/packages/react-generators/src/generators/auth/_providers/index.ts @@ -0,0 +1,2 @@ +export * from './auth-components.js'; +export * from './auth-hooks.js'; diff --git a/packages/react-generators/src/generators/auth/auth-apollo/auth-apollo.generator.ts b/packages/react-generators/src/generators/auth/auth-apollo/auth-apollo.generator.ts deleted file mode 100644 index 51d4c703f..000000000 --- a/packages/react-generators/src/generators/auth/auth-apollo/auth-apollo.generator.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - projectScope, - TypescriptCodeUtils, -} from '@halfdomelabs/core-generators'; -import { - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -import { reactApolloSetupProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; - -import { authServiceProvider } from '../auth-service/auth-service.generator.js'; - -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); - -export type AuthApolloProvider = unknown; - -export const authApolloProvider = - createProviderType('auth-apollo'); - -export const authApolloGenerator = createGenerator({ - name: 'auth/auth-apollo', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: () => ({ - main: createGeneratorTask({ - dependencies: { - reactApolloSetup: reactApolloSetupProvider.dependency(), - authService: authServiceProvider, - }, - exports: { - authApollo: authApolloProvider.export(projectScope), - }, - run({ reactApolloSetup, authService }) { - return { - providers: { - authApollo: {}, - }, - build: async (builder) => { - const linkTemplate = await builder.readTemplate('auth-link.ts'); - const authLink = TypescriptCodeUtils.extractTemplateSnippet( - linkTemplate, - 'AUTH_LINK', - ); - - reactApolloSetup.addLink({ - name: 'authLink', - httpOnly: true, - bodyExpression: TypescriptCodeUtils.createBlock( - authLink, - [ - 'import { setContext } from "@apollo/client/link/context"', - 'import { authService } from "%auth-service"', - ], - { importMappers: [authService] }, - ), - dependencies: [['refreshTokenLink', 'authLink']], - }); - - const refreshTokenLink = TypescriptCodeUtils.extractTemplateSnippet( - linkTemplate, - 'REFRESH_TOKEN_LINK', - ); - - reactApolloSetup.addLink({ - name: 'refreshTokenLink', - httpOnly: true, - bodyExpression: TypescriptCodeUtils.createBlock( - refreshTokenLink, - [ - 'import { onError } from "@apollo/client/link/error";', - 'import { authService } from "%auth-service"', - 'import { ServerError } from "@apollo/client/link/utils";', - ], - { importMappers: [authService] }, - ), - dependencies: [['errorLink', 'refreshTokenLink']], - }); - - reactApolloSetup.addWebsocketOption( - 'on', - TypescriptCodeUtils.createExpression( - `{ - closed: (e) => { - if (e instanceof CloseEvent && e.reason === 'token-expired') { - authService.invalidateAccessToken(); - } - }, - }`, - 'import { authService } from "%auth-service"', - { importMappers: [authService] }, - ), - ); - - reactApolloSetup.addWebsocketOption( - 'connectionParams', - TypescriptCodeUtils.createExpression( - `async () => { - const isAuthenticated = authService.isAuthenticated(); - if (!isAuthenticated) { - return {}; - } - const accessToken = await authService.getAccessToken(); - return { authorization: \`Bearer \${accessToken}\` }; - }`, - 'import { authService } from "%auth-service"', - { importMappers: [authService] }, - ), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-apollo/templates/auth-link.ts b/packages/react-generators/src/generators/auth/auth-apollo/templates/auth-link.ts deleted file mode 100644 index a94a05b41..000000000 --- a/packages/react-generators/src/generators/auth/auth-apollo/templates/auth-link.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck - -// AUTH_LINK:START -const authLink = setContext(async () => { - const isAuthenticated = authService.isAuthenticated(); - if (!isAuthenticated) { - return {}; - } - const accessToken = await authService.getAccessToken(); - return { - headers: { - authorization: `Bearer ${accessToken}`, - }, - }; -}); -// AUTH_LINK:END - -// REFRESH_TOKEN_LINK:START -const refreshTokenLink = onError( - ({ graphQLErrors, networkError, operation, forward }) => { - const hasInvalidTokenError = graphQLErrors?.some((error) => { - const { extensions } = error; - const errorExtensions: ErrorExtensions | undefined = extensions; - return ( - errorExtensions?.code === 'invalid-token' || - errorExtensions?.code === 'token-expired' - ); - }); - if ( - ((networkError as ServerError)?.statusCode === 401 || - hasInvalidTokenError) && - authService.isAuthenticated() - ) { - authService.invalidateAccessToken(); - return forward(operation); - } - return undefined; - }, -); -// REFRESH_TOKEN_LINK:END diff --git a/packages/react-generators/src/generators/auth/auth-components/auth-components.generator.ts b/packages/react-generators/src/generators/auth/auth-components/auth-components.generator.ts deleted file mode 100644 index ca7b9bfd9..000000000 --- a/packages/react-generators/src/generators/auth/auth-components/auth-components.generator.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ImportMapper } from '@halfdomelabs/core-generators'; - -import { - makeImportAndFilePath, - projectScope, - typescriptProvider, -} from '@halfdomelabs/core-generators'; -import { - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; - -import { authHooksProvider } from '../auth-hooks/auth-hooks.generator.js'; - -const descriptorSchema = z.object({ - loginPath: z.string().min(1), -}); - -export type AuthComponentsProvider = ImportMapper; - -export const authComponentsProvider = - createProviderType('auth-components'); - -export const authComponentsGenerator = createGenerator({ - name: 'auth/auth-components', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: ({ loginPath }) => ({ - main: createGeneratorTask({ - dependencies: { - authHooks: authHooksProvider, - reactComponents: reactComponentsProvider, - typescript: typescriptProvider, - }, - exports: { - authComponents: authComponentsProvider.export(projectScope), - }, - run({ authHooks, reactComponents, typescript }) { - const [, requireAuthPath] = makeImportAndFilePath( - `${reactComponents.getComponentsFolder()}/RequireAuth/index.tsx`, - ); - reactComponents.registerComponent({ name: 'RequireAuth' }); - - return { - providers: { - authComponents: { - getImportMap: () => ({ - '%auth-components': { - path: reactComponents.getComponentsImport(), - allowedImports: ['RequireAuth'], - }, - }), - }, - }, - build: async (builder) => { - await builder.apply( - typescript.createCopyAction({ - source: 'RequireAuth.tsx', - destination: requireAuthPath, - importMappers: [authHooks], - replacements: { - LOGIN_PATH: loginPath, - }, - }), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-components/templates/RequireAuth.tsx b/packages/react-generators/src/generators/auth/auth-components/templates/RequireAuth.tsx deleted file mode 100644 index f3d34ab03..000000000 --- a/packages/react-generators/src/generators/auth/auth-components/templates/RequireAuth.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-nocheck -import { Navigate, useLocation } from 'react-router-dom'; -import { useSession } from '%auth-hooks/useSession'; - -interface Props { - children: JSX.Element; -} - -function RequireAuth({ children }: Props): JSX.Element { - const { isAuthenticated } = useSession(); - const location = useLocation(); - - if (!isAuthenticated) { - return ; - } - - return children; -} - -export default RequireAuth; diff --git a/packages/react-generators/src/generators/auth/auth-hooks/auth-hooks.generator.ts b/packages/react-generators/src/generators/auth/auth-hooks/auth-hooks.generator.ts deleted file mode 100644 index 5d59d0480..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/auth-hooks.generator.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { ImportMapper } from '@halfdomelabs/core-generators'; - -import { - createNodePackagesTask, - extractPackageVersions, - makeImportAndFilePath, - projectScope, - typescriptProvider, -} from '@halfdomelabs/core-generators'; -import { - copyFileAction, - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -import { REACT_PACKAGES } from '@src/constants/react-packages.js'; -import { reactApolloProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; -import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; -import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; -import { reactLoggerProvider } from '@src/generators/core/react-logger/react-logger.generator.js'; - -import { authServiceProvider } from '../auth-service/auth-service.generator.js'; - -const descriptorSchema = z.object({ - userQueryName: z.string().default('user'), -}); - -export interface AuthHooksProvider extends ImportMapper { - addCurrentUserField: (field: string) => void; -} - -export const authHooksProvider = - createProviderType('auth-hooks'); - -export const authHooksGenerator = createGenerator({ - name: 'auth/auth-hooks', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: ({ userQueryName }) => ({ - nodePackages: createNodePackagesTask({ - prod: extractPackageVersions(REACT_PACKAGES, ['use-subscription']), - dev: extractPackageVersions(REACT_PACKAGES, ['@types/use-subscription']), - }), - main: createGeneratorTask({ - dependencies: { - typescript: typescriptProvider, - reactComponents: reactComponentsProvider, - reactApollo: reactApolloProvider, - authService: authServiceProvider, - reactLogger: reactLoggerProvider, - reactError: reactErrorProvider, - }, - exports: { - authHooks: authHooksProvider.export(projectScope), - }, - run({ - typescript, - reactComponents, - reactApollo, - authService, - reactLogger, - reactError, - }) { - const currentUserFields: string[] = []; - - const hookFolder = 'src/hooks'; - const [useCurrentUserImport, useCurrentUserPath] = - makeImportAndFilePath(`${hookFolder}/useCurrentUser.ts`); - const [useLogOutImport, useLogOutPath] = makeImportAndFilePath( - `${hookFolder}/useLogOut.ts`, - ); - const [useSessionImport, useSessionPath] = makeImportAndFilePath( - `${hookFolder}/useSession.ts`, - ); - const [useRequiredUserIdImport, useRequiredUserIdPath] = - makeImportAndFilePath(`${hookFolder}/useRequiredUserId.ts`); - - return { - providers: { - authHooks: { - addCurrentUserField: (field: string) => { - currentUserFields.push(field); - }, - getImportMap: () => ({ - '%auth-hooks/useCurrentUser': { - path: useCurrentUserImport, - allowedImports: ['useCurrentUser'], - }, - '%auth-hooks/useLogOut': { - path: useLogOutImport, - allowedImports: ['useLogOut'], - }, - '%auth-hooks/useRequiredUserId': { - path: useRequiredUserIdImport, - allowedImports: ['useRequiredUserId'], - }, - '%auth-hooks/useSession': { - path: useSessionImport, - allowedImports: ['useSession'], - }, - }), - }, - }, - build: async (builder) => { - await builder.apply( - typescript.createCopyAction({ - source: 'hooks/useCurrentUser.ts', - destination: useCurrentUserPath, - replacements: { - USER_QUERY: userQueryName, - }, - importMappers: [reactApollo], - }), - ); - - await builder.apply( - copyFileAction({ - source: 'hooks/useCurrentUser.gql', - destination: `${hookFolder}/useCurrentUser.gql`, - replacements: { - CURRENT_USER_FIELDS: currentUserFields.join('\n'), - USER_QUERY: userQueryName, - }, - }), - ); - reactApollo.registerGqlFile(`${hookFolder}/useCurrentUser.gql`); - - await builder.apply( - typescript.createCopyAction({ - source: 'hooks/useLogOut.ts', - destination: useLogOutPath, - importMappers: [ - reactApollo, - reactComponents, - authService, - reactLogger, - reactError, - ], - }), - ); - - await builder.apply( - copyFileAction({ - source: 'hooks/useLogOut.gql', - destination: `${hookFolder}/useLogOut.gql`, - }), - ); - reactApollo.registerGqlFile(`${hookFolder}/useLogOut.gql`); - - await builder.apply( - typescript.createCopyAction({ - source: 'hooks/useSession.ts', - destination: useSessionPath, - importMappers: [authService], - }), - ); - - await builder.apply( - typescript.createCopyAction({ - source: 'hooks/useRequiredUserId.ts', - destination: useRequiredUserIdPath, - }), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.gql b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.gql deleted file mode 100644 index e8969c9e1..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.gql +++ /dev/null @@ -1,11 +0,0 @@ -fragment CurrentUser on User { - id - email - CURRENT_USER_FIELDS -} - -query getUserById($id: Uuid!) { - USER_QUERY(id: $id) { - ...CurrentUser - } -} diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.ts b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.ts deleted file mode 100644 index 0236863c9..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useCurrentUser.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @ts-nocheck - -import { useSession } from './useSession'; -import { - CurrentUserFragment, - useGetUserByIdQuery, -} from '%react-apollo/generated'; - -interface UseCurrentUserResult { - user?: CurrentUserFragment; - loading: boolean; - error?: Error | null; -} - -export function useCurrentUser(): UseCurrentUserResult { - const { userId } = useSession(); - const { data, loading, error } = useGetUserByIdQuery({ - variables: { id: userId ?? '' }, - skip: !userId, - }); - - const noUserError = - data && data.USER_QUERY === null ? new Error('No user found') : null; - - return { - user: data?.USER_QUERY ?? undefined, - loading, - error: error ?? noUserError, - }; -} diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.gql b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.gql deleted file mode 100644 index 405b0332e..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.gql +++ /dev/null @@ -1,5 +0,0 @@ -mutation logOut { - logOut { - success - } -} diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.ts b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.ts deleted file mode 100644 index 39f7cce82..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useLogOut.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck - -import { useToast } from '%react-components/useToast'; -import { useLogOutMutation } from '%react-apollo/generated'; -import { authService } from '%auth-service'; -import { logAndFormatError } from '%react-error/formatter'; -import { logger } from '%react-logger'; - -export function useLogOut(): () => void { - const [logOut] = useLogOutMutation(); - const toast = useToast(); - - return () => { - // TODO: Figure out how to catch log out errors - logOut() - .then(() => { - authService.setAuthPayload(null); - toast.success('You have been successfully logged out!'); - }) - .catch((err) => { - toast.error(logAndFormatError(err, 'Sorry, we could not log you out.')); - logger.error(err); - }); - }; -} diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useRequiredUserId.ts b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useRequiredUserId.ts deleted file mode 100644 index b0c4eb159..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useRequiredUserId.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useSession } from './useSession'; - -export function useRequiredUserId(): string { - const { userId } = useSession(); - if (!userId) { - throw new Error('User is not authenticated'); - } - return userId; -} diff --git a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useSession.ts b/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useSession.ts deleted file mode 100644 index dc3f81f71..000000000 --- a/packages/react-generators/src/generators/auth/auth-hooks/templates/hooks/useSession.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-nocheck - -import { useMemo } from 'react'; -import { Subscription, useSubscription } from 'use-subscription'; -import { authService } from '%auth-service'; - -export interface SessionData { - userId: string | null; - isAuthenticated: boolean; -} - -export function useSession(): SessionData { - const userIdSubscription: Subscription = useMemo( - () => ({ - getCurrentValue: () => authService.getUserId(), - subscribe: (callback) => authService.onUserIdChanged(() => callback()), - }), - [], - ); - const userId = useSubscription(userIdSubscription); - const sessionData: SessionData = useMemo( - () => ({ - userId, - isAuthenticated: !!userId, - }), - [userId], - ); - return sessionData; -} diff --git a/packages/react-generators/src/generators/auth/auth-identify/auth-identify.generator.ts b/packages/react-generators/src/generators/auth/auth-identify/auth-identify.generator.ts index e2fad509f..fe87a2017 100644 --- a/packages/react-generators/src/generators/auth/auth-identify/auth-identify.generator.ts +++ b/packages/react-generators/src/generators/auth/auth-identify/auth-identify.generator.ts @@ -17,7 +17,7 @@ import { z } from 'zod'; import { reactRouterConfigProvider } from '@src/generators/core/react-router/react-router.generator.js'; -import { authHooksProvider } from '../auth-hooks/auth-hooks.generator.js'; +import { authHooksProvider } from '../_providers/auth-hooks.js'; const descriptorSchema = z.object({}); diff --git a/packages/react-generators/src/generators/auth/auth-layout/auth-layout.generator.ts b/packages/react-generators/src/generators/auth/auth-layout/auth-layout.generator.ts deleted file mode 100644 index 7064bc70d..000000000 --- a/packages/react-generators/src/generators/auth/auth-layout/auth-layout.generator.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - makeImportAndFilePath, - projectScope, - TypescriptCodeUtils, - typescriptProvider, -} from '@halfdomelabs/core-generators'; -import { - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; -import { reactRoutesProvider } from '@src/providers/routes.js'; -import { createRouteElement } from '@src/utils/routes.js'; -import { writeReactComponent } from '@src/writers/component/index.js'; - -const descriptorSchema = z.object({ - name: z.string().min(1), -}); - -export type AuthLayoutProvider = unknown; - -export const authLayoutProvider = - createProviderType('auth-layout'); - -export const authLayoutGenerator = createGenerator({ - name: 'auth/auth-layout', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: ({ name }) => ({ - main: createGeneratorTask({ - dependencies: { - reactComponents: reactComponentsProvider, - reactRoutes: reactRoutesProvider, - typescript: typescriptProvider, - }, - exports: { - authLayout: authLayoutProvider.export(projectScope), - }, - run({ reactRoutes, typescript }) { - const [layoutImport, layoutPath] = makeImportAndFilePath( - `${reactRoutes.getDirectoryBase()}/components/AuthLayout/index.tsx`, - ); - - const layoutExpression = createRouteElement(name, layoutImport); - - reactRoutes.registerLayout({ - key: 'auth', - element: layoutExpression, - }); - - return { - providers: { - authLayout: {}, - }, - build: async (builder) => { - const body = TypescriptCodeUtils.createBlock( - `return
;`, - `import { Outlet } from 'react-router-dom'`, - ); - - const component = writeReactComponent({ name, body }); - - await builder.apply( - typescript.renderBlockToAction(component, layoutPath), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-login-page/auth-login-page.generator.ts b/packages/react-generators/src/generators/auth/auth-login-page/auth-login-page.generator.ts deleted file mode 100644 index 9acbb7935..000000000 --- a/packages/react-generators/src/generators/auth/auth-login-page/auth-login-page.generator.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - makeImportAndFilePath, - projectScope, - TypescriptCodeUtils, - typescriptProvider, -} from '@halfdomelabs/core-generators'; -import { - copyFileAction, - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { quot } from '@halfdomelabs/utils'; -import { z } from 'zod'; - -import { apolloErrorProvider } from '@src/generators/apollo/apollo-error/apollo-error.generator.js'; -import { reactApolloProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; -import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; -import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; -import { reactRoutesProvider } from '@src/providers/routes.js'; -import { createRouteElement } from '@src/utils/routes.js'; - -import { authServiceProvider } from '../auth-service/auth-service.generator.js'; - -const descriptorSchema = z.object({ - allowedRoles: z.array(z.string().min(1)), -}); - -export type AuthLoginPageProvider = unknown; - -export const authLoginPageProvider = - createProviderType('auth-login-page'); - -export const authLoginPageGenerator = createGenerator({ - name: 'auth/auth-login-page', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: ({ allowedRoles }) => ({ - main: createGeneratorTask({ - dependencies: { - reactApollo: reactApolloProvider, - reactRoutes: reactRoutesProvider, - typescript: typescriptProvider, - authService: authServiceProvider, - reactError: reactErrorProvider, - apolloError: apolloErrorProvider, - reactComponents: reactComponentsProvider, - }, - exports: { - authLoginPage: authLoginPageProvider.export(projectScope), - }, - run({ - reactApollo, - reactRoutes, - typescript, - authService, - reactError, - apolloError, - reactComponents, - }) { - const rootFolder = `${reactRoutes.getDirectoryBase()}/Login`; - const [loginPageImport, loginPagePath] = makeImportAndFilePath( - `${rootFolder}/index.tsx`, - ); - const loginPageFile = typescript.createTemplate( - { - ALLOWED_ROLES: { type: 'code-expression' }, - }, - { - importMappers: [ - reactComponents, - reactApollo, - authService, - reactError, - apolloError, - ], - }, - ); - loginPageFile.addCodeEntries({ - ALLOWED_ROLES: TypescriptCodeUtils.mergeExpressionsAsArray( - allowedRoles.map(quot), - ), - }); - - reactRoutes.registerRoute({ - path: 'login', - layoutKey: 'auth', - element: createRouteElement('LoginPage', loginPageImport), - }); - - return { - providers: { - authLoginPage: {}, - }, - build: async (builder) => { - await builder.apply( - loginPageFile.renderToAction('index.tsx', loginPagePath), - ); - - const loginGqlPath = `${rootFolder}/login.gql`; - reactApollo.registerGqlFile(loginGqlPath); - await builder.apply( - copyFileAction({ - source: 'login.gql', - destination: loginGqlPath, - }), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-login-page/templates/index.tsx b/packages/react-generators/src/generators/auth/auth-login-page/templates/index.tsx deleted file mode 100644 index 796673bc1..000000000 --- a/packages/react-generators/src/generators/auth/auth-login-page/templates/index.tsx +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-nocheck - -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { Location, useLocation, useNavigate } from 'react-router-dom'; -import { z } from 'zod'; -import { Alert, Button, Card, TextInput } from '%react-components'; -import { useLoginWithEmailAndPasswordMutation } from '%react-apollo/generated'; -import { useStatus } from '%react-components/useStatus'; -import { authService } from '%auth-service'; -import { logAndFormatError } from '%react-error/formatter'; -import { getApolloErrorCode } from '%apollo-error/utils'; - -const formSchema = z.object({ - email: z - .string() - .email() - .transform((value) => value.toLowerCase()), - password: z.string().min(8), -}); - -type FormData = z.infer; - -const REQUIRED_ROLES = ALLOWED_ROLES; - -interface LoginLocationState { - from?: Location; -} - -function LoginPage(): JSX.Element { - const { - register, - handleSubmit, - resetField, - setError: setFormError, - formState: { errors }, - } = useForm({ - resolver: zodResolver(formSchema), - }); - const { status, setError } = useStatus(); - const navigate = useNavigate(); - const location = useLocation(); - const [login, { loading }] = useLoginWithEmailAndPasswordMutation({ - onCompleted: (data) => { - const userRoles = data.loginWithEmailAndPassword.user.roles; - if (!userRoles.some((role) => REQUIRED_ROLES.includes(role.role))) { - setError('Sorry, you do not have permission to access this website.'); - return; - } - authService.setAuthPayload(data.loginWithEmailAndPassword.authPayload); - const from = - (location.state as LoginLocationState)?.from?.pathname ?? '/'; - navigate(from); - }, - }); - - const onSubmit = (data: FormData): void => { - login({ - variables: { - input: { - email: data.email, - password: data.password, - }, - }, - }).catch((err) => { - const errorCode = getApolloErrorCode(err, [ - 'user-not-found', - 'user-has-no-password', - 'invalid-password', - ] as const); - switch (errorCode) { - case 'user-not-found': - setFormError( - 'email', - { message: 'User does not exist' }, - { shouldFocus: true }, - ); - break; - case 'invalid-password': - resetField('password'); - setFormError( - 'password', - { message: 'Password is incorrect' }, - { shouldFocus: true }, - ); - break; - case 'user-has-no-password': - setError( - "You don't have a password assigned to your account. Maybe you logged in another way?", - ); - break; - default: - setError(logAndFormatError(err, 'Sorry, we could not log you in.')); - } - }); - }; - - return ( - -
-

Login

- -
- - - - -
-
- ); -} - -export default LoginPage; diff --git a/packages/react-generators/src/generators/auth/auth-login-page/templates/login.gql b/packages/react-generators/src/generators/auth/auth-login-page/templates/login.gql deleted file mode 100644 index 5f750477b..000000000 --- a/packages/react-generators/src/generators/auth/auth-login-page/templates/login.gql +++ /dev/null @@ -1,12 +0,0 @@ -mutation LoginWithEmailAndPassword($input: LoginWithEmailAndPasswordInput!) { - loginWithEmailAndPassword(input: $input) { - authPayload { - ...AuthPayload - } - user { - roles { - role - } - } - } -} diff --git a/packages/react-generators/src/generators/auth/auth-pages/auth-pages.generator.ts b/packages/react-generators/src/generators/auth/auth-pages/auth-pages.generator.ts deleted file mode 100644 index 514c0e0a7..000000000 --- a/packages/react-generators/src/generators/auth/auth-pages/auth-pages.generator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { projectScope } from '@halfdomelabs/core-generators'; -import { - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); - -export type AuthPagesProvider = unknown; - -export const authPagesProvider = - createProviderType('auth-pages'); - -export const authPagesGenerator = createGenerator({ - name: 'auth/auth-pages', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: () => ({ - main: createGeneratorTask({ - dependencies: {}, - exports: { - authPages: authPagesProvider.export(projectScope), - }, - run() { - return { - providers: { - authPages: {}, - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-service/auth-service.generator.ts b/packages/react-generators/src/generators/auth/auth-service/auth-service.generator.ts deleted file mode 100644 index 7b415881e..000000000 --- a/packages/react-generators/src/generators/auth/auth-service/auth-service.generator.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { ImportMapper } from '@halfdomelabs/core-generators'; - -import { - makeImportAndFilePath, - projectScope, - tsUtilsProvider, - typescriptProvider, -} from '@halfdomelabs/core-generators'; -import { - copyFileAction, - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; -import { z } from 'zod'; - -import { reactApolloSetupProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; -import { reactUtilsProvider } from '@src/generators/core/react-utils/react-utils.generator.js'; - -const descriptorSchema = z.object({ - placeholder: z.string().optional(), -}); - -export type AuthServiceProvider = ImportMapper; - -export const authServiceProvider = - createProviderType('auth-service'); - -export const authServiceGenerator = createGenerator({ - name: 'auth/auth-service', - generatorFileUrl: import.meta.url, - descriptorSchema, - buildTasks: () => ({ - main: createGeneratorTask({ - dependencies: { - tsUtils: tsUtilsProvider, - reactApolloSetup: reactApolloSetupProvider, - typescript: typescriptProvider, - reactUtils: reactUtilsProvider, - }, - exports: { - authService: authServiceProvider.export(projectScope), - }, - run({ tsUtils, reactApolloSetup, typescript, reactUtils }) { - const authFolder = 'src/services/auth'; - const [serviceImport, servicePath] = makeImportAndFilePath( - `${authFolder}/index.ts`, - ); - - const tokensFile = typescript.createTemplate( - { - API_ENDPOINT_URI: { type: 'code-expression' }, - }, - { - importMappers: [reactApolloSetup], - }, - ); - tokensFile.addCodeEntries({ - API_ENDPOINT_URI: reactApolloSetup.getApiEndpointExpression(), - }); - reactApolloSetup.registerGqlFile(`${authFolder}/tokens.gql`); - - const [, tokensPath] = makeImportAndFilePath(`${authFolder}/tokens.ts`); - const [, typesPath] = makeImportAndFilePath(`${authFolder}/types.ts`); - - return { - providers: { - authService: { - getImportMap: () => ({ - '%auth-service': { - path: serviceImport, - allowedImports: ['authService'], - }, - }), - }, - }, - build: async (builder) => { - await builder.apply( - typescript.createCopyAction({ - source: 'index.ts', - destination: servicePath, - importMappers: [tsUtils, reactUtils], - }), - ); - - await builder.apply( - tokensFile.renderToAction('tokens.ts', tokensPath), - ); - - await builder.apply( - typescript.createCopyAction({ - source: 'types.ts', - destination: typesPath, - }), - ); - - await builder.apply( - copyFileAction({ - source: 'tokens.gql', - destination: `${authFolder}/tokens.gql`, - }), - ); - }, - }; - }, - }), - }), -}); diff --git a/packages/react-generators/src/generators/auth/auth-service/templates/index.ts b/packages/react-generators/src/generators/auth/auth-service/templates/index.ts deleted file mode 100644 index a66c3ddf3..000000000 --- a/packages/react-generators/src/generators/auth/auth-service/templates/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @ts-nocheck -import { ApolloError } from '@apollo/client'; -import { getRefreshedAccessToken } from './tokens'; -import { AuthPayload } from './types'; -import { createTypedEventEmitter } from '%ts-utils/typedEventEmitter'; -import { getSafeLocalStorage } from '%react-utils/safeLocalStorage'; - -interface AuthService { - destroy(): void; - isAuthenticated(): boolean; - getUserId(): string | null; - getAccessToken(): Promise; - invalidateAccessToken(): void; - setAuthPayload(payload: AuthPayload | null): void; - onUserIdChanged(handler: (payload: string | null) => void): () => void; -} - -const USER_ID_KEY = 'AUTH_USER_ID'; -const ACCESS_TOKEN_KEY = 'AUTH_ACCESS_TOKEN'; -const REFRESH_TOKEN_KEY = 'AUTH_REFRESH_TOKEN'; - -const safeLocalStorage = getSafeLocalStorage(); - -export function createAuthService(): AuthService { - const events = createTypedEventEmitter<{ userIdChanged: string | null }>(); - - const storageEventListener = (e: StorageEvent): void => { - if (e.key === USER_ID_KEY && e.oldValue !== e.newValue) { - events.emit('userIdChanged', e.newValue); - } - }; - - window.addEventListener('storage', storageEventListener); - - function getUserId(): string | null { - return safeLocalStorage.getItem(USER_ID_KEY); - } - - function setAuthPayload(payload: AuthPayload | null): void { - const userIdChanged = payload?.userId !== getUserId(); - if (!payload) { - safeLocalStorage.removeItem(ACCESS_TOKEN_KEY); - safeLocalStorage.removeItem(REFRESH_TOKEN_KEY); - safeLocalStorage.removeItem(USER_ID_KEY); - } else { - safeLocalStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken); - if (payload.refreshToken) { - safeLocalStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken); - } - safeLocalStorage.setItem(USER_ID_KEY, payload.userId); - } - if (userIdChanged) { - events.emit('userIdChanged', null); - } - } - - async function renewAccessToken(): Promise { - const userId = getUserId(); - const refreshToken = safeLocalStorage.getItem(REFRESH_TOKEN_KEY); - if (!userId) { - throw new Error('No user ID found'); - } - try { - const newPayload = await getRefreshedAccessToken(userId, refreshToken); - setAuthPayload(newPayload); - return newPayload.accessToken; - } catch (err) { - if ( - err instanceof ApolloError && - err.graphQLErrors.some( - (gqlErr) => - gqlErr.extensions?.code === 'invalid-token' || - gqlErr.extensions?.code === 'token-expired', - ) - ) { - // log us out if the refresh token is invalid - setAuthPayload(null); - } - throw err; - } - } - - return { - destroy() { - window.removeEventListener('storage', storageEventListener); - }, - isAuthenticated() { - return !!getUserId(); - }, - getUserId, - async getAccessToken() { - const accessToken = safeLocalStorage.getItem(ACCESS_TOKEN_KEY); - // Check if access token has been invalidated - if (!accessToken) { - return renewAccessToken(); - } - return accessToken; - }, - invalidateAccessToken() { - safeLocalStorage.setItem(ACCESS_TOKEN_KEY, ''); - }, - setAuthPayload, - onUserIdChanged(handler) { - return events.on('userIdChanged', handler); - }, - }; -} - -export const authService = createAuthService(); diff --git a/packages/react-generators/src/generators/auth/auth-service/templates/tokens.gql b/packages/react-generators/src/generators/auth/auth-service/templates/tokens.gql deleted file mode 100644 index 5797a5bc7..000000000 --- a/packages/react-generators/src/generators/auth/auth-service/templates/tokens.gql +++ /dev/null @@ -1,13 +0,0 @@ -fragment AuthPayload on AuthPayload { - accessToken - refreshToken - userId -} - -mutation RefreshToken($input: RefreshTokenInput!) { - refreshToken(input: $input) { - authPayload { - ...AuthPayload - } - } -} diff --git a/packages/react-generators/src/generators/auth/auth-service/templates/tokens.ts b/packages/react-generators/src/generators/auth/auth-service/templates/tokens.ts deleted file mode 100644 index e287c51c9..000000000 --- a/packages/react-generators/src/generators/auth/auth-service/templates/tokens.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @ts-nocheck - -import { ApolloClient, InMemoryCache } from '@apollo/client'; -import { AuthPayload } from './types'; -import { - RefreshTokenDocument, - RefreshTokenMutation, - RefreshTokenMutationVariables, -} from '%react-apollo/generated'; - -const refreshApolloClient = new ApolloClient({ - uri: API_ENDPOINT_URI, - cache: new InMemoryCache(), -}); - -async function refreshAccessToken( - userId: string, - refreshToken?: string | null, -): Promise { - const { data } = await refreshApolloClient.mutate< - RefreshTokenMutation, - RefreshTokenMutationVariables - >({ - mutation: RefreshTokenDocument, - variables: { - input: { userId, refreshToken }, - }, - fetchPolicy: 'no-cache', - }); - - if (!data?.refreshToken?.authPayload) { - throw new Error('No data returned from refresh token mutation'); - } - - return data.refreshToken.authPayload; -} - -// wrapper to ensure we don't run multiple refreshes at once -let refreshPromise: Promise | null = null; - -// TODO: This should use https://github.com/supertokens/browser-tabs-lock to sync across tabs - -export async function getRefreshedAccessToken( - userId: string, - refreshToken?: string | null, -): Promise { - if (refreshPromise) { - return refreshPromise; - } - refreshPromise = refreshAccessToken(userId, refreshToken).finally(() => { - refreshPromise = null; - }); - return refreshPromise; -} diff --git a/packages/react-generators/src/generators/auth/auth-service/templates/types.ts b/packages/react-generators/src/generators/auth/auth-service/templates/types.ts deleted file mode 100644 index 365ab739c..000000000 --- a/packages/react-generators/src/generators/auth/auth-service/templates/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface AuthPayload { - userId: string; - accessToken: string; - refreshToken?: string | null; -} diff --git a/packages/react-generators/src/generators/auth/index.ts b/packages/react-generators/src/generators/auth/index.ts index 61fbf7a41..d74e1e20b 100644 --- a/packages/react-generators/src/generators/auth/index.ts +++ b/packages/react-generators/src/generators/auth/index.ts @@ -1,8 +1,2 @@ -export * from './auth-apollo/auth-apollo.generator.js'; -export * from './auth-components/auth-components.generator.js'; -export * from './auth-hooks/auth-hooks.generator.js'; +export * from './_providers/index.js'; export * from './auth-identify/auth-identify.generator.js'; -export * from './auth-layout/auth-layout.generator.js'; -export * from './auth-login-page/auth-login-page.generator.js'; -export * from './auth-pages/auth-pages.generator.js'; -export * from './auth-service/auth-service.generator.js'; diff --git a/packages/react-generators/src/generators/auth0/auth0-apollo/auth0-apollo.generator.ts b/packages/react-generators/src/generators/auth0/auth0-apollo/auth0-apollo.generator.ts index 57dd1cef5..892340d59 100644 --- a/packages/react-generators/src/generators/auth0/auth0-apollo/auth0-apollo.generator.ts +++ b/packages/react-generators/src/generators/auth0/auth0-apollo/auth0-apollo.generator.ts @@ -1,24 +1,16 @@ import { - projectScope, + tsCodeFragment, + tsImportBuilder, TypescriptCodeUtils, } from '@halfdomelabs/core-generators'; -import { - createGenerator, - createGeneratorTask, - createProviderType, -} from '@halfdomelabs/sync'; +import { createGenerator, createGeneratorTask } from '@halfdomelabs/sync'; import { z } from 'zod'; import { apolloErrorProvider } from '@src/generators/apollo/apollo-error/apollo-error.generator.js'; -import { reactApolloSetupProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; +import { reactApolloConfigProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; const descriptorSchema = z.object({}); -export type Auth0ApolloProvider = unknown; - -export const auth0ApolloProvider = - createProviderType('auth0-apollo'); - export const auth0ApolloGenerator = createGenerator({ name: 'auth0/auth0-apollo', generatorFileUrl: import.meta.url, @@ -26,25 +18,16 @@ export const auth0ApolloGenerator = createGenerator({ buildTasks: () => ({ main: createGeneratorTask({ dependencies: { - reactApolloSetup: reactApolloSetupProvider, + reactApolloConfig: reactApolloConfigProvider, apolloError: apolloErrorProvider, }, - exports: { - auth0Apollo: auth0ApolloProvider.export(projectScope), - }, - run({ reactApolloSetup }) { - reactApolloSetup.addCreateArg({ + run({ reactApolloConfig }) { + reactApolloConfig.createApolloClientArguments.add({ name: 'getAccessToken', - type: TypescriptCodeUtils.createExpression( - '() => Promise', - ), - creatorValue: TypescriptCodeUtils.createExpression( - 'getAccessTokenSilently', - ), - hookDependency: 'getAccessTokenSilently', - renderBody: TypescriptCodeUtils.createBlock( - 'const { getAccessTokenSilently } = useAuth0();', - "import { useAuth0 } from '@auth0/auth0-react';", + type: '() => Promise', + reactRenderBody: tsCodeFragment( + 'const { getAccessTokenSilently: getAccessToken } = useAuth0();', + tsImportBuilder(['useAuth0']).from('@auth0/auth0-react'), ), }); @@ -59,12 +42,14 @@ export const auth0ApolloGenerator = createGenerator({ 'AUTH_LINK', ); - reactApolloSetup.addLink({ + reactApolloConfig.apolloLinks.add({ name: 'authLink', - bodyExpression: TypescriptCodeUtils.createBlock(authLink, [ - 'import { setContext } from "@apollo/client/link/context"', + bodyFragment: tsCodeFragment(authLink, [ + tsImportBuilder(['setContext']).from( + '@apollo/client/link/context', + ), ]), - dependencies: [['errorLink', 'authLink']], + priority: 'auth', }); }, }; diff --git a/packages/react-generators/src/generators/auth0/auth0-callback/auth0-callback.generator.ts b/packages/react-generators/src/generators/auth0/auth0-callback/auth0-callback.generator.ts index 2f4efe214..8ee488016 100644 --- a/packages/react-generators/src/generators/auth0/auth0-callback/auth0-callback.generator.ts +++ b/packages/react-generators/src/generators/auth0/auth0-callback/auth0-callback.generator.ts @@ -7,7 +7,7 @@ import { import { createGenerator, createGeneratorTask } from '@halfdomelabs/sync'; import { z } from 'zod'; -import { authHooksProvider } from '@src/generators/auth/auth-hooks/auth-hooks.generator.js'; +import { authHooksProvider } from '@src/generators/auth/index.js'; import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; import { reactRoutesProvider } from '@src/providers/routes.js'; diff --git a/packages/react-generators/src/generators/auth0/auth0-components/auth0-components.generator.ts b/packages/react-generators/src/generators/auth0/auth0-components/auth0-components.generator.ts index 25d721130..ebebd0a83 100644 --- a/packages/react-generators/src/generators/auth0/auth0-components/auth0-components.generator.ts +++ b/packages/react-generators/src/generators/auth0/auth0-components/auth0-components.generator.ts @@ -6,7 +6,7 @@ import { import { createGenerator, createGeneratorTask } from '@halfdomelabs/sync'; import { z } from 'zod'; -import { authComponentsProvider } from '@src/generators/auth/auth-components/auth-components.generator.js'; +import { authComponentsProvider } from '@src/generators/auth/_providers/auth-components.js'; import { reactComponentsProvider } from '@src/generators/core/react-components/react-components.generator.js'; const descriptorSchema = z.object({}); diff --git a/packages/react-generators/src/generators/auth0/auth0-hooks/auth0-hooks.generator.ts b/packages/react-generators/src/generators/auth0/auth0-hooks/auth0-hooks.generator.ts index 7dc936593..57b4082f4 100644 --- a/packages/react-generators/src/generators/auth0/auth0-hooks/auth0-hooks.generator.ts +++ b/packages/react-generators/src/generators/auth0/auth0-hooks/auth0-hooks.generator.ts @@ -11,7 +11,7 @@ import { import { z } from 'zod'; import { reactApolloProvider } from '@src/generators/apollo/react-apollo/react-apollo.generator.js'; -import { authHooksProvider } from '@src/generators/auth/auth-hooks/auth-hooks.generator.js'; +import { authHooksProvider } from '@src/generators/auth/_providers/auth-hooks.js'; import { reactErrorProvider } from '@src/generators/core/react-error/react-error.generator.js'; const descriptorSchema = z.object({ diff --git a/packages/react-generators/src/generators/core/react-router/react-router.generator.ts b/packages/react-generators/src/generators/core/react-router/react-router.generator.ts index 71dd86369..4ef041c22 100644 --- a/packages/react-generators/src/generators/core/react-router/react-router.generator.ts +++ b/packages/react-generators/src/generators/core/react-router/react-router.generator.ts @@ -14,6 +14,7 @@ import { createGenerator, createGeneratorTask, createProviderTask, + createReadOnlyProviderType, } from '@halfdomelabs/sync'; import { z } from 'zod'; @@ -47,6 +48,11 @@ export { reactRouterConfigProvider }; const pagesPath = '@/src/pages/index.tsx'; +const reactRouteValuesProvider = createReadOnlyProviderType<{ + routes: ReactRoute[]; + layouts: ReactRouteLayout[]; +}>('react-route-values'); + export const reactRouterGenerator = createGenerator({ name: 'core/react-router', generatorFileUrl: import.meta.url, @@ -75,25 +81,15 @@ export const reactRouterGenerator = createGenerator({ ); }, ), - main: createGeneratorTask({ - dependencies: { - reactRouterConfigValues: reactRouterConfigValuesProvider, - typescriptFile: typescriptFileProvider, - }, + routes: createGeneratorTask({ exports: { reactRoutes: reactRoutesProvider.export(projectScope), reactRoutesReadOnly: reactRoutesReadOnlyProvider.export(projectScope), }, - run({ - reactRouterConfigValues: { - routesComponent = tsCodeFragment( - 'Routes', - tsImportBuilder(['Routes']).from('react-router-dom'), - ), - renderHeaders, - }, - typescriptFile, - }) { + outputs: { + reactRouteValuesProvider: reactRouteValuesProvider.export(), + }, + run() { const routes: ReactRoute[] = []; const layouts: ReactRouteLayout[] = []; @@ -114,6 +110,33 @@ export const reactRouterGenerator = createGenerator({ getRoutePrefix: () => ``, }, }, + build: () => ({ + reactRouteValuesProvider: { + routes, + layouts, + }, + }), + }; + }, + }), + main: createGeneratorTask({ + dependencies: { + reactRouterConfigValues: reactRouterConfigValuesProvider, + reactRouteValues: reactRouteValuesProvider, + typescriptFile: typescriptFileProvider, + }, + run({ + reactRouterConfigValues: { + routesComponent = tsCodeFragment( + 'Routes', + tsImportBuilder(['Routes']).from('react-router-dom'), + ), + renderHeaders, + }, + reactRouteValues: { routes, layouts }, + typescriptFile, + }) { + return { build: async (builder) => { // TODO: Make sure we don't have more than one layout key diff --git a/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-maps.ts b/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-maps.ts new file mode 100644 index 000000000..8f1b74ed4 --- /dev/null +++ b/packages/react-generators/src/generators/core/react-sentry/generated/ts-import-maps.ts @@ -0,0 +1,35 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMap, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; +import path from 'node:path/posix'; + +const reactSentryImportsSchema = createTsImportMapSchema({ + logBreadcrumbToSentry: {}, + logErrorToSentry: {}, +}); + +type ReactSentryImportsProvider = TsImportMapProviderFromSchema< + typeof reactSentryImportsSchema +>; + +export const reactSentryImportsProvider = + createReadOnlyProviderType( + 'react-sentry-imports', + ); + +export function createReactSentryImports( + importBase: string, +): ReactSentryImportsProvider { + if (!importBase.startsWith('@/')) { + throw new Error('importBase must start with @/'); + } + + return createTsImportMap(reactSentryImportsSchema, { + logBreadcrumbToSentry: path.join(importBase, 'sentry.js'), + logErrorToSentry: path.join(importBase, 'sentry.js'), + }); +} diff --git a/packages/react-generators/src/generators/core/react-sentry/generated/ts-templates.ts b/packages/react-generators/src/generators/core/react-sentry/generated/ts-templates.ts index 0096054c6..8dce3c092 100644 --- a/packages/react-generators/src/generators/core/react-sentry/generated/ts-templates.ts +++ b/packages/react-generators/src/generators/core/react-sentry/generated/ts-templates.ts @@ -5,7 +5,7 @@ import { reactConfigImportsProvider } from '../../react-config/generated/ts-impo const sentry = createTsTemplateFile({ importMapProviders: { reactConfigImports: reactConfigImportsProvider }, name: 'sentry', - projectExports: {}, + projectExports: { logBreadcrumbToSentry: {}, logErrorToSentry: {} }, source: { path: 'sentry.ts' }, variables: { TPL_SENTRY_SCOPE_ACTIONS: {} }, }); diff --git a/packages/react-generators/src/generators/core/react-sentry/react-sentry.generator.ts b/packages/react-generators/src/generators/core/react-sentry/react-sentry.generator.ts index 73202caff..90d4c7921 100644 --- a/packages/react-generators/src/generators/core/react-sentry/react-sentry.generator.ts +++ b/packages/react-generators/src/generators/core/react-sentry/react-sentry.generator.ts @@ -27,6 +27,10 @@ import { } from '../react-config/react-config.generator.js'; import { reactErrorConfigProvider } from '../react-error/react-error.generator.js'; import { reactRouterConfigProvider } from '../react-router/react-router.generator.js'; +import { + createReactSentryImports, + reactSentryImportsProvider, +} from './generated/ts-import-maps.js'; import { CORE_REACT_SENTRY_TS_TEMPLATES } from './generated/ts-templates.js'; const descriptorSchema = z.object({}); @@ -92,6 +96,18 @@ export const reactSentryGenerator = createGenerator({ } }, ), + imports: createGeneratorTask({ + exports: { + reactSentryImports: reactSentryImportsProvider.export(projectScope), + }, + run() { + return { + providers: { + reactSentryImports: createReactSentryImports('@/src/services'), + }, + }; + }, + }), main: createGeneratorTask({ dependencies: { typescriptFile: typescriptFileProvider, @@ -148,3 +164,5 @@ export const reactSentryGenerator = createGenerator({ }), }), }); + +export { reactSentryImportsProvider } from './generated/ts-import-maps.js'; diff --git a/packages/react-generators/src/generators/core/react-utils/generated/ts-import-maps.ts b/packages/react-generators/src/generators/core/react-utils/generated/ts-import-maps.ts new file mode 100644 index 000000000..bd3360a96 --- /dev/null +++ b/packages/react-generators/src/generators/core/react-utils/generated/ts-import-maps.ts @@ -0,0 +1,31 @@ +import type { TsImportMapProviderFromSchema } from '@halfdomelabs/core-generators'; + +import { + createTsImportMap, + createTsImportMapSchema, +} from '@halfdomelabs/core-generators'; +import { createReadOnlyProviderType } from '@halfdomelabs/sync'; +import path from 'node:path/posix'; + +const reactUtilsImportsSchema = createTsImportMapSchema({ + getSafeLocalStorage: {}, +}); + +type ReactUtilsImportsProvider = TsImportMapProviderFromSchema< + typeof reactUtilsImportsSchema +>; + +export const reactUtilsImportsProvider = + createReadOnlyProviderType('react-utils-imports'); + +export function createReactUtilsImports( + importBase: string, +): ReactUtilsImportsProvider { + if (!importBase.startsWith('@/')) { + throw new Error('importBase must start with @/'); + } + + return createTsImportMap(reactUtilsImportsSchema, { + getSafeLocalStorage: path.join(importBase, 'safe-local-storage.js'), + }); +} diff --git a/packages/react-generators/src/generators/core/react-utils/generated/ts-templates.ts b/packages/react-generators/src/generators/core/react-utils/generated/ts-templates.ts new file mode 100644 index 000000000..279a52dc7 --- /dev/null +++ b/packages/react-generators/src/generators/core/react-utils/generated/ts-templates.ts @@ -0,0 +1,12 @@ +import { createTsTemplateFile } from '@halfdomelabs/core-generators'; + +const safeLocalStorage = createTsTemplateFile({ + name: 'safe-local-storage', + projectExports: { getSafeLocalStorage: {} }, + source: { path: 'safe-local-storage.ts' }, + variables: {}, +}); + +export const REACT_UTILS_TS_TEMPLATES = { + safeLocalStorage, +}; diff --git a/packages/react-generators/src/generators/core/react-utils/react-utils.generator.ts b/packages/react-generators/src/generators/core/react-utils/react-utils.generator.ts index 67d5bcef2..7e61f7f17 100644 --- a/packages/react-generators/src/generators/core/react-utils/react-utils.generator.ts +++ b/packages/react-generators/src/generators/core/react-utils/react-utils.generator.ts @@ -1,37 +1,40 @@ import type { ImportMapper } from '@halfdomelabs/core-generators'; +import type { TemplateFileSource } from '@halfdomelabs/sync'; import { projectScope, - typescriptProvider, + typescriptFileProvider, } from '@halfdomelabs/core-generators'; import { createGenerator, createGeneratorTask, createProviderType, } from '@halfdomelabs/sync'; +import path from 'node:path'; import { z } from 'zod'; -const descriptorSchema = z.object({}); - -interface UtilConfig { - file: string; - exports: string[]; - dependencies?: string[]; -} +import { + createReactUtilsImports, + reactUtilsImportsProvider, +} from './generated/ts-import-maps.js'; +import { REACT_UTILS_TS_TEMPLATES } from './generated/ts-templates.js'; -const UTIL_CONFIG_MAP: Record = { - safeLocalStorage: { - file: 'safe-local-storage.ts', - exports: ['getSafeLocalStorage'], - dependencies: [], - }, -}; +const descriptorSchema = z.object({}); type ReactUtilsProvider = ImportMapper; export const reactUtilsProvider = createProviderType('react-utils'); +function getUtilsPath(source: TemplateFileSource): string { + if (!('path' in source)) { + throw new Error('Template path is required'); + } + return path.join('@/src/utils', source.path); +} + +type ReactUtilKey = keyof typeof REACT_UTILS_TS_TEMPLATES; + export const reactUtilsGenerator = createGenerator({ name: 'core/react-utils', generatorFileUrl: import.meta.url, @@ -39,63 +42,75 @@ export const reactUtilsGenerator = createGenerator({ buildTasks: () => ({ main: createGeneratorTask({ dependencies: { - typescript: typescriptProvider, + typescriptFile: typescriptFileProvider, }, exports: { reactUtils: reactUtilsProvider.export(projectScope), + reactUtilsImports: reactUtilsImportsProvider.export(projectScope), }, - run({ typescript }) { - const usedTemplates: Record = {}; + run({ typescriptFile }) { + const usedTemplates = new Set(); + + const files = Object.entries(REACT_UTILS_TS_TEMPLATES).map( + ([key, template]) => ({ + key, + template, + }), + ); return { providers: { reactUtils: { getImportMap: () => Object.fromEntries( - Object.entries(UTIL_CONFIG_MAP).map(([key, config]) => [ + files.map(({ key, template }) => [ `%react-utils/${key}`, { - path: `@/src/utils/${config.file.replace(/\.ts$/, '')}`, - allowedImports: config.exports, + path: getUtilsPath(template.source), + allowedImports: Object.keys( + template.projectExports ?? {}, + ), onImportUsed: () => { - usedTemplates[key] = true; + usedTemplates.add(key as ReactUtilKey); }, }, ]), ), }, + reactUtilsImports: createReactUtilsImports('@/src/utils'), }, build: async (builder) => { - // recursively resolve dependencies - const markDependenciesAsUsed = (key: string): void => { - const config = UTIL_CONFIG_MAP[key]; - if (config.dependencies) - for (const dep of config.dependencies) { - usedTemplates[dep] = true; - markDependenciesAsUsed(dep); - } - }; - for (const key of Object.keys(usedTemplates)) { - markDependenciesAsUsed(key); - } - // Copy all the util files that were used - const templateFiles = Object.keys(usedTemplates).map( - (key) => UTIL_CONFIG_MAP[key].file, - ); - + // render all ts-utils files that were used await Promise.all( - templateFiles.map((file) => - builder.apply( - typescript.createCopyAction({ - source: file, - destination: `src/utils/${file}`, + [...usedTemplates].map((key) => { + const template = REACT_UTILS_TS_TEMPLATES[key]; + return builder.apply( + typescriptFile.renderTemplateFile({ + template, + destination: getUtilsPath(template.source), }), - ), - ), + ); + }), ); + + // add all remaining files as lazy files + const unusedTemplates = Object.keys( + REACT_UTILS_TS_TEMPLATES, + ).filter((key) => !usedTemplates.has(key as ReactUtilKey)); + + for (const key of unusedTemplates) { + const template = REACT_UTILS_TS_TEMPLATES[key as ReactUtilKey]; + typescriptFile.addLazyTemplateFile({ + template, + destination: getUtilsPath(template.source), + generatorInfo: builder.generatorInfo, + }); + } }, }; }, }), }), }); + +export { reactUtilsImportsProvider } from './generated/ts-import-maps.js'; diff --git a/packages/react-generators/src/writers/component/index.ts b/packages/react-generators/src/writers/component/index.ts deleted file mode 100644 index 8e9801d73..000000000 --- a/packages/react-generators/src/writers/component/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TypescriptCodeBlock } from '@halfdomelabs/core-generators'; - -import { TypescriptCodeUtils } from '@halfdomelabs/core-generators'; - -interface ReactComponent { - name: string; - body: TypescriptCodeBlock; -} - -export function writeReactComponent({ - name, - body, -}: ReactComponent): TypescriptCodeBlock { - return TypescriptCodeUtils.formatBlock( - ` -function NAME(): JSX.Element { - BODY; -} - -export default NAME; -`, - { - NAME: name, - BODY: body, - }, - ); -} diff --git a/packages/sync/src/phases/sort-task-phases.ts b/packages/sync/src/phases/sort-task-phases.ts index 29d4f3268..d536af2d2 100644 --- a/packages/sync/src/phases/sort-task-phases.ts +++ b/packages/sync/src/phases/sort-task-phases.ts @@ -1,4 +1,4 @@ -import { toposort } from '@halfdomelabs/utils'; +import { toposortOrdered } from '@halfdomelabs/utils'; import type { TaskPhase } from './types.js'; @@ -50,7 +50,7 @@ export function sortTaskPhases(phases: TaskPhase[]): TaskPhase[] { } // Perform topological sort - const sortedNames = toposort(nodes, edges); + const sortedNames = toposortOrdered(nodes, edges); // Convert sorted names back to phases return sortedNames.map((name) => { diff --git a/packages/sync/src/runner/dependency-sort.ts b/packages/sync/src/runner/dependency-sort.ts index c08ba549c..6f3b57b04 100644 --- a/packages/sync/src/runner/dependency-sort.ts +++ b/packages/sync/src/runner/dependency-sort.ts @@ -1,4 +1,4 @@ -import { toposort } from '@halfdomelabs/utils'; +import { toposortDfs } from '@halfdomelabs/utils'; import type { GeneratorOutputMetadata } from '@src/output/generator-task-output.js'; @@ -81,7 +81,7 @@ export function getSortedRunSteps( const fullSteps = entries.flatMap(({ id }) => [`init|${id}`, `build|${id}`]); const fullEdges = dependencyGraph; - const result = toposort(fullSteps, fullEdges); + const result = toposortDfs(fullSteps, fullEdges); return { steps: result, diff --git a/packages/sync/src/utils/ordered-list.ts b/packages/sync/src/utils/ordered-list.ts index cf794606c..1a2140f69 100644 --- a/packages/sync/src/utils/ordered-list.ts +++ b/packages/sync/src/utils/ordered-list.ts @@ -1,4 +1,4 @@ -import { toposort } from '@halfdomelabs/utils'; +import { toposortOrdered } from '@halfdomelabs/utils'; import { notEmpty } from './arrays.js'; @@ -38,7 +38,7 @@ export function createOrderedList(): OrderedList { (rule): [string, string] => [item.key, rule], ), ); - return toposort( + return toposortOrdered( items.map((item) => item.key), [...comesBeforeRules, ...comesAfterRules], ) diff --git a/packages/utils/package.json b/packages/utils/package.json index a94e5a603..95787fa4e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -38,6 +38,7 @@ "es-toolkit": "1.31.0", "nanoid": "5.0.9", "sort-keys": "^5.1.0", + "tinyqueue": "3.0.0", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/utils/src/field-map/field-map.ts b/packages/utils/src/field-map/field-map.ts index cc07ed016..155f003b5 100644 --- a/packages/utils/src/field-map/field-map.ts +++ b/packages/utils/src/field-map/field-map.ts @@ -1,3 +1,5 @@ +import { sortBy } from 'es-toolkit'; + export type FieldContainerDynamicSourceGetter = () => string | undefined; export interface FieldContainerOptions { @@ -178,6 +180,59 @@ export class MapContainer } } +/** + * Named field container + * + * This container stores objects that contains a name field that can be used for + * detecting duplicate names. + */ +export class NamedArrayFieldContainer + implements FieldContainer +{ + private readonly _value: Map< + string, + { value: V; setBySource: string | undefined } + >; + protected getDynamicSource: FieldContainerDynamicSourceGetter | undefined; + + constructor(initialValue?: V[], options?: FieldContainerOptions) { + this.getDynamicSource = options?.getDynamicSource; + const value = initialValue ?? []; + this._value = new Map( + value.map((val) => [ + val.name, + { value: val, setBySource: this.getDynamicSource?.() }, + ]), + ); + } + + add(value: V, source?: string): void { + const existingValue = this._value.get(value.name); + if (existingValue?.setBySource) { + throw new Error( + `Value for name ${value.name} has already been set by ${existingValue.setBySource} and cannot be overwritten by ${source}`, + ); + } + this._value.set(value.name, { + value, + setBySource: source ?? this.getDynamicSource?.(), + }); + } + + addMany(values: V[], source?: string): void { + for (const value of values) { + this.add(value, source); + } + } + + getValue(): V[] { + return sortBy( + [...this._value.values()].map((value) => value.value), + [(v) => v.name], + ); + } +} + // Map of maps field container export class MapOfMapsContainer< K1 extends string | number | symbol, @@ -347,6 +402,12 @@ export class FieldMapSchemaBuilder { ); } + namedArray( + initialValue?: V[], + ): NamedArrayFieldContainer { + return new NamedArrayFieldContainer(initialValue, this.options); + } + mapOfMaps< K1 extends string | number | symbol, K2 extends string | number | symbol, diff --git a/packages/utils/src/toposort/errors.ts b/packages/utils/src/toposort/errors.ts new file mode 100644 index 000000000..68b6e5119 --- /dev/null +++ b/packages/utils/src/toposort/errors.ts @@ -0,0 +1,19 @@ +export class ToposortCyclicalDependencyError extends Error { + public cyclePath: unknown[]; + constructor(nodes: unknown[]) { + super( + `Cyclical dependency detected: ${nodes.map((n) => JSON.stringify(n)).join(' -> ')}`, + ); + this.name = 'ToposortCyclicalDependencyError'; + this.cyclePath = nodes; // Store the path for potential inspection + } +} + +export class ToposortUnknownNodeError extends Error { + public unknownNode: unknown; + constructor(node: unknown) { + super(`Unknown node referenced in edges: ${JSON.stringify(node)}`); + this.name = 'ToposortUnknownNodeError'; + this.unknownNode = node; // Store the node for potential inspection + } +} diff --git a/packages/utils/src/toposort/index.ts b/packages/utils/src/toposort/index.ts index 7537629c5..02071e676 100644 --- a/packages/utils/src/toposort/index.ts +++ b/packages/utils/src/toposort/index.ts @@ -1 +1,3 @@ +export * from './errors.js'; +export * from './toposort-dfs.js'; export * from './toposort.js'; diff --git a/packages/utils/src/toposort/toposort-dfs.ts b/packages/utils/src/toposort/toposort-dfs.ts new file mode 100644 index 000000000..4a1c331cf --- /dev/null +++ b/packages/utils/src/toposort/toposort-dfs.ts @@ -0,0 +1,93 @@ +import { + ToposortCyclicalDependencyError, + ToposortUnknownNodeError, +} from './errors.js'; + +function makeOutgoingEdges( + nodes: Map, + edgeArr: [T, T][], +): Map> { + const edges = new Map>(); + for (const edge of edgeArr) { + const [source, target] = edge; + const sourceIndex = nodes.get(source); + const targetIndex = nodes.get(target); + // Check both source and target exist in the provided nodes set + if (sourceIndex === undefined) throw new ToposortUnknownNodeError(source); + if (targetIndex === undefined) throw new ToposortUnknownNodeError(target); + + const sourceEdges = edges.get(sourceIndex); + if (sourceEdges) { + sourceEdges.add(targetIndex); + } else { + edges.set(sourceIndex, new Set([targetIndex])); + } + } + return edges; +} + +/** + * Topological sort of nodes using the depth-first search algorithm + * + * This algorithm is deprecated for now since it is less performant than BFS. + * However, it is still being used in sorting run steps so kept around for the moment. + * + * @param nodes - The nodes to sort + * @param edges - The edges of the graph + * + * @returns The sorted nodes + */ +export function toposortDfs(nodes: T[], edges: [T, T][]): T[] { + const nodeIndexMap = new Map( + nodes.map((node, index) => [node, index]), + ); + + let cursor = nodes.length; + const sorted = Array.from({ length: cursor }); + const outgoingEdgesMap = makeOutgoingEdges(nodeIndexMap, edges); + + const visited = new Set(); // Nodes whose subgraph is fully explored (Black set) + const visiting = new Set(); // Nodes currently on the recursion stack (Gray set) + + function visit(idx: number, path: number[]): void { + if (visited.has(idx)) { + return; // Already fully processed, do nothing + } + if (visiting.has(idx)) { + // Cycle detected! Reconstruct the cycle path from the current path + const cycleStartIndex = path.indexOf(idx); + const cyclePath = [...path.slice(cycleStartIndex), idx].map( + (i) => nodes[i], + ); + throw new ToposortCyclicalDependencyError(cyclePath); + } + + visiting.add(idx); + path.push(idx); // Add node to current path (for error reporting) + + const outgoingEdges = outgoingEdgesMap.get(idx); + if (outgoingEdges) { + // TODO: Reversing the array is necessary to keep the behavior consistent + // with toposort.array from the toposort package. Once we make the + // generation order independent, we can remove this logic and the reverse + // iteration below. + const outgoingEdgesArray = [...outgoingEdges]; + for (const neighbor of outgoingEdgesArray.reverse()) { + visit(neighbor, path); + } + } + + path.pop(); // Remove node from current path as we backtrack + visiting.delete(idx); // Move from visiting (Gray) set... + visited.add(idx); // ...to visited (Black) set + sorted[--cursor] = nodes[idx]; // Add to the head of the sorted list + } + + // Iterate through the original nodes array to maintain initial order preference + // for disconnected components or nodes with same topological level. + for (let i = nodes.length - 1; i >= 0; i--) { + visit(i, []); + } + + return sorted; +} diff --git a/packages/utils/src/toposort/toposort-dfs.unit.test.ts b/packages/utils/src/toposort/toposort-dfs.unit.test.ts new file mode 100644 index 000000000..8d6864c24 --- /dev/null +++ b/packages/utils/src/toposort/toposort-dfs.unit.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import { + ToposortCyclicalDependencyError, + ToposortUnknownNodeError, +} from './errors.js'; +import { toposortDfs } from './toposort-dfs.js'; + +describe('toposortDfs', () => { + // Helper to check if dependencies are met in the sorted output + const expectOrder = (sorted: T[], edges: [T, T][]): void => { + const positions = new Map(); + for (const [index, node] of sorted.entries()) positions.set(node, index); + + for (const [source, target] of edges) { + const sourcePos = positions.get(source); + const targetPos = positions.get(target); + // Check if both nodes are in the sorted output before comparing positions + // (Handles cases where edges might involve nodes not in the primary 'nodes' list, + // although our makeOutgoingEdges prevents this) + if (sourcePos !== undefined && targetPos !== undefined) { + expect( + sourcePos, + `Dependency violated: ${JSON.stringify(source)} should come before ${JSON.stringify(target)}`, + ).toBeLessThan(targetPos); + } else { + // This case should ideally not be reached if input validation is correct + if (!positions.has(source)) + throw new Error( + `Source node ${JSON.stringify(source)} not found in sorted output`, + ); + if (!positions.has(target)) + throw new Error( + `Target node ${JSON.stringify(target)} not found in sorted output`, + ); + } + } + }; + + it('should return an empty array for an empty graph', () => { + expect(toposortDfs([], [])).toEqual([]); + }); + + it('should return the single node for a graph with one node', () => { + expect(toposortDfs(['a'], [])).toEqual(['a']); + expect(toposortDfs([1], [])).toEqual([1]); + }); + + it('should sort nodes in a simple linear chain', () => { + const nodes = ['a', 'b', 'c']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ]; + const sorted = toposortDfs(nodes, edges); + expect(sorted).toEqual(['a', 'b', 'c']); + expectOrder(sorted, edges); + }); + + it('should sort nodes in a simple linear chain (numbers)', () => { + const nodes = [1, 2, 3, 0]; + const edges: [number, number][] = [ + [1, 2], + [2, 3], + [0, 1], + ]; + const sorted = toposortDfs(nodes, edges); + expect(sorted).toEqual([0, 1, 2, 3]); + expectOrder(sorted, edges); + }); + + it('should handle multiple paths correctly', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['a', 'c'], + ['b', 'd'], + ['c', 'd'], + ]; + const sorted = toposortDfs(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); // Ensure all nodes are present + expectOrder(sorted, edges); + // Note: Multiple valid sorts exist, e.g., ['a', 'c', 'b', 'd'] or ['a', 'b', 'c', 'd'] + // expectOrder verifies the constraints. + }); + + it('should handle disconnected components', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['c', 'd'], + ]; + const sorted = toposortDfs(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); + expectOrder(sorted, edges); + // Example valid sorts: ['c', 'd', 'a', 'b'], ['a', 'b', 'c', 'd'] + }); + + it('should handle nodes with no edges', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [['a', 'b']]; + const sorted = toposortDfs(nodes, edges); + expect(sorted).toHaveLength(4); + expect(new Set(sorted)).toEqual(new Set(nodes)); + expectOrder(sorted, edges); + // Example valid sorts: ['c', 'd', 'a', 'b'], ['d', 'a', 'b', 'c'] etc. + // Check that 'a' comes before 'b'. + expect(sorted.indexOf('a')).toBeLessThan(sorted.indexOf('b')); + }); + + it('should throw ToposortCyclicalDependencyError for a simple cycle', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'a'], + ]; + try { + toposortDfs(nodes.reverse(), edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + // Depending on traversal order, the reported cycle might start at 'b' + expect((e as ToposortCyclicalDependencyError).cyclePath).satisfies( + (path: unknown[]) => + (path.length === 3 && + path[0] === 'a' && + path[1] === 'b' && + path[2] === 'a') || + (path.length === 3 && + path[0] === 'b' && + path[1] === 'a' && + path[2] === 'b'), + ); + } + }); + + it('should throw ToposortCyclicalDependencyError for a longer cycle', () => { + const nodes = ['a', 'b', 'c', 'd']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ['c', 'd'], + ['d', 'b'], + ]; // Cycle: b -> c -> d -> b + try { + toposortDfs(nodes.reverse(), edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + expect((e as ToposortCyclicalDependencyError).cyclePath).toEqual([ + 'b', + 'c', + 'd', + 'b', + ]); + } + }); + + it('should throw ToposortCyclicalDependencyError for self-loop', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [ + ['a', 'a'], + ['a', 'b'], + ]; + expect(() => toposortDfs(nodes, edges)).toThrowError( + ToposortCyclicalDependencyError, + ); + expect(() => toposortDfs(nodes, edges)).toThrowError( + /Cyclical dependency detected: "a" -> "a"/, + ); + try { + toposortDfs(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); + expect((e as ToposortCyclicalDependencyError).cyclePath).toEqual([ + 'a', + 'a', + ]); + } + }); + + it('should throw ToposortUnknownNodeError if edge source is not in nodes', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [['c', 'a']]; // 'c' is unknown + expect(() => toposortDfs(nodes, edges)).toThrowError( + ToposortUnknownNodeError, + ); + expect(() => toposortDfs(nodes, edges)).toThrowError( + /Unknown node referenced in edges: "c"/, + ); + try { + toposortDfs(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortUnknownNodeError); + expect((e as ToposortUnknownNodeError).unknownNode).toBe('c'); + } + }); + + it('should throw ToposortUnknownNodeError if edge target is not in nodes', () => { + const nodes = ['a', 'b']; + const edges: [string, string][] = [['a', 'c']]; // 'c' is unknown + expect(() => toposortDfs(nodes, edges)).toThrowError( + ToposortUnknownNodeError, + ); + expect(() => toposortDfs(nodes, edges)).toThrowError( + /Unknown node referenced in edges: "c"/, + ); + try { + toposortDfs(nodes, edges); + } catch (e) { + expect(e).toBeInstanceOf(ToposortUnknownNodeError); + expect((e as ToposortUnknownNodeError).unknownNode).toBe('c'); + } + }); + + it('should handle nodes as objects (reference equality)', () => { + const nodeA = { id: 'a' }; + const nodeB = { id: 'b' }; + const nodeC = { id: 'c' }; + const nodes = [nodeA, nodeB, nodeC]; + const edges: [object, object][] = [ + [nodeA, nodeB], + [nodeB, nodeC], + ]; + const sorted = toposortDfs(nodes, edges); + // Use toStrictEqual for deep equality check with objects + expect(sorted).toStrictEqual([nodeA, nodeB, nodeC]); + expectOrder(sorted, edges); + }); + + it('should handle duplicate edges gracefully', () => { + const nodes = ['a', 'b', 'c']; + const edges: [string, string][] = [ + ['a', 'b'], + ['b', 'c'], + ['a', 'b'], + ]; // Duplicate a -> b + const sorted = toposortDfs(nodes, edges); + expect(sorted).toEqual(['a', 'b', 'c']); + expectOrder(sorted, edges.slice(0, 2)); // Check order against unique edges + }); +}); diff --git a/packages/utils/src/toposort/toposort.ts b/packages/utils/src/toposort/toposort.ts index c28df5929..cb748692e 100644 --- a/packages/utils/src/toposort/toposort.ts +++ b/packages/utils/src/toposort/toposort.ts @@ -1,28 +1,18 @@ -export class ToposortCyclicalDependencyError extends Error { - public cyclePath: unknown[]; - constructor(nodes: unknown[]) { - super( - `Cyclical dependency detected: ${nodes.map((n) => JSON.stringify(n)).join(' -> ')}`, - ); - this.name = 'ToposortCyclicalDependencyError'; - this.cyclePath = nodes; // Store the path for potential inspection - } -} +import TinyQueue from 'tinyqueue'; -export class ToposortUnknownNodeError extends Error { - public unknownNode: unknown; - constructor(node: unknown) { - super(`Unknown node referenced in edges: ${JSON.stringify(node)}`); - this.name = 'ToposortUnknownNodeError'; - this.unknownNode = node; // Store the node for potential inspection - } -} +import { + ToposortCyclicalDependencyError, + ToposortUnknownNodeError, +} from './errors.js'; +/** + * Creates a map of outgoing edges from node indices to their target indices + */ function makeOutgoingEdges( nodes: Map, edgeArr: [T, T][], ): Map> { - const edges = new Map>(); + const outgoingEdgesMap = new Map>(); for (const edge of edgeArr) { const [source, target] = edge; const sourceIndex = nodes.get(source); @@ -31,74 +21,190 @@ function makeOutgoingEdges( if (sourceIndex === undefined) throw new ToposortUnknownNodeError(source); if (targetIndex === undefined) throw new ToposortUnknownNodeError(target); - const sourceEdges = edges.get(sourceIndex); + const sourceEdges = outgoingEdgesMap.get(sourceIndex); if (sourceEdges) { sourceEdges.add(targetIndex); } else { - edges.set(sourceIndex, new Set([targetIndex])); + outgoingEdgesMap.set(sourceIndex, new Set([targetIndex])); + } + } + return outgoingEdgesMap; +} + +/** + * Creates a map of node indices to their in-degree + */ +function makeNodeInDegrees( + outgoingEdgesMap: Map>, + nodeLength: number, +): number[] { + const nodeInDegrees = Array.from({ length: nodeLength }, () => 0); + for (const [, targets] of outgoingEdgesMap.entries()) { + for (const target of targets) { + nodeInDegrees[target]++; + } + } + return nodeInDegrees; +} + +/** + * Detects cycles in a graph by checking if all nodes are included in the topological sort + */ +function detectCycle( + nodes: T[], + visited: Set, + edges: Map>, +): T[] { + // If all nodes were visited, no cycle exists + if (visited.size === nodes.length) { + return []; + } + + // Run DFS from any unvisited node to find a cycle + const path: number[] = []; + const visitSet = new Set(); + + function dfs(node: number): boolean { + if (visitSet.has(node)) { + path.push(node); + return true; + } + + if (visited.has(node)) { + return false; + } + + visitSet.add(node); + path.push(node); + + const neighbors = edges.get(node) ?? new Set(); + for (const neighbor of neighbors) { + if (dfs(neighbor)) { + return true; + } } + + path.pop(); + visitSet.delete(node); + return false; + } + + // For cycle detection, we need to find nodes that weren't visited + const unvistedNodeIdx = nodes.findIndex((node, idx) => !visited.has(idx)); + + if (unvistedNodeIdx === -1) { + return []; + } + + // Start DFS from any unvisited node + dfs(unvistedNodeIdx); + + // Convert path indices to actual nodes + return path.map((idx) => nodes[idx]); +} + +/** + * Default comparison function for stable topological sort + */ +function defaultCompareFunc(a: T, b: T): number { + if (typeof a === 'string' && typeof b === 'string') { + return a.localeCompare(b); } - return edges; + if (a === b) return 0; + return a < b ? -1 : 1; +} + +interface ToposortOptions { + /** + * Optional custom comparison function to break ties between nodes with the same topological level + * + * This allows for a stable topological sort that is consistent with the input order of nodes with the same topological level. + */ + compareFunc?: (a: T, b: T) => number; } /** - * Topological sort of nodes using the depth-first search algorithm + * Performs a topological sort on a directed acyclic graph. * * @param nodes - The nodes to sort * @param edges - The edges of the graph + * @param options - Optional options for the topological sort * @returns The sorted nodes */ -export function toposort(nodes: T[], edges: [T, T][]): T[] { +export function toposort( + nodes: T[], + edges: [T, T][], + options: ToposortOptions = {}, +): T[] { + const { compareFunc } = options; + + // Map each node to its index const nodeIndexMap = new Map( nodes.map((node, index) => [node, index]), ); - let cursor = nodes.length; - const sorted = Array.from({ length: cursor }); + // Create a map of outgoing edges from each node const outgoingEdgesMap = makeOutgoingEdges(nodeIndexMap, edges); + const nodeInDegrees = makeNodeInDegrees(outgoingEdgesMap, nodes.length); - const visited = new Set(); // Nodes whose subgraph is fully explored (Black set) - const visiting = new Set(); // Nodes currently on the recursion stack (Gray set) + // Create a queue of nodes with no incoming edges (in-degree == 0) + const zeroInDegreeQueue = compareFunc + ? new TinyQueue([], (a, b) => compareFunc(nodes[a], nodes[b])) + : ([] as number[]); - function visit(idx: number, path: number[]): void { - if (visited.has(idx)) { - return; // Already fully processed, do nothing - } - if (visiting.has(idx)) { - // Cycle detected! Reconstruct the cycle path from the current path - const cycleStartIndex = path.indexOf(idx); - const cyclePath = [...path.slice(cycleStartIndex), idx].map( - (i) => nodes[i], - ); - throw new ToposortCyclicalDependencyError(cyclePath); + for (const [i, nodeInDegree] of nodeInDegrees.entries()) { + if (nodeInDegree === 0) { + zeroInDegreeQueue.push(i); } + } - visiting.add(idx); - path.push(idx); // Add node to current path (for error reporting) + const result: T[] = []; + const visited = new Set(); - const outgoingEdges = outgoingEdgesMap.get(idx); + // Process nodes in BFS order + while (zeroInDegreeQueue.length > 0) { + const current = zeroInDegreeQueue.pop(); + if (current === undefined) break; + visited.add(current); + result.push(nodes[current]); + + // Process all outgoing edges from the current node + const outgoingEdges = outgoingEdgesMap.get(current); if (outgoingEdges) { - // TODO: Reversing the array is necessary to keep the behavior consistent - // with toposort.array from the toposort package. Once we make the - // generation order independent, we can remove this logic and the reverse - // iteration below. - const outgoingEdgesArray = [...outgoingEdges]; - for (const neighbor of outgoingEdgesArray.reverse()) { - visit(neighbor, path); + for (const target of outgoingEdges) { + nodeInDegrees[target]--; + + // If the target node now has no incoming edges, add it to the queue + if (nodeInDegrees[target] === 0) { + zeroInDegreeQueue.push(target); + } } } - - path.pop(); // Remove node from current path as we backtrack - visiting.delete(idx); // Move from visiting (Gray) set... - visited.add(idx); // ...to visited (Black) set - sorted[--cursor] = nodes[idx]; // Add to the head of the sorted list } - // Iterate through the original nodes array to maintain initial order preference - // for disconnected components or nodes with same topological level. - for (let i = nodes.length - 1; i >= 0; i--) { - visit(i, []); + // Check for cycles + if (result.length !== nodes.length) { + const cyclePath = detectCycle(nodes, visited, outgoingEdgesMap); + throw new ToposortCyclicalDependencyError(cyclePath); } - return sorted; + return result; +} + +/** + * Performs a topological sort on a directed acyclic graph, always selecting + * the smallest available node according to the provided comparison function, + * yielding the lexicographically minimal ordering. + * + * @param nodes - The nodes to sort + * @param edges - The edges of the graph + * @param compareFunc - Optional custom comparison function to break ties between nodes with the same topological level (default is string comparison) + * @returns The sorted nodes + */ +export function toposortOrdered( + nodes: T[], + edges: [T, T][], + compareFunc: (a: T, b: T) => number = defaultCompareFunc, +): T[] { + return toposort(nodes, edges, { compareFunc }); } diff --git a/packages/utils/src/toposort/toposort.unit.test.ts b/packages/utils/src/toposort/toposort.unit.test.ts index 65e58ab22..3a4cbbd0b 100644 --- a/packages/utils/src/toposort/toposort.unit.test.ts +++ b/packages/utils/src/toposort/toposort.unit.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { - toposort, ToposortCyclicalDependencyError, ToposortUnknownNodeError, -} from './toposort.js'; +} from './errors.js'; +import { toposort } from './toposort.js'; describe('toposort', () => { // Helper to check if dependencies are met in the sorted output @@ -148,10 +148,10 @@ describe('toposort', () => { } catch (e) { expect(e).toBeInstanceOf(ToposortCyclicalDependencyError); expect((e as ToposortCyclicalDependencyError).cyclePath).toEqual([ + 'd', 'b', 'c', 'd', - 'b', ]); } }); @@ -235,4 +235,21 @@ describe('toposort', () => { expect(sorted).toEqual(['a', 'b', 'c']); expectOrder(sorted, edges.slice(0, 2)); // Check order against unique edges }); + + it('should sort nodes lexically when there is a tie', () => { + const nodes = ['a', 'b', 'c']; + const edges: [string, string][] = [ + ['a', 'b'], + ['a', 'c'], + ]; + const sorted = toposort(nodes, edges, { + compareFunc: (a, b) => a.localeCompare(b), + }); + expect(sorted).toEqual(['a', 'b', 'c']); + const sortedReverse = toposort(nodes, edges, { + compareFunc: (a, b) => b.localeCompare(a), + }); + expect(sortedReverse).toEqual(['a', 'c', 'b']); + expectOrder(sorted, edges); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac6b85ab4..9862c716d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1250,6 +1250,9 @@ importers: sort-keys: specifier: ^5.1.0 version: 5.1.0 + tinyqueue: + specifier: 3.0.0 + version: 3.0.0 zod: specifier: 'catalog:' version: 3.24.1 @@ -7509,6 +7512,9 @@ packages: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -15119,6 +15125,8 @@ snapshots: tinypool@1.0.2: {} + tinyqueue@3.0.0: {} + tinyrainbow@1.2.0: {} tinyrainbow@2.0.0: {}