mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	6653 serverless functions store and use environment variables in serverless function scripts (#7390)
 
This commit is contained in:
		| @@ -25,15 +25,15 @@ const documents = { | ||||
|     "\n  \n  query GetManyRemoteTables($input: FindManyRemoteTablesInput!) {\n    findDistantTablesWithStatus(input: $input) {\n      ...RemoteTableFields\n    }\n  }\n": types.GetManyRemoteTablesDocument, | ||||
|     "\n  \n  query GetOneDatabaseConnection($input: RemoteServerIdInput!) {\n    findOneRemoteServerById(input: $input) {\n      ...RemoteServerFields\n    }\n  }\n": types.GetOneDatabaseConnectionDocument, | ||||
|     "\n  mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {\n    createOneObject(input: $input) {\n      id\n      dataSourceId\n      nameSingular\n      namePlural\n      labelSingular\n      labelPlural\n      description\n      icon\n      isCustom\n      isActive\n      createdAt\n      updatedAt\n      labelIdentifierFieldMetadataId\n      imageIdentifierFieldMetadataId\n    }\n  }\n": types.CreateOneObjectMetadataItemDocument, | ||||
|     "\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      defaultValue\n      options\n    }\n  }\n": types.CreateOneFieldMetadataItemDocument, | ||||
|     "\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n      defaultValue\n      options\n    }\n  }\n": types.CreateOneFieldMetadataItemDocument, | ||||
|     "\n  mutation CreateOneRelationMetadata($input: CreateOneRelationInput!) {\n    createOneRelation(input: $input) {\n      id\n      relationType\n      fromObjectMetadataId\n      toObjectMetadataId\n      fromFieldMetadataId\n      toFieldMetadataId\n      createdAt\n      updatedAt\n    }\n  }\n": types.CreateOneRelationMetadataDocument, | ||||
|     "\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n": types.UpdateOneFieldMetadataItemDocument, | ||||
|     "\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n": types.UpdateOneFieldMetadataItemDocument, | ||||
|     "\n  mutation UpdateOneObjectMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateObjectPayload!\n  ) {\n    updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      dataSourceId\n      nameSingular\n      namePlural\n      labelSingular\n      labelPlural\n      description\n      icon\n      isCustom\n      isActive\n      createdAt\n      updatedAt\n      labelIdentifierFieldMetadataId\n      imageIdentifierFieldMetadataId\n    }\n  }\n": types.UpdateOneObjectMetadataItemDocument, | ||||
|     "\n  mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n    deleteOneObject(input: { id: $idToDelete }) {\n      id\n      dataSourceId\n      nameSingular\n      namePlural\n      labelSingular\n      labelPlural\n      description\n      icon\n      isCustom\n      isActive\n      createdAt\n      updatedAt\n      labelIdentifierFieldMetadataId\n      imageIdentifierFieldMetadataId\n    }\n  }\n": types.DeleteOneObjectMetadataItemDocument, | ||||
|     "\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n": types.DeleteOneFieldMetadataItemDocument, | ||||
|     "\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n": types.DeleteOneFieldMetadataItemDocument, | ||||
|     "\n  mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n    deleteOneRelation(input: { id: $idToDelete }) {\n      id\n    }\n  }\n": types.DeleteOneRelationMetadataItemDocument, | ||||
|     "\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n": types.ObjectMetadataItemsDocument, | ||||
|     "\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    sourceCodeHash\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n": types.ServerlessFunctionFieldsFragmentDoc, | ||||
|     "\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                settings\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n": types.ObjectMetadataItemsDocument, | ||||
|     "\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n": types.ServerlessFunctionFieldsFragmentDoc, | ||||
|     "\n  \n  mutation CreateOneServerlessFunctionItem(\n    $input: CreateServerlessFunctionInput!\n  ) {\n    createOneServerlessFunction(input: $input) {\n      ...ServerlessFunctionFields\n    }\n  }\n": types.CreateOneServerlessFunctionItemDocument, | ||||
|     "\n  \n  mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n    deleteOneServerlessFunction(input: $input) {\n      ...ServerlessFunctionFields\n    }\n  }\n": types.DeleteOneServerlessFunctionDocument, | ||||
|     "\n  mutation ExecuteOneServerlessFunction(\n    $input: ExecuteServerlessFunctionInput!\n  ) {\n    executeOneServerlessFunction(input: $input) {\n      data\n      duration\n      status\n      error\n    }\n  }\n": types.ExecuteOneServerlessFunctionDocument, | ||||
| @@ -110,7 +110,7 @@ export function graphql(source: "\n  mutation CreateOneObjectMetadataItem($input | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| export function graphql(source: "\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      defaultValue\n      options\n    }\n  }\n"): (typeof documents)["\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      defaultValue\n      options\n    }\n  }\n"]; | ||||
| export function graphql(source: "\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n      defaultValue\n      options\n    }\n  }\n"): (typeof documents)["\n  mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n    createOneField(input: $input) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n      defaultValue\n      options\n    }\n  }\n"]; | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| @@ -118,7 +118,7 @@ export function graphql(source: "\n  mutation CreateOneRelationMetadata($input: | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| export function graphql(source: "\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n"): (typeof documents)["\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n"]; | ||||
| export function graphql(source: "\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n"): (typeof documents)["\n  mutation UpdateOneFieldMetadataItem(\n    $idToUpdate: UUID!\n    $updatePayload: UpdateFieldInput!\n  ) {\n    updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n"]; | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| @@ -130,7 +130,7 @@ export function graphql(source: "\n  mutation DeleteOneObjectMetadataItem($idToD | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| export function graphql(source: "\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n"): (typeof documents)["\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n    }\n  }\n"]; | ||||
| export function graphql(source: "\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n"): (typeof documents)["\n  mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n    deleteOneField(input: { id: $idToDelete }) {\n      id\n      type\n      name\n      label\n      description\n      icon\n      isCustom\n      isActive\n      isNullable\n      createdAt\n      updatedAt\n      settings\n    }\n  }\n"]; | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| @@ -138,11 +138,11 @@ export function graphql(source: "\n  mutation DeleteOneRelationMetadataItem($idT | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| export function graphql(source: "\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n"): (typeof documents)["\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n"]; | ||||
| export function graphql(source: "\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                settings\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n"): (typeof documents)["\n  query ObjectMetadataItems(\n    $objectFilter: objectFilter\n    $fieldFilter: fieldFilter\n  ) {\n    objects(paging: { first: 1000 }, filter: $objectFilter) {\n      edges {\n        node {\n          id\n          dataSourceId\n          nameSingular\n          namePlural\n          labelSingular\n          labelPlural\n          description\n          icon\n          isCustom\n          isRemote\n          isActive\n          isSystem\n          createdAt\n          updatedAt\n          labelIdentifierFieldMetadataId\n          imageIdentifierFieldMetadataId\n          fields(paging: { first: 1000 }, filter: $fieldFilter) {\n            edges {\n              node {\n                id\n                type\n                name\n                label\n                description\n                icon\n                isCustom\n                isActive\n                isSystem\n                isNullable\n                createdAt\n                updatedAt\n                defaultValue\n                options\n                settings\n                relationDefinition {\n                  relationId\n                  direction\n                  sourceObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  sourceFieldMetadata {\n                    id\n                    name\n                  }\n                  targetObjectMetadata {\n                    id\n                    nameSingular\n                    namePlural\n                  }\n                  targetFieldMetadata {\n                    id\n                    name\n                  }\n                }\n              }\n            }\n            pageInfo {\n              hasNextPage\n              hasPreviousPage\n              startCursor\n              endCursor\n            }\n          }\n        }\n      }\n      pageInfo {\n        hasNextPage\n        hasPreviousPage\n        startCursor\n        endCursor\n      }\n    }\n  }\n"]; | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
| export function graphql(source: "\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    sourceCodeHash\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n"): (typeof documents)["\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    sourceCodeHash\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n"]; | ||||
| export function graphql(source: "\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n"): (typeof documents)["\n  fragment ServerlessFunctionFields on ServerlessFunction {\n    id\n    name\n    description\n    runtime\n    syncStatus\n    latestVersion\n    createdAt\n    updatedAt\n  }\n"]; | ||||
| /** | ||||
|  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. | ||||
|  */ | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,9 +1,8 @@ | ||||
| import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; | ||||
| import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; | ||||
| import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; | ||||
| import { SettingsPath } from '@/types/SettingsPath'; | ||||
| import { Button } from '@/ui/input/button/components/Button'; | ||||
| import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor'; | ||||
| import { CodeEditor, File } from '@/ui/input/code-editor/components/CodeEditor'; | ||||
| import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader'; | ||||
| import { Section } from '@/ui/layout/section/components/Section'; | ||||
| import { TabList } from '@/ui/layout/tab/components/TabList'; | ||||
| @@ -13,13 +12,16 @@ import { useNavigate } from 'react-router-dom'; | ||||
| import { Key } from 'ts-key-enum'; | ||||
| import { H2Title, IconGitCommit, IconPlayerPlay, IconRestore } from 'twenty-ui'; | ||||
| import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; | ||||
| import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId'; | ||||
| import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; | ||||
| import { useRecoilValue } from 'recoil'; | ||||
|  | ||||
| const StyledTabList = styled(TabList)` | ||||
|   border-bottom: none; | ||||
| `; | ||||
|  | ||||
| export const SettingsServerlessFunctionCodeEditorTab = ({ | ||||
|   formValues, | ||||
|   files, | ||||
|   handleExecute, | ||||
|   handlePublish, | ||||
|   handleReset, | ||||
| @@ -28,15 +30,19 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ | ||||
|   onChange, | ||||
|   setIsCodeValid, | ||||
| }: { | ||||
|   formValues: ServerlessFunctionFormValues; | ||||
|   files: File[]; | ||||
|   handleExecute: () => void; | ||||
|   handlePublish: () => void; | ||||
|   handleReset: () => void; | ||||
|   resetDisabled: boolean; | ||||
|   publishDisabled: boolean; | ||||
|   onChange: (key: string) => (value: string) => void; | ||||
|   onChange: (filePath: string, value: string) => void; | ||||
|   setIsCodeValid: (isCodeValid: boolean) => void; | ||||
| }) => { | ||||
|   const { activeTabIdState } = useTabList( | ||||
|     SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID, | ||||
|   ); | ||||
|   const activeTabId = useRecoilValue(activeTabIdState); | ||||
|   const TestButton = ( | ||||
|     <Button | ||||
|       title="Test" | ||||
| @@ -68,21 +74,15 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ | ||||
|     /> | ||||
|   ); | ||||
|  | ||||
|   const TAB_LIST_COMPONENT_ID = 'serverless-function-editor'; | ||||
|  | ||||
|   const HeaderTabList = ( | ||||
|     <StyledTabList | ||||
|       tabListId={TAB_LIST_COMPONENT_ID} | ||||
|       tabs={[{ id: 'index.ts', title: 'index.ts' }]} | ||||
|       tabListId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID} | ||||
|       tabs={files.map((file) => { | ||||
|         return { id: file.path, title: file.path.split('/').at(-1) || '' }; | ||||
|       })} | ||||
|     /> | ||||
|   ); | ||||
|  | ||||
|   const Header = ( | ||||
|     <CoreEditorHeader | ||||
|       leftNodes={[HeaderTabList]} | ||||
|       rightNodes={[ResetButton, PublishButton, TestButton]} | ||||
|     /> | ||||
|   ); | ||||
|   const navigate = useNavigate(); | ||||
|   useHotkeyScopeOnMount( | ||||
|     SettingsServerlessFunctionHotkeyScope.ServerlessFunctionEditorTab, | ||||
| @@ -95,18 +95,25 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ | ||||
|     }, | ||||
|     SettingsServerlessFunctionHotkeyScope.ServerlessFunctionEditorTab, | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Section> | ||||
|       <H2Title | ||||
|         title="Code your function" | ||||
|         description="Write your function (in typescript) below" | ||||
|       /> | ||||
|       <CodeEditor | ||||
|         value={formValues.code} | ||||
|         onChange={onChange('code')} | ||||
|         setIsCodeValid={setIsCodeValid} | ||||
|         header={Header} | ||||
|       <CoreEditorHeader | ||||
|         leftNodes={[HeaderTabList]} | ||||
|         rightNodes={[ResetButton, PublishButton, TestButton]} | ||||
|       /> | ||||
|       {activeTabId && ( | ||||
|         <CodeEditor | ||||
|           files={files} | ||||
|           currentFilePath={activeTabId} | ||||
|           onChange={(newCodeValue) => onChange(activeTabId, newCodeValue)} | ||||
|           setIsCodeValid={setIsCodeValid} | ||||
|         /> | ||||
|       )} | ||||
|     </Section> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -44,28 +44,6 @@ export const SettingsServerlessFunctionTestTab = ({ | ||||
|     settingsServerlessFunctionOutput.error || | ||||
|     ''; | ||||
|  | ||||
|   const InputHeader = ( | ||||
|     <CoreEditorHeader | ||||
|       title={'Input'} | ||||
|       rightNodes={[ | ||||
|         <Button | ||||
|           title="Run Function" | ||||
|           variant="primary" | ||||
|           accent="blue" | ||||
|           size="small" | ||||
|           Icon={IconPlayerPlay} | ||||
|           onClick={handleExecute} | ||||
|         />, | ||||
|       ]} | ||||
|     /> | ||||
|   ); | ||||
|  | ||||
|   const OutputHeader = ( | ||||
|     <CoreEditorHeader | ||||
|       leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]} | ||||
|       rightNodes={[<LightCopyIconButton copyText={result} />]} | ||||
|     /> | ||||
|   ); | ||||
|   const navigate = useNavigate(); | ||||
|   useHotkeyScopeOnMount( | ||||
|     SettingsServerlessFunctionHotkeyScope.ServerlessFunctionTestTab, | ||||
| @@ -86,20 +64,52 @@ export const SettingsServerlessFunctionTestTab = ({ | ||||
|         description='Insert a JSON input, then press "Run" to test your function.' | ||||
|       /> | ||||
|       <StyledInputsContainer> | ||||
|         <CodeEditor | ||||
|           value={settingsServerlessFunctionInput} | ||||
|           height={200} | ||||
|           onChange={setSettingsServerlessFunctionInput} | ||||
|           language={'json'} | ||||
|           header={InputHeader} | ||||
|         /> | ||||
|         <CodeEditor | ||||
|           value={result} | ||||
|           height={settingsServerlessFunctionCodeEditorOutputParams.height} | ||||
|           language={settingsServerlessFunctionCodeEditorOutputParams.language} | ||||
|           options={{ readOnly: true, domReadOnly: true }} | ||||
|           header={OutputHeader} | ||||
|         /> | ||||
|         <div> | ||||
|           <CoreEditorHeader | ||||
|             title={'Input'} | ||||
|             rightNodes={[ | ||||
|               <Button | ||||
|                 title="Run Function" | ||||
|                 variant="primary" | ||||
|                 accent="blue" | ||||
|                 size="small" | ||||
|                 Icon={IconPlayerPlay} | ||||
|                 onClick={handleExecute} | ||||
|               />, | ||||
|             ]} | ||||
|           /> | ||||
|           <CodeEditor | ||||
|             files={[ | ||||
|               { | ||||
|                 content: settingsServerlessFunctionInput, | ||||
|                 language: 'json', | ||||
|                 path: 'input.json', | ||||
|               }, | ||||
|             ]} | ||||
|             currentFilePath={'input.json'} | ||||
|             height={200} | ||||
|             onChange={setSettingsServerlessFunctionInput} | ||||
|           /> | ||||
|         </div> | ||||
|         <div> | ||||
|           <CoreEditorHeader | ||||
|             leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]} | ||||
|             rightNodes={[<LightCopyIconButton copyText={result} />]} | ||||
|           /> | ||||
|           <CodeEditor | ||||
|             files={[ | ||||
|               { | ||||
|                 content: result, | ||||
|                 language: | ||||
|                   settingsServerlessFunctionCodeEditorOutputParams.language, | ||||
|                 path: 'result.any', | ||||
|               }, | ||||
|             ]} | ||||
|             currentFilePath={'result.any'} | ||||
|             height={settingsServerlessFunctionCodeEditorOutputParams.height} | ||||
|             options={{ readOnly: true, domReadOnly: true }} | ||||
|           /> | ||||
|         </div> | ||||
|       </StyledInputsContainer> | ||||
|     </Section> | ||||
|   ); | ||||
|   | ||||
| @@ -0,0 +1,2 @@ | ||||
| export const SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID = | ||||
|   'settings-serverless-function-editor-tab-list'; | ||||
| @@ -5,7 +5,6 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql` | ||||
|     id | ||||
|     name | ||||
|     description | ||||
|     sourceCodeHash | ||||
|     runtime | ||||
|     syncStatus | ||||
|     latestVersion | ||||
|   | ||||
| @@ -9,7 +9,7 @@ export type ServerlessFunctionNewFormValues = { | ||||
| }; | ||||
|  | ||||
| export type ServerlessFunctionFormValues = ServerlessFunctionNewFormValues & { | ||||
|   code: string; | ||||
|   code: { [filePath: string]: string } | undefined; | ||||
| }; | ||||
|  | ||||
| type SetServerlessFunctionFormValues = Dispatch< | ||||
| @@ -26,7 +26,7 @@ export const useServerlessFunctionUpdateFormState = ( | ||||
|   const [formValues, setFormValues] = useState<ServerlessFunctionFormValues>({ | ||||
|     name: '', | ||||
|     description: '', | ||||
|     code: '', | ||||
|     code: undefined, | ||||
|   }); | ||||
|  | ||||
|   const { serverlessFunction } = | ||||
| @@ -37,7 +37,7 @@ export const useServerlessFunctionUpdateFormState = ( | ||||
|     version: 'draft', | ||||
|     onCompleted: (data: FindOneServerlessFunctionSourceCodeQuery) => { | ||||
|       const newState = { | ||||
|         code: data?.getServerlessFunctionSourceCode || '', | ||||
|         code: data?.getServerlessFunctionSourceCode || undefined, | ||||
|         name: serverlessFunction?.name || '', | ||||
|         description: serverlessFunction?.description || '', | ||||
|       }; | ||||
|   | ||||
| @@ -1,22 +1,13 @@ | ||||
| import Editor, { Monaco, EditorProps } from '@monaco-editor/react'; | ||||
| import dotenv from 'dotenv'; | ||||
| import { AutoTypings } from 'monaco-editor-auto-typings'; | ||||
| import { editor, MarkerSeverity } from 'monaco-editor'; | ||||
| import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme'; | ||||
| import { useTheme } from '@emotion/react'; | ||||
| import styled from '@emotion/styled'; | ||||
| import { useEffect } from 'react'; | ||||
| import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages'; | ||||
| import { isDefined } from '~/utils/isDefined'; | ||||
|  | ||||
| export const DEFAULT_CODE = `export const handler = async ( | ||||
|   event: object, | ||||
|   context: object | ||||
| ): Promise<object> => { | ||||
|   // Your code here | ||||
|   return {}; | ||||
| } | ||||
| `; | ||||
|  | ||||
| const StyledEditor = styled(Editor)` | ||||
|   border: 1px solid ${({ theme }) => theme.border.color.medium}; | ||||
|   border-top: none; | ||||
| @@ -24,25 +15,34 @@ const StyledEditor = styled(Editor)` | ||||
|     ${({ theme }) => theme.border.radius.sm}; | ||||
| `; | ||||
|  | ||||
| export type File = { | ||||
|   language: string; | ||||
|   content: string; | ||||
|   path: string; | ||||
| }; | ||||
|  | ||||
| type CodeEditorProps = Omit<EditorProps, 'onChange'> & { | ||||
|   header: React.ReactNode; | ||||
|   currentFilePath: string; | ||||
|   files: File[]; | ||||
|   onChange?: (value: string) => void; | ||||
|   setIsCodeValid?: (isCodeValid: boolean) => void; | ||||
| }; | ||||
|  | ||||
| export const CodeEditor = ({ | ||||
|   value = DEFAULT_CODE, | ||||
|   currentFilePath, | ||||
|   files, | ||||
|   onChange, | ||||
|   setIsCodeValid, | ||||
|   language = 'typescript', | ||||
|   height = 450, | ||||
|   options = undefined, | ||||
|   header, | ||||
| }: CodeEditorProps) => { | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   const { availablePackages } = useGetAvailablePackages(); | ||||
|  | ||||
|   const currentFile = files.find((file) => file.path === currentFilePath); | ||||
|   const environmentVariablesFile = files.find((file) => file.path === '.env'); | ||||
|  | ||||
|   const handleEditorDidMount = async ( | ||||
|     editor: editor.IStandaloneCodeEditor, | ||||
|     monaco: Monaco, | ||||
| @@ -50,7 +50,57 @@ export const CodeEditor = ({ | ||||
|     monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme)); | ||||
|     monaco.editor.setTheme('codeEditorTheme'); | ||||
|  | ||||
|     if (language === 'typescript') { | ||||
|     if (files.length > 1) { | ||||
|       files.forEach((file) => { | ||||
|         const model = monaco.editor.getModel(monaco.Uri.file(file.path)); | ||||
|         if (!isDefined(model)) { | ||||
|           monaco.editor.createModel( | ||||
|             file.content, | ||||
|             file.language, | ||||
|             monaco.Uri.file(file.path), | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ | ||||
|         ...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(), | ||||
|         moduleResolution: | ||||
|           monaco.languages.typescript.ModuleResolutionKind.NodeJs, | ||||
|         baseUrl: 'file:///src', | ||||
|         paths: { | ||||
|           'src/*': ['file:///src/*'], | ||||
|         }, | ||||
|         allowSyntheticDefaultImports: true, | ||||
|         esModuleInterop: true, | ||||
|         noEmit: true, | ||||
|         target: monaco.languages.typescript.ScriptTarget.ESNext, | ||||
|       }); | ||||
|  | ||||
|       if (isDefined(environmentVariablesFile)) { | ||||
|         const environmentVariables = dotenv.parse( | ||||
|           environmentVariablesFile.content, | ||||
|         ); | ||||
|  | ||||
|         const environmentDefinition = ` | ||||
|         declare namespace NodeJS { | ||||
|           interface ProcessEnv { | ||||
|             ${Object.keys(environmentVariables) | ||||
|               .map((key) => `${key}: string;`) | ||||
|               .join('\n')} | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         declare const process: { | ||||
|           env: NodeJS.ProcessEnv; | ||||
|         }; | ||||
|       `; | ||||
|  | ||||
|         monaco.languages.typescript.typescriptDefaults.addExtraLib( | ||||
|           environmentDefinition, | ||||
|           'ts:process-env.d.ts', | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       await AutoTypings.create(editor, { | ||||
|         monaco, | ||||
|         preloadPackages: true, | ||||
| @@ -71,43 +121,28 @@ export const CodeEditor = ({ | ||||
|     setIsCodeValid?.(true); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const style = document.createElement('style'); | ||||
|     style.innerHTML = ` | ||||
|       .monaco-editor .margin .line-numbers { | ||||
|         font-weight: bold; | ||||
|       } | ||||
|     `; | ||||
|     document.head.appendChild(style); | ||||
|     return () => { | ||||
|       document.head.removeChild(style); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     isDefined(currentFile) && | ||||
|     isDefined(availablePackages) && ( | ||||
|       <> | ||||
|         {header} | ||||
|         <StyledEditor | ||||
|           height={height} | ||||
|           language={language} | ||||
|           value={value} | ||||
|           onMount={handleEditorDidMount} | ||||
|           onChange={(value?: string) => value && onChange?.(value)} | ||||
|           onValidate={handleEditorValidation} | ||||
|           options={{ | ||||
|             ...options, | ||||
|             overviewRulerLanes: 0, | ||||
|             scrollbar: { | ||||
|               vertical: 'hidden', | ||||
|               horizontal: 'hidden', | ||||
|             }, | ||||
|             minimap: { | ||||
|               enabled: false, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </> | ||||
|       <StyledEditor | ||||
|         height={height} | ||||
|         value={currentFile.content} | ||||
|         language={currentFile.language} | ||||
|         onMount={handleEditorDidMount} | ||||
|         onChange={(value?: string) => value && onChange?.(value)} | ||||
|         onValidate={handleEditorValidation} | ||||
|         options={{ | ||||
|           ...options, | ||||
|           overviewRulerLanes: 0, | ||||
|           scrollbar: { | ||||
|             vertical: 'hidden', | ||||
|             horizontal: 'hidden', | ||||
|           }, | ||||
|           minimap: { | ||||
|             enabled: false, | ||||
|           }, | ||||
|         }} | ||||
|       /> | ||||
|     ) | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||
| import { IconCode, IconFunction, IconSettings, IconTestPipe } from 'twenty-ui'; | ||||
| import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback'; | ||||
| import { isDefined } from '~/utils/isDefined'; | ||||
| import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; | ||||
|  | ||||
| const TAB_LIST_COMPONENT_ID = 'serverless-function-detail'; | ||||
|  | ||||
| @@ -81,14 +82,24 @@ export const SettingsServerlessFunctionDetail = () => { | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   const onCodeChange = async (filePath: string, value: string) => { | ||||
|     setFormValues((prevState) => ({ | ||||
|       ...prevState, | ||||
|       code: { ...prevState.code, [filePath]: value }, | ||||
|     })); | ||||
|     await handleSave(); | ||||
|   }; | ||||
|  | ||||
|   const resetDisabled = | ||||
|     !isDefined(latestVersionCode) || latestVersionCode === formValues.code; | ||||
|   const publishDisabled = !isCodeValid || latestVersionCode === formValues.code; | ||||
|     !isDefined(latestVersionCode) || | ||||
|     isDeeplyEqual(latestVersionCode, formValues.code); | ||||
|   const publishDisabled = | ||||
|     !isCodeValid || isDeeplyEqual(latestVersionCode, formValues.code); | ||||
|  | ||||
|   const handleReset = async () => { | ||||
|     try { | ||||
|       const newState = { | ||||
|         code: latestVersionCode || '', | ||||
|         code: latestVersionCode || {}, | ||||
|       }; | ||||
|       setFormValues((prevState) => ({ | ||||
|         ...prevState, | ||||
| @@ -166,18 +177,30 @@ export const SettingsServerlessFunctionDetail = () => { | ||||
|     { id: 'settings', title: 'Settings', Icon: IconSettings }, | ||||
|   ]; | ||||
|  | ||||
|   const files = formValues.code | ||||
|     ? Object.keys(formValues.code) | ||||
|         .map((key) => { | ||||
|           return { | ||||
|             path: key, | ||||
|             language: key === '.env' ? 'ini' : 'typescript', | ||||
|             content: formValues.code?.[key] || '', | ||||
|           }; | ||||
|         }) | ||||
|         .reverse() | ||||
|     : []; | ||||
|  | ||||
|   const renderActiveTabContent = () => { | ||||
|     switch (activeTabId) { | ||||
|       case 'editor': | ||||
|         return ( | ||||
|           <SettingsServerlessFunctionCodeEditorTab | ||||
|             formValues={formValues} | ||||
|             files={files} | ||||
|             handleExecute={handleExecute} | ||||
|             handlePublish={handlePublish} | ||||
|             handleReset={handleReset} | ||||
|             resetDisabled={resetDisabled} | ||||
|             publishDisabled={publishDisabled} | ||||
|             onChange={onChange} | ||||
|             onChange={onCodeChange} | ||||
|             setIsCodeValid={setIsCodeValid} | ||||
|           /> | ||||
|         ); | ||||
|   | ||||
| @@ -9,7 +9,6 @@ import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions | ||||
| import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; | ||||
| import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; | ||||
| import { SettingsPath } from '@/types/SettingsPath'; | ||||
| import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor'; | ||||
| import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; | ||||
| import { useState } from 'react'; | ||||
| import { Key } from 'ts-key-enum'; | ||||
| @@ -31,7 +30,6 @@ export const SettingsServerlessFunctionsNew = () => { | ||||
|     const newServerlessFunction = await createOneServerlessFunction({ | ||||
|       name: formValues.name, | ||||
|       description: formValues.description, | ||||
|       code: DEFAULT_CODE, | ||||
|     }); | ||||
|  | ||||
|     if (!isDefined(newServerlessFunction?.data)) { | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor'; | ||||
| import { Meta, StoryObj } from '@storybook/react'; | ||||
| import { within } from '@storybook/test'; | ||||
| import { graphql, http, HttpResponse } from 'msw'; | ||||
| @@ -38,7 +37,6 @@ const meta: Meta<PageDecoratorArgs> = { | ||||
|                 description: '', | ||||
|                 syncStatus: 'READY', | ||||
|                 runtime: 'nodejs18.x', | ||||
|                 sourceCodeHash: '42d2734b3dc8a7b45a16803ed7f417bc', | ||||
|                 updatedAt: '2024-02-24T10:23:10.673Z', | ||||
|                 createdAt: '2024-02-24T10:23:10.673Z', | ||||
|               }, | ||||
| @@ -46,7 +44,7 @@ const meta: Meta<PageDecoratorArgs> = { | ||||
|           }); | ||||
|         }), | ||||
|         http.get(getImageAbsoluteURI(SOURCE_CODE_FULL_PATH) || '', () => { | ||||
|           return HttpResponse.text(DEFAULT_CODE); | ||||
|           return HttpResponse.text('export const handler = () => {}'); | ||||
|         }), | ||||
|       ], | ||||
|     }, | ||||
|   | ||||
| @@ -6,17 +6,21 @@ | ||||
|     "builder": "swc", | ||||
|     "typeCheck": true, | ||||
|     "assets": [ | ||||
|       { | ||||
|         "include": "**/serverless/drivers/constants/base-typescript-project/**", | ||||
|         "outDir": "dist/assets" | ||||
|       }, | ||||
|       { | ||||
|         "include": "**/serverless/drivers/layers/*/package.json", | ||||
|         "outDir": "dist/src" | ||||
|         "outDir": "dist/assets" | ||||
|       }, | ||||
|       { | ||||
|         "include": "**/serverless/drivers/layers/*/yarn.lock", | ||||
|         "outDir": "dist/src" | ||||
|         "outDir": "dist/assets" | ||||
|       }, | ||||
|       { | ||||
|         "include": "**/serverless/drivers/layers/engine/**", | ||||
|         "outDir": "dist/src" | ||||
|         "outDir": "dist/assets" | ||||
|       } | ||||
|     ], | ||||
|     "watchAssets": true | ||||
|   | ||||
							
								
								
									
										3
									
								
								packages/twenty-server/src/constants/assets-path.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/twenty-server/src/constants/assets-path.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| import path from 'path'; | ||||
|  | ||||
| export const ASSET_PATH = path.resolve(__dirname, `../../assets`); | ||||
| @@ -0,0 +1,19 @@ | ||||
| import { MigrationInterface, QueryRunner } from 'typeorm'; | ||||
|  | ||||
| export class RemoveServerlessSourceCodeHashColumn1726240847733 | ||||
|   implements MigrationInterface | ||||
| { | ||||
|   name = 'RemoveServerlessSourceCodeHashColumn1726240847733'; | ||||
|  | ||||
|   public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query( | ||||
|       `ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "sourceCodeHash"`, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|     await queryRunner.query( | ||||
|       `ALTER TABLE "metadata"."serverlessFunction" ADD "sourceCodeHash" character varying NOT NULL`, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -17,4 +17,8 @@ export interface StorageDriver { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void>; | ||||
|   download(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void>; | ||||
| } | ||||
|   | ||||
| @@ -21,10 +21,6 @@ export class LocalDriver implements StorageDriver { | ||||
|   } | ||||
|  | ||||
|   async createFolder(path: string) { | ||||
|     if (existsSync(path)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     return fs.mkdir(path, { recursive: true }); | ||||
|   } | ||||
|  | ||||
| @@ -122,21 +118,24 @@ export class LocalDriver implements StorageDriver { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async copy(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void> { | ||||
|   async copy( | ||||
|     params: { | ||||
|       from: { folderPath: string; filename?: string }; | ||||
|       to: { folderPath: string; filename?: string }; | ||||
|     }, | ||||
|     toInMemory = false, | ||||
|   ): Promise<void> { | ||||
|     if (!params.from.filename && params.to.filename) { | ||||
|       throw new Error('Cannot copy folder to file'); | ||||
|     } | ||||
|     const fromPath = join( | ||||
|       `${this.options.storagePath}/`, | ||||
|       this.options.storagePath, | ||||
|       params.from.folderPath, | ||||
|       params.from.filename || '', | ||||
|     ); | ||||
|  | ||||
|     const toPath = join( | ||||
|       `${this.options.storagePath}/`, | ||||
|       toInMemory ? '' : this.options.storagePath, | ||||
|       params.to.folderPath, | ||||
|       params.to.filename || '', | ||||
|     ); | ||||
| @@ -156,4 +155,11 @@ export class LocalDriver implements StorageDriver { | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async download(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void> { | ||||
|     await this.copy(params, true); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| import { Readable } from 'stream'; | ||||
| import fs from 'fs'; | ||||
| import { mkdir } from 'fs/promises'; | ||||
| import { join } from 'path'; | ||||
| import { pipeline } from 'stream/promises'; | ||||
|  | ||||
| import { | ||||
|   CopyObjectCommand, | ||||
| @@ -188,6 +192,23 @@ export class S3Driver implements StorageDriver { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   extractFolderAndFilePaths(objectKey: string | undefined) { | ||||
|     if (!isDefined(objectKey)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const result = /(?<folder>.*)\/(?<file>.*)/.exec(objectKey); | ||||
|  | ||||
|     if (!isDefined(result) || !isDefined(result.groups)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const fromFolderPath = result.groups.folder; | ||||
|     const filename = result.groups.file; | ||||
|  | ||||
|     return { fromFolderPath, filename }; | ||||
|   } | ||||
|  | ||||
|   async copy(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
| @@ -239,17 +260,18 @@ export class S3Driver implements StorageDriver { | ||||
|     ); | ||||
|  | ||||
|     if (!listedObjects.Contents || listedObjects.Contents.length === 0) { | ||||
|       throw new Error('No objects found in the source folder.'); | ||||
|       throw new Error(`No objects found in the source folder ${fromKey}.`); | ||||
|     } | ||||
|  | ||||
|     for (const object of listedObjects.Contents) { | ||||
|       const match = object.Key?.match(/(.*)\/(.*)/); | ||||
|       const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); | ||||
|  | ||||
|       if (!isDefined(match)) { | ||||
|       if (!isDefined(folderAndFilePaths)) { | ||||
|         continue; | ||||
|       } | ||||
|       const fromFolderPath = match[1]; | ||||
|       const filename = match[2]; | ||||
|  | ||||
|       const { fromFolderPath, filename } = folderAndFilePaths; | ||||
|  | ||||
|       const toFolderPath = fromFolderPath.replace( | ||||
|         params.from.folderPath, | ||||
|         params.to.folderPath, | ||||
| @@ -269,6 +291,85 @@ export class S3Driver implements StorageDriver { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async download(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void> { | ||||
|     if (!params.from.filename && params.to.filename) { | ||||
|       throw new Error('Cannot copy folder to file'); | ||||
|     } | ||||
|  | ||||
|     if (isDefined(params.from.filename)) { | ||||
|       try { | ||||
|         const dir = params.to.folderPath; | ||||
|  | ||||
|         await mkdir(dir, { recursive: true }); | ||||
|  | ||||
|         const fileStream = await this.read({ | ||||
|           folderPath: params.from.folderPath, | ||||
|           filename: params.from.filename, | ||||
|         }); | ||||
|  | ||||
|         const toPath = join( | ||||
|           params.to.folderPath, | ||||
|           params.to.filename || params.from.filename, | ||||
|         ); | ||||
|  | ||||
|         await pipeline(fileStream, fs.createWriteStream(toPath)); | ||||
|  | ||||
|         return; | ||||
|       } catch (error) { | ||||
|         if (error.name === 'NotFound') { | ||||
|           throw new FileStorageException( | ||||
|             'File not found', | ||||
|             FileStorageExceptionCode.FILE_NOT_FOUND, | ||||
|           ); | ||||
|         } | ||||
|         // For other errors, throw the original error | ||||
|         throw error; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const listedObjects = await this.s3Client.send( | ||||
|       new ListObjectsV2Command({ | ||||
|         Bucket: this.bucketName, | ||||
|         Prefix: params.from.folderPath, | ||||
|       }), | ||||
|     ); | ||||
|  | ||||
|     if (!listedObjects.Contents || listedObjects.Contents.length === 0) { | ||||
|       throw new Error( | ||||
|         `No objects found in the source folder ${params.from.folderPath}.`, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     for (const object of listedObjects.Contents) { | ||||
|       const folderAndFilePaths = this.extractFolderAndFilePaths(object.Key); | ||||
|  | ||||
|       if (!isDefined(folderAndFilePaths)) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       const { fromFolderPath, filename } = folderAndFilePaths; | ||||
|       const toFolderPath = fromFolderPath.replace( | ||||
|         params.from.folderPath, | ||||
|         params.to.folderPath, | ||||
|       ); | ||||
|  | ||||
|       if (!isDefined(toFolderPath)) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       await this.download({ | ||||
|         from: { | ||||
|           folderPath: fromFolderPath, | ||||
|           filename, | ||||
|         }, | ||||
|         to: { folderPath: toFolderPath, filename }, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async checkBucketExists(args: HeadBucketCommandInput) { | ||||
|     try { | ||||
|       await this.s3Client.headBucket(args); | ||||
|   | ||||
| @@ -40,4 +40,11 @@ export class FileStorageService implements StorageDriver { | ||||
|   }): Promise<void> { | ||||
|     return this.driver.copy(params); | ||||
|   } | ||||
|  | ||||
|   download(params: { | ||||
|     from: { folderPath: string; filename?: string }; | ||||
|     to: { folderPath: string; filename?: string }; | ||||
|   }): Promise<void> { | ||||
|     return this.driver.download(params); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,25 +0,0 @@ | ||||
| import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; | ||||
| import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content'; | ||||
| import { SOURCE_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/source-file-name'; | ||||
| import { compileTypescript } from 'src/engine/core-modules/serverless/drivers/utils/compile-typescript'; | ||||
| import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; | ||||
| import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils'; | ||||
|  | ||||
| export class BaseServerlessDriver { | ||||
|   async getCompiledCode( | ||||
|     serverlessFunction: ServerlessFunctionEntity, | ||||
|     fileStorageService: FileStorageService, | ||||
|   ) { | ||||
|     const folderPath = getServerlessFolder({ | ||||
|       serverlessFunction, | ||||
|       version: 'draft', | ||||
|     }); | ||||
|     const fileStream = await fileStorageService.read({ | ||||
|       folderPath, | ||||
|       filename: SOURCE_FILE_NAME, | ||||
|     }); | ||||
|     const typescriptCode = await readFileContent(fileStream); | ||||
|  | ||||
|     return compileTypescript(typescriptCode); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1
									
								
								packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| !base-typescript-project/**/.env | ||||
| @@ -0,0 +1,2 @@ | ||||
| # Add your environment variables here. | ||||
| # Access them in your serverless function code using process.env.VARIABLE | ||||
| @@ -0,0 +1,7 @@ | ||||
| export const handler = async ( | ||||
|   event: object, | ||||
|   context: object, | ||||
| ): Promise<object> => { | ||||
|   // Your code here | ||||
|   return {}; | ||||
| }; | ||||
| @@ -0,0 +1 @@ | ||||
| export const ENV_FILE_NAME = '.env'; | ||||
| @@ -0,0 +1 @@ | ||||
| export const INDEX_FILE_NAME = 'index.ts'; | ||||
| @@ -0,0 +1 @@ | ||||
| export const OUTDIR_FOLDER = 'dist'; | ||||
| @@ -1 +0,0 @@ | ||||
| export const SOURCE_FILE_NAME = 'source.ts'; | ||||
| @@ -1,6 +1,7 @@ | ||||
| import * as fs from 'fs/promises'; | ||||
| import { join } from 'path'; | ||||
|  | ||||
| import dotenv from 'dotenv'; | ||||
| import { | ||||
|   CreateFunctionCommand, | ||||
|   DeleteFunctionCommand, | ||||
| @@ -18,6 +19,8 @@ import { | ||||
|   waitUntilFunctionUpdatedV2, | ||||
|   ListLayerVersionsCommandInput, | ||||
|   ListLayerVersionsCommand, | ||||
|   UpdateFunctionConfigurationCommand, | ||||
|   UpdateFunctionConfigurationCommandInput, | ||||
| } from '@aws-sdk/client-lambda'; | ||||
| import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand'; | ||||
| import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand'; | ||||
| @@ -36,7 +39,6 @@ import { | ||||
|   NODE_LAYER_SUBFOLDER, | ||||
| } from 'src/engine/core-modules/serverless/drivers/utils/lambda-build-directory-manager'; | ||||
| import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; | ||||
| import { BaseServerlessDriver } from 'src/engine/core-modules/serverless/drivers/base-serverless.driver'; | ||||
| import { createZipFile } from 'src/engine/core-modules/serverless/drivers/utils/create-zip-file'; | ||||
| import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; | ||||
| import { | ||||
| @@ -46,6 +48,11 @@ import { | ||||
| import { isDefined } from 'src/utils/is-defined'; | ||||
| import { COMMON_LAYER_NAME } from 'src/engine/core-modules/serverless/drivers/constants/common-layer-name'; | ||||
| import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies'; | ||||
| import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils'; | ||||
| import { SERVERLESS_TMPDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/serverless-tmpdir-folder'; | ||||
| import { compileTypescript } from 'src/engine/core-modules/serverless/drivers/utils/compile-typescript'; | ||||
| import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name'; | ||||
| import { OUTDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/outdir-folder'; | ||||
|  | ||||
| export interface LambdaDriverOptions extends LambdaClientConfig { | ||||
|   fileStorageService: FileStorageService; | ||||
| @@ -53,16 +60,12 @@ export interface LambdaDriverOptions extends LambdaClientConfig { | ||||
|   role: string; | ||||
| } | ||||
|  | ||||
| export class LambdaDriver | ||||
|   extends BaseServerlessDriver | ||||
|   implements ServerlessDriver | ||||
| { | ||||
| export class LambdaDriver implements ServerlessDriver { | ||||
|   private readonly lambdaClient: Lambda; | ||||
|   private readonly lambdaRole: string; | ||||
|   private readonly fileStorageService: FileStorageService; | ||||
|  | ||||
|   constructor(options: LambdaDriverOptions) { | ||||
|     super(); | ||||
|     const { region, role, ...lambdaOptions } = options; | ||||
|  | ||||
|     this.lambdaClient = new Lambda({ ...lambdaOptions, region }); | ||||
| @@ -165,24 +168,50 @@ export class LambdaDriver | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async build(serverlessFunction: ServerlessFunctionEntity) { | ||||
|     const javascriptCode = await this.getCompiledCode( | ||||
|   private getInMemoryServerlessFunctionFolderPath = ( | ||||
|     serverlessFunction: ServerlessFunctionEntity, | ||||
|     version: string, | ||||
|   ) => { | ||||
|     return join(SERVERLESS_TMPDIR_FOLDER, serverlessFunction.id, version); | ||||
|   }; | ||||
|  | ||||
|   async build(serverlessFunction: ServerlessFunctionEntity, version: string) { | ||||
|     const computedVersion = | ||||
|       version === 'latest' ? serverlessFunction.latestVersion : version; | ||||
|  | ||||
|     const inMemoryServerlessFunctionFolderPath = | ||||
|       this.getInMemoryServerlessFunctionFolderPath( | ||||
|         serverlessFunction, | ||||
|         computedVersion, | ||||
|       ); | ||||
|  | ||||
|     const folderPath = getServerlessFolder({ | ||||
|       serverlessFunction, | ||||
|       this.fileStorageService, | ||||
|       version, | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.download({ | ||||
|       from: { folderPath }, | ||||
|       to: { folderPath: inMemoryServerlessFunctionFolderPath }, | ||||
|     }); | ||||
|  | ||||
|     compileTypescript(inMemoryServerlessFunctionFolderPath); | ||||
|  | ||||
|     const lambdaZipPath = join( | ||||
|       inMemoryServerlessFunctionFolderPath, | ||||
|       'lambda.zip', | ||||
|     ); | ||||
|  | ||||
|     const lambdaBuildDirectoryManager = new LambdaBuildDirectoryManager(); | ||||
|  | ||||
|     const { | ||||
|       sourceTemporaryDir, | ||||
|     await createZipFile( | ||||
|       join(inMemoryServerlessFunctionFolderPath, OUTDIR_FOLDER), | ||||
|       lambdaZipPath, | ||||
|       javascriptFilePath, | ||||
|       lambdaHandler, | ||||
|     } = await lambdaBuildDirectoryManager.init(); | ||||
|     ); | ||||
|  | ||||
|     await fs.writeFile(javascriptFilePath, javascriptCode); | ||||
|     const envFileContent = await fs.readFile( | ||||
|       join(inMemoryServerlessFunctionFolderPath, ENV_FILE_NAME), | ||||
|     ); | ||||
|  | ||||
|     await createZipFile(sourceTemporaryDir, lambdaZipPath); | ||||
|     const envVariables = dotenv.parse(envFileContent); | ||||
|  | ||||
|     const functionExists = await this.checkFunctionExists( | ||||
|       serverlessFunction.id, | ||||
| @@ -198,8 +227,11 @@ export class LambdaDriver | ||||
|           ZipFile: await fs.readFile(lambdaZipPath), | ||||
|         }, | ||||
|         FunctionName: serverlessFunction.id, | ||||
|         Handler: lambdaHandler, | ||||
|         Handler: 'src/index.handler', | ||||
|         Layers: [layerArn], | ||||
|         Environment: { | ||||
|           Variables: envVariables, | ||||
|         }, | ||||
|         Role: this.lambdaRole, | ||||
|         Runtime: serverlessFunction.runtime, | ||||
|         Description: 'Lambda function to run user script', | ||||
| @@ -210,23 +242,37 @@ export class LambdaDriver | ||||
|  | ||||
|       await this.lambdaClient.send(command); | ||||
|     } else { | ||||
|       const params: UpdateFunctionCodeCommandInput = { | ||||
|       const updateCodeParams: UpdateFunctionCodeCommandInput = { | ||||
|         ZipFile: await fs.readFile(lambdaZipPath), | ||||
|         FunctionName: serverlessFunction.id, | ||||
|       }; | ||||
|  | ||||
|       const command = new UpdateFunctionCodeCommand(params); | ||||
|       const updateCodeCommand = new UpdateFunctionCodeCommand(updateCodeParams); | ||||
|  | ||||
|       await this.lambdaClient.send(command); | ||||
|       await this.lambdaClient.send(updateCodeCommand); | ||||
|  | ||||
|       const updateConfigurationParams: UpdateFunctionConfigurationCommandInput = | ||||
|         { | ||||
|           Environment: { | ||||
|             Variables: envVariables, | ||||
|           }, | ||||
|           FunctionName: serverlessFunction.id, | ||||
|         }; | ||||
|  | ||||
|       const updateConfigurationCommand = new UpdateFunctionConfigurationCommand( | ||||
|         updateConfigurationParams, | ||||
|       ); | ||||
|  | ||||
|       await this.waitFunctionUpdates(serverlessFunction.id, 10); | ||||
|  | ||||
|       await this.lambdaClient.send(updateConfigurationCommand); | ||||
|     } | ||||
|  | ||||
|     await this.waitFunctionUpdates(serverlessFunction.id, 10); | ||||
|  | ||||
|     await lambdaBuildDirectoryManager.clean(); | ||||
|   } | ||||
|  | ||||
|   async publish(serverlessFunction: ServerlessFunctionEntity) { | ||||
|     await this.build(serverlessFunction); | ||||
|     await this.build(serverlessFunction, 'draft'); | ||||
|     const params: PublishVersionCommandInput = { | ||||
|       FunctionName: serverlessFunction.id, | ||||
|     }; | ||||
| @@ -240,6 +286,20 @@ export class LambdaDriver | ||||
|       throw new Error('New published version is undefined'); | ||||
|     } | ||||
|  | ||||
|     const draftFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: serverlessFunction, | ||||
|       version: 'draft', | ||||
|     }); | ||||
|     const newFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: serverlessFunction, | ||||
|       version: newVersion, | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.copy({ | ||||
|       from: { folderPath: draftFolderPath }, | ||||
|       to: { folderPath: newFolderPath }, | ||||
|     }); | ||||
|  | ||||
|     return newVersion; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| import { fork } from 'child_process'; | ||||
| import { promises as fs, existsSync } from 'fs'; | ||||
| import { promises as fs } from 'fs'; | ||||
| import { join } from 'path'; | ||||
|  | ||||
| import { v4 } from 'uuid'; | ||||
| import dotenv from 'dotenv'; | ||||
|  | ||||
| import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; | ||||
| import { | ||||
|   ServerlessDriver, | ||||
|   ServerlessExecuteError, | ||||
| @@ -12,35 +11,36 @@ import { | ||||
| } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface'; | ||||
|  | ||||
| import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; | ||||
| import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content'; | ||||
| import { BaseServerlessDriver } from 'src/engine/core-modules/serverless/drivers/base-serverless.driver'; | ||||
| import { BUILD_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/build-file-name'; | ||||
| import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils'; | ||||
| import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; | ||||
| import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; | ||||
| import { | ||||
|   ServerlessFunctionException, | ||||
|   ServerlessFunctionExceptionCode, | ||||
| } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; | ||||
| import { COMMON_LAYER_NAME } from 'src/engine/core-modules/serverless/drivers/constants/common-layer-name'; | ||||
| import { copyAndBuildDependencies } from 'src/engine/core-modules/serverless/drivers/utils/copy-and-build-dependencies'; | ||||
| import { SERVERLESS_TMPDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/serverless-tmpdir-folder'; | ||||
| import { compileTypescript } from 'src/engine/core-modules/serverless/drivers/utils/compile-typescript'; | ||||
| import { OUTDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/outdir-folder'; | ||||
| import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name'; | ||||
|  | ||||
| const LISTENER_FILE_NAME = 'listener.js'; | ||||
|  | ||||
| export interface LocalDriverOptions { | ||||
|   fileStorageService: FileStorageService; | ||||
| } | ||||
|  | ||||
| export class LocalDriver | ||||
|   extends BaseServerlessDriver | ||||
|   implements ServerlessDriver | ||||
| { | ||||
| export class LocalDriver implements ServerlessDriver { | ||||
|   private readonly fileStorageService: FileStorageService; | ||||
|  | ||||
|   constructor(options: LocalDriverOptions) { | ||||
|     super(); | ||||
|     this.fileStorageService = options.fileStorageService; | ||||
|   } | ||||
|  | ||||
|   private getInMemoryServerlessFunctionFolderPath = ( | ||||
|     serverlessFunction: ServerlessFunctionEntity, | ||||
|     version: string, | ||||
|   ) => { | ||||
|     return join(SERVERLESS_TMPDIR_FOLDER, serverlessFunction.id, version); | ||||
|   }; | ||||
|  | ||||
|   private getInMemoryLayerFolderPath = (version: number) => { | ||||
|     return join(SERVERLESS_TMPDIR_FOLDER, COMMON_LAYER_NAME, `${version}`); | ||||
|   }; | ||||
| @@ -49,88 +49,54 @@ export class LocalDriver | ||||
|     const inMemoryLastVersionLayerFolderPath = | ||||
|       this.getInMemoryLayerFolderPath(version); | ||||
|  | ||||
|     if (existsSync(inMemoryLastVersionLayerFolderPath)) { | ||||
|       return; | ||||
|     try { | ||||
|       await fs.access(inMemoryLastVersionLayerFolderPath); | ||||
|     } catch (e) { | ||||
|       await copyAndBuildDependencies(inMemoryLastVersionLayerFolderPath); | ||||
|     } | ||||
|  | ||||
|     await copyAndBuildDependencies(inMemoryLastVersionLayerFolderPath); | ||||
|   } | ||||
|  | ||||
|   async delete() {} | ||||
|  | ||||
|   async build(serverlessFunction: ServerlessFunctionEntity) { | ||||
|     await this.createLayerIfNotExists(serverlessFunction.layerVersion); | ||||
|     const javascriptCode = await this.getCompiledCode( | ||||
|       serverlessFunction, | ||||
|       this.fileStorageService, | ||||
|     ); | ||||
|   async build(serverlessFunction: ServerlessFunctionEntity, version: string) { | ||||
|     const computedVersion = | ||||
|       version === 'latest' ? serverlessFunction.latestVersion : version; | ||||
|  | ||||
|     const draftFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction, | ||||
|       version: 'draft', | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.write({ | ||||
|       file: javascriptCode, | ||||
|       name: BUILD_FILE_NAME, | ||||
|       mimeType: undefined, | ||||
|       folder: draftFolderPath, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async publish(serverlessFunction: ServerlessFunctionEntity) { | ||||
|     await this.build(serverlessFunction); | ||||
|  | ||||
|     return serverlessFunction.latestVersion | ||||
|       ? `${parseInt(serverlessFunction.latestVersion, 10) + 1}` | ||||
|       : '1'; | ||||
|   } | ||||
|  | ||||
|   async execute( | ||||
|     serverlessFunction: ServerlessFunctionEntity, | ||||
|     payload: object, | ||||
|     version: string, | ||||
|   ): Promise<ServerlessExecuteResult> { | ||||
|     await this.createLayerIfNotExists(serverlessFunction.layerVersion); | ||||
|  | ||||
|     const startTime = Date.now(); | ||||
|     let fileContent = ''; | ||||
|     const inMemoryServerlessFunctionFolderPath = | ||||
|       this.getInMemoryServerlessFunctionFolderPath( | ||||
|         serverlessFunction, | ||||
|         computedVersion, | ||||
|       ); | ||||
|  | ||||
|     try { | ||||
|       const fileStream = await this.fileStorageService.read({ | ||||
|         folderPath: getServerlessFolder({ | ||||
|           serverlessFunction, | ||||
|           version, | ||||
|         }), | ||||
|         filename: BUILD_FILE_NAME, | ||||
|       }); | ||||
|     const folderPath = getServerlessFolder({ | ||||
|       serverlessFunction, | ||||
|       version, | ||||
|     }); | ||||
|  | ||||
|       fileContent = await readFileContent(fileStream); | ||||
|     } catch (error) { | ||||
|       if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) { | ||||
|         throw new ServerlessFunctionException( | ||||
|           `Function Version '${version}' does not exist`, | ||||
|           ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND, | ||||
|         ); | ||||
|       } | ||||
|       throw error; | ||||
|     } | ||||
|     await this.fileStorageService.download({ | ||||
|       from: { folderPath }, | ||||
|       to: { folderPath: inMemoryServerlessFunctionFolderPath }, | ||||
|     }); | ||||
|  | ||||
|     const tmpFolderPath = join(SERVERLESS_TMPDIR_FOLDER, v4()); | ||||
|     compileTypescript(inMemoryServerlessFunctionFolderPath); | ||||
|  | ||||
|     const tmpFilePath = join(tmpFolderPath, 'index.js'); | ||||
|  | ||||
|     await fs.symlink( | ||||
|       this.getInMemoryLayerFolderPath(serverlessFunction.layerVersion), | ||||
|       tmpFolderPath, | ||||
|       'dir', | ||||
|     const envFileContent = await fs.readFile( | ||||
|       join(inMemoryServerlessFunctionFolderPath, ENV_FILE_NAME), | ||||
|     ); | ||||
|  | ||||
|     const modifiedContent = ` | ||||
|     const envVariables = dotenv.parse(envFileContent); | ||||
|  | ||||
|     const listener = ` | ||||
|     const index_1 = require("./src/index"); | ||||
|      | ||||
|     process.env = ${JSON.stringify(envVariables)} | ||||
|      | ||||
|     process.on('message', async (message) => { | ||||
|       const { event, context } = message; | ||||
|       try { | ||||
|         const result = await handler(event, context); | ||||
|         const result = await index_1.handler(event, context); | ||||
|         process.send(result); | ||||
|       } catch (error) { | ||||
|         process.send({ | ||||
| @@ -140,82 +106,158 @@ export class LocalDriver | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     ${fileContent} | ||||
|     `; | ||||
|  | ||||
|     await fs.writeFile(tmpFilePath, modifiedContent); | ||||
|     await fs.writeFile( | ||||
|       join( | ||||
|         inMemoryServerlessFunctionFolderPath, | ||||
|         OUTDIR_FOLDER, | ||||
|         LISTENER_FILE_NAME, | ||||
|       ), | ||||
|       listener, | ||||
|     ); | ||||
|  | ||||
|     return await new Promise((resolve, reject) => { | ||||
|       const child = fork(tmpFilePath, { silent: true }); | ||||
|     try { | ||||
|       await fs.symlink( | ||||
|         join( | ||||
|           this.getInMemoryLayerFolderPath(serverlessFunction.layerVersion), | ||||
|           'node_modules', | ||||
|         ), | ||||
|         join( | ||||
|           inMemoryServerlessFunctionFolderPath, | ||||
|           OUTDIR_FOLDER, | ||||
|           'node_modules', | ||||
|         ), | ||||
|         'dir', | ||||
|       ); | ||||
|     } catch (err) { | ||||
|       if (err.code !== 'EEXIST') { | ||||
|         throw err; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|       child.on('message', (message: object | ServerlessExecuteError) => { | ||||
|         const duration = Date.now() - startTime; | ||||
|   async publish(serverlessFunction: ServerlessFunctionEntity) { | ||||
|     const newVersion = serverlessFunction.latestVersion | ||||
|       ? `${parseInt(serverlessFunction.latestVersion, 10) + 1}` | ||||
|       : '1'; | ||||
|  | ||||
|     const draftFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: serverlessFunction, | ||||
|       version: 'draft', | ||||
|     }); | ||||
|     const newFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: serverlessFunction, | ||||
|       version: newVersion, | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.copy({ | ||||
|       from: { folderPath: draftFolderPath }, | ||||
|       to: { folderPath: newFolderPath }, | ||||
|     }); | ||||
|  | ||||
|     await this.build(serverlessFunction, newVersion); | ||||
|  | ||||
|     return newVersion; | ||||
|   } | ||||
|  | ||||
|   async execute( | ||||
|     serverlessFunction: ServerlessFunctionEntity, | ||||
|     payload: object, | ||||
|     version: string, | ||||
|   ): Promise<ServerlessExecuteResult> { | ||||
|     const startTime = Date.now(); | ||||
|     const computedVersion = | ||||
|       version === 'latest' ? serverlessFunction.latestVersion : version; | ||||
|  | ||||
|     const listenerFile = join( | ||||
|       this.getInMemoryServerlessFunctionFolderPath( | ||||
|         serverlessFunction, | ||||
|         computedVersion, | ||||
|       ), | ||||
|       OUTDIR_FOLDER, | ||||
|       LISTENER_FILE_NAME, | ||||
|     ); | ||||
|  | ||||
|     try { | ||||
|       return await new Promise((resolve, reject) => { | ||||
|         const child = fork(listenerFile, { silent: true }); | ||||
|  | ||||
|         child.on('message', (message: object | ServerlessExecuteError) => { | ||||
|           const duration = Date.now() - startTime; | ||||
|  | ||||
|           if ('errorType' in message) { | ||||
|             resolve({ | ||||
|               data: null, | ||||
|               duration, | ||||
|               error: message, | ||||
|               status: ServerlessFunctionExecutionStatus.ERROR, | ||||
|             }); | ||||
|           } else { | ||||
|             resolve({ | ||||
|               data: message, | ||||
|               duration, | ||||
|               status: ServerlessFunctionExecutionStatus.SUCCESS, | ||||
|             }); | ||||
|           } | ||||
|           child.kill(); | ||||
|         }); | ||||
|  | ||||
|         child.stderr?.on('data', (data) => { | ||||
|           const stackTrace = data | ||||
|             .toString() | ||||
|             .split('\n') | ||||
|             .filter((line: string) => line.trim() !== ''); | ||||
|           const errorTrace = stackTrace.filter((line: string) => | ||||
|             line.includes('Error: '), | ||||
|           )?.[0]; | ||||
|  | ||||
|           let errorType = 'Unknown'; | ||||
|           let errorMessage = ''; | ||||
|  | ||||
|           if (errorTrace) { | ||||
|             errorType = errorTrace.split(':')[0]; | ||||
|             errorMessage = errorTrace.split(': ')[1]; | ||||
|           } | ||||
|           const duration = Date.now() - startTime; | ||||
|  | ||||
|         if ('errorType' in message) { | ||||
|           resolve({ | ||||
|             data: null, | ||||
|             duration, | ||||
|             error: message, | ||||
|             status: ServerlessFunctionExecutionStatus.ERROR, | ||||
|             error: { | ||||
|               errorType, | ||||
|               errorMessage, | ||||
|               stackTrace: stackTrace, | ||||
|             }, | ||||
|           }); | ||||
|         } else { | ||||
|           resolve({ | ||||
|             data: message, | ||||
|             duration, | ||||
|             status: ServerlessFunctionExecutionStatus.SUCCESS, | ||||
|           }); | ||||
|         } | ||||
|         child.kill(); | ||||
|         fs.unlink(tmpFilePath).catch(console.error); | ||||
|       }); | ||||
|  | ||||
|       child.stderr?.on('data', (data) => { | ||||
|         const stackTrace = data | ||||
|           .toString() | ||||
|           .split('\n') | ||||
|           .filter((line: string) => line.trim() !== ''); | ||||
|         const errorTrace = stackTrace.filter((line: string) => | ||||
|           line.includes('Error: '), | ||||
|         )?.[0]; | ||||
|  | ||||
|         let errorType = 'Unknown'; | ||||
|         let errorMessage = ''; | ||||
|  | ||||
|         if (errorTrace) { | ||||
|           errorType = errorTrace.split(':')[0]; | ||||
|           errorMessage = errorTrace.split(': ')[1]; | ||||
|         } | ||||
|         const duration = Date.now() - startTime; | ||||
|  | ||||
|         resolve({ | ||||
|           data: null, | ||||
|           duration, | ||||
|           status: ServerlessFunctionExecutionStatus.ERROR, | ||||
|           error: { | ||||
|             errorType, | ||||
|             errorMessage, | ||||
|             stackTrace: stackTrace, | ||||
|           }, | ||||
|           child.kill(); | ||||
|         }); | ||||
|         child.kill(); | ||||
|         fs.unlink(tmpFilePath).catch(console.error); | ||||
|       }); | ||||
|  | ||||
|       child.on('error', (error) => { | ||||
|         reject(error); | ||||
|         child.kill(); | ||||
|         fs.unlink(tmpFilePath).catch(console.error); | ||||
|       }); | ||||
|         child.on('error', (error) => { | ||||
|           reject(error); | ||||
|           child.kill(); | ||||
|         }); | ||||
|  | ||||
|       child.on('exit', (code) => { | ||||
|         if (code && code !== 0) { | ||||
|           reject(new Error(`Child process exited with code ${code}`)); | ||||
|           fs.unlink(tmpFilePath).catch(console.error); | ||||
|         } | ||||
|       }); | ||||
|         child.on('exit', (code) => { | ||||
|           if (code && code !== 0) { | ||||
|             reject(new Error(`Child process exited with code ${code}`)); | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|       child.send({ event: payload }); | ||||
|     }); | ||||
|         child.send({ event: payload }); | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       return { | ||||
|         data: null, | ||||
|         duration: Date.now() - startTime, | ||||
|         error: { | ||||
|           errorType: 'UnhandledError', | ||||
|           errorMessage: error.message || 'Unknown error', | ||||
|           stackTrace: error.stack ? error.stack.split('\n') : [], | ||||
|         }, | ||||
|         status: ServerlessFunctionExecutionStatus.ERROR, | ||||
|       }; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| import ts from 'typescript'; | ||||
| import { join } from 'path'; | ||||
|  | ||||
| export const compileTypescript = (typescriptCode: string): string => { | ||||
| import ts, { createProgram } from 'typescript'; | ||||
|  | ||||
| import { OUTDIR_FOLDER } from 'src/engine/core-modules/serverless/drivers/constants/outdir-folder'; | ||||
| import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name'; | ||||
|  | ||||
| export const compileTypescript = (folderPath: string) => { | ||||
|   const options: ts.CompilerOptions = { | ||||
|     module: ts.ModuleKind.CommonJS, | ||||
|     target: ts.ScriptTarget.ES2017, | ||||
| @@ -8,12 +13,9 @@ export const compileTypescript = (typescriptCode: string): string => { | ||||
|     esModuleInterop: true, | ||||
|     resolveJsonModule: true, | ||||
|     allowSyntheticDefaultImports: true, | ||||
|     outDir: join(folderPath, OUTDIR_FOLDER, 'src'), | ||||
|     types: ['node'], | ||||
|   }; | ||||
|  | ||||
|   const result = ts.transpileModule(typescriptCode, { | ||||
|     compilerOptions: options, | ||||
|   }); | ||||
|  | ||||
|   return result.outputText; | ||||
|   createProgram([join(folderPath, 'src', INDEX_FILE_NAME)], options).emit(); | ||||
| }; | ||||
|   | ||||
| @@ -0,0 +1,39 @@ | ||||
| import path, { join } from 'path'; | ||||
| import fs from 'fs/promises'; | ||||
|  | ||||
| import { ASSET_PATH } from 'src/constants/assets-path'; | ||||
|  | ||||
| type File = { name: string; path: string; content: Buffer }; | ||||
|  | ||||
| const getAllFiles = async ( | ||||
|   rootDir: string, | ||||
|   dir: string = rootDir, | ||||
|   files: File[] = [], | ||||
| ): Promise<File[]> => { | ||||
|   const dirEntries = await fs.readdir(dir, { withFileTypes: true }); | ||||
|  | ||||
|   for (const entry of dirEntries) { | ||||
|     const fullPath = path.join(dir, entry.name); | ||||
|  | ||||
|     if (entry.isDirectory()) { | ||||
|       return getAllFiles(rootDir, fullPath, files); | ||||
|     } else { | ||||
|       files.push({ | ||||
|         path: path.relative(rootDir, dir), | ||||
|         name: entry.name, | ||||
|         content: await fs.readFile(fullPath), | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return files; | ||||
| }; | ||||
|  | ||||
| export const getBaseTypescriptProjectFiles = (async () => { | ||||
|   const baseTypescriptProjectPath = join( | ||||
|     ASSET_PATH, | ||||
|     `engine/core-modules/serverless/drivers/constants/base-typescript-project`, | ||||
|   ); | ||||
|  | ||||
|   return await getAllFiles(baseTypescriptProjectPath); | ||||
| })(); | ||||
| @@ -1,12 +1,17 @@ | ||||
| import path from 'path'; | ||||
| import path, { join } from 'path'; | ||||
|  | ||||
| import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version'; | ||||
| import { ASSET_PATH } from 'src/constants/assets-path'; | ||||
|  | ||||
| // Can only be used in src/engine/integrations/serverless/drivers/utils folder | ||||
| export const getLayerDependenciesDirName = ( | ||||
|   version: 'latest' | 'engine' | number, | ||||
| ): string => { | ||||
|   const formattedVersion = version === 'latest' ? LAST_LAYER_VERSION : version; | ||||
|  | ||||
|   return path.resolve(__dirname, `../layers/${formattedVersion}`); | ||||
|   const baseTypescriptProjectPath = join( | ||||
|     ASSET_PATH, | ||||
|     `engine/core-modules/serverless/drivers/layers/${formattedVersion}`, | ||||
|   ); | ||||
|  | ||||
|   return path.resolve(baseTypescriptProjectPath); | ||||
| }; | ||||
|   | ||||
| @@ -10,14 +10,12 @@ export const NODE_LAYER_SUBFOLDER = 'nodejs'; | ||||
| const TEMPORARY_LAMBDA_FOLDER = 'lambda-build'; | ||||
| const TEMPORARY_LAMBDA_SOURCE_FOLDER = 'src'; | ||||
| const LAMBDA_ZIP_FILE_NAME = 'lambda.zip'; | ||||
| const LAMBDA_ENTRY_FILE_NAME = 'index.js'; | ||||
|  | ||||
| export class LambdaBuildDirectoryManager { | ||||
|   private temporaryDir = join( | ||||
|     SERVERLESS_TMPDIR_FOLDER, | ||||
|     `${TEMPORARY_LAMBDA_FOLDER}-${v4()}`, | ||||
|   ); | ||||
|   private lambdaHandler = `${LAMBDA_ENTRY_FILE_NAME.split('.')[0]}.handler`; | ||||
|  | ||||
|   async init() { | ||||
|     const sourceTemporaryDir = join( | ||||
| @@ -25,15 +23,12 @@ export class LambdaBuildDirectoryManager { | ||||
|       TEMPORARY_LAMBDA_SOURCE_FOLDER, | ||||
|     ); | ||||
|     const lambdaZipPath = join(this.temporaryDir, LAMBDA_ZIP_FILE_NAME); | ||||
|     const javascriptFilePath = join(sourceTemporaryDir, LAMBDA_ENTRY_FILE_NAME); | ||||
|  | ||||
|     await fs.mkdir(sourceTemporaryDir, { recursive: true }); | ||||
|  | ||||
|     return { | ||||
|       sourceTemporaryDir, | ||||
|       lambdaZipPath, | ||||
|       javascriptFilePath, | ||||
|       lambdaHandler: this.lambdaHandler, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| import { Field, InputType } from '@nestjs/graphql'; | ||||
|  | ||||
| import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||||
|  | ||||
| @InputType() | ||||
| export class CreateServerlessFunctionFromFileInput { | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   @Field() | ||||
|   name: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsOptional() | ||||
|   @Field({ nullable: true }) | ||||
|   description?: string; | ||||
| } | ||||
| @@ -1,13 +1,16 @@ | ||||
| import { Field, InputType } from '@nestjs/graphql'; | ||||
|  | ||||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||||
|  | ||||
| import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input'; | ||||
| import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||||
|  | ||||
| @InputType() | ||||
| export class CreateServerlessFunctionInput extends CreateServerlessFunctionFromFileInput { | ||||
| export class CreateServerlessFunctionInput { | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   @Field() | ||||
|   code: string; | ||||
|   name: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsOptional() | ||||
|   @Field({ nullable: true }) | ||||
|   description?: string; | ||||
| } | ||||
|   | ||||
| @@ -50,11 +50,6 @@ export class ServerlessFunctionDTO { | ||||
|   @Field({ nullable: true }) | ||||
|   description: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   @Field() | ||||
|   sourceCodeHash: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   @Field() | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { Field, InputType } from '@nestjs/graphql'; | ||||
|  | ||||
| import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; | ||||
| import { IsNotEmpty, IsObject, IsString, IsUUID } from 'class-validator'; | ||||
| import graphqlTypeJson from 'graphql-type-json'; | ||||
|  | ||||
| import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; | ||||
|  | ||||
| @@ -21,7 +22,7 @@ export class UpdateServerlessFunctionInput { | ||||
|   @Field({ nullable: true }) | ||||
|   description?: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @Field() | ||||
|   code: string; | ||||
|   @Field(() => graphqlTypeJson) | ||||
|   @IsObject() | ||||
|   code: JSON; | ||||
| } | ||||
|   | ||||
| @@ -29,9 +29,6 @@ export class ServerlessFunctionEntity { | ||||
|   @Column({ nullable: true }) | ||||
|   latestVersion: string; | ||||
|  | ||||
|   @Column({ nullable: false }) | ||||
|   sourceCodeHash: string; | ||||
|  | ||||
|   @Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 }) | ||||
|   runtime: ServerlessFunctionRuntime; | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
|  | ||||
| import graphqlTypeJson from 'graphql-type-json'; | ||||
| import { FileUpload, GraphQLUpload } from 'graphql-upload'; | ||||
| import { Repository } from 'typeorm'; | ||||
|  | ||||
| import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; | ||||
| @@ -11,7 +10,6 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- | ||||
| import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; | ||||
| import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; | ||||
| import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; | ||||
| import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input'; | ||||
| import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; | ||||
| import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input'; | ||||
| import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input'; | ||||
| @@ -63,7 +61,7 @@ export class ServerlessFunctionResolver { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @Query(() => String, { nullable: true }) | ||||
|   @Query(() => graphqlTypeJson, { nullable: true }) | ||||
|   async getServerlessFunctionSourceCode( | ||||
|     @Args('input') input: GetServerlessFunctionSourceCodeInput, | ||||
|     @AuthWorkspace() { id: workspaceId }: Workspace, | ||||
| @@ -130,28 +128,6 @@ export class ServerlessFunctionResolver { | ||||
|           name: input.name, | ||||
|           description: input.description, | ||||
|         }, | ||||
|         input.code, | ||||
|         workspaceId, | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       serverlessFunctionGraphQLApiExceptionHandler(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @Mutation(() => ServerlessFunctionDTO) | ||||
|   async createOneServerlessFunctionFromFile( | ||||
|     @Args({ name: 'file', type: () => GraphQLUpload }) | ||||
|     file: FileUpload, | ||||
|     @Args('input') | ||||
|     input: CreateServerlessFunctionFromFileInput, | ||||
|     @AuthWorkspace() { id: workspaceId }: Workspace, | ||||
|   ) { | ||||
|     try { | ||||
|       await this.checkFeatureFlag(workspaceId); | ||||
|  | ||||
|       return await this.serverlessFunctionService.createOneServerlessFunction( | ||||
|         input, | ||||
|         file, | ||||
|         workspaceId, | ||||
|       ); | ||||
|     } catch (error) { | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
|  | ||||
| import { basename, dirname, join } from 'path'; | ||||
|  | ||||
| import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; | ||||
| import { FileUpload } from 'graphql-upload'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import deepEqual from 'deep-equal'; | ||||
|  | ||||
| import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; | ||||
| import { ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface'; | ||||
| @@ -12,10 +14,9 @@ import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.se | ||||
| import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; | ||||
| import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; | ||||
| import { readFileContent } from 'src/engine/core-modules/file-storage/utils/read-file-content'; | ||||
| import { SOURCE_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/source-file-name'; | ||||
| import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name'; | ||||
| import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service'; | ||||
| import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils'; | ||||
| import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input'; | ||||
| import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input'; | ||||
| import { | ||||
|   ServerlessFunctionEntity, | ||||
| @@ -25,10 +26,12 @@ import { | ||||
|   ServerlessFunctionException, | ||||
|   ServerlessFunctionExceptionCode, | ||||
| } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; | ||||
| import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils'; | ||||
| import { isDefined } from 'src/utils/is-defined'; | ||||
| import { getLastLayerDependencies } from 'src/engine/core-modules/serverless/drivers/utils/get-last-layer-dependencies'; | ||||
| import { LAST_LAYER_VERSION } from 'src/engine/core-modules/serverless/drivers/layers/last-layer-version'; | ||||
| import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; | ||||
| import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files'; | ||||
| import { ENV_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/env-file-name'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> { | ||||
| @@ -47,7 +50,7 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|     workspaceId: string, | ||||
|     id: string, | ||||
|     version: string, | ||||
|   ) { | ||||
|   ): Promise<{ [filePath: string]: string } | undefined> { | ||||
|     const serverlessFunction = await this.serverlessFunctionRepository.findOne({ | ||||
|       where: { | ||||
|         id, | ||||
| @@ -68,12 +71,20 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|         version, | ||||
|       }); | ||||
|  | ||||
|       const fileStream = await this.fileStorageService.read({ | ||||
|         folderPath, | ||||
|         filename: SOURCE_FILE_NAME, | ||||
|       const indexFileStream = await this.fileStorageService.read({ | ||||
|         folderPath: join(folderPath, 'src'), | ||||
|         filename: INDEX_FILE_NAME, | ||||
|       }); | ||||
|  | ||||
|       return await readFileContent(fileStream); | ||||
|       const envFileStream = await this.fileStorageService.read({ | ||||
|         folderPath: folderPath, | ||||
|         filename: ENV_FILE_NAME, | ||||
|       }); | ||||
|  | ||||
|       return { | ||||
|         '.env': await readFileContent(envFileStream), | ||||
|         'src/index.ts': await readFileContent(indexFileStream), | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) { | ||||
|         return; | ||||
| @@ -132,10 +143,7 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|         'draft', | ||||
|       ); | ||||
|  | ||||
|       if ( | ||||
|         serverlessFunctionCreateHash(latestCode || '') === | ||||
|         serverlessFunctionCreateHash(draftCode || '') | ||||
|       ) { | ||||
|       if (deepEqual(latestCode, draftCode)) { | ||||
|         throw new Error( | ||||
|           'Cannot publish a new version when code has not changed', | ||||
|         ); | ||||
| @@ -146,20 +154,6 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|       existingServerlessFunction, | ||||
|     ); | ||||
|  | ||||
|     const draftFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: existingServerlessFunction, | ||||
|       version: 'draft', | ||||
|     }); | ||||
|     const newFolderPath = getServerlessFolder({ | ||||
|       serverlessFunction: existingServerlessFunction, | ||||
|       version: newVersion, | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.copy({ | ||||
|       from: { folderPath: draftFolderPath }, | ||||
|       to: { folderPath: newFolderPath }, | ||||
|     }); | ||||
|  | ||||
|     await super.updateOne(existingServerlessFunction.id, { | ||||
|       latestVersion: newVersion, | ||||
|     }); | ||||
| @@ -213,9 +207,6 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|       name: serverlessFunctionInput.name, | ||||
|       description: serverlessFunctionInput.description, | ||||
|       syncStatus: ServerlessFunctionSyncStatus.NOT_READY, | ||||
|       sourceCodeHash: serverlessFunctionCreateHash( | ||||
|         serverlessFunctionInput.code, | ||||
|       ), | ||||
|     }); | ||||
|  | ||||
|     const fileFolder = getServerlessFolder({ | ||||
| @@ -223,12 +214,14 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|       version: 'draft', | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.write({ | ||||
|       file: serverlessFunctionInput.code, | ||||
|       name: SOURCE_FILE_NAME, | ||||
|       mimeType: undefined, | ||||
|       folder: fileFolder, | ||||
|     }); | ||||
|     for (const key of Object.keys(serverlessFunctionInput.code)) { | ||||
|       await this.fileStorageService.write({ | ||||
|         file: serverlessFunctionInput.code[key], | ||||
|         name: basename(key), | ||||
|         mimeType: undefined, | ||||
|         folder: join(fileFolder, dirname(key)), | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     await this.serverlessService.build(existingServerlessFunction, 'draft'); | ||||
|     await super.updateOne(existingServerlessFunction.id, { | ||||
| @@ -259,22 +252,12 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|   } | ||||
|  | ||||
|   async createOneServerlessFunction( | ||||
|     serverlessFunctionInput: CreateServerlessFunctionFromFileInput, | ||||
|     code: FileUpload | string, | ||||
|     serverlessFunctionInput: CreateServerlessFunctionInput, | ||||
|     workspaceId: string, | ||||
|   ) { | ||||
|     let typescriptCode: string; | ||||
|  | ||||
|     if (typeof code === 'string') { | ||||
|       typescriptCode = code; | ||||
|     } else { | ||||
|       typescriptCode = await readFileContent(code.createReadStream()); | ||||
|     } | ||||
|  | ||||
|     const createdServerlessFunction = await super.createOne({ | ||||
|       ...serverlessFunctionInput, | ||||
|       workspaceId, | ||||
|       sourceCodeHash: serverlessFunctionCreateHash(typescriptCode), | ||||
|       layerVersion: LAST_LAYER_VERSION, | ||||
|     }); | ||||
|  | ||||
| @@ -283,12 +266,14 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun | ||||
|       version: 'draft', | ||||
|     }); | ||||
|  | ||||
|     await this.fileStorageService.write({ | ||||
|       file: typescriptCode, | ||||
|       name: SOURCE_FILE_NAME, | ||||
|       mimeType: undefined, | ||||
|       folder: draftFileFolder, | ||||
|     }); | ||||
|     for (const file of await getBaseTypescriptProjectFiles) { | ||||
|       await this.fileStorageService.write({ | ||||
|         file: file.content, | ||||
|         name: file.name, | ||||
|         mimeType: undefined, | ||||
|         folder: join(draftFileFolder, file.path), | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     await this.serverlessService.build(createdServerlessFunction, 'draft'); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 martmull
					martmull