├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── publish-prerelease-npm.yml │ └── publish-stable-npm.yml ├── .gitignore ├── .prettierrc ├── .vtex ├── catalog-info.yaml └── deployment.yaml ├── CHANGELOG.md ├── CODEOWNERS ├── README.md ├── __mocks__ └── @vtex │ └── diagnostics-semconv.ts ├── docs ├── images │ ├── tracing-http-request1.png │ ├── tracing-http-request2.png │ └── tracing-http-request3.png └── tracing.md ├── gen └── manifest.schema ├── jest.config.js ├── package.json ├── scripts └── publishLock.sh ├── src ├── HttpClient │ ├── GraphQLClient.ts │ ├── HttpClient.ts │ ├── agents.ts │ ├── index.ts │ ├── middlewares │ │ ├── cache.ts │ │ ├── cancellationToken.ts │ │ ├── inflight.ts │ │ ├── memoization.ts │ │ ├── metrics.ts │ │ ├── notFound.ts │ │ ├── recorder.ts │ │ ├── request │ │ │ ├── HttpAgentSingleton.ts │ │ │ ├── index.ts │ │ │ └── setupAxios │ │ │ │ ├── __tests__ │ │ │ │ ├── TestServer.ts │ │ │ │ ├── TestTracer.ts │ │ │ │ ├── TracedTestRequest.ts │ │ │ │ ├── axiosTracing.test.ts │ │ │ │ └── axiosTracingTestSuite.ts │ │ │ │ ├── index.ts │ │ │ │ └── interceptors │ │ │ │ ├── exponentialBackoff.ts │ │ │ │ ├── index.ts │ │ │ │ └── tracing │ │ │ │ ├── axiosHelpers.d.ts │ │ │ │ ├── index.ts │ │ │ │ └── spanSetup.ts │ │ └── tracing.ts │ └── typings.ts ├── axios.d.ts ├── caches │ ├── CacheLayer.ts │ ├── DiskCache.ts │ ├── LRUCache.ts │ ├── LRUDiskCache.ts │ ├── MultilayeredCache.ts │ ├── index.ts │ └── typings.ts ├── clients │ ├── IOClient.ts │ ├── IOClients.ts │ ├── IOGraphQLClient.ts │ ├── apps │ │ ├── AppClient.ts │ │ ├── AppGraphQLClient.ts │ │ ├── Billing.ts │ │ ├── Builder.ts │ │ ├── MessagesGraphQL.ts │ │ ├── Settings.ts │ │ ├── catalogGraphQL │ │ │ ├── brand.ts │ │ │ ├── category.ts │ │ │ ├── index.ts │ │ │ ├── product.ts │ │ │ └── sku.ts │ │ └── index.ts │ ├── external │ │ ├── ExternalClient.ts │ │ ├── ID.ts │ │ ├── Masterdata.ts │ │ ├── PaymentProvider.ts │ │ └── index.ts │ ├── index.ts │ ├── infra │ │ ├── Apps.ts │ │ ├── Assets.ts │ │ ├── BillingMetrics.ts │ │ ├── Events.ts │ │ ├── Housekeeper.ts │ │ ├── InfraClient.ts │ │ ├── Registry.ts │ │ ├── Router.ts │ │ ├── Sphinx.ts │ │ ├── VBase.ts │ │ ├── Workspaces.ts │ │ └── index.ts │ └── janus │ │ ├── JanusClient.ts │ │ ├── LicenseManager.ts │ │ ├── Segment.ts │ │ ├── Session.ts │ │ ├── Tenant.ts │ │ └── index.ts ├── constants.test.ts ├── constants.ts ├── errors │ ├── AuthenticationError.ts │ ├── ForbiddenError.ts │ ├── NotFoundError.ts │ ├── RequestCancelledError.ts │ ├── ResolverError.ts │ ├── ResolverWarning.ts │ ├── TooManyRequestsError.ts │ ├── UserInputError.ts │ ├── customGraphQLError.ts │ └── index.ts ├── index.ts ├── metrics │ └── MetricsAccumulator.ts ├── responses.ts ├── service │ ├── index.ts │ ├── loaders.ts │ ├── logger │ │ ├── client.ts │ │ ├── console.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── loggerTypes.ts │ │ └── metricsLogger.ts │ ├── master.ts │ ├── metrics │ │ ├── client.ts │ │ ├── instruments │ │ │ └── hostMetrics.ts │ │ ├── metrics.ts │ │ ├── otelRequestMetricsMiddleware.ts │ │ └── requestMetricsMiddleware.ts │ ├── telemetry │ │ ├── client.ts │ │ └── index.ts │ ├── tracing │ │ ├── TracerSingleton.ts │ │ ├── metrics │ │ │ ├── MetricNames.ts │ │ │ ├── instruments.ts │ │ │ └── measurers │ │ │ │ └── EventLoopLagMeasurer.ts │ │ └── tracingMiddlewares.ts │ └── worker │ │ ├── index.ts │ │ ├── listeners.ts │ │ └── runtime │ │ ├── Service.ts │ │ ├── builtIn │ │ ├── handlers.ts │ │ └── middlewares.ts │ │ ├── events │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── body.ts │ │ │ └── context.ts │ │ └── router.ts │ │ ├── graphql │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── context.ts │ │ │ ├── error.ts │ │ │ ├── query.ts │ │ │ ├── response.ts │ │ │ ├── run.ts │ │ │ ├── updateSchema.ts │ │ │ └── upload.ts │ │ ├── schema │ │ │ ├── index.ts │ │ │ ├── messagesLoaderV2.ts │ │ │ ├── schemaDirectives │ │ │ │ ├── Auth.ts │ │ │ │ ├── CacheControl.ts │ │ │ │ ├── Deprecated.ts │ │ │ │ ├── Metric.ts │ │ │ │ ├── Sanitize.ts │ │ │ │ ├── Settings.ts │ │ │ │ ├── SmartCacheDirective.ts │ │ │ │ ├── TranslatableV2.ts │ │ │ │ ├── TranslateTo.ts │ │ │ │ └── index.ts │ │ │ └── typeDefs │ │ │ │ ├── index.ts │ │ │ │ ├── ioUpload.ts │ │ │ │ └── sanitizedString.ts │ │ ├── typings.ts │ │ └── utils │ │ │ ├── cacheControl.ts │ │ │ ├── error.ts │ │ │ ├── pathname.ts │ │ │ └── translations.ts │ │ ├── http │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── authTokens.ts │ │ │ ├── cancellationToken.ts │ │ │ ├── clients.ts │ │ │ ├── context.ts │ │ │ ├── error.ts │ │ │ ├── rateLimit.test.ts │ │ │ ├── rateLimit.ts │ │ │ ├── requestStats.ts │ │ │ ├── setCookie.ts │ │ │ ├── settings.ts │ │ │ ├── timings.ts │ │ │ └── vary.ts │ │ ├── router.ts │ │ └── routes.ts │ │ ├── method.ts │ │ ├── statusTrack.ts │ │ ├── typings.ts │ │ └── utils │ │ ├── compose.ts │ │ ├── context.ts │ │ ├── diff.ts │ │ ├── recorder.ts │ │ ├── toArray.ts │ │ └── tokenBucket.ts ├── tracing │ ├── LogEvents.ts │ ├── LogFields.ts │ ├── Tags.ts │ ├── UserLandTracer.ts │ ├── errorReporting │ │ └── ErrorReport.ts │ ├── index.ts │ ├── spanReference │ │ ├── SpanReferenceTypes.ts │ │ └── createSpanReference.ts │ ├── utils.test.ts │ └── utils.ts ├── typings │ ├── tar-fs.d.ts │ └── tokenbucket.d.ts └── utils │ ├── MineWinsConflictsResolver.test.ts │ ├── MineWinsConflictsResolver.ts │ ├── app.ts │ ├── appsStaleIfError.ts │ ├── billingOptions.ts │ ├── binding.ts │ ├── buildFullPath.ts │ ├── cancel.ts │ ├── conflicts.mock.ts │ ├── domain.ts │ ├── error.ts │ ├── index.ts │ ├── json.ts │ ├── log.ts │ ├── message.ts │ ├── renameBy.ts │ ├── retry.ts │ ├── status.ts │ ├── tenant.ts │ ├── throwOnGraphQLErrors.ts │ └── time.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png -text 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | 8 | 9 | ## Current Behavior 10 | 11 | 12 | 13 | 14 | 15 | ## Possible Solution 16 | 17 | 18 | 19 | 20 | 21 | ## Steps to Reproduce (for bugs) 22 | 23 | 24 | 25 | 26 | 27 | 1. 2. 3. 4. 28 | 29 | ## Context 30 | 31 | 32 | 33 | 34 | 35 | ## Your Environment 36 | 37 | 38 | 39 | * Version used: 40 | * Environment name and version (e.g. Chrome 39, node.js 5.4): 41 | * Operating System and version (desktop or mobile): 42 | * Link to your project: 43 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What is the purpose of this pull request? 2 | 3 | 4 | 5 | #### What problem is this solving? 6 | 7 | 8 | 9 | #### How should this be manually tested? 10 | 11 | #### Screenshots or example usage 12 | 13 | #### Types of changes 14 | 15 | * [ ] Bug fix (a non-breaking change which fixes an issue) 16 | * [ ] New feature (a non-breaking change which adds functionality) 17 | * [ ] Breaking change (fix or feature that would cause existing functionality to change) 18 | * [ ] Requires change to documentation, which has been updated accordingly. 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - '**' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 22 18 | - run: yarn install --ignore-scripts 19 | - run: yarn run lint 20 | 21 | test: 22 | needs: [lint] 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | fail-fast: true 26 | matrix: 27 | node-version: [22] 28 | os: [ubuntu-latest] 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-node@v1 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | - run: yarn install --ignore-scripts 36 | - run: yarn run ci:test -------------------------------------------------------------------------------- /.github/workflows/publish-prerelease-npm.yml: -------------------------------------------------------------------------------- 1 | name: publish-prerelease:npm 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '**' 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+-beta*' 9 | 10 | jobs: 11 | npm-publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 22 18 | registry-url: https://registry.npmjs.org/ 19 | - run: yarn install --ignore-scripts 20 | - run: yarn ci:build 21 | - run: npm publish --tag beta 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 24 | IS_CI: true -------------------------------------------------------------------------------- /.github/workflows/publish-stable-npm.yml: -------------------------------------------------------------------------------- 1 | name: publish-stable:npm 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '**' 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | 10 | jobs: 11 | npm-publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 22 18 | registry-url: https://registry.npmjs.org/ 19 | - run: yarn install --ignore-scripts 20 | - run: yarn ci:build 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 24 | IS_CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### SublimeText ### 2 | *.sublime-workspace 3 | 4 | ### VSCode ### 5 | .vscode 6 | 7 | ### OSX ### 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear on external disk 17 | .Spotlight-V100 18 | .Trashes 19 | 20 | ### Windows ### 21 | # Windows image file caches 22 | Thumbs.db 23 | ehthumbs.db 24 | 25 | # Folder config file 26 | Desktop.ini 27 | 28 | # Recycle Bin used on file shares 29 | $RECYCLE.BIN/ 30 | 31 | # App specific 32 | node_modules/ 33 | docs/_book/ 34 | .tmp/ 35 | .idea/ 36 | npm-debug.log 37 | yarn-error.log 38 | .build/ 39 | lib/ 40 | 41 | package-lock.json 42 | yarn-error.log 43 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /.vtex/catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: node-vtex-api 5 | description: VTEX IO API Client for Node 6 | tags: 7 | - typescript 8 | annotations: 9 | vtex.com/janus-acronym: "" 10 | vtex.com/o11y-os-index: "" 11 | grafana/dashboard-selector: "" 12 | github.com/project-slug: vtex/node-vtex-api 13 | backstage.io/techdocs-ref: dir:../ 14 | vtex.com/application-id: SHSTJA2W 15 | spec: 16 | lifecycle: stable 17 | owner: te-0029 18 | type: library 19 | dependsOn: [] 20 | -------------------------------------------------------------------------------- /.vtex/deployment.yaml: -------------------------------------------------------------------------------- 1 | - name: node-vtex-api 2 | referenceId: SHSTJA2W 3 | build: 4 | provider: dkcicd 5 | pipelines: 6 | - name: techdocs-v1 7 | parameters: 8 | entityReference: default/component/node-vtex-api 9 | sourceDir: ./ 10 | when: 11 | - event: push 12 | source: branch 13 | regex: main 14 | path: 15 | - "docs/**" 16 | - README.md 17 | - .vtex/deployment.yaml -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @vtex/composable-commerce-sq4 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VTEX IO API Client for Node 2 | 3 | This library enables developers to quickly integrate with the VTEX IO APIs and create full fledged node services using VTEX IO. 4 | 5 | [![Build Status](https://travis-ci.org/vtex/node-vtex-api.svg?branch=master)](https://travis-ci.org/vtex/node-vtex-api) 6 | 7 | ## Getting started 8 | 9 | For a complete example on using `@vtex/api`, check out this app: https://github.com/vtex-apps/service-example 10 | 11 | The most basic usage is to export a new `Service()` with your route handlers: 12 | 13 | ```javascript 14 | // Import global types 15 | import './globals' 16 | 17 | import { Service } from '@vtex/api' 18 | 19 | import { clients } from './clients' 20 | import example from './handlers/example' 21 | 22 | // Export a service that defines route handlers and client options. 23 | export default new Service({ 24 | clients, 25 | routes: { 26 | example, 27 | }, 28 | }) 29 | ``` 30 | 31 | This allows you to define middlewares that receive a `Context` param which contains all IO Clients in the `clients` property: 32 | 33 | ```javascript 34 | export const example = async (ctx: Context, next: () => Promise) => { 35 | const {state: {code}, clients: {apps}} = ctx 36 | console.log('Received code:', code) 37 | 38 | const apps = await apps.listApps() 39 | 40 | ctx.status = 200 41 | ctx.body = apps 42 | ctx.set('Cache-Control', 'private') 43 | 44 | await next() 45 | } 46 | ``` 47 | 48 | `ctx.clients.apps` is an instance of `Apps`. 49 | 50 | ## Development 51 | 52 | - Install the dependencies: `yarn` 53 | - Watch for changes: `yarn watch` 54 | 55 | ### Development with IO clients 56 | 57 | - Install the dependencies: `yarn` 58 | - [Link](https://classic.yarnpkg.com/en/docs/cli/link/) this package: `yarn link` 59 | - Watch for changes: `yarn watch` 60 | - Move to the app that depends on the changes made on this package: `cd ..//node` 61 | - Link this package to your app's node_modules: `yarn link @vtex/api` 62 | 63 | Now, when you get a workspace up and running for your app with `vtex link`, you'll have this package linked as well. 64 | 65 | > When done developing, don't forget to unlink it from `/node`: `yarn unlink @vtex/api` 66 | -------------------------------------------------------------------------------- /__mocks__/@vtex/diagnostics-semconv.ts: -------------------------------------------------------------------------------- 1 | // Mock para @vtex/diagnostics-semconv 2 | const ATTR_VTEX_ACCOUNT_NAME = 'vtex.account.name' 3 | const ATTR_VTEX_IO_WORKSPACE_NAME = 'vtex_io.workspace.name' 4 | const ATTR_VTEX_IO_WORKSPACE_TYPE = 'vtex_io.workspace.type' 5 | const ATTR_VTEX_IO_APP_ID = 'vtex_io.app.id' 6 | const ATTR_VTEX_IO_APP_AUTHOR_TYPE = 'vtex_io.app.author-type' 7 | 8 | export { 9 | ATTR_VTEX_ACCOUNT_NAME, 10 | ATTR_VTEX_IO_WORKSPACE_NAME, 11 | ATTR_VTEX_IO_WORKSPACE_TYPE, 12 | ATTR_VTEX_IO_APP_ID, 13 | ATTR_VTEX_IO_APP_AUTHOR_TYPE, 14 | } 15 | -------------------------------------------------------------------------------- /docs/images/tracing-http-request1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtex/node-vtex-api/3d43ad10aa2df6598a15fd438ce5fe7dbc6ad71c/docs/images/tracing-http-request1.png -------------------------------------------------------------------------------- /docs/images/tracing-http-request2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtex/node-vtex-api/3d43ad10aa2df6598a15fd438ce5fe7dbc6ad71c/docs/images/tracing-http-request2.png -------------------------------------------------------------------------------- /docs/images/tracing-http-request3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vtex/node-vtex-api/3d43ad10aa2df6598a15fd438ce5fe7dbc6ad71c/docs/images/tracing-http-request3.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(.*(test|spec)).tsx?$', 7 | testEnvironment: 'node', 8 | moduleNameMapper: { 9 | '^@vtex/diagnostics-semconv$': '/__mocks__/@vtex/diagnostics-semconv.ts', 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /scripts/publishLock.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | BOLD="\e[1m" 4 | YELLOW="\e[33m" 5 | NO_COLOR="\033[0m" 6 | 7 | if [ ! "$IS_CI" == "true" ]; then 8 | echo -e "${BOLD}${YELLOW}" 9 | echo -e "=============================================================================================" 10 | echo -e "The CI is configured to publish when a new tag is created." 11 | echo -e "If you still want to publish yourself you have to set the env variable IS_CI to \"true\"" 12 | echo -e "=============================================================================================" 13 | exit 1 14 | fi -------------------------------------------------------------------------------- /src/HttpClient/GraphQLClient.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | 3 | import CustomGraphQLError from '../errors/customGraphQLError' 4 | import { HttpClient } from './HttpClient' 5 | import { inflightUrlWithQuery } from './middlewares/inflight' 6 | import { RequestConfig } from './typings' 7 | 8 | interface QueryOptions { 9 | query: string 10 | variables: Variables 11 | inflight?: boolean 12 | throwOnError?: boolean 13 | extensions?: Record 14 | } 15 | 16 | interface MutateOptions { 17 | mutate: string 18 | variables: Variables 19 | throwOnError?: boolean 20 | } 21 | 22 | export type Serializable = object | boolean | string | number 23 | 24 | export interface GraphQLResponse { 25 | data?: T 26 | errors?: GraphQLError[] 27 | extensions?: Record 28 | } 29 | 30 | const throwOnGraphQLErrors = (message: string, response: GraphQLResponse) => { 31 | if (response && response.errors && response.errors.length > 0) { 32 | throw new CustomGraphQLError(message, response.errors) 33 | } 34 | return response 35 | } 36 | 37 | export class GraphQLClient { 38 | constructor( 39 | private http: HttpClient 40 | ) {} 41 | 42 | public query = ( 43 | { query, variables, inflight, extensions, throwOnError }: QueryOptions, 44 | config: RequestConfig = {} 45 | ): Promise> => this.http.getWithBody>( 46 | config.url || '', 47 | { query, variables, extensions }, 48 | { 49 | inflightKey: inflight !== false ? inflightUrlWithQuery : undefined, 50 | ...config, 51 | }) 52 | .then(graphqlResponse => throwOnError === false 53 | ? graphqlResponse 54 | : throwOnGraphQLErrors(this.http.name, graphqlResponse) 55 | ) 56 | 57 | public mutate = ( 58 | { mutate, variables, throwOnError }: MutateOptions, 59 | config: RequestConfig = {} 60 | ) => 61 | this.http.post>( 62 | config.url || '', 63 | { query: mutate, variables }, 64 | config 65 | ) 66 | .then(graphqlResponse => throwOnError === false 67 | ? graphqlResponse 68 | : throwOnGraphQLErrors(this.http.name, graphqlResponse) 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/HttpClient/agents.ts: -------------------------------------------------------------------------------- 1 | import HttpAgent, { HttpOptions as AgentHttpOptions, HttpsAgent, HttpsOptions as AgentHttpsOptions } from 'agentkeepalive' 2 | 3 | type HttpOptions = Omit 4 | 5 | export const createHttpAgent = (opts?: HttpOptions) => new HttpAgent({ 6 | ...opts, 7 | freeSocketTimeout: 30 * 1000, 8 | keepAlive: true, 9 | maxFreeSockets: 256, 10 | socketActiveTTL: 120 * 1000, 11 | }) 12 | 13 | type HttpsOptions = Omit 14 | 15 | export const createHttpsAgent = (opts?: HttpsOptions) => new HttpsAgent({ 16 | ...opts, 17 | freeSocketTimeout: 30 * 1000, 18 | keepAlive: true, 19 | maxFreeSockets: 256, 20 | socketActiveTTL: 120 * 1000, 21 | }) 22 | -------------------------------------------------------------------------------- /src/HttpClient/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HttpClient' 2 | export * from './typings' 3 | export * from './GraphQLClient' 4 | export * from './agents' 5 | 6 | export { Cached, CacheType } from './middlewares/cache' 7 | export { inflightURL, inflightUrlWithQuery } from './middlewares/inflight' 8 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/cancellationToken.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { cancellableMethods } from '../../constants' 3 | import { Cancellation } from '../../service/worker/runtime/typings' 4 | import { MiddlewareContext } from '../typings' 5 | 6 | const production = process.env.VTEX_PRODUCTION === 'true' 7 | 8 | const handleCancellation = (ctx: MiddlewareContext, cancellation: Cancellation) => { 9 | let cancellable = true 10 | return { 11 | cancelToken: new axios.CancelToken((canceller) => { 12 | cancellation.source.token.promise.then(cancel => { 13 | if (cancellable) { 14 | canceller(cancel.message) 15 | } 16 | }) 17 | }), 18 | onRequestFinish: () => { 19 | if (!ctx.config.cancelToken) { 20 | // don't have cancelToken: not cancelable 21 | cancellable = false 22 | } else if (!ctx.response) { 23 | // response is not ready: cancelable 24 | cancellable = true 25 | } else if (ctx.config.responseType !== 'stream') { 26 | // response is ready and it is not a stream: not cancelable 27 | cancellable = false 28 | } else if (ctx.response.data.readableEnded) { 29 | // response stream has ended: not cancelable 30 | cancellable = false 31 | } else { 32 | // when response stream ends: not cancelable 33 | ctx.response.data.on('end', function streamEnded() { 34 | cancellable = false 35 | }) 36 | } 37 | }, 38 | } 39 | } 40 | 41 | export const cancellationToken = (cancellation?: Cancellation) => async (ctx: MiddlewareContext, next: () => Promise) => { 42 | const { config: { method } } = ctx 43 | 44 | if (!cancellation) { 45 | return await next() 46 | } 47 | 48 | if (method && !cancellableMethods.has(method.toUpperCase())) { 49 | cancellation.cancelable = false 50 | } 51 | 52 | if (!cancellation.cancelable || !cancellation.source) { 53 | return await next() 54 | } 55 | 56 | if (!cancellation.source.token.throwIfRequested) { 57 | if (!production) { 58 | throw new Error('Missing cancellation function. Are you trying to use HttpClient via workers threads?') 59 | } else { 60 | return await next() 61 | } 62 | } 63 | 64 | const {onRequestFinish, cancelToken} = handleCancellation(ctx, cancellation) 65 | ctx.config.cancelToken = cancelToken 66 | 67 | try { 68 | await next() 69 | } finally { 70 | onRequestFinish() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/inflight.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'qs' 2 | import { InflightKeyGenerator, MiddlewareContext, RequestConfig } from '../typings' 3 | 4 | export type Inflight = Required> 5 | 6 | const inflight = new Map>() 7 | let metricsAdded = false 8 | 9 | export const singleFlightMiddleware = async (ctx: MiddlewareContext, next: () => Promise) => { 10 | const { inflightKey } = ctx.config 11 | 12 | if (!inflightKey) { 13 | return await next() 14 | } 15 | 16 | // We cannot allow single flight requests to 17 | // cancel any request 18 | ctx.config.cancelToken = undefined 19 | 20 | if (!metricsAdded) { 21 | metrics.addOnFlushMetric(() => ({ 22 | name: 'node-vtex-api-inflight-map-size', 23 | size: inflight.entries.length, 24 | })) 25 | metricsAdded = true 26 | } 27 | 28 | const key = inflightKey(ctx.config) 29 | const isInflight = !!inflight.has(key) 30 | 31 | if (isInflight) { 32 | const memoized = await inflight.get(key)! 33 | ctx.inflightHit = isInflight 34 | ctx.response = memoized.response 35 | return 36 | } else { 37 | const promise = new Promise(async (resolve, reject) => { 38 | try { 39 | await next() 40 | resolve({ 41 | cacheHit: ctx.cacheHit!, 42 | response: ctx.response!, 43 | }) 44 | } 45 | catch (err) { 46 | reject(err) 47 | } 48 | finally { 49 | inflight.delete(key) 50 | } 51 | }) 52 | inflight.set(key, promise) 53 | await promise 54 | } 55 | } 56 | 57 | export const inflightURL: InflightKeyGenerator = ({baseURL, url}: RequestConfig) => baseURL! + url! 58 | 59 | export const inflightUrlWithQuery: InflightKeyGenerator = ({baseURL, url, params}: RequestConfig) => baseURL! + url! + stringify(params, {arrayFormat: 'repeat', addQueryPrefix: true}) 60 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/memoization.ts: -------------------------------------------------------------------------------- 1 | import { HttpLogEvents } from '../../tracing/LogEvents' 2 | import { HttpCacheLogFields } from '../../tracing/LogFields' 3 | import { CustomHttpTags } from '../../tracing/Tags' 4 | import { MiddlewareContext } from '../typings' 5 | import { cacheKey, CacheResult, CacheType, isLocallyCacheable } from './cache' 6 | 7 | export type Memoized = Required> 8 | 9 | interface MemoizationOptions { 10 | memoizedCache: Map> 11 | } 12 | 13 | export const memoizationMiddleware = ({ memoizedCache }: MemoizationOptions) => { 14 | return async (ctx: MiddlewareContext, next: () => Promise) => { 15 | 16 | if (!isLocallyCacheable(ctx.config, CacheType.Any) || !ctx.config.memoizable) { 17 | return next() 18 | } 19 | 20 | const span = ctx.tracing?.rootSpan 21 | 22 | const key = cacheKey(ctx.config) 23 | const isMemoized = !!memoizedCache.has(key) 24 | 25 | span?.log({ event: HttpLogEvents.CACHE_KEY_CREATE, [HttpCacheLogFields.CACHE_TYPE]: 'memoization', [HttpCacheLogFields.KEY]: key }) 26 | 27 | if (isMemoized) { 28 | span?.setTag(CustomHttpTags.HTTP_MEMOIZATION_CACHE_RESULT, CacheResult.HIT) 29 | const memoized = await memoizedCache.get(key)! 30 | ctx.memoizedHit = isMemoized 31 | ctx.response = memoized.response 32 | return 33 | } else { 34 | span?.setTag(CustomHttpTags.HTTP_MEMOIZATION_CACHE_RESULT, CacheResult.MISS) 35 | const promise = new Promise(async (resolve, reject) => { 36 | try { 37 | await next() 38 | resolve({ 39 | cacheHit: ctx.cacheHit!, 40 | response: ctx.response!, 41 | }) 42 | 43 | span?.log({ event: HttpLogEvents.MEMOIZATION_CACHE_SAVED, [HttpCacheLogFields.KEY_SET]: key }) 44 | } catch (err) { 45 | reject(err) 46 | span?.log({ event: HttpLogEvents.MEMOIZATION_CACHE_SAVED_ERROR, [HttpCacheLogFields.KEY_SET]: key }) 47 | } 48 | }) 49 | memoizedCache.set(key, promise) 50 | await promise 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/notFound.ts: -------------------------------------------------------------------------------- 1 | import {AxiosRequestConfig} from 'axios' 2 | import {MiddlewareContext} from '../typings' 3 | 4 | const addNotFound = (validateStatus: (status: number) => boolean) => 5 | (status: number) => validateStatus(status) || status === 404 6 | 7 | function nullIfNotFound (config: any): boolean { 8 | return config && config.nullIfNotFound 9 | } 10 | 11 | export const acceptNotFoundMiddleware = async (ctx: MiddlewareContext, next: () => Promise) => { 12 | const {config} = ctx 13 | if (nullIfNotFound(config)) { 14 | ctx.config.validateStatus = addNotFound(config.validateStatus!) 15 | } 16 | 17 | await next() 18 | } 19 | 20 | export const notFoundFallbackMiddleware = async (ctx: MiddlewareContext, next: () => Promise) => { 21 | await next() 22 | 23 | const {config} = ctx 24 | if (nullIfNotFound(config) && ctx.response && ctx.response.status === 404) { 25 | ctx.response.data = null 26 | } 27 | } 28 | 29 | export type IgnoreNotFoundRequestConfig = AxiosRequestConfig & { 30 | nullIfNotFound?: boolean, 31 | } 32 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/recorder.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareContext } from '../typings' 2 | import { 3 | Recorder, 4 | } from './../../service/worker/runtime/utils/recorder' 5 | 6 | export const recorderMiddleware = (recorder: Recorder) => 7 | async (ctx: MiddlewareContext, next: () => Promise) => { 8 | if (ctx.config?.ignoreRecorder) { 9 | await next() 10 | return 11 | } 12 | 13 | try { 14 | await next() 15 | if (ctx.response) { 16 | (recorder as Recorder).record(ctx.response.headers) 17 | } 18 | } catch (err: any) { 19 | if (err.response && err.response.headers && err.response.status === 404) { 20 | (recorder as Recorder).record(err.response.headers) 21 | } 22 | throw err 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/HttpAgentSingleton.ts: -------------------------------------------------------------------------------- 1 | import HttpAgent from 'agentkeepalive' 2 | import { createHttpAgent } from '../../agents' 3 | 4 | export class HttpAgentSingleton { 5 | public static getHttpAgent() { 6 | if (!HttpAgentSingleton.httpAgent) { 7 | HttpAgentSingleton.httpAgent = createHttpAgent() 8 | } 9 | 10 | return HttpAgentSingleton.httpAgent 11 | } 12 | 13 | public static httpAgentStats() { 14 | const sockets = HttpAgentSingleton.count(HttpAgentSingleton.httpAgent.sockets) 15 | const freeSockets = HttpAgentSingleton.count((HttpAgentSingleton.httpAgent as any).freeSockets) 16 | const pendingRequests = HttpAgentSingleton.count(HttpAgentSingleton.httpAgent.requests) 17 | 18 | return { 19 | freeSockets, 20 | pendingRequests, 21 | sockets, 22 | } 23 | } 24 | private static httpAgent: HttpAgent 25 | 26 | private static count(obj: { [key: string]: any[] }) { 27 | try { 28 | return Object.values(obj).reduce((acc, val) => acc += val.length, 0) 29 | } catch (_) { 30 | return 0 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/__tests__/TestServer.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import getPort from 'get-port' 3 | import http from 'http' 4 | import { TracedTestRequest } from './TracedTestRequest' 5 | 6 | type ExpectFn = (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void 7 | 8 | interface TestRequestArgs { 9 | params: Record 10 | url?: string 11 | retries?: number 12 | timeout?: number 13 | baseURL?: string 14 | } 15 | 16 | export class TestServer { 17 | public static async getAndStartTestServer() { 18 | const port = await getPort({ port: [3000, 3001, 3002, 3003] }) 19 | const testServer = new TestServer(port) 20 | testServer.startServer() 21 | return testServer 22 | } 23 | 24 | private server: http.Server 25 | private expectFn: ExpectFn 26 | 27 | private responseHeaders: Record 28 | 29 | constructor(private port: number) { 30 | // tslint:disable-next-line 31 | this.expectFn = () => {} 32 | this.responseHeaders = {} 33 | this.server = http.createServer(async (req, res) => { 34 | this.setHeaders(res) 35 | await this.expectFn(req, res) 36 | if (!res.writableEnded) { 37 | res.end() 38 | } 39 | }) 40 | } 41 | 42 | public setExpectFn(expectFn: ExpectFn) { 43 | this.expectFn = expectFn 44 | } 45 | 46 | public startServer() { 47 | console.log(`Starting test server on port ${this.port}...`) 48 | this.server.listen(this.port) 49 | } 50 | public closeServer() { 51 | console.log('Closing test server...') 52 | return new Promise((resolve, reject) => { 53 | this.server.close(err => { 54 | if (err) { 55 | return reject(err) 56 | } 57 | 58 | resolve() 59 | }) 60 | }) 61 | } 62 | 63 | public getUrl(path = '') { 64 | return `http://localhost:${this.port}${path}` 65 | } 66 | 67 | public mockResponseHeaders(headers: Record) { 68 | this.responseHeaders = headers 69 | } 70 | 71 | public async doRequest(client: AxiosInstance, reqArgs?: TestRequestArgs) { 72 | const url = reqArgs?.url ?? this.getUrl() 73 | const tracedTestRequest = new TracedTestRequest(client) 74 | await tracedTestRequest.runRequest({ ...reqArgs, url }) 75 | return tracedTestRequest 76 | } 77 | 78 | private setHeaders(response: http.ServerResponse) { 79 | Object.keys(this.responseHeaders).forEach(header => { 80 | response.setHeader(header, this.responseHeaders[header]) 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/__tests__/TestTracer.ts: -------------------------------------------------------------------------------- 1 | import { MockSpan, MockTracer } from '@tiagonapoli/opentracing-alternate-mock' 2 | import { Span, SpanContext, SpanOptions } from 'opentracing' 3 | import { IUserLandTracer } from '../../../../../tracing' 4 | 5 | export class TestTracer implements IUserLandTracer { 6 | public fallbackSpan: MockSpan 7 | public mockTracer: MockTracer 8 | 9 | public traceId: string 10 | public isTraceSampled: boolean 11 | 12 | constructor() { 13 | this.mockTracer = new MockTracer() 14 | this.fallbackSpan = this.mockTracer.startSpan('fallback-span') as MockSpan 15 | this.fallbackSpan.finish() 16 | 17 | const spanContext = this.fallbackSpan.context() 18 | this.traceId = spanContext.toTraceId() 19 | this.isTraceSampled = true 20 | } 21 | 22 | public startSpan(name: string, options?: SpanOptions) { 23 | return this.mockTracer.startSpan(name, options) 24 | } 25 | 26 | public inject(spanContext: Span | SpanContext, format: string, carrier: any) { 27 | return this.mockTracer.inject(spanContext, format, carrier) 28 | } 29 | 30 | public fallbackSpanContext() { 31 | return this.fallbackSpan.context() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/__tests__/TracedTestRequest.ts: -------------------------------------------------------------------------------- 1 | import { MockReport, MockSpan } from '@tiagonapoli/opentracing-alternate-mock' 2 | import { AxiosInstance, AxiosResponse } from 'axios' 3 | import { Span } from 'opentracing' 4 | import { Logger } from '../../../../../service/logger' 5 | import { RequestTracingConfig } from '../../../../typings' 6 | import { TraceableRequestConfig } from '../../../tracing' 7 | import { TestTracer } from './TestTracer' 8 | 9 | interface TracedTestRequestConfig extends RequestTracingConfig { 10 | url: string 11 | params?: any 12 | retries?: number 13 | timeout?: number 14 | baseURL?: string 15 | } 16 | 17 | export class TracedTestRequest { 18 | public static async doRequest(http: AxiosInstance, reqConfig: TracedTestRequestConfig) { 19 | const request = new TracedTestRequest(http) 20 | await request.runRequest(reqConfig) 21 | return request 22 | } 23 | 24 | public tracer: TestTracer 25 | public rootSpan: MockSpan 26 | public tracerReport?: MockReport 27 | public lastRequestSpan?: MockSpan 28 | 29 | // tslint:disable-next-line 30 | private _res?: AxiosResponse 31 | // tslint:disable-next-line 32 | private _error?: any 33 | 34 | constructor(private http: AxiosInstance) { 35 | this.tracer = new TestTracer() 36 | this.rootSpan = this.tracer.startSpan('root-span') as MockSpan 37 | } 38 | 39 | get error() { 40 | if (!this._error) { 41 | throw new Error('No error on request') 42 | } 43 | return this._error 44 | } 45 | 46 | get res() { 47 | if (!this._res) { 48 | throw new Error('No response on request') 49 | } 50 | return this._res 51 | } 52 | 53 | get allRequestSpans() { 54 | const spans = this.tracerReport?.spans.filter((span) => span.operationName().startsWith('http-request')) 55 | if (!spans?.length) { 56 | throw new Error('No request spans') 57 | } 58 | 59 | return spans 60 | } 61 | 62 | public async runRequest(reqConf: TracedTestRequestConfig) { 63 | try { 64 | const axiosReqConf: TraceableRequestConfig = { 65 | ...reqConf, 66 | tracing: { 67 | ...reqConf.tracing, 68 | isSampled: true, 69 | logger: new Logger({ 70 | account: 'mock-account', 71 | operationId: 'mock-operation-id', 72 | production: false, 73 | requestId: 'mock-request-id', 74 | workspace: 'mock-workspace', 75 | }), 76 | rootSpan: (this.rootSpan as unknown) as Span, 77 | tracer: this.tracer, 78 | }, 79 | } 80 | 81 | this._res = await this.http.get(reqConf.url, axiosReqConf) 82 | } catch (err) { 83 | this._error = err 84 | } finally { 85 | this.rootSpan.finish() 86 | this.tracerReport = this.tracer.mockTracer.report() 87 | 88 | this.lastRequestSpan = this.tracerReport.spans 89 | .slice() 90 | .reverse() 91 | .find((span) => span.operationName().startsWith('http-request')) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { HttpAgentSingleton } from '../HttpAgentSingleton' 3 | import { 4 | addExponentialBackoffResponseInterceptor, 5 | addTracingPreRequestInterceptor, 6 | addTracingResponseInterceptor, 7 | } from './interceptors' 8 | 9 | export const getConfiguredAxios = () => { 10 | const httpAgent = HttpAgentSingleton.getHttpAgent() 11 | const http = axios.create({ 12 | httpAgent, 13 | }) 14 | 15 | addTracingPreRequestInterceptor(http) 16 | 17 | // Do not change this order, otherwise each request span will 18 | // wait all retries to finish before finishing the span 19 | addTracingResponseInterceptor(http) 20 | addExponentialBackoffResponseInterceptor(http) 21 | 22 | return http 23 | } 24 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/interceptors/index.ts: -------------------------------------------------------------------------------- 1 | export { addExponentialBackoffResponseInterceptor } from './exponentialBackoff' 2 | export { addTracingPreRequestInterceptor, addTracingResponseInterceptor } from './tracing' 3 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/interceptors/tracing/axiosHelpers.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'axios/lib/core/buildFullPath' { 2 | function buildFullPath(baseURL: string | undefined | null, requestedURL: string | undefined | null) 3 | export = buildFullPath 4 | } 5 | -------------------------------------------------------------------------------- /src/HttpClient/middlewares/request/setupAxios/interceptors/tracing/spanSetup.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import buildFullPath from '../../../../../../utils/buildFullPath' 3 | import { Span } from 'opentracing' 4 | import { HeaderKeys } from '../../../../../../constants' 5 | import { CustomHttpTags, OpentracingTags } from '../../../../../../tracing/Tags' 6 | import { cloneAndSanitizeHeaders } from '../../../../../../tracing/utils' 7 | 8 | export const injectRequestInfoOnSpan = (span: Span | undefined, http: AxiosInstance, config: AxiosRequestConfig) => { 9 | span?.addTags({ 10 | [OpentracingTags.SPAN_KIND]: OpentracingTags.SPAN_KIND_RPC_CLIENT, 11 | [OpentracingTags.HTTP_METHOD]: config.method, 12 | [OpentracingTags.HTTP_URL]: buildFullPath(config.baseURL, http.getUri(config)), 13 | }) 14 | 15 | span?.log({ 'request-headers': cloneAndSanitizeHeaders(config.headers as any) }) 16 | } 17 | 18 | // Response may be undefined in case of client timeout, invalid URL, ... 19 | export const injectResponseInfoOnSpan = (span: Span | undefined, response: AxiosResponse | undefined) => { 20 | if (!response) { 21 | span?.setTag(CustomHttpTags.HTTP_NO_RESPONSE, 'true') 22 | return 23 | } 24 | 25 | span?.log({ 'response-headers': cloneAndSanitizeHeaders(response.headers) }) 26 | span?.setTag(OpentracingTags.HTTP_STATUS_CODE, response.status) 27 | 28 | if (response.headers[HeaderKeys.ROUTER_CACHE]) { 29 | span?.setTag(CustomHttpTags.HTTP_ROUTER_CACHE_RESULT, response.headers[HeaderKeys.ROUTER_CACHE]) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/caches/CacheLayer.ts: -------------------------------------------------------------------------------- 1 | import { FetchResult } from './typings' 2 | 3 | export interface CacheLayer { 4 | get (key: K, fetcher?: () => Promise>): Promise | V | void, 5 | has (key: K): Promise | boolean, 6 | set (key: K, value: V, maxAge?: number | void): Promise | boolean, 7 | getStats? (name?: string): any, 8 | } 9 | -------------------------------------------------------------------------------- /src/caches/DiskCache.ts: -------------------------------------------------------------------------------- 1 | import { CacheLayer } from './CacheLayer' 2 | import { DiskStats } from './typings' 3 | 4 | import { outputJSON, pathExistsSync, readJSON } from 'fs-extra' 5 | import { join } from 'path' 6 | import ReadWriteLock from 'rwlock' 7 | 8 | export class DiskCache implements CacheLayer{ 9 | 10 | private hits = 0 11 | private total = 0 12 | private lock: ReadWriteLock 13 | 14 | constructor(private cachePath: string, private readFile=readJSON, private writeFile=outputJSON) { 15 | this.lock = new ReadWriteLock() 16 | } 17 | 18 | public has = (key: string): boolean => { 19 | const pathKey = this.getPathKey(key) 20 | return pathExistsSync(pathKey) 21 | } 22 | 23 | public getStats = (name='disk-cache'): DiskStats => { 24 | const stats = { 25 | hits: this.hits, 26 | name, 27 | total: this.total, 28 | } 29 | this.hits = 0 30 | this.total = 0 31 | return stats 32 | } 33 | 34 | public get = async (key: string): Promise => { 35 | const pathKey = this.getPathKey(key) 36 | this.total += 1 37 | const data = await new Promise(resolve => { 38 | this.lock.readLock(key, async (release: () => void) => { 39 | try { 40 | const fileData = await this.readFile(pathKey) 41 | release() 42 | this.hits += 1 43 | resolve(fileData) 44 | } catch (e) { 45 | release() 46 | resolve(null as unknown as V) 47 | } 48 | }) 49 | }) 50 | return data 51 | } 52 | 53 | public set = async (key: string, value: V) => { 54 | const pathKey = this.getPathKey(key) 55 | const failure = await new Promise(resolve => { 56 | this.lock.writeLock(key, async (release: () => void) => { 57 | try { 58 | const writePromise = await this.writeFile(pathKey, value) 59 | release() 60 | resolve(writePromise) 61 | } catch (e) { 62 | release() 63 | resolve(true) 64 | } 65 | }) 66 | }) 67 | return !failure 68 | } 69 | 70 | private getPathKey = (key: string) => join(this.cachePath, key) 71 | } 72 | -------------------------------------------------------------------------------- /src/caches/LRUCache.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache' 2 | import { CacheLayer } from './CacheLayer' 3 | import { MultilayeredCache } from './MultilayeredCache' 4 | import { FetchResult, LRUStats } from './typings' 5 | 6 | export class LRUCache implements CacheLayer{ 7 | private multilayer: MultilayeredCache 8 | private storage: LRU 9 | private hits: number 10 | private total: number 11 | private disposed: number 12 | 13 | constructor (options: LRU.Options) { 14 | this.hits = 0 15 | this.total = 0 16 | this.disposed = 0 17 | this.storage = new LRU({ 18 | ...options, 19 | dispose: () => this.disposed += 1, 20 | noDisposeOnSet: true, 21 | }) 22 | this.multilayer = new MultilayeredCache([this]) 23 | } 24 | 25 | public get = (key: K): V | void => { 26 | const value = this.storage.get(key) 27 | if (this.storage.has(key)) { 28 | this.hits += 1 29 | } 30 | this.total += 1 31 | return value 32 | } 33 | 34 | public getOrSet = async (key: K, fetcher?: () => Promise>): Promise => this.multilayer.get(key, fetcher) 35 | 36 | public set = (key: K, value: V, maxAge?: number): boolean => this.storage.set(key, value, maxAge) 37 | 38 | public has = (key: K): boolean => this.storage.has(key) 39 | 40 | public getStats = (name='lru-cache'): LRUStats => { 41 | const stats = { 42 | disposedItems: this.disposed, 43 | hitRate: this.total > 0 ? this.hits / this.total : undefined, 44 | hits: this.hits, 45 | itemCount: this.storage.itemCount, 46 | length: this.storage.length, 47 | max: this.storage.max, 48 | name, 49 | total: this.total, 50 | } 51 | this.hits = 0 52 | this.total = 0 53 | this.disposed = 0 54 | return stats 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/caches/MultilayeredCache.ts: -------------------------------------------------------------------------------- 1 | import { any, map, slice } from 'ramda' 2 | import { CacheLayer } from './CacheLayer' 3 | import { FetchResult, MultilayerStats } from './typings' 4 | 5 | export class MultilayeredCache implements CacheLayer{ 6 | 7 | private hits = 0 8 | private total = 0 9 | 10 | constructor (private caches: Array>) {} 11 | 12 | public get = async (key: K, fetcher?: () => Promise>): Promise => { 13 | let value: V | void = undefined 14 | let maxAge: number | void 15 | let successIndex = await this.findIndex(async (cache: CacheLayer) => { 16 | const [getValue, hasKey] = await Promise.all([cache.get(key), cache.has(key)]) 17 | value = getValue 18 | return hasKey 19 | }, this.caches) 20 | if (successIndex === -1) { 21 | if (fetcher) { 22 | const fetched = await fetcher() 23 | value = fetched.value 24 | maxAge = fetched.maxAge 25 | } else { 26 | return undefined 27 | } 28 | successIndex = Infinity 29 | } 30 | const failedCaches = slice(0, successIndex, this.caches) 31 | 32 | const [firstPromise] = map(cache => cache.set(key, value as V, maxAge), failedCaches) 33 | await firstPromise 34 | return value 35 | } 36 | 37 | public set = async (key: K, value: V, maxAge?: number): Promise => { 38 | const isSet = await Promise.all(map(cache => cache.set(key, value, maxAge), this.caches)) 39 | return any(item => item, isSet) 40 | } 41 | 42 | public has = async (key: K): Promise => { 43 | const hasList = await Promise.all(map(cache => cache.has(key), this.caches)) 44 | return any(item => item, hasList) 45 | } 46 | 47 | public getStats = (name='multilayred-cache'): MultilayerStats => { 48 | const multilayerStats = { 49 | hitRate: this.total > 0 ? this.hits / this.total : undefined, 50 | hits: this.hits, 51 | name, 52 | total: this.total, 53 | } 54 | this.resetCounters() 55 | return multilayerStats 56 | } 57 | 58 | private findIndex = async (func: (item: T) => Promise, array: T[]): Promise => { 59 | this.total += 1 60 | for (let index = 0; index < array.length; index++) { 61 | const hasKey = await func(array[index]) 62 | if (hasKey) { 63 | this.hits += 1 64 | return index 65 | } 66 | } 67 | return -1 68 | } 69 | 70 | private resetCounters () { 71 | this.hits = 0 72 | this.total = 0 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/caches/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CacheLayer' 2 | export * from './DiskCache' 3 | export * from './LRUCache' 4 | export * from './LRUDiskCache' 5 | export * from './MultilayeredCache' 6 | export * from './typings' 7 | -------------------------------------------------------------------------------- /src/caches/typings.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:interface-over-type-literal 2 | export type FetchResult = { 3 | value: V, 4 | maxAge?: number, 5 | } 6 | 7 | // tslint:disable-next-line:interface-over-type-literal 8 | export type DiskStats = { 9 | hits: number, 10 | total: number, 11 | name: string, 12 | } 13 | 14 | // tslint:disable-next-line:interface-over-type-literal 15 | export type LRUStats = { 16 | itemCount: number, 17 | length: number, 18 | disposedItems: number, 19 | hitRate: number | undefined, 20 | hits: number, 21 | max: number, 22 | name: string, 23 | total: number, 24 | } 25 | 26 | // tslint:disable-next-line:interface-over-type-literal 27 | export type MultilayerStats = { 28 | hitRate: number | undefined, 29 | hits: number, 30 | total: number, 31 | name: string, 32 | } 33 | 34 | // tslint:disable-next-line:interface-over-type-literal 35 | export type LRUDiskCacheOptions = { 36 | /** 37 | * The maximum size of the cache, checked by applying the length 38 | * function to all values in the cache. Not setting this is kind of silly, 39 | * since that's the whole purpose of this lib, but it defaults to `Infinity`. 40 | */ 41 | max?: number 42 | /** 43 | * Maximum age in ms. Items are not pro-actively pruned out as they age, 44 | * but if you try to get an item that is too old, it'll drop it and return 45 | * undefined instead of giving it to you. 46 | */ 47 | maxAge?: number 48 | /** 49 | * By default, if you set a `maxAge`, it'll only actually pull stale items 50 | * out of the cache when you `get(key)`. (That is, it's not pre-emptively 51 | * doing a `setTimeout` or anything.) If you set `stale:true`, it'll return 52 | * the stale value before deleting it. If you don't set this, then it'll 53 | * return `undefined` when you try to get a stale entry, 54 | * as if it had already been deleted. 55 | */ 56 | stale?: boolean, 57 | } 58 | -------------------------------------------------------------------------------- /src/clients/IOClient.ts: -------------------------------------------------------------------------------- 1 | import { IOContext } from '../service/worker/runtime/typings' 2 | 3 | import { HttpClient } from '../HttpClient/HttpClient' 4 | import { InstanceOptions } from '../HttpClient/typings' 5 | 6 | export type IOClientConstructor = new (context: IOContext, options?: InstanceOptions) => IOClient 7 | 8 | /** 9 | * A client that can be instantiated by the Serviceruntime layer. 10 | */ 11 | export class IOClient { 12 | protected http: HttpClient 13 | 14 | constructor(protected context: IOContext, protected options?: InstanceOptions) { 15 | this.http = new HttpClient({ 16 | name: this.constructor.name, 17 | ...context, 18 | ...options, 19 | metrics: options && options.metrics, 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/clients/IOGraphQLClient.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from '../HttpClient/GraphQLClient' 2 | import { InstanceOptions } from '../HttpClient/typings' 3 | import { IOContext } from '../service/worker/runtime/typings' 4 | import { IOClient } from './IOClient' 5 | 6 | /** 7 | * A GraphQL client that can be instantiated by the Serviceruntime layer. 8 | */ 9 | export class IOGraphQLClient extends IOClient { 10 | protected graphql: GraphQLClient 11 | 12 | constructor(protected context: IOContext, protected options?: InstanceOptions) { 13 | super(context, options) 14 | this.graphql = new GraphQLClient(this.http) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/clients/apps/AppClient.ts: -------------------------------------------------------------------------------- 1 | import { AuthType, InstanceOptions } from '../../HttpClient/typings' 2 | import { formatPrivateRoute } from '../../service/worker/runtime/http/routes' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { IOClient } from '../IOClient' 5 | 6 | const useHttps = !process.env.VTEX_IO 7 | 8 | /** 9 | * Used to perform calls on apps you declared a dependency for in your manifest. 10 | */ 11 | export class AppClient extends IOClient { 12 | constructor(app: string, context: IOContext, options?: InstanceOptions) { 13 | const {account, workspace, region} = context 14 | const [appName, appVersion] = app.split('@') 15 | const [vendor, name] = appName.split('.') // vtex.messages 16 | const protocol = useHttps ? 'https' : 'http' 17 | let baseURL: string 18 | if (appVersion) { 19 | const [major] = appVersion.split('.') 20 | baseURL = formatPrivateRoute({account, workspace, vendor, name, major, protocol}) 21 | } else { 22 | console.warn(`${account} in ${workspace} is using old routing for ${app}. Please change vendor.app to vendor.app@major in client ${(options && options.name) || ''}`) 23 | const service = [name, vendor].join('.') // messages.vtex 24 | baseURL = `http://${service}.${region}.vtex.io/${account}/${workspace}` 25 | } 26 | 27 | super( 28 | context, 29 | { 30 | ...options, 31 | authType: AuthType.bearer, 32 | baseURL, 33 | name, 34 | } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/clients/apps/AppGraphQLClient.ts: -------------------------------------------------------------------------------- 1 | import { AuthType, InstanceOptions } from '../../HttpClient/typings' 2 | import { formatPrivateRoute } from '../../service/worker/runtime/http/routes' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { IOGraphQLClient } from '../IOGraphQLClient' 5 | 6 | const useHttps = !process.env.VTEX_IO 7 | /** 8 | * Used to perform calls on apps you declared a dependency for in your manifest. 9 | */ 10 | export class AppGraphQLClient extends IOGraphQLClient { 11 | constructor(app: string, context: IOContext, options?: InstanceOptions) { 12 | const {account, workspace, region} = context 13 | const [appName, appVersion] = app.split('@') 14 | const [vendor, name] = appName.split('.') // vtex.messages 15 | const protocol = useHttps ? 'https' : 'http' 16 | let baseURL: string 17 | if (appVersion) { 18 | const [major] = appVersion.split('.') 19 | baseURL = formatPrivateRoute({account, workspace, major, name, vendor, protocol, path: '/_v/graphql'}) 20 | } else { 21 | console.warn(`${account} in ${workspace} is using old routing for ${app}. Please change vendor.app to vendor.app@major in client ${(options && options.name) || ''}`) 22 | const service = [name, vendor].join('.') // messages.vtex 23 | baseURL = `http://${service}.${region}.vtex.io/${account}/${workspace}/_v/graphql` 24 | } 25 | 26 | super( 27 | context, 28 | { 29 | ...options, 30 | authType: AuthType.bearer, 31 | baseURL, 32 | name, 33 | } 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/clients/apps/Billing.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { AppClient } from './AppClient' 4 | 5 | export class Billing extends AppClient { 6 | constructor(context: IOContext, options?: InstanceOptions) { 7 | super('vtex.billing@0.x', context, options) 8 | } 9 | 10 | public status = (tracingConfig?: RequestTracingConfig) => 11 | this.http.get('/_v/contractStatus', { 12 | tracing: { 13 | requestSpanNameSuffix: 'billing-status', 14 | ...tracingConfig?.tracing, 15 | }, 16 | }) 17 | } 18 | 19 | export enum ContractStatus { 20 | ACTIVE = 'active_contract', 21 | INACTIVE = 'inactive_contract', 22 | NO_CONTRACT = 'no_contract', 23 | } 24 | -------------------------------------------------------------------------------- /src/clients/apps/Settings.ts: -------------------------------------------------------------------------------- 1 | import { any } from 'ramda' 2 | 3 | import { inflightUrlWithQuery, InstanceOptions, RequestTracingConfig } from '../../HttpClient' 4 | import { getDependenciesHash, getFilteredDependencies } from '../../service/worker/runtime/http/middlewares/settings' 5 | import { IOContext } from '../../service/worker/runtime/typings' 6 | import { isLinkedApp } from '../../utils/app' 7 | import { AppMetaInfo } from '../infra/Apps' 8 | import { AppClient } from './AppClient' 9 | 10 | const LINKED_ROUTE = 'linked' 11 | 12 | const containsLinks = any(isLinkedApp) 13 | 14 | export interface SettingsParams { 15 | merge?: boolean 16 | files?: string[] 17 | } 18 | 19 | export class Settings extends AppClient { 20 | constructor (context: IOContext, options?: InstanceOptions) { 21 | super('vtex.settings-server@0.x', context, options) 22 | } 23 | 24 | public getFilteredDependencies(appAtMajor: string, dependencies: AppMetaInfo[]): AppMetaInfo[] { 25 | return getFilteredDependencies(appAtMajor, dependencies) 26 | } 27 | 28 | public getDependenciesHash(dependencies: AppMetaInfo[]): string { 29 | return getDependenciesHash(dependencies) 30 | } 31 | 32 | public async getSettings(dependencies: AppMetaInfo[], appAtMajor: string, params?: SettingsParams, tracingConfig?: RequestTracingConfig) { 33 | const filtered = this.getFilteredDependencies(appAtMajor, dependencies) 34 | // Settings server exposes a smartCache-enabled route for when the workspace contains links. 35 | const lastSegment = containsLinks(filtered) 36 | ? LINKED_ROUTE 37 | : this.getDependenciesHash(filtered) 38 | 39 | const metric = 'settings-get' 40 | return this.http.get(`/settings/${appAtMajor}/${lastSegment}`, { 41 | inflightKey: inflightUrlWithQuery, 42 | metric, 43 | params, 44 | tracing: { 45 | requestSpanNameSuffix: metric, 46 | ...tracingConfig?.tracing, 47 | }, 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/clients/apps/catalogGraphQL/brand.ts: -------------------------------------------------------------------------------- 1 | export const query = ` 2 | query GetBrand($id: ID!) { 3 | brand (id: $id) { 4 | id 5 | name 6 | text 7 | keywords 8 | siteTitle 9 | active 10 | menuHome 11 | adWordsRemarketingCode 12 | lomadeeCampaignCode 13 | score 14 | } 15 | } 16 | ` 17 | 18 | export interface Brand { 19 | id: string 20 | name: string 21 | text?: string 22 | keywords?: string[] 23 | siteTitle?: string 24 | active: boolean 25 | menuHome: boolean 26 | adWordsRemarketingCode?: string 27 | lomadeeCampaignCode?: string 28 | score?: number 29 | } 30 | -------------------------------------------------------------------------------- /src/clients/apps/catalogGraphQL/category.ts: -------------------------------------------------------------------------------- 1 | export const query = ` 2 | query GetCategory($id: ID!) { 3 | category (id: $id) { 4 | id 5 | name 6 | title 7 | parentCategoryId 8 | description 9 | isActive 10 | globalCategoryId 11 | score 12 | } 13 | } 14 | ` 15 | 16 | export interface Category { 17 | id: string 18 | name: string 19 | title?: string 20 | parentCategoryId?: string 21 | description?: string 22 | isActive: boolean 23 | globalCategoryId: number 24 | score?: number 25 | } 26 | -------------------------------------------------------------------------------- /src/clients/apps/catalogGraphQL/index.ts: -------------------------------------------------------------------------------- 1 | import { prop } from 'ramda' 2 | 3 | import { AppGraphQLClient } from '..' 4 | import { InstanceOptions, IOContext } from '../../..' 5 | import { RequestTracingConfig } from '../../../HttpClient' 6 | import { Brand, query as getBrand } from './brand' 7 | import { Category, query as getCategory } from './category' 8 | import { Product, query as getProduct } from './product' 9 | import { query as getSKU, SKU } from './sku' 10 | 11 | export class CatalogGraphQL extends AppGraphQLClient { 12 | public constructor(ctx: IOContext, opts?: InstanceOptions) { 13 | super('vtex.catalog-graphql@1.x', ctx, { 14 | ...opts, 15 | headers: { 16 | ...opts && opts.headers, 17 | cookie: `VtexIdclientAutCookie=${ctx.authToken}`, 18 | }}) 19 | } 20 | 21 | public sku = (id: string, tracingConfig?: RequestTracingConfig) => { 22 | const variables = { 23 | identifier: { 24 | field: 'id', 25 | value: id, 26 | }, 27 | } 28 | return this.graphql 29 | .query<{sku: SKU}, typeof variables>({ 30 | inflight: true, 31 | query: getSKU, 32 | variables, 33 | }, 34 | { 35 | forceMaxAge: 5, 36 | tracing: { 37 | requestSpanNameSuffix: 'catalog-sku', 38 | ...tracingConfig?.tracing, 39 | }, 40 | }) 41 | .then(prop('data')) 42 | } 43 | 44 | public product = (id: string, tracingConfig?: RequestTracingConfig) => { 45 | const variables = { 46 | identifier: { 47 | field: 'id', 48 | value: id, 49 | }, 50 | } 51 | return this.graphql 52 | .query<{product: Product}, typeof variables>({ 53 | inflight: true, 54 | query: getProduct, 55 | variables, 56 | }, 57 | { 58 | forceMaxAge: 5, 59 | tracing: { 60 | requestSpanNameSuffix: 'catalog-product', 61 | ...tracingConfig?.tracing, 62 | }, 63 | }) 64 | .then(prop('data')) 65 | } 66 | 67 | public category = (id: string, tracingConfig?: RequestTracingConfig) => 68 | this.graphql 69 | .query<{category: Category}, { id: string }>({ 70 | inflight: true, 71 | query: getCategory, 72 | variables: { id }, 73 | }, 74 | { 75 | forceMaxAge: 5, 76 | tracing: { 77 | requestSpanNameSuffix: 'catalog-category', 78 | ...tracingConfig?.tracing, 79 | }, 80 | }) 81 | .then(prop('data')) 82 | 83 | public brand = (id: string, tracingConfig?: RequestTracingConfig) => 84 | this.graphql 85 | .query<{brand: Brand}, { id: string }>({ 86 | inflight: true, 87 | query: getBrand, 88 | variables: { id }, 89 | }, 90 | { 91 | forceMaxAge: 5, 92 | tracing: { 93 | requestSpanNameSuffix: 'catalog-brand', 94 | ...tracingConfig?.tracing, 95 | }, 96 | }) 97 | .then(prop('data')) 98 | } 99 | -------------------------------------------------------------------------------- /src/clients/apps/catalogGraphQL/product.ts: -------------------------------------------------------------------------------- 1 | export const query = ` 2 | query GetProduct ($identifier: ProductUniqueIdentifier!) { 3 | product (identifier: $identifier) { 4 | id 5 | brandId 6 | categoryId 7 | departmentId 8 | name 9 | linkId 10 | refId 11 | isVisible 12 | description 13 | shortDescription 14 | releaseDate 15 | keywords 16 | title 17 | isActive 18 | taxCode 19 | metaTagDescription 20 | supplierId 21 | showWithoutStock 22 | score 23 | salesChannel { 24 | id 25 | } 26 | } 27 | } 28 | ` 29 | 30 | export interface Product { 31 | id: string 32 | brandId: string 33 | categoryId: string 34 | departmentId: string 35 | name: string 36 | linkId: string 37 | refId?: string 38 | isVisible: boolean 39 | description?: string 40 | shortDescription?: string 41 | releaseDate?: string 42 | keywords: string[] 43 | title?: string 44 | isActive: boolean 45 | taxCode?: string 46 | metaTagDescription?: string 47 | supplierId?: string 48 | showWithoutStock: boolean 49 | score?: number 50 | salesChannel?: Array<{ id: string }> 51 | } 52 | -------------------------------------------------------------------------------- /src/clients/apps/catalogGraphQL/sku.ts: -------------------------------------------------------------------------------- 1 | export const query = ` 2 | query GetSKU ($identifier: SKUUniqueIdentifier!) { 3 | sku (identifier: $identifier) { 4 | id 5 | productId 6 | isActive 7 | name 8 | height 9 | length 10 | width 11 | weightKg 12 | packagedHeight 13 | packagedWidth 14 | packagedLength 15 | packagedWeightKg 16 | cubicWeight 17 | isKit 18 | creationDate 19 | rewardValue 20 | manufacturerCode 21 | commercialConditionId 22 | measurementUnit 23 | unitMultiplier 24 | modalType 25 | kitItensSellApart 26 | } 27 | } 28 | ` 29 | 30 | export interface SKU { 31 | id: string 32 | productId: string 33 | isActive: boolean 34 | name: string 35 | height?: number 36 | length?: number 37 | width?: number 38 | weightKg?: number 39 | packagedHeight?: number 40 | packagedWidth?: number 41 | packagedLength?: number 42 | packagedWeightKg?: number 43 | cubicWeight: number 44 | isKit: boolean 45 | creationDate: string 46 | rewardValue?: number 47 | estimatedDateArrival?: string 48 | manufacturerCode: string 49 | commercialConditionId: string 50 | measurementUnit: string 51 | unitMultiplier: number 52 | modalType?: string 53 | kitItensSellApart: boolean 54 | } 55 | -------------------------------------------------------------------------------- /src/clients/apps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AppClient' 2 | export * from './AppGraphQLClient' 3 | export * from './Billing' 4 | export * from './Builder' 5 | export * from './MessagesGraphQL' 6 | export * from './Settings' 7 | export * from './catalogGraphQL' 8 | -------------------------------------------------------------------------------- /src/clients/external/ExternalClient.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions } from '../../HttpClient/typings' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { IOClient } from '../IOClient' 4 | 5 | /** 6 | * Used to perform calls to external endpoints for which you have declared outbound access policies in your manifest. 7 | */ 8 | export class ExternalClient extends IOClient { 9 | constructor(baseURL: string, context: IOContext, options?: InstanceOptions) { 10 | const {authToken} = context 11 | const headers = options && options.headers || {} 12 | 13 | super( 14 | context, 15 | { 16 | ...options, 17 | baseURL, 18 | headers: { 19 | ...headers, 20 | 'Proxy-Authorization': authToken, 21 | }, 22 | } 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/clients/external/ID.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { ExternalClient } from './ExternalClient' 4 | 5 | const routes = { 6 | SEND: '/accesskey/send', 7 | START: '/start', 8 | VALIDATE: '/accesskey/validate', 9 | VALIDATE_CLASSIC: '/classic/validate', 10 | } 11 | 12 | const VTEXID_ENDPOINTS: Record = { 13 | STABLE: 'https://vtexid.vtex.com.br/api/vtexid/pub/authentication', 14 | } 15 | 16 | const endpoint = (env: string) => { 17 | return VTEXID_ENDPOINTS[env] || env 18 | } 19 | 20 | export class ID extends ExternalClient { 21 | constructor (context: IOContext, opts?: InstanceOptions) { 22 | super(endpoint(VTEXID_ENDPOINTS.STABLE), context, opts) 23 | } 24 | 25 | public getTemporaryToken = (tracingConfig?: RequestTracingConfig) => { 26 | const metric = 'vtexid-temp-token' 27 | return this.http.get(routes.START, {metric, tracing: { 28 | requestSpanNameSuffix: metric, 29 | ...tracingConfig?.tracing, 30 | }}).then(({authenticationToken}) => authenticationToken) 31 | } 32 | 33 | public sendCodeToEmail = (token: string, email: string, tracingConfig?: RequestTracingConfig) => { 34 | const params = {authenticationToken: token, email} 35 | const metric = 'vtexid-send-code' 36 | return this.http.get(routes.SEND, {metric, params, tracing: { 37 | requestSpanNameSuffix: metric, 38 | ...tracingConfig?.tracing, 39 | }}) 40 | } 41 | 42 | public getEmailCodeAuthenticationToken = (token: string, email: string, code: string, tracingConfig?: RequestTracingConfig) => { 43 | const params = { 44 | accesskey: code, 45 | authenticationToken: token, 46 | login: email, 47 | } 48 | const metric = 'vtexid-email-token' 49 | return this.http.get(routes.VALIDATE, {metric, params, tracing: { 50 | requestSpanNameSuffix: metric, 51 | ...tracingConfig?.tracing, 52 | }}) 53 | } 54 | 55 | public getPasswordAuthenticationToken = (token: string, email: string, password: string, tracingConfig?: RequestTracingConfig) => { 56 | const params = { 57 | authenticationToken: token, 58 | login: email, 59 | password, 60 | } 61 | const metric = 'vtexid-pass-token' 62 | return this.http.get(routes.VALIDATE_CLASSIC, {metric, params, tracing: { 63 | requestSpanNameSuffix: metric, 64 | ...tracingConfig?.tracing, 65 | }}) 66 | } 67 | } 68 | 69 | interface TemporaryToken { 70 | authenticationToken: string, 71 | } 72 | 73 | export interface AuthenticationResponse { 74 | promptMFA: boolean, 75 | clientToken: any, 76 | authCookie: { 77 | Name: string, 78 | Value: string, 79 | }, 80 | accountAuthCookie: any, 81 | expiresIn: number, 82 | userId: string, 83 | phoneNumber: string, 84 | scope: any, 85 | } 86 | -------------------------------------------------------------------------------- /src/clients/external/PaymentProvider.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, IOContext, Maybe } from '../..' 2 | import { RequestTracingConfig } from '../../HttpClient' 3 | import { ExternalClient } from './ExternalClient' 4 | 5 | const routes = { 6 | callback: (transactionId: string, paymentId: string) => 7 | `${routes.payment(transactionId, paymentId)}/notification`, 8 | inbound: (transactionId: string, paymentId: string, action: string) => 9 | `${routes.payment(transactionId, paymentId)}/inbound-request/${action}`, 10 | payment: (transactionId: string, paymentId: string) => 11 | `/transactions/${transactionId}/payments/${paymentId}`, 12 | } 13 | 14 | export class PaymentProvider extends ExternalClient { 15 | constructor(protected context: IOContext, options?: InstanceOptions) { 16 | super( 17 | `http://${context.account}.vtexpayments.com.br/payment-provider`, 18 | context, 19 | { 20 | ...(options ?? {}), 21 | headers: { 22 | ...(options?.headers ?? {}), 23 | 'X-Vtex-Use-Https': 'true', 24 | }, 25 | } 26 | ) 27 | } 28 | 29 | public callback = ( 30 | transactionId: string, 31 | paymentId: string, 32 | callback: AuthorizationCallback, 33 | tracingConfig?: RequestTracingConfig 34 | ) => { 35 | const metric = 'gateway-callback' 36 | return this.http.post( 37 | routes.callback(transactionId, paymentId), 38 | callback, 39 | { 40 | metric, 41 | tracing: { 42 | requestSpanNameSuffix: metric, 43 | ...tracingConfig?.tracing, 44 | }, 45 | } 46 | ) 47 | } 48 | 49 | public inbound = ( 50 | transactionId: string, 51 | paymentId: string, 52 | action: string, 53 | payload: TRequest, 54 | tracingConfig?: RequestTracingConfig 55 | ) => { 56 | const metric = 'gateway-inbound-request' 57 | return this.http.post( 58 | routes.inbound(transactionId, paymentId, action), 59 | payload, 60 | { 61 | metric, 62 | tracing: { 63 | requestSpanNameSuffix: metric, 64 | ...tracingConfig?.tracing, 65 | }, 66 | } 67 | ) 68 | } 69 | } 70 | 71 | export interface AuthorizationCallback { 72 | paymentId: string 73 | status: string 74 | tid: string 75 | authorizationId?: Maybe 76 | nsu?: Maybe 77 | acquirer: string 78 | paymentUrl?: Maybe 79 | paymentAppData?: Maybe<{ 80 | appName: string 81 | payload: string 82 | }> 83 | identificationNumber?: Maybe 84 | identificationNumberFormatted?: Maybe 85 | barCodeImageType?: Maybe 86 | barCodeImageNumber?: Maybe 87 | code?: Maybe 88 | message?: Maybe 89 | delayToAutoSettle?: Maybe 90 | delayToAutoSettleAfterAntifraud?: Maybe 91 | delayToCancel?: Maybe 92 | } 93 | -------------------------------------------------------------------------------- /src/clients/external/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ExternalClient' 2 | export * from './ID' 3 | export * from './PaymentProvider' 4 | export * from './Masterdata' 5 | -------------------------------------------------------------------------------- /src/clients/index.ts: -------------------------------------------------------------------------------- 1 | export * from './apps' 2 | export * from './external' 3 | export * from './infra' 4 | export * from './janus' 5 | export * from './IOClient' 6 | export * from './IOClients' 7 | export * from './IOGraphQLClient' 8 | 9 | import promclient from 'prom-client' 10 | export { promclient } 11 | -------------------------------------------------------------------------------- /src/clients/infra/BillingMetrics.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { InfraClient } from './InfraClient' 4 | 5 | export class BillingMetrics extends InfraClient { 6 | constructor(context: IOContext, options?: InstanceOptions) { 7 | super('colossus@0.x', context, options) 8 | } 9 | 10 | public sendMetric = (metric: BillingMetric, tracingConfig?: RequestTracingConfig) => { 11 | return this.http.post('/metrics', metric, { tracing: { 12 | requestSpanNameSuffix: 'send-metric', 13 | ...tracingConfig?.tracing, 14 | }}) 15 | } 16 | } 17 | 18 | export interface BillingMetric { 19 | value: number 20 | unit: string 21 | metricId: string 22 | timestamp?: number 23 | } 24 | -------------------------------------------------------------------------------- /src/clients/infra/Events.ts: -------------------------------------------------------------------------------- 1 | import { isNullOrUndefined, isObject } from 'util' 2 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { InfraClient } from './InfraClient' 5 | 6 | const ANY_APP = '' 7 | const eventRoute = (route: string) => `/events/${route}` 8 | const isResourceVRN = (appIdOrResource: string | ResourceVRN): appIdOrResource is ResourceVRN => 9 | isObject(appIdOrResource) && !isNullOrUndefined((appIdOrResource as ResourceVRN).service) 10 | 11 | export class Events extends InfraClient { 12 | constructor({ recorder, ...context }: IOContext, options?: InstanceOptions) { 13 | super('courier@0.x', context, options) 14 | } 15 | 16 | public sendEvent = ( 17 | appIdOrResource: string | ResourceVRN, 18 | route: string, 19 | message?: any, 20 | tracingConfig?: RequestTracingConfig 21 | ) => { 22 | const resource = this.resourceFor(appIdOrResource) 23 | return this.http.put(eventRoute(route), message, { 24 | metric: 'events-send', 25 | params: { resource }, 26 | tracing: { 27 | requestSpanNameSuffix: 'events-send', 28 | ...tracingConfig?.tracing, 29 | }, 30 | }) 31 | } 32 | 33 | private resourceFor = (appIdOrResource: string | ResourceVRN) => { 34 | if (appIdOrResource === ANY_APP) { 35 | return ANY_APP 36 | } 37 | const { service, path } = isResourceVRN(appIdOrResource) 38 | ? appIdOrResource 39 | : { service: 'apps', path: `/apps/${appIdOrResource}` } 40 | return `vrn:${service}:${this.context.region}:${this.context.account}:${this.context.workspace}:${path}` 41 | } 42 | } 43 | 44 | export interface ResourceVRN { 45 | service: string 46 | path: string 47 | } 48 | -------------------------------------------------------------------------------- /src/clients/infra/Housekeeper.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient' 2 | import { HousekeeperStatesAndUpdates } from '../../responses' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { InfraClient } from './InfraClient' 5 | 6 | export class Housekeeper extends InfraClient { 7 | constructor(context: IOContext, options?: InstanceOptions) { 8 | super('housekeeper@0.x', context, options) 9 | } 10 | 11 | public apply = async (data: HousekeeperStatesAndUpdates, tracingConfig?: RequestTracingConfig) =>{ 12 | const metric = 'housekeeper-apply' 13 | return this.http.post('v2/housekeeping/apply', data, { metric, tracing: { 14 | requestSpanNameSuffix: metric, 15 | ...tracingConfig?.tracing, 16 | }}) 17 | } 18 | 19 | public perform = async (tracingConfig?: RequestTracingConfig) => { 20 | const metric = 'housekeeper-perform' 21 | return this.http.post('v2/_housekeeping/perform', {}, { metric, tracing: { 22 | requestSpanNameSuffix: metric, 23 | ...tracingConfig?.tracing, 24 | }}) 25 | } 26 | 27 | public resolve = async (tracingConfig?: RequestTracingConfig): Promise => { 28 | const metric = 'housekeeper-resolve' 29 | return this.http.get('v2/housekeeping/resolve', { metric, tracing: { 30 | requestSpanNameSuffix: metric, 31 | ...tracingConfig?.tracing, 32 | }}) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/clients/infra/InfraClient.ts: -------------------------------------------------------------------------------- 1 | import { IS_IO } from '../../constants' 2 | import { AuthType, InstanceOptions } from '../../HttpClient/typings' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { IOClient } from '../IOClient' 5 | 6 | const useHttps = !IS_IO 7 | /** 8 | * Used to perform calls on infra apps (e.g. sphinx, apps, vbase). 9 | */ 10 | export class InfraClient extends IOClient { 11 | constructor(app: string, context: IOContext, options?: InstanceOptions, isRoot: boolean = false) { 12 | const {account, workspace, region} = context 13 | const [appName, appVersion] = app.split('@') 14 | const protocol = useHttps ? 'https' : 'http' 15 | let baseURL: string 16 | if (appVersion) { 17 | const [appMajor] = appVersion.split('.') 18 | baseURL = `${protocol}://infra.io.vtex.com/${appName}/v${appMajor}${isRoot ? '' : `/${account}/${workspace}`}` 19 | } else if (app === 'router') { 20 | baseURL = `${protocol}://platform.io.vtex.com/${isRoot ? '' : `/${account}/${workspace}`}` 21 | } else { 22 | console.warn(`${account} in ${workspace} is using old routing for ${app}. This will stop working soon`) 23 | baseURL = `http://${app}.${region}.vtex.io${isRoot ? '' : `/${account}/${workspace}`}` 24 | } 25 | 26 | super( 27 | context, 28 | { 29 | ...options, 30 | authType: AuthType.bearer, 31 | baseURL, 32 | } 33 | ) 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/clients/infra/Sphinx.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions, RequestTracingConfig } from '../../HttpClient/typings' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { InfraClient } from './InfraClient' 4 | 5 | export class Sphinx extends InfraClient { 6 | constructor (ioContext: IOContext, opts?: InstanceOptions) { 7 | super('sphinx@0.x', ioContext, opts, false) 8 | } 9 | 10 | public validatePolicies = (policies: PolicyRequest[], tracingConfig?: RequestTracingConfig) => { 11 | const metric = 'sphinx-validate-policy' 12 | return this.http.post('/policies/validate', { policies }, { 13 | metric, 14 | tracing: { 15 | requestSpanNameSuffix: metric, 16 | ...tracingConfig?.tracing, 17 | }, 18 | }) 19 | } 20 | 21 | public isAdmin = (email: string, tracingConfig?: RequestTracingConfig) => { 22 | const metric = 'sphinx-is-admin' 23 | return this.http.get(`/user/${email}/isAdmin`, { 24 | metric, 25 | tracing: { 26 | requestSpanNameSuffix: metric, 27 | ...tracingConfig?.tracing, 28 | }, 29 | }) 30 | } 31 | } 32 | 33 | export interface PolicyRequest { 34 | name: string 35 | reason: string 36 | attrs: Record 37 | } 38 | -------------------------------------------------------------------------------- /src/clients/infra/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Apps' 2 | export * from './Assets' 3 | export * from './BillingMetrics' 4 | export * from './Events' 5 | export * from './Housekeeper' 6 | export * from './InfraClient' 7 | export * from './Registry' 8 | export * from './Router' 9 | export * from './VBase' 10 | export * from './Workspaces' 11 | export * from './Sphinx' 12 | -------------------------------------------------------------------------------- /src/clients/janus/JanusClient.ts: -------------------------------------------------------------------------------- 1 | import { InstanceOptions } from '../../HttpClient/typings' 2 | import { IOContext } from '../../service/worker/runtime/typings' 3 | import { ExternalClient } from '../external/ExternalClient' 4 | 5 | type Environment = 'stable' | 'beta' 6 | 7 | /** 8 | * Used to perform calls on APIs in the VTEX Janus infrastructure, to which you must declare an outbound policy. 9 | * 10 | * Example policy: 11 | * 12 | * { 13 | * "name": "outbound-access", 14 | * "attrs": { 15 | * "host": "portal.vtexcommercestable.com.br", 16 | * "path": "/api/*" 17 | * } 18 | * } 19 | */ 20 | export class JanusClient extends ExternalClient { 21 | constructor( 22 | context: IOContext, 23 | options?: InstanceOptions, 24 | environment?: Environment 25 | ) { 26 | const { account } = context 27 | const env = 28 | context.janusEnv === 'beta' || environment === 'beta' ? 'beta' : 'stable' 29 | 30 | super(`http://portal.vtexcommerce${env}.com.br`, context, { 31 | ...options, 32 | params: { 33 | an: account, 34 | ...options?.params, 35 | }, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/clients/janus/LicenseManager.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'qs' 2 | 3 | import { RequestConfig, RequestTracingConfig } from '../../HttpClient' 4 | import { JanusClient } from './JanusClient' 5 | 6 | const TWO_MINUTES_S = 2 * 60 7 | 8 | const BASE_URL = '/api/license-manager' 9 | 10 | const routes = { 11 | accountData: `${BASE_URL}/account`, 12 | resourceAccess: `${BASE_URL}/resources`, 13 | topbarData: `${BASE_URL}/site/pvt/newtopbar`, 14 | } 15 | 16 | const inflightKey = ({baseURL, url, params}: RequestConfig) => { 17 | return baseURL! + url! + stringify(params, {arrayFormat: 'repeat', addQueryPrefix: true}) 18 | } 19 | 20 | export class LicenseManager extends JanusClient { 21 | public getAccountData (VtexIdclientAutCookie: string, tracingConfig?: RequestTracingConfig) { 22 | const metric = 'lm-account-data' 23 | return this.http.get(routes.accountData, { 24 | forceMaxAge: TWO_MINUTES_S, 25 | headers: { 26 | VtexIdclientAutCookie, 27 | }, 28 | inflightKey, 29 | metric, 30 | tracing: { 31 | requestSpanNameSuffix: metric, 32 | ...tracingConfig?.tracing, 33 | }, 34 | }) 35 | } 36 | 37 | public getTopbarData (VtexIdclientAutCookie: string, tracingConfig?: RequestTracingConfig) { 38 | const metric = 'lm-topbar-data' 39 | return this.http.get(routes.topbarData, { 40 | headers: { 41 | VtexIdclientAutCookie, 42 | }, 43 | metric, 44 | tracing: { 45 | requestSpanNameSuffix: metric, 46 | ...tracingConfig?.tracing, 47 | }, 48 | }) 49 | } 50 | 51 | public canAccessResource (VtexIdclientAutCookie: string, resourceKey: string, tracingConfig?: RequestTracingConfig) { 52 | const metric = 'lm-resource-access' 53 | return this.http.get(`${routes.resourceAccess}/${resourceKey}/access`, { 54 | headers: { 55 | VtexIdclientAutCookie, 56 | }, 57 | metric, 58 | tracing: { 59 | requestSpanNameSuffix: metric, 60 | ...tracingConfig?.tracing, 61 | }, 62 | }).then(() => true, () => false) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/clients/janus/Session.ts: -------------------------------------------------------------------------------- 1 | import parseCookie from 'cookie' 2 | 3 | import { RequestTracingConfig } from '../../HttpClient' 4 | import { JanusClient } from './JanusClient' 5 | 6 | const SESSION_COOKIE = 'vtex_session' 7 | 8 | const routes = { 9 | base: '/api/sessions', 10 | } 11 | 12 | export class Session extends JanusClient { 13 | /** 14 | * Get the session data using the given token 15 | */ 16 | public getSession = async (token: string, items: string[], tracingConfig?: RequestTracingConfig) => { 17 | const metric = 'session-get' 18 | const { data: sessionData, headers } = await this.http.getRaw(routes.base, { 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | Cookie: `vtex_session=${token};`, 22 | }, 23 | metric, 24 | params: { 25 | items: items.join(','), 26 | }, 27 | tracing: { 28 | requestSpanNameSuffix: metric, 29 | ...tracingConfig?.tracing, 30 | }, 31 | }) 32 | 33 | return { 34 | sessionData, 35 | sessionToken: extractSessionCookie(headers) ?? token, 36 | } 37 | } 38 | 39 | /** 40 | * Update the public portion of this session 41 | */ 42 | public updateSession = ( 43 | key: string, 44 | value: any, 45 | items: string[], 46 | token: any, 47 | tracingConfig?: RequestTracingConfig 48 | ) => { 49 | const data = { public: { [key]: { value } } } 50 | const metric = 'session-update' 51 | const config = { 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | Cookie: `vtex_session=${token};`, 55 | }, 56 | metric, 57 | params: { 58 | items: items.join(','), 59 | }, 60 | tracing: { 61 | requestSpanNameSuffix: metric, 62 | ...tracingConfig?.tracing, 63 | }, 64 | } 65 | 66 | return this.http.post(routes.base, data, config) 67 | } 68 | } 69 | 70 | function extractSessionCookie(headers: Record) { 71 | for (const setCookie of headers['set-cookie'] ?? []) { 72 | const parsedCookie = parseCookie.parse(setCookie) 73 | const sessionCookie = parsedCookie[SESSION_COOKIE] 74 | if (sessionCookie != null) { 75 | return sessionCookie 76 | } 77 | } 78 | 79 | return null 80 | } 81 | -------------------------------------------------------------------------------- /src/clients/janus/Tenant.ts: -------------------------------------------------------------------------------- 1 | import { inflightUrlWithQuery } from '../../HttpClient' 2 | import { InstanceOptions, RequestConfig } from '../../HttpClient/typings' 3 | import { IOContext } from '../../service/worker/runtime/typings' 4 | import { JanusClient } from './JanusClient' 5 | 6 | export interface Binding { 7 | id: string 8 | canonicalBaseAddress: string 9 | alternateBaseAddresses: string[] 10 | defaultLocale: string 11 | supportedLocales: string[] 12 | defaultCurrency: string 13 | supportedCurrencies: string[] 14 | extraContext: Record 15 | targetProduct: string 16 | } 17 | 18 | export interface Tenant { 19 | id: string 20 | slug: string 21 | title: string 22 | edition: string 23 | infra: string 24 | bindings: Binding[] 25 | defaultCurrency: string 26 | defaultLocale: string 27 | metadata: Record 28 | } 29 | 30 | export class TenantClient extends JanusClient { 31 | constructor(ctx: IOContext, opts?: InstanceOptions) { 32 | super(ctx, { 33 | ...opts, 34 | params: { 35 | q: ctx.account, 36 | }, 37 | }) 38 | } 39 | 40 | public info = (config?: RequestConfig) => { 41 | const metric = 'get-tenant-info' 42 | return this.http.get('/api/tenant/tenants', { 43 | inflightKey: inflightUrlWithQuery, 44 | memoizeable: true, 45 | metric, 46 | ...config, 47 | tracing: { 48 | requestSpanNameSuffix: metric, 49 | ...config?.tracing, 50 | }, 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/clients/janus/index.ts: -------------------------------------------------------------------------------- 1 | export * from './JanusClient' 2 | export * from './LicenseManager' 3 | export * from './Segment' 4 | export * from './Session' 5 | export * from './Tenant' 6 | -------------------------------------------------------------------------------- /src/errors/AuthenticationError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { ErrorLike } from './ResolverError' 4 | import { ResolverWarning } from './ResolverWarning' 5 | 6 | /** 7 | * Indicates user did not provide valid credentials for authenticating this request. 8 | * ResolverWarnings are logged with level "warning" denoting they were handled by user code. 9 | * 10 | * @class AuthenticationError 11 | * @extends {ResolverWarning} 12 | */ 13 | export class AuthenticationError extends ResolverWarning { 14 | public name = 'AuthenticationError' 15 | 16 | /** 17 | * Creates an instance of AuthenticationError 18 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 19 | */ 20 | constructor(messageOrError: string | AxiosError | ErrorLike) { 21 | super(messageOrError, 401, 'UNAUTHENTICATED') 22 | 23 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 24 | Error.captureStackTrace(this, AuthenticationError) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/errors/ForbiddenError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { ErrorLike } from './ResolverError' 4 | import { ResolverWarning } from './ResolverWarning' 5 | 6 | /** 7 | * Indicates user is not authorized to perform this action. 8 | * ResolverWarnings are logged with level "warning" denoting they were handled by user code. 9 | * 10 | * @class ForbiddenError 11 | * @extends {ResolverWarning} 12 | */ 13 | export class ForbiddenError extends ResolverWarning { 14 | public name = 'ForbiddenError' 15 | 16 | /** 17 | * Creates an instance of ForbiddenError 18 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 19 | */ 20 | constructor(messageOrError: string | AxiosError | ErrorLike) { 21 | super(messageOrError, 403, 'FORBIDDEN') 22 | 23 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 24 | Error.captureStackTrace(this, ForbiddenError) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/errors/NotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { ErrorLike } from './ResolverError' 4 | import { ResolverWarning } from './ResolverWarning' 5 | 6 | /** 7 | * Indicates a requested resource was not found. 8 | * ResolverWarnings are logged with level "warning" denoting they were handled by user code. 9 | * 10 | * @class NotFoundError 11 | * @extends {ResolverWarning} 12 | */ 13 | export class NotFoundError extends ResolverWarning { 14 | public name = 'NotFoundError' 15 | 16 | /** 17 | * Creates an instance of NotFoundError 18 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 19 | */ 20 | constructor(messageOrError: string | AxiosError | ErrorLike) { 21 | super(messageOrError, 404, 'NOT_FOUND') 22 | 23 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 24 | Error.captureStackTrace(this, NotFoundError) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/errors/RequestCancelledError.ts: -------------------------------------------------------------------------------- 1 | export const cancelledRequestStatus = 499 2 | 3 | export const cancelledErrorCode = 'request_cancelled' 4 | 5 | export class RequestCancelledError extends Error { 6 | public code = cancelledErrorCode 7 | 8 | constructor(message: string) { 9 | super(message) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/errors/ResolverError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { LogLevel } from '../service/logger' 4 | import { cleanError } from '../utils/error' 5 | 6 | export interface ErrorLike { 7 | name?: string 8 | message: string 9 | stack?: string 10 | [key: string]: any 11 | } 12 | 13 | /** 14 | * The generic Error class to be thrown for caught errors inside resolvers. 15 | * Errors with status code greater than or equal to 500 are logged as errors. 16 | * All other status codes are logged as warnings. @see ResolverWarning 17 | * 18 | * @class ResolverError 19 | * @extends {Error} 20 | */ 21 | export class ResolverError extends Error { 22 | public name = 'ResolverError' 23 | public level = LogLevel.Error 24 | 25 | /** 26 | * Creates an instance of ResolverError 27 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 28 | * @param {number} [status=500] 29 | * @param {string} [code='RESOLVER_ERROR'] 30 | */ 31 | constructor( 32 | messageOrError: string | AxiosError | ErrorLike, 33 | public status: number = 500, 34 | public code: string = 'RESOLVER_ERROR' 35 | ) { 36 | super(typeof messageOrError === 'string' ? messageOrError : messageOrError.message) 37 | 38 | if (typeof messageOrError === 'object') { 39 | // Copy original error properties without circular references 40 | Object.assign(this, cleanError(messageOrError)) 41 | } 42 | 43 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 44 | Error.captureStackTrace(this, ResolverError) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/errors/ResolverWarning.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { LogLevel } from '../service/logger' 4 | import { ErrorLike, ResolverError } from './ResolverError' 5 | 6 | 7 | /** 8 | * Indicates a non-fatal error occurred and was handled. 9 | * ResolverWarnings are logged with level "warning" denoting they were handled by user code. 10 | * 11 | * @class ResolverWarning 12 | * @extends {ResolverError} 13 | */ 14 | export class ResolverWarning extends ResolverError { 15 | public name = 'ResolverWarning' 16 | public level = LogLevel.Warn 17 | 18 | /** 19 | * Creates an instance of ResolverWarning 20 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 21 | */ 22 | constructor( 23 | messageOrError: string | AxiosError | ErrorLike, 24 | public status: number = 422, 25 | public code: string = 'RESOLVER_WARNING' 26 | ) { 27 | super(messageOrError, status, code) 28 | 29 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 30 | Error.captureStackTrace(this, ResolverWarning) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/errors/TooManyRequestsError.ts: -------------------------------------------------------------------------------- 1 | export const tooManyRequestsStatus = 429 2 | 3 | export class TooManyRequestsError extends Error { 4 | constructor(message?: string) { 5 | super(message || 'TOO_MANY_REQUESTS') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/errors/UserInputError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | 3 | import { ErrorLike } from './ResolverError' 4 | import { ResolverWarning } from './ResolverWarning' 5 | 6 | /** 7 | * Indicates user input is not valid for this action. 8 | * ResolverWarnings are logged with level "warning" denoting they were handled by user code. 9 | * 10 | * @class UserInputError 11 | * @extends {ResolverWarning} 12 | */ 13 | export class UserInputError extends ResolverWarning { 14 | public name = 'UserInputError' 15 | 16 | /** 17 | * Creates an instance of UserInputError 18 | * @param {(string | AxiosError | ErrorLike)} messageOrError Either a message string or the complete original error object. 19 | */ 20 | constructor(messageOrError: string | AxiosError | ErrorLike) { 21 | super(messageOrError, 400, 'BAD_USER_INPUT') 22 | 23 | if (typeof messageOrError === 'string' || !messageOrError.stack) { 24 | Error.captureStackTrace(this, UserInputError) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/errors/customGraphQLError.ts: -------------------------------------------------------------------------------- 1 | export default class CustomGraphQLError extends Error { 2 | public graphQLErrors: any 3 | 4 | constructor(message: string, graphQLErrors: any[]) { 5 | super(message) 6 | this.graphQLErrors = graphQLErrors 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthenticationError' 2 | export * from './ForbiddenError' 3 | export * from './NotFoundError' 4 | export * from './ResolverError' 5 | export * from './ResolverWarning' 6 | export * from './UserInputError' 7 | export * from './RequestCancelledError' 8 | export * from './TooManyRequestsError' 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | process.env.FORCE_COLOR = '1' 2 | 3 | export * from './caches' 4 | export * from './clients' 5 | export * from './errors' 6 | export * from './HttpClient' 7 | export * from './metrics/MetricsAccumulator' 8 | export * from './responses' 9 | export * from './service/worker/runtime/Service' 10 | export * from './service/worker/runtime/method' 11 | export * from './service/worker/runtime/typings' 12 | export * from './service/worker/runtime/graphql/schema/schemaDirectives' 13 | export * from './service/worker/runtime/graphql/schema/messagesLoaderV2' 14 | export * from './service/worker/runtime/utils/recorder' 15 | export * from './service' 16 | export * from './service/logger' 17 | export * from './utils' 18 | export * from './constants' 19 | export * from './tracing' 20 | 21 | -------------------------------------------------------------------------------- /src/responses.ts: -------------------------------------------------------------------------------- 1 | import { BillingOptions } from './utils/billingOptions' 2 | 3 | export interface Policy { 4 | name: string, 5 | attrs?: { 6 | [name: string]: string, 7 | } 8 | } 9 | 10 | interface PublicAppManifest { 11 | vendor: string, 12 | name: string, 13 | version: string, 14 | title?: string, 15 | description?: string, 16 | mustUpdateAt?: string, 17 | builders: { 18 | [name: string]: string, 19 | } 20 | categories?: string[], 21 | dependencies?: { 22 | [name: string]: string, 23 | }, 24 | peerDependencies?: { 25 | [name: string]: string, 26 | }, 27 | settingsSchema?: any, 28 | registries?: string[], 29 | credentialType?: string, 30 | policies?: Policy[], 31 | billingOptions?: BillingOptions, 32 | } 33 | 34 | export interface AppManifest extends PublicAppManifest { 35 | [internal: string]: any // internal fields like _id, _link, _registry 36 | _resolvedDependencies?: { 37 | [name: string]: string[], 38 | }, 39 | } 40 | 41 | export interface FileListItem { 42 | path: string, 43 | hash: string, 44 | } 45 | 46 | export interface AppFilesList { 47 | data: FileListItem[], 48 | } 49 | 50 | export interface BucketMetadata { 51 | state: string, 52 | lastModified: string, 53 | hash: string, 54 | } 55 | 56 | export interface AppBundleResponse { 57 | message: string, 58 | id: string, 59 | } 60 | 61 | export type AppBundlePublished = AppBundleResponse & { 62 | bundleSize?: number, 63 | } 64 | 65 | export type AppBundleLinked = AppBundleResponse & { 66 | bundleSize?: number, 67 | } 68 | 69 | export type AppState = 'stable' | 'releaseCandidate' 70 | 71 | export interface HouseKeeperState { 72 | infra: string[] 73 | edition: string[] 74 | runtimes: string[] 75 | apps: Array<{id: string, source: string}> 76 | } 77 | 78 | export interface HouseKeeperUpdates extends HouseKeeperState { 79 | editionApps?: { 80 | install?: string[] 81 | uninstall?: string[] 82 | } 83 | } 84 | 85 | export interface HousekeeperStatesAndUpdates { 86 | state: HouseKeeperState 87 | updates: HouseKeeperUpdates 88 | } 89 | 90 | export type VBaseSaveResponse = FileListItem[] 91 | -------------------------------------------------------------------------------- /src/service/index.ts: -------------------------------------------------------------------------------- 1 | import { initializeTelemetry } from './telemetry' 2 | import cluster from 'cluster' 3 | 4 | import { HTTP_SERVER_PORT } from '../constants' 5 | import { MetricsAccumulator } from '../metrics/MetricsAccumulator' 6 | import { getServiceJSON } from './loaders' 7 | import { LogLevel, logOnceToDevConsole } from './logger' 8 | 9 | export const startApp = async () => { 10 | await initializeTelemetry() 11 | 12 | // Initialize global.metrics for both master and worker processes 13 | global.metrics = new MetricsAccumulator() 14 | 15 | const serviceJSON = getServiceJSON() 16 | try { 17 | // if it is a master process then call setting up worker process 18 | if(cluster.isMaster) { 19 | const { startMaster } = await import('./master') 20 | startMaster(serviceJSON) 21 | } else { 22 | // to setup server configurations and share port address for incoming requests 23 | const { startWorker } = await import('./worker') 24 | const app = await startWorker(serviceJSON) 25 | app.listen(HTTP_SERVER_PORT) 26 | } 27 | } catch (err: any) { 28 | logOnceToDevConsole(err.stack || err.message, LogLevel.Error) 29 | process.exit(2) 30 | } 31 | } 32 | 33 | export { appPath } from './loaders' 34 | 35 | declare global { 36 | namespace NodeJS { 37 | interface Global { 38 | metrics: MetricsAccumulator 39 | } 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/service/loaders.ts: -------------------------------------------------------------------------------- 1 | import { cpus } from 'os' 2 | import { join } from 'path' 3 | 4 | import { IOClients } from '../clients/IOClients' 5 | import { LINKED, MAX_WORKERS } from '../constants' 6 | import { Service } from './worker/runtime/Service' 7 | import { 8 | ClientsConfig, 9 | ParamsContext, 10 | RawServiceJSON, 11 | RecorderState, 12 | ServiceJSON, 13 | } from './worker/runtime/typings' 14 | 15 | export const appPath = join(process.cwd(), './service/src/node/') 16 | export const bundlePath = join(appPath, 'index') 17 | export const serviceJsonPath = join(process.cwd(), './service/service.json') 18 | 19 | const getWorkers = (workers?: any) => { 20 | // We need to have only one worker while linking so the debugger 21 | // works properly 22 | if (LINKED) { 23 | return 1 24 | } 25 | // If user didn't set workers property, let's use the cpu count 26 | const workersFromUser = workers && Number.isInteger(workers) ? workers : cpus().length 27 | // never spawns more than MAX_WORKERS 28 | return Math.min(workersFromUser, MAX_WORKERS) 29 | } 30 | 31 | export const getServiceJSON = (): ServiceJSON => { 32 | const service: RawServiceJSON = require(serviceJsonPath) 33 | return { 34 | ...service, 35 | workers: getWorkers(service.workers), 36 | } 37 | } 38 | 39 | const defaultClients: ClientsConfig = { 40 | options: { 41 | messages: { 42 | concurrency: 10, 43 | retries: 2, 44 | timeout: 1000, 45 | }, 46 | messagesGraphQL: { 47 | concurrency: 12, 48 | retries: 2, 49 | timeout: 2000, 50 | }, 51 | }, 52 | } 53 | 54 | export const getService = (): Service => { 55 | const { default: service } = require(bundlePath) 56 | const { config: { clients } } = service 57 | service.config.clients = { 58 | implementation: clients?.implementation ?? IOClients, 59 | options: { 60 | ...defaultClients.options, 61 | ...clients?.options, 62 | }, 63 | } 64 | return service 65 | } 66 | -------------------------------------------------------------------------------- /src/service/logger/client.ts: -------------------------------------------------------------------------------- 1 | import { Types } from '@vtex/diagnostics-nodejs'; 2 | import { initializeTelemetry } from '../telemetry'; 3 | 4 | let client: Types.LogClient | undefined; 5 | let isInitializing = false; 6 | let initPromise: Promise | undefined = undefined; 7 | 8 | export async function getLogClient(): Promise { 9 | 10 | if (client) { 11 | return client; 12 | } 13 | 14 | if (initPromise) { 15 | return initPromise; 16 | } 17 | 18 | isInitializing = true; 19 | initPromise = initializeClient(); 20 | 21 | return initPromise; 22 | } 23 | 24 | async function initializeClient(): Promise { 25 | try { 26 | const { logsClient } = await initializeTelemetry(); 27 | client = logsClient; 28 | initPromise = undefined; 29 | return logsClient; 30 | } catch (error) { 31 | console.error('Failed to initialize logs client:', error); 32 | initPromise = undefined; 33 | throw error; 34 | } finally { 35 | isInitializing = false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/service/logger/console.ts: -------------------------------------------------------------------------------- 1 | import { isMaster, isWorker } from 'cluster' 2 | 3 | import { LRUCache } from '../../caches' 4 | import { LogLevel } from './loggerTypes' 5 | 6 | export interface LogMessage { 7 | cmd: typeof LOG_ONCE 8 | message: string, 9 | level: LogLevel 10 | } 11 | 12 | const history = new LRUCache({ 13 | max: 42, 14 | }) 15 | 16 | export const LOG_ONCE = 'logOnce' 17 | 18 | export const isLog = (message: any): message is LogMessage => message?.cmd === LOG_ONCE 19 | 20 | export const log = (message: any, level: LogLevel) => { 21 | const logger = console[level] 22 | if (typeof logger === 'function') { 23 | logger(message) 24 | } 25 | } 26 | 27 | // Since we are now using clusters, if we simply console.log something, 28 | // it may overwhelm the programmer's console with lots of repeated info. 29 | // This function should be used when you want to warn about something 30 | // only once 31 | export const logOnceToDevConsole = (message: any, level: LogLevel): void => { 32 | const strigified = JSON.stringify(message) 33 | if (!history.has(strigified)) { 34 | history.set(strigified, true) 35 | 36 | if (isMaster) { 37 | log(message, level) 38 | } 39 | else if (isWorker && process.send) { 40 | const logMessage: LogMessage = { 41 | cmd: LOG_ONCE, 42 | level, 43 | message, 44 | } 45 | process.send(logMessage) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/logger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './console' 2 | export * from './logger' 3 | export * from './loggerTypes' 4 | export * from './client' 5 | -------------------------------------------------------------------------------- /src/service/logger/loggerTypes.ts: -------------------------------------------------------------------------------- 1 | import { IOContext } from '../worker/runtime/typings' 2 | 3 | export interface LoggerContext extends Pick { 4 | tracer?: IOContext['tracer'] 5 | } 6 | 7 | export interface TracingState { 8 | isTraceSampled: boolean, 9 | traceId?: string 10 | } 11 | 12 | export enum LogLevel { 13 | Debug = 'debug', 14 | Info = 'info', 15 | Warn = 'warn', 16 | Error = 'error', 17 | } 18 | -------------------------------------------------------------------------------- /src/service/metrics/client.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "@vtex/diagnostics-nodejs"; 2 | import { initializeTelemetry } from '../telemetry'; 3 | 4 | class MetricClientSingleton { 5 | private static instance: MetricClientSingleton | undefined; 6 | private client: Types.MetricClient | undefined; 7 | private initPromise: Promise | undefined; 8 | 9 | private constructor() {} 10 | 11 | public static getInstance(): MetricClientSingleton { 12 | if (!MetricClientSingleton.instance) { 13 | MetricClientSingleton.instance = new MetricClientSingleton(); 14 | } 15 | return MetricClientSingleton.instance; 16 | } 17 | 18 | public async getClient(): Promise { 19 | if (this.client) { 20 | return this.client; 21 | } 22 | 23 | if (this.initPromise) { 24 | return this.initPromise; 25 | } 26 | 27 | this.initPromise = this.initializeClient(); 28 | 29 | return this.initPromise; 30 | } 31 | 32 | private async initializeClient(): Promise { 33 | try { 34 | const { metricsClient } = await initializeTelemetry(); 35 | this.client = metricsClient; 36 | this.initPromise = undefined; 37 | return metricsClient; 38 | } catch (error) { 39 | console.error('Failed to initialize metrics client:', error); 40 | this.initPromise = undefined; 41 | throw error; 42 | } 43 | } 44 | } 45 | 46 | export const getMetricClient = () => MetricClientSingleton.getInstance().getClient(); 47 | -------------------------------------------------------------------------------- /src/service/metrics/instruments/hostMetrics.ts: -------------------------------------------------------------------------------- 1 | import { InstrumentationBase, InstrumentationConfig } from "@opentelemetry/instrumentation"; 2 | import { MeterProvider } from '@opentelemetry/api'; 3 | import { HostMetrics } from "@opentelemetry/host-metrics"; 4 | 5 | interface HostMetricsInstrumentationConfig extends InstrumentationConfig { 6 | name?: string; 7 | meterProvider?: MeterProvider; 8 | } 9 | 10 | export class HostMetricsInstrumentation extends InstrumentationBase { 11 | private hostMetrics?: HostMetrics; 12 | 13 | constructor(config: HostMetricsInstrumentationConfig = {}) { 14 | const instrumentation_name = config.name || 'host-metrics-instrumentation'; 15 | const instrumentation_version = '1.0.0'; 16 | super(instrumentation_name, instrumentation_version, config); 17 | } 18 | 19 | init(): void {} 20 | 21 | enable(): void { 22 | if (!this._config.meterProvider) { 23 | throw new Error('MeterProvider is required for HostMetricsInstrumentation'); 24 | } 25 | 26 | this.hostMetrics = new HostMetrics({ 27 | meterProvider: this._config.meterProvider, 28 | name: this._config.name || 'host-metrics', 29 | }); 30 | 31 | this.hostMetrics.start(); 32 | console.debug('HostMetricsInstrumentation enabled'); 33 | } 34 | 35 | disable(): void { 36 | if (this.hostMetrics) { 37 | this.hostMetrics = undefined; 38 | console.debug('HostMetricsInstrumentation disabled'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/service/metrics/requestMetricsMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { finished as onStreamFinished } from 'stream' 2 | import { hrToMillisFloat } from '../../utils' 3 | import { 4 | createConcurrentRequestsInstrument, 5 | createRequestsResponseSizesInstrument, 6 | createRequestsTimingsInstrument, 7 | createTotalAbortedRequestsInstrument, 8 | createTotalRequestsInstrument, 9 | RequestsMetricLabels, 10 | } from '../tracing/metrics/instruments' 11 | import { ServiceContext } from '../worker/runtime/typings' 12 | 13 | 14 | export const addRequestMetricsMiddleware = () => { 15 | const concurrentRequests = createConcurrentRequestsInstrument() 16 | const requestTimings = createRequestsTimingsInstrument() 17 | const totalRequests = createTotalRequestsInstrument() 18 | const responseSizes = createRequestsResponseSizesInstrument() 19 | const abortedRequests = createTotalAbortedRequestsInstrument() 20 | 21 | return async function addRequestMetrics(ctx: ServiceContext, next: () => Promise) { 22 | const start = process.hrtime() 23 | concurrentRequests.inc(1) 24 | 25 | ctx.req.once('aborted', () => 26 | abortedRequests.inc({ [RequestsMetricLabels.REQUEST_HANDLER]: ctx.requestHandlerName }, 1) 27 | ) 28 | 29 | let responseClosed = false 30 | ctx.res.once('close', () => (responseClosed = true)) 31 | 32 | try { 33 | await next() 34 | } finally { 35 | const responseLength = ctx.response.length 36 | if (responseLength) { 37 | responseSizes.observe( 38 | { [RequestsMetricLabels.REQUEST_HANDLER]: ctx.requestHandlerName }, 39 | responseLength 40 | ) 41 | } 42 | 43 | totalRequests.inc( 44 | { 45 | [RequestsMetricLabels.REQUEST_HANDLER]: ctx.requestHandlerName, 46 | [RequestsMetricLabels.STATUS_CODE]: ctx.response.status, 47 | }, 48 | 1 49 | ) 50 | 51 | const onResFinished = () => { 52 | requestTimings.observe( 53 | { 54 | [RequestsMetricLabels.REQUEST_HANDLER]: ctx.requestHandlerName, 55 | }, 56 | hrToMillisFloat(process.hrtime(start)) 57 | ) 58 | 59 | concurrentRequests.dec(1) 60 | } 61 | 62 | if (responseClosed) { 63 | onResFinished() 64 | } else { 65 | onStreamFinished(ctx.res, onResFinished) 66 | } 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/service/telemetry/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client' 2 | -------------------------------------------------------------------------------- /src/service/tracing/TracerSingleton.ts: -------------------------------------------------------------------------------- 1 | import { initTracer as initJaegerTracer, PrometheusMetricsFactory, TracingConfig, TracingOptions } from 'jaeger-client' 2 | import { Tracer } from 'opentracing' 3 | import promClient from 'prom-client' 4 | import { APP, LINKED, NODE_ENV, NODE_VTEX_API_VERSION, PRODUCTION, REGION, WORKSPACE } from '../../constants' 5 | import { AppTags } from '../../tracing/Tags' 6 | import { appIdToAppAtMajor } from '../../utils' 7 | 8 | export class TracerSingleton { 9 | public static getTracer() { 10 | if (!TracerSingleton.singleton) { 11 | TracerSingleton.singleton = TracerSingleton.initServiceTracer() 12 | } 13 | 14 | return TracerSingleton.singleton 15 | } 16 | 17 | private static singleton: Tracer 18 | 19 | private static initServiceTracer() { 20 | return TracerSingleton.createJaegerTracer(appIdToAppAtMajor(APP.ID), { 21 | [AppTags.VTEX_APP_LINKED]: LINKED, 22 | [AppTags.VTEX_APP_NODE_VTEX_API_VERSION]: NODE_VTEX_API_VERSION, 23 | [AppTags.VTEX_APP_PRODUCTION]: PRODUCTION, 24 | [AppTags.VTEX_APP_REGION]: REGION, 25 | [AppTags.VTEX_APP_VERSION]: APP.VERSION, 26 | [AppTags.VTEX_APP_WORKSPACE]: WORKSPACE, 27 | [AppTags.VTEX_APP_NODE_ENV]: NODE_ENV ?? 'undefined', 28 | }) 29 | } 30 | 31 | private static createJaegerTracer(serviceName: string, defaultTags: Record) { 32 | const config: TracingConfig = { 33 | reporter: { 34 | agentHost: process.env.VTEX_OWN_NODE_IP, 35 | }, 36 | serviceName, 37 | } 38 | 39 | const options: TracingOptions = { 40 | /** 41 | * Jaeger metric names are available in: 42 | * https://github.com/jaegertracing/jaeger-client-node/blob/master/src/metrics/metrics.js 43 | * 44 | * Runtime will prefix these metrics with 'runtime:' 45 | */ 46 | metrics: new PrometheusMetricsFactory(promClient as any, 'runtime'), 47 | tags: defaultTags, 48 | } 49 | 50 | return initJaegerTracer(config, options) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/service/tracing/metrics/MetricNames.ts: -------------------------------------------------------------------------------- 1 | const enum METRIC_TYPES { 2 | /** Counter is monotonic */ 3 | COUNTER = 'counter', 4 | /** Gauge is a counter that can be increased and decreased */ 5 | GAUGE = 'gauge', 6 | /** Histogram creates a counter timeseries for each bucket specified */ 7 | HISTOGRAM = 'histogram', 8 | } 9 | 10 | /* tslint:disable:object-literal-sort-keys */ 11 | export const enum RequestsMetricLabels { 12 | /** The status code for the HTTP request */ 13 | STATUS_CODE = 'status_code', 14 | 15 | /** The service.json handler name for the current request (e.g. 'public-handler:render') */ 16 | REQUEST_HANDLER = 'handler', 17 | } 18 | 19 | /* tslint:disable:object-literal-sort-keys */ 20 | export const enum EventLoopMetricLabels { 21 | PERCENTILE = 'percentile', 22 | } 23 | 24 | export const CONCURRENT_REQUESTS = { 25 | name: 'io_http_requests_current', 26 | help: 'The current number of requests in course.', 27 | type: METRIC_TYPES.GAUGE, 28 | } 29 | 30 | export const REQUESTS_TOTAL = { 31 | name: 'runtime_http_requests_total', 32 | help: 'The total number of HTTP requests.', 33 | labelNames: [RequestsMetricLabels.STATUS_CODE, RequestsMetricLabels.REQUEST_HANDLER], 34 | type: METRIC_TYPES.COUNTER, 35 | } 36 | 37 | export const REQUESTS_ABORTED = { 38 | name: 'runtime_http_aborted_requests_total', 39 | help: 'The total number of HTTP requests aborted.', 40 | labelNames: [RequestsMetricLabels.REQUEST_HANDLER], 41 | type: METRIC_TYPES.COUNTER, 42 | } 43 | 44 | export const REQUEST_TIMINGS = { 45 | name: 'runtime_http_requests_duration_milliseconds', 46 | help: 'The incoming http requests total duration.', 47 | labelNames: [RequestsMetricLabels.REQUEST_HANDLER], 48 | buckets: [10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120], 49 | type: METRIC_TYPES.HISTOGRAM, 50 | } 51 | 52 | export const REQUEST_RESPONSE_SIZES = { 53 | name: 'runtime_http_response_size_bytes', 54 | help: `The outgoing response sizes (only applicable when the response isn't a stream).`, 55 | labelNames: [RequestsMetricLabels.REQUEST_HANDLER], 56 | buckets: [500, 2000, 8000, 16000, 64000, 256000, 1024000, 4096000], 57 | type: METRIC_TYPES.HISTOGRAM, 58 | } 59 | 60 | export const BETWEEN_SCRAPES_EVENT_LOOP_LAG_MAX = { 61 | name: 'runtime_event_loop_lag_max_between_scrapes_seconds', 62 | help: 'The max event loop lag that occurred between this and the previous scrape', 63 | type: METRIC_TYPES.GAUGE, 64 | } 65 | 66 | export const BETWEEN_SCRAPES_EVENT_LOOP_LAG_PERCENTILES = { 67 | name: 'runtime_event_loop_lag_percentiles_between_scrapes_seconds', 68 | help: 'Event loop lag percentiles from the observations that occurred between this and the previous scrape', 69 | labelNames: [EventLoopMetricLabels.PERCENTILE], 70 | type: METRIC_TYPES.GAUGE, 71 | } 72 | -------------------------------------------------------------------------------- /src/service/tracing/metrics/instruments.ts: -------------------------------------------------------------------------------- 1 | import { Counter, Gauge, Histogram } from 'prom-client' 2 | import { 3 | BETWEEN_SCRAPES_EVENT_LOOP_LAG_MAX, 4 | BETWEEN_SCRAPES_EVENT_LOOP_LAG_PERCENTILES, 5 | CONCURRENT_REQUESTS, 6 | REQUEST_RESPONSE_SIZES, 7 | REQUEST_TIMINGS, 8 | REQUESTS_ABORTED, 9 | REQUESTS_TOTAL, 10 | } from './MetricNames' 11 | export { EventLoopMetricLabels, RequestsMetricLabels } from './MetricNames' 12 | 13 | export const createTotalRequestsInstrument = () => new Counter(REQUESTS_TOTAL) 14 | export const createTotalAbortedRequestsInstrument = () => new Counter(REQUESTS_ABORTED) 15 | export const createRequestsTimingsInstrument = () => new Histogram(REQUEST_TIMINGS) 16 | export const createRequestsResponseSizesInstrument = () => new Histogram(REQUEST_RESPONSE_SIZES) 17 | export const createConcurrentRequestsInstrument = () => new Gauge(CONCURRENT_REQUESTS) 18 | export const createEventLoopLagMaxInstrument = () => new Gauge(BETWEEN_SCRAPES_EVENT_LOOP_LAG_MAX) 19 | export const createEventLoopLagPercentilesInstrument = () => new Gauge(BETWEEN_SCRAPES_EVENT_LOOP_LAG_PERCENTILES) 20 | -------------------------------------------------------------------------------- /src/service/tracing/metrics/measurers/EventLoopLagMeasurer.ts: -------------------------------------------------------------------------------- 1 | import { EventLoopDelayMonitor, monitorEventLoopDelay } from 'perf_hooks' 2 | import { Gauge } from 'prom-client' 3 | import { nanosecondsToSeconds } from '../../../../utils' 4 | import { createEventLoopLagMaxInstrument, createEventLoopLagPercentilesInstrument } from '../instruments' 5 | import { EventLoopMetricLabels } from '../MetricNames' 6 | 7 | export class EventLoopLagMeasurer { 8 | private eventLoopDelayMonitor: EventLoopDelayMonitor 9 | 10 | private percentilesInstrument: Gauge 11 | private maxInstrument: Gauge 12 | 13 | constructor() { 14 | this.eventLoopDelayMonitor = monitorEventLoopDelay({ resolution: 10 }) 15 | this.percentilesInstrument = createEventLoopLagPercentilesInstrument() 16 | this.maxInstrument = createEventLoopLagMaxInstrument() 17 | } 18 | 19 | public start() { 20 | this.eventLoopDelayMonitor.enable() 21 | } 22 | 23 | public async updateInstrumentsAndReset() { 24 | this.maxInstrument.set(nanosecondsToSeconds(this.eventLoopDelayMonitor.max)) 25 | this.setPercentileObservation(95) 26 | this.setPercentileObservation(99) 27 | this.eventLoopDelayMonitor.reset() 28 | } 29 | 30 | private setPercentileObservation(percentile: number) { 31 | this.percentilesInstrument.set( 32 | { [EventLoopMetricLabels.PERCENTILE]: percentile }, 33 | nanosecondsToSeconds(this.eventLoopDelayMonitor.percentile(percentile)) 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/service/worker/listeners.ts: -------------------------------------------------------------------------------- 1 | import { constants } from 'os' 2 | 3 | import { RequestCancelledError } from '../../errors/RequestCancelledError' 4 | import { Logger } from '../logger' 5 | 6 | export const logger = new Logger({account: 'unhandled', workspace: 'unhandled', requestId: 'unhandled', operationId: 'unhandled', production: process.env.VTEX_PRODUCTION === 'true'}) 7 | let watched: NodeJS.Process 8 | 9 | const handleSignal: NodeJS.SignalsListener = signal => { 10 | process.exit((constants.signals as any)[signal]) 11 | } 12 | 13 | export const addProcessListeners = () => { 14 | // Listeners already set up 15 | if (watched) { 16 | return 17 | } 18 | 19 | watched = process.on('uncaughtException', (err: any) => { 20 | console.error('uncaughtException', err) 21 | if (err && logger) { 22 | err.type = 'uncaughtException' 23 | logger.error(err) 24 | } 25 | process.exit(420) 26 | }) 27 | 28 | process.on('unhandledRejection', (reason: Error | any, promise: Promise) => { 29 | if (reason instanceof RequestCancelledError) { 30 | return 31 | } 32 | console.error('unhandledRejection', reason, promise) 33 | if (reason && logger) { 34 | reason.type = 'unhandledRejection' 35 | logger.error(reason) 36 | } 37 | }) 38 | 39 | process.on('warning', (warning) => { 40 | console.warn(warning) 41 | }) 42 | 43 | process.on('SIGINT', handleSignal) 44 | process.on('SIGTERM', handleSignal) 45 | } 46 | -------------------------------------------------------------------------------- /src/service/worker/runtime/Service.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../clients/IOClients' 2 | import { LogLevel, logOnceToDevConsole } from '../../logger' 3 | import { ParamsContext, RecorderState, ServiceConfig } from './typings' 4 | 5 | /** 6 | * This is the client definition to allow type checking on code editors. 7 | * In you `index.ts`, you must `export default new Service({...})` with your 8 | * client options, implementation and route handlers. 9 | * 10 | * @export 11 | * @class Service 12 | * @template ClientsT Your Clients implementation that extends IOClients and adds extra clients. 13 | * @template StateT The state bag in `ctx.state` 14 | * @template CustomT Any custom fields in `ctx`. THIS IS NOT RECOMMENDED. Use StateT instead. 15 | */ 16 | export class Service< 17 | T extends IOClients, 18 | U extends RecorderState, 19 | V extends ParamsContext 20 | >{ 21 | constructor(public config: ServiceConfig) { 22 | if (config.routes && config.routes.graphql) { 23 | logOnceToDevConsole(`Route id "graphql" is reserved and apps containing this routes will stop working in the near future. To create a GraphQL app, export a "graphql" key with {resolvers}.`, LogLevel.Warn) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/service/worker/runtime/builtIn/handlers.ts: -------------------------------------------------------------------------------- 1 | import { ServiceContext, ServiceJSON } from '../typings' 2 | 3 | export const whoAmIHandler = ({ 4 | events, 5 | routes, 6 | }: ServiceJSON) => (ctx: ServiceContext) => { 7 | ctx.requestHandlerName = 'builtin:whoami' 8 | ctx.tracing?.currentSpan?.setOperationName(ctx.requestHandlerName) 9 | ctx.status = 200 10 | ctx.body = { 11 | events, 12 | routes, 13 | } 14 | ctx.set('Cache-Control', 'public, max-age=86400') // cache for 24 hours 15 | } 16 | 17 | export const healthcheckHandler = ({ 18 | events, 19 | routes, 20 | }: ServiceJSON) => (ctx: ServiceContext) => { 21 | ctx.requestHandlerName = 'builtin:healthcheck' 22 | ctx.tracing?.currentSpan?.setOperationName(ctx.requestHandlerName) 23 | ctx.status = 200 24 | ctx.body = { 25 | events, 26 | routes, 27 | } 28 | } 29 | 30 | export const metricsLoggerHandler = (ctx: ServiceContext) => { 31 | ctx.requestHandlerName = 'builtin:metrics-logger' 32 | ctx.tracing?.currentSpan?.setOperationName(ctx.requestHandlerName) 33 | ctx.status = 200 34 | ctx.body = ctx.metricsLogger.getSummaries() 35 | } 36 | -------------------------------------------------------------------------------- /src/service/worker/runtime/builtIn/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { collectDefaultMetrics, register } from 'prom-client' 2 | import { HeaderKeys } from '../../../../constants' 3 | import { MetricsLogger } from '../../../logger/metricsLogger' 4 | import { EventLoopLagMeasurer } from '../../../tracing/metrics/measurers/EventLoopLagMeasurer' 5 | import { ServiceContext } from '../typings' 6 | import { Recorder } from '../utils/recorder' 7 | 8 | export async function recorderMiddleware(ctx: ServiceContext, next: () => Promise) { 9 | const recorder = new Recorder() 10 | ctx.state.recorder = recorder 11 | await next() 12 | recorder.flush(ctx) 13 | return 14 | } 15 | 16 | export const addMetricsLoggerMiddleware = () => { 17 | const metricsLogger = new MetricsLogger() 18 | return (ctx: ServiceContext, next: () => Promise) => { 19 | ctx.metricsLogger = metricsLogger 20 | return next() 21 | } 22 | } 23 | 24 | export const prometheusLoggerMiddleware = () => { 25 | collectDefaultMetrics() 26 | const eventLoopLagMeasurer = new EventLoopLagMeasurer() 27 | eventLoopLagMeasurer.start() 28 | 29 | return async (ctx: ServiceContext, next: () => Promise) => { 30 | if (ctx.request.path !== '/metrics') { 31 | return next() 32 | } 33 | 34 | const routeId = ctx.get(HeaderKeys.COLOSSUS_ROUTE_ID) 35 | if (routeId) { 36 | return next() 37 | } 38 | 39 | await eventLoopLagMeasurer.updateInstrumentsAndReset() 40 | ctx.set('Content-Type', register.contentType) 41 | ctx.body = await register.metrics() 42 | ctx.status = 200 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/service/worker/runtime/events/index.ts: -------------------------------------------------------------------------------- 1 | import TokenBucket from 'tokenbucket' 2 | import { IOClients } from '../../../../clients/IOClients' 3 | import { nameSpanOperationMiddleware, traceUserLandRemainingPipelineMiddleware } from '../../../tracing/tracingMiddlewares' 4 | import { clients } from '../http/middlewares/clients' 5 | import { concurrentRateLimiter, perMinuteRateLimiter } from '../http/middlewares/rateLimit' 6 | import { getServiceSettings } from '../http/middlewares/settings' 7 | import { timings } from '../http/middlewares/timings' 8 | import { 9 | ClientsConfig, 10 | EventHandler, 11 | ParamsContext, 12 | RecorderState, 13 | ServiceContext, 14 | ServiceEvent, 15 | } from '../typings' 16 | import { compose, composeForEvents } from '../utils/compose' 17 | import { toArray } from '../utils/toArray' 18 | import { parseBodyMiddleware } from './middlewares/body' 19 | import { eventContextMiddleware } from './middlewares/context' 20 | 21 | 22 | export const createEventHandler = ( 23 | clientsConfig: ClientsConfig, 24 | eventId: string, 25 | handler: EventHandler | Array>, 26 | serviceEvent: ServiceEvent | undefined, 27 | globalLimiter: TokenBucket | undefined 28 | ) => { 29 | const { implementation, options } = clientsConfig 30 | const middlewares = toArray(handler) 31 | const pipeline = [ 32 | nameSpanOperationMiddleware('event-handler', eventId), 33 | eventContextMiddleware, 34 | parseBodyMiddleware, 35 | clients(implementation!, options), 36 | ...(serviceEvent?.settingsType === 'workspace' || serviceEvent?.settingsType === 'userAndWorkspace' ? [getServiceSettings()] : []), 37 | timings, 38 | concurrentRateLimiter(serviceEvent?.rateLimitPerReplica?.concurrent), 39 | perMinuteRateLimiter(serviceEvent?.rateLimitPerReplica?.perMinute, globalLimiter), 40 | traceUserLandRemainingPipelineMiddleware(), 41 | contextAdapter(middlewares), 42 | ] 43 | return compose(pipeline) 44 | } 45 | 46 | function contextAdapter (middlewares: Array>) { 47 | return async function middlewareCascade(ctx: ServiceContext) { 48 | const ctxEvent = { 49 | body: (ctx.state as any).body, 50 | clients: ctx.clients, 51 | key: ctx.vtex.eventInfo? ctx.vtex.eventInfo.key : '', 52 | metrics: ctx.metrics, 53 | sender: ctx.vtex.eventInfo? ctx.vtex.eventInfo.sender : '', 54 | state: ctx.state, 55 | subject: ctx.vtex.eventInfo? ctx.vtex.eventInfo.subject : '', 56 | timings: ctx.timings, 57 | vtex: ctx.vtex, 58 | } 59 | await composeForEvents(middlewares)(ctxEvent) 60 | ctx.status = 204 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/service/worker/runtime/events/middlewares/body.ts: -------------------------------------------------------------------------------- 1 | import bodyParse from 'co-body' 2 | 3 | import { IOClients } from '../../../../../clients/IOClients' 4 | import { LogLevel } from '../../../../logger' 5 | import { logOnceToDevConsole } from '../../../../logger/console' 6 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 7 | 8 | export async function parseBodyMiddleware (ctx: ServiceContext, next: () => Promise) { 9 | try { 10 | ctx.state.body = await bodyParse(ctx.req) 11 | } catch (err: any) { 12 | const msg = `Error parsing event body: ${err.message || err}` 13 | ctx.status = 500 14 | ctx.body = msg 15 | logOnceToDevConsole(msg, LogLevel.Error) 16 | return 17 | } 18 | await next() 19 | } 20 | -------------------------------------------------------------------------------- /src/service/worker/runtime/events/middlewares/context.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../../../clients/IOClients' 2 | import { 3 | HeaderKeys, 4 | } from '../../../../../constants' 5 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 6 | import { prepareHandlerCtx } from '../../utils/context' 7 | 8 | export async function eventContextMiddleware (ctx: ServiceContext, next: () => Promise) { 9 | const { request: { header } } = ctx 10 | ctx.vtex = { 11 | ...prepareHandlerCtx(header, ctx.tracing), 12 | eventInfo: { 13 | key: header[HeaderKeys.EVENT_KEY], 14 | sender: header[HeaderKeys.EVENT_SENDER], 15 | subject: header[HeaderKeys.EVENT_SUBJECT], 16 | }, 17 | route: { 18 | id: header[HeaderKeys.EVENT_HANDLER_ID], 19 | params: {}, 20 | type: 'event', 21 | }, 22 | } 23 | await next() 24 | } 25 | -------------------------------------------------------------------------------- /src/service/worker/runtime/events/router.ts: -------------------------------------------------------------------------------- 1 | import { HeaderKeys } from '../../../../constants' 2 | import { LogLevel } from '../../../logger' 3 | import { RouteHandler, ServiceContext } from '../typings' 4 | import { logOnceToDevConsole } from './../../../logger/console' 5 | 6 | export const routerFromEventHandlers = (events: Record | null) => { 7 | return async (ctx: ServiceContext, next: () => Promise) => { 8 | const handlerId = ctx.get(HeaderKeys.EVENT_HANDLER_ID) 9 | 10 | if (!handlerId || !events) { 11 | return next() 12 | } 13 | 14 | if (handlerId == null) { 15 | ctx.response.status = 400 16 | ctx.response.body = `Request header doesn't have the field x-event-handler-id` 17 | return 18 | } 19 | 20 | const handler = events[handlerId] 21 | if (!handler) { 22 | const msg = `Event handler not found for ${handlerId}` 23 | ctx.response.status = 404 24 | ctx.response.body = msg 25 | logOnceToDevConsole(msg, LogLevel.Error) 26 | return 27 | } 28 | 29 | await handler(ctx, next) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/index.ts: -------------------------------------------------------------------------------- 1 | import TokenBucket from 'tokenbucket' 2 | import { IOClients } from '../../../../clients/IOClients' 3 | import { nameSpanOperationMiddleware } from '../../../tracing/tracingMiddlewares' 4 | import { createPrivateHttpRoute } from '../http' 5 | import { ClientsConfig, GraphQLOptions, ParamsContext, RecorderState, ServiceRoute } from '../typings' 6 | import { injectGraphqlContext } from './middlewares/context' 7 | import { graphqlError } from './middlewares/error' 8 | import { extractQuery } from './middlewares/query' 9 | import { response } from './middlewares/response' 10 | import { run } from './middlewares/run' 11 | import { updateSchema } from './middlewares/updateSchema' 12 | import { upload } from './middlewares/upload' 13 | import { makeSchema } from './schema' 14 | import { GraphQLContext } from './typings' 15 | 16 | export const GRAPHQL_ROUTE = '__graphql' 17 | 18 | export const createGraphQLRoute = ( 19 | graphql: GraphQLOptions, 20 | clientsConfig: ClientsConfig, 21 | serviceRoute: ServiceRoute, 22 | routeId: string, 23 | globalLimiter: TokenBucket | undefined 24 | ) => { 25 | const executableSchema = makeSchema(graphql) 26 | const pipeline = [ 27 | nameSpanOperationMiddleware('graphql-handler', GRAPHQL_ROUTE), 28 | updateSchema(graphql, executableSchema), 29 | injectGraphqlContext, 30 | response, 31 | graphqlError, 32 | upload, 33 | extractQuery(executableSchema), 34 | run(executableSchema), 35 | ] 36 | return createPrivateHttpRoute(clientsConfig, pipeline, serviceRoute, routeId, globalLimiter) 37 | } 38 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/context.ts: -------------------------------------------------------------------------------- 1 | import { MAX_AGE } from '../../../../../constants' 2 | import { GraphQLServiceContext } from '../typings' 3 | 4 | export async function injectGraphqlContext (ctx: GraphQLServiceContext, next: () => Promise) { 5 | ctx.graphql = { 6 | cacheControl: { 7 | maxAge: MAX_AGE.LONG, 8 | noCache: false, 9 | noStore: false, 10 | scope: 'public', 11 | }, 12 | status: 'success', 13 | } 14 | 15 | await next() 16 | } 17 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/query.ts: -------------------------------------------------------------------------------- 1 | import { json } from 'co-body' 2 | import { DocumentNode, GraphQLSchema, parse as gqlParse, validate } from 'graphql' 3 | import { parse } from 'url' 4 | import { ExecutableSchema } from './../typings' 5 | 6 | import { BODY_HASH } from '../../../../../constants' 7 | import { GraphQLServiceContext, Query } from '../typings' 8 | import { LRUCache } from './../../../../../caches/LRUCache' 9 | 10 | const documentStorage = new LRUCache({ 11 | max: 500, 12 | }) 13 | 14 | const queryFromUrl = (url: string) => { 15 | const parsedUrl = parse(url, true) 16 | const { query: querystringObj } = parsedUrl 17 | 18 | // Having a BODY_HASH means the query is in the body 19 | if (querystringObj && querystringObj[BODY_HASH]) { 20 | return null 21 | } 22 | 23 | // We need to JSON.parse the variables since they are a stringified 24 | // in the querystring 25 | if (querystringObj && typeof querystringObj.variables === 'string') { 26 | querystringObj.variables = JSON.parse(querystringObj.variables) 27 | } 28 | 29 | return querystringObj 30 | } 31 | 32 | const parseAndValidateQueryToSchema = (query: string, schema: GraphQLSchema) => { 33 | const document = gqlParse(query) 34 | const validation = validate(schema, document) 35 | if (Array.isArray(validation) && validation.length > 0) { 36 | throw validation 37 | } 38 | return document 39 | } 40 | 41 | export const extractQuery = (executableSchema: ExecutableSchema) => 42 | async function parseAndValidateQuery(ctx: GraphQLServiceContext, next: () => Promise) { 43 | const { request, req } = ctx 44 | 45 | let query: Query & { query: string } 46 | if (request.is('multipart/form-data')) { 47 | query = (request as any).body 48 | } else if (request.method.toUpperCase() === 'POST') { 49 | query = await json(req, { limit: '3mb' }) 50 | } else { 51 | query = queryFromUrl(request.url) || (await json(req, { limit: '3mb' })) 52 | } 53 | 54 | // Assign the query before setting the query.document because if the 55 | // validation fails we don't loose the query in our error log 56 | ctx.graphql.query = query 57 | 58 | query.document = (await documentStorage.getOrSet(query.query, async () => ({ 59 | value: parseAndValidateQueryToSchema(query.query, executableSchema.schema), 60 | }))) as DocumentNode 61 | 62 | await next() 63 | } 64 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/response.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HeaderKeys, 3 | } from '../../../../../constants' 4 | import { Maybe } from '../../typings' 5 | import { Recorder } from '../../utils/recorder' 6 | import { GraphQLCacheControl, GraphQLServiceContext } from '../typings' 7 | import { cacheControlHTTP } from '../utils/cacheControl' 8 | 9 | function setVaryHeaders (ctx: GraphQLServiceContext, cacheControl: GraphQLCacheControl) { 10 | ctx.vary(HeaderKeys.FORWARDED_HOST) 11 | if (cacheControl.scope === 'segment') { 12 | ctx.vary(HeaderKeys.SEGMENT) 13 | } 14 | if (cacheControl.scope === 'private' || ctx.query.scope === 'private') { 15 | ctx.vary(HeaderKeys.SEGMENT) 16 | ctx.vary(HeaderKeys.SESSION) 17 | } else if (ctx.vtex.sessionToken) { 18 | ctx.vtex.logger.warn({ 19 | message: 'GraphQL resolver receiving session token without private scope', 20 | userAgent: ctx.get('user-agent'), 21 | }) 22 | } 23 | } 24 | 25 | export async function response (ctx: GraphQLServiceContext, next: () => Promise) { 26 | await next() 27 | const { 28 | cacheControl, 29 | status, 30 | graphqlResponse, 31 | } = ctx.graphql 32 | 33 | const cacheControlHeader = cacheControlHTTP(ctx) 34 | ctx.set(HeaderKeys.CACHE_CONTROL, cacheControlHeader) 35 | 36 | if (status === 'error') { 37 | // Do not generate etag for errors 38 | ctx.remove(HeaderKeys.META) 39 | ctx.remove(HeaderKeys.ETAG) 40 | ctx.vtex.recorder?.clear() 41 | } 42 | 43 | if (ctx.method.toUpperCase() === 'GET') { 44 | setVaryHeaders(ctx, cacheControl) 45 | } 46 | 47 | ctx.body = graphqlResponse 48 | } 49 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/run.ts: -------------------------------------------------------------------------------- 1 | import { execute } from 'graphql' 2 | 3 | import { ExecutableSchema, GraphQLServiceContext } from '../typings' 4 | 5 | export const run = (executableSchema: ExecutableSchema) => 6 | async function runHttpQuery(ctx: GraphQLServiceContext, next: () => Promise) { 7 | const { 8 | graphql: { query }, 9 | } = ctx 10 | 11 | const { document, operationName, variables: variableValues } = query! 12 | const schema = executableSchema.schema 13 | const response = await execute({ 14 | contextValue: ctx, 15 | document, 16 | fieldResolver: (root, _, __, info) => root[info.fieldName], 17 | operationName, 18 | rootValue: null, 19 | schema, 20 | variableValues, 21 | }) 22 | ctx.graphql.graphqlResponse = response 23 | 24 | await next() 25 | } 26 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/updateSchema.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../../../clients' 2 | import { HeaderKeys } from '../../../../../constants' 3 | import { majorEqualAndGreaterThan, parseAppId } from '../../../../../utils' 4 | import { GraphQLOptions, ParamsContext, RecorderState } from '../../typings' 5 | import { makeSchema } from '../schema/index' 6 | import { ExecutableSchema } from '../typings' 7 | import { GraphQLServiceContext } from '../typings' 8 | 9 | export const updateSchema = ( 10 | graphql: GraphQLOptions, 11 | executableSchema: ExecutableSchema 12 | ) => 13 | async function updateRunnableSchema(ctx: GraphQLServiceContext, next: () => Promise) { 14 | const { 15 | clients: { apps }, 16 | vtex: { logger }, 17 | app, 18 | } = ctx 19 | 20 | if (!ctx.headers[HeaderKeys.PROVIDER]) { 21 | await next() 22 | return 23 | } 24 | 25 | // fetches the new schema and generate a new runnable schema, updates the provider app, 26 | if ( 27 | executableSchema.hasProvider && 28 | (!executableSchema.provider || 29 | majorEqualAndGreaterThan( 30 | parseAppId(ctx.headers[HeaderKeys.PROVIDER]).version, 31 | parseAppId(executableSchema.provider).version 32 | )) 33 | ) { 34 | try { 35 | const newSchema = (await apps.getAppFile(ctx.headers[HeaderKeys.PROVIDER], 'public/schema.graphql')).data.toString( 36 | 'utf-8' 37 | ) 38 | graphql.schema = newSchema 39 | const newRunnableSchema = makeSchema(graphql) 40 | executableSchema.schema = newRunnableSchema.schema 41 | executableSchema.provider = ctx.headers[HeaderKeys.PROVIDER] 42 | } catch (error) { 43 | logger.error({ error, message: 'Update schema failed', app }) 44 | } 45 | } 46 | await next() 47 | } 48 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/middlewares/upload.ts: -------------------------------------------------------------------------------- 1 | import { graphqlUploadKoa } from 'graphql-upload' 2 | 3 | import { GraphQLServiceContext } from '../typings' 4 | 5 | const graphqlUpload = graphqlUploadKoa({ 6 | maxFieldSize: 1e6, // size in Bytes 7 | maxFileSize: 4 * 1e6, // size in Bytes 8 | maxFiles: 10, 9 | }) 10 | 11 | function graphqlUploadKoaMiddleware( 12 | ctx: GraphQLServiceContext, 13 | next: () => Promise 14 | ): Promise { 15 | return graphqlUpload(ctx as any, next) 16 | } 17 | 18 | export const upload = graphqlUploadKoaMiddleware 19 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs-extra' 2 | import { makeExecutableSchema } from 'graphql-tools' 3 | import { keys, map, zipObj } from 'ramda' 4 | 5 | import { IOClients } from '../../../../../clients/IOClients' 6 | import { GraphQLOptions, ParamsContext, RecorderState } from '../../typings' 7 | import { nativeSchemaDirectives, nativeSchemaDirectivesTypeDefs } from './schemaDirectives' 8 | import { nativeResolvers, nativeTypeDefs } from './typeDefs' 9 | 10 | export type SchemaMetaData = Record 11 | 12 | const mergeTypeDefs = (appTypeDefs: string, schemaMetaData: SchemaMetaData) => 13 | [appTypeDefs, nativeTypeDefs(schemaMetaData), nativeSchemaDirectivesTypeDefs].join('\n\n') 14 | 15 | const hasScalar = (typeDefs: string) => (scalar: string) => new RegExp(`scalar(\\s)+${scalar}(\\s\\n)+`).test(typeDefs) 16 | 17 | const extractSchemaMetaData = (typeDefs: string) => { 18 | const scalars = keys(nativeResolvers) 19 | const scalarsPresentInSchema = map(hasScalar(typeDefs), scalars) 20 | return zipObj(scalars, scalarsPresentInSchema) 21 | } 22 | 23 | export const makeSchema = < 24 | ClientsT extends IOClients = IOClients, 25 | StateT extends RecorderState = RecorderState, 26 | CustomT extends ParamsContext = ParamsContext 27 | >( 28 | options: GraphQLOptions 29 | ) => { 30 | const { resolvers: appResolvers, schema: appSchema, schemaDirectives: appDirectives } = options 31 | const appTypeDefs = appSchema || readFileSync('./service/schema.graphql', 'utf8') 32 | 33 | const schemaMetaData = extractSchemaMetaData(appTypeDefs!) 34 | 35 | const executableSchema = makeExecutableSchema({ 36 | resolvers: { 37 | ...appResolvers, 38 | ...nativeResolvers, 39 | }, 40 | schemaDirectives: { 41 | ...appDirectives, 42 | ...nativeSchemaDirectives, 43 | }, 44 | typeDefs: mergeTypeDefs(appTypeDefs, schemaMetaData), 45 | }) 46 | 47 | return { schema: executableSchema, hasProvider: appSchema ? true : false } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/CacheControl.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | 4 | import { GraphQLServiceContext } from '../../typings' 5 | 6 | interface CacheControlArgs { 7 | maxAge: number 8 | scope: 'PRIVATE' | 'SEGMENT' | 'PUBLIC' 9 | } 10 | 11 | export class CacheControl extends SchemaDirectiveVisitor { 12 | public visitFieldDefinition (field: GraphQLField) { 13 | const { resolve = defaultFieldResolver } = field 14 | const { 15 | maxAge: directiveMaxAge, 16 | scope: directiveScope, 17 | } = this.args as CacheControlArgs 18 | 19 | field.resolve = (root, args, ctx, info) => { 20 | const { 21 | maxAge, 22 | scope, 23 | } = ctx.graphql.cacheControl 24 | 25 | if (Number.isInteger(directiveMaxAge) && directiveMaxAge < maxAge) { 26 | ctx.graphql.cacheControl.maxAge = directiveMaxAge 27 | } 28 | 29 | if (directiveScope === 'PRIVATE') { 30 | ctx.graphql.cacheControl.scope = 'private' 31 | } else if (directiveScope === 'SEGMENT' && scope === 'public') { 32 | ctx.graphql.cacheControl.scope = 'segment' 33 | } 34 | 35 | return resolve(root, args, ctx, info) 36 | } 37 | } 38 | } 39 | 40 | export const cacheControlDirectiveTypeDefs = ` 41 | 42 | enum IOCacheControlScope { 43 | SEGMENT 44 | PUBLIC 45 | PRIVATE 46 | } 47 | 48 | directive @cacheControl( 49 | maxAge: Int 50 | scope: IOCacheControlScope 51 | ) on FIELD_DEFINITION 52 | ` 53 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/Deprecated.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultFieldResolver, 3 | GraphQLArgument, 4 | GraphQLField, 5 | GraphQLInputField, 6 | print, 7 | } from 'graphql' 8 | import { SchemaDirectiveVisitor } from 'graphql-tools' 9 | 10 | import { Logger } from '../../../../../logger/logger' 11 | import { logger as globalLogger } from '../../../../listeners' 12 | import { GraphQLServiceContext } from '../../typings' 13 | 14 | let lastLog = process.hrtime() 15 | 16 | const LOG_PERIOD_S = 60 17 | 18 | interface DeprecatedOptions { 19 | reason?: string 20 | } 21 | 22 | const hrtimeToS = (time: [number, number]) => time[0] + (time[1] / 1e9) 23 | 24 | export class Deprecated extends SchemaDirectiveVisitor { 25 | public visitArgumentDefinition (argument: GraphQLArgument) { 26 | this.maybeLogToSplunk({ 27 | description: argument.description, 28 | name: argument.name, 29 | }) 30 | } 31 | 32 | public visitInputFieldDefinition (field: GraphQLInputField) { 33 | this.maybeLogToSplunk({ 34 | description: field.description, 35 | name: field.name, 36 | }) 37 | } 38 | 39 | public visitFieldDefinition (field: GraphQLField) { 40 | const { resolve = defaultFieldResolver, name, type } = field 41 | const { reason }: DeprecatedOptions = this.args 42 | 43 | field.resolve = (root, args, ctx, info) => { 44 | this.maybeLogToSplunk({ 45 | headers: ctx.request.headers, 46 | name, 47 | query: ctx.graphql.query?.document && print(ctx.graphql.query?.document), 48 | reason, 49 | variables: ctx.graphql.query?.variables, 50 | }, ctx.vtex.logger) 51 | return resolve(root, args, ctx, info) 52 | } 53 | } 54 | 55 | protected maybeLogToSplunk (payload: T, logger: Logger = globalLogger) { 56 | const now = process.hrtime() 57 | const timeSinceLastLog = hrtimeToS([ 58 | now[0] - lastLog[0], 59 | now[1] - lastLog[1], 60 | ]) 61 | if (timeSinceLastLog > LOG_PERIOD_S) { 62 | lastLog = now 63 | logger.warn({ 64 | message: 'Deprecated field in use', 65 | ...payload, 66 | }) 67 | } 68 | } 69 | } 70 | 71 | export const deprecatedDirectiveTypeDefs = ` 72 | directive @deprecated( 73 | reason: String = "No longer supported" 74 | ) on 75 | FIELD_DEFINITION 76 | | INPUT_FIELD_DEFINITION 77 | | ARGUMENT_DEFINITION 78 | | FRAGMENT_DEFINITION 79 | ` 80 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/Metric.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | import { APP } from '../../../../../..' 4 | import { GraphQLServiceContext } from '../../typings' 5 | 6 | interface Args { 7 | name?: string 8 | } 9 | 10 | export class Metric extends SchemaDirectiveVisitor { 11 | public visitFieldDefinition(field: GraphQLField) { 12 | const { resolve = defaultFieldResolver, name: fieldName } = field 13 | const { name = `${APP.NAME}-${fieldName}` } = this.args as Args 14 | 15 | field.resolve = async (root, args, ctx, info) => { 16 | let failedToResolve = false 17 | let result: any = null 18 | let ellapsed: [number, number] = [0, 0] 19 | 20 | try { 21 | const start = process.hrtime() 22 | result = await resolve(root, args, ctx, info) 23 | ellapsed = process.hrtime(start) 24 | } catch (error) { 25 | result = error 26 | failedToResolve = true 27 | } 28 | 29 | ctx.graphql.status = failedToResolve ? 'error' : 'success' 30 | 31 | const payload = { 32 | [ctx.graphql.status]: 1, 33 | } 34 | 35 | metrics.batch(`graphql-metric-${name}`, failedToResolve ? undefined : ellapsed, payload) 36 | 37 | if (failedToResolve) { 38 | throw result 39 | } 40 | 41 | return result 42 | } 43 | } 44 | } 45 | 46 | export const metricDirectiveTypeDefs = ` 47 | directive @metric ( 48 | name: String 49 | ) on FIELD_DEFINITION 50 | ` 51 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/Sanitize.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLArgument, 3 | GraphQLField, 4 | GraphQLInputField, 5 | GraphQLNonNull, 6 | GraphQLScalarType, 7 | } from 'graphql' 8 | import { SchemaDirectiveVisitor } from 'graphql-tools' 9 | 10 | import { 11 | IOSanitizedStringType, 12 | SanitizeOptions, 13 | } from '../typeDefs/sanitizedString' 14 | 15 | export class SanitizeDirective extends SchemaDirectiveVisitor { 16 | public visitFieldDefinition (field: GraphQLField) { 17 | this.wrapType(field) 18 | } 19 | 20 | public visitInputFieldDefinition(field: GraphQLInputField) { 21 | this.wrapType(field) 22 | } 23 | 24 | public visitArgumentDefinition(argument: GraphQLArgument) { 25 | this.wrapType(argument) 26 | } 27 | 28 | public wrapType(field: any) { 29 | const options = this.args as SanitizeOptions 30 | if (field.type instanceof GraphQLNonNull && field.type.ofType instanceof GraphQLScalarType) { 31 | field.type = new GraphQLNonNull(new IOSanitizedStringType(options)) 32 | } else if (field.type instanceof GraphQLScalarType) { 33 | field.type = new IOSanitizedStringType(options) 34 | } else { 35 | throw new Error('Can not apply @sanitize directive to non-scalar GraphQL type') 36 | } 37 | } 38 | } 39 | 40 | export const sanitizeDirectiveTypeDefs = ` 41 | directive @sanitize( 42 | allowHTMLTags: Boolean 43 | stripIgnoreTag: Boolean 44 | ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION 45 | ` 46 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/Settings.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | import { Apps } from '../../../../../../clients/infra/Apps' 4 | import { getDependenciesSettings } from '../../../http/middlewares/settings' 5 | import { RouteSettingsType } from '../../../typings' 6 | 7 | const addSettings = async (settings: RouteSettingsType, ctx: any) => { 8 | if (settings === 'pure') { return ctx } 9 | 10 | const { clients: { apps, assets } } = ctx 11 | const dependenciesSettings = await getDependenciesSettings(apps as Apps, assets) 12 | if (!ctx.vtex) { 13 | ctx.vtex = {} 14 | } 15 | ctx.vtex.settings = { ...ctx.vtex.settings, dependenciesSettings } 16 | } 17 | 18 | export class SettingsDirective extends SchemaDirectiveVisitor { 19 | public visitFieldDefinition (field: GraphQLField) { 20 | const {resolve = defaultFieldResolver} = field 21 | const { settingsType } = this.args 22 | field.resolve = async (root, args, ctx, info) => { 23 | if (settingsType) { 24 | await addSettings(settingsType, ctx) 25 | } 26 | return resolve(root, args, ctx, info) 27 | } 28 | } 29 | } 30 | 31 | export const settingsDirectiveTypeDefs = ` 32 | directive @settings( 33 | settingsType: String 34 | ) on FIELD_DEFINITION 35 | ` 36 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/SmartCacheDirective.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | 4 | import { MAX_AGE } from '../../../../../../constants' 5 | 6 | const ETAG_CONTROL_HEADER = 'x-vtex-etag-control' 7 | 8 | interface Args { 9 | maxAge: keyof typeof MAX_AGE | undefined 10 | } 11 | 12 | const DEFAULT_ARGS: Args = { 13 | maxAge: undefined, 14 | } 15 | 16 | export class SmartCacheDirective extends SchemaDirectiveVisitor { 17 | public visitFieldDefinition (field: GraphQLField) { 18 | const {resolve = defaultFieldResolver} = field 19 | const { maxAge } = this.args as Args || DEFAULT_ARGS 20 | const maxAgeS = maxAge && MAX_AGE[maxAge] 21 | field.resolve = (root, args, context, info) => { 22 | if (maxAgeS) { 23 | context.set(ETAG_CONTROL_HEADER, `public, max-age=${maxAgeS}`) 24 | } 25 | context.vtex.recorder = context.state.recorder 26 | return resolve(root, args, context, info) 27 | } 28 | } 29 | } 30 | 31 | export const smartCacheDirectiveTypeDefs = ` 32 | directive @smartcache( 33 | maxAge: String 34 | ) on FIELD_DEFINITION 35 | ` 36 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/TranslatableV2.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | import { Behavior } from '../../../../../../clients' 4 | 5 | import { ServiceContext } from '../../../typings' 6 | import { handleSingleString } from '../../utils/translations' 7 | import { createMessagesLoader } from '../messagesLoaderV2' 8 | 9 | interface Args { 10 | behavior: Behavior 11 | withAppsMetaInfo: boolean 12 | } 13 | 14 | export class TranslatableV2 extends SchemaDirectiveVisitor { 15 | public visitFieldDefinition (field: GraphQLField) { 16 | const { resolve = defaultFieldResolver } = field 17 | const { behavior = 'FULL', withAppsMetaInfo = false } = this.args as Args 18 | field.resolve = async (root, args, ctx, info) => { 19 | if (!ctx.loaders?.messagesV2) { 20 | const { vtex: { locale: to } } = ctx 21 | 22 | if (to == null) { 23 | throw new Error('@translatableV2 directive needs the locale variable available in IOContext. You can do this by either setting \`ctx.vtex.locale\` directly or calling this app with \`x-vtex-locale\` header') 24 | } 25 | 26 | const dependencies = withAppsMetaInfo ? await ctx.clients.apps.getAppsMetaInfos() : undefined 27 | ctx.loaders = { 28 | ...ctx.loaders, 29 | messagesV2: createMessagesLoader(ctx.clients, to, dependencies), 30 | } 31 | } 32 | const response = await resolve(root, args, ctx, info) as string | string[] | null 33 | const { vtex, loaders: { messagesV2 } } = ctx 34 | const handler = handleSingleString(vtex, messagesV2!, behavior, 'translatableV2') 35 | return Array.isArray(response) 36 | ? Promise.all(response.map(handler)) 37 | : handler(response) 38 | } 39 | } 40 | } 41 | 42 | export const translatableV2DirectiveTypeDefs = ` 43 | directive @translatableV2( 44 | behavior: String 45 | withAppsMetaInfo: Boolean 46 | ) on FIELD_DEFINITION 47 | ` 48 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/TranslateTo.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, GraphQLField } from 'graphql' 2 | import { SchemaDirectiveVisitor } from 'graphql-tools' 3 | import { Behavior } from '../../../../../../clients' 4 | 5 | import { ServiceContext } from '../../../typings' 6 | import { handleSingleString } from '../../utils/translations' 7 | import { createMessagesLoader } from '../messagesLoaderV2' 8 | 9 | interface Args { 10 | behavior: Behavior 11 | language: string 12 | } 13 | 14 | export class TranslateTo extends SchemaDirectiveVisitor { 15 | public visitFieldDefinition(field: GraphQLField) { 16 | const { resolve = defaultFieldResolver } = field 17 | const { language, behavior = 'FULL' } = this.args as Args 18 | field.resolve = async (root, args, ctx, info) => { 19 | if (!ctx.loaders?.immutableMessagesV2) { 20 | const dependencies = await ctx.clients.apps.getAppsMetaInfos() 21 | ctx.loaders = { 22 | ...ctx.loaders, 23 | immutableMessagesV2: createMessagesLoader(ctx.clients, language, dependencies), 24 | } 25 | } 26 | const response = (await resolve(root, args, ctx, info)) as string | string[] | null 27 | const { 28 | vtex, 29 | loaders: { immutableMessagesV2 }, 30 | } = ctx 31 | const handler = handleSingleString(vtex, immutableMessagesV2!, behavior, 'translateTo') 32 | return Array.isArray(response) ? Promise.all(response.map(handler)) : handler(response) 33 | } 34 | } 35 | } 36 | 37 | export const translateToDirectiveTypeDefs = ` 38 | directive @translateTo( 39 | language: String! 40 | behavior: String 41 | ) on FIELD_DEFINITION 42 | ` 43 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/schemaDirectives/index.ts: -------------------------------------------------------------------------------- 1 | import { Auth, authDirectiveTypeDefs } from './Auth' 2 | import { CacheControl, cacheControlDirectiveTypeDefs } from './CacheControl' 3 | import { Deprecated, deprecatedDirectiveTypeDefs } from './Deprecated' 4 | import { Metric, metricDirectiveTypeDefs } from './Metric' 5 | import { SanitizeDirective, sanitizeDirectiveTypeDefs } from './Sanitize' 6 | import { SettingsDirective, settingsDirectiveTypeDefs } from './Settings' 7 | import { SmartCacheDirective, smartCacheDirectiveTypeDefs } from './SmartCacheDirective' 8 | import { TranslatableV2, translatableV2DirectiveTypeDefs } from './TranslatableV2' 9 | import { TranslateTo, translateToDirectiveTypeDefs } from './TranslateTo' 10 | 11 | export { parseTranslatableStringV2, formatTranslatableStringV2 } from '../../utils/translations' 12 | 13 | export const nativeSchemaDirectives = { 14 | auth: Auth, 15 | cacheControl: CacheControl, 16 | deprecated: Deprecated, 17 | metric: Metric, 18 | sanitize: SanitizeDirective, 19 | settings: SettingsDirective, 20 | smartcache: SmartCacheDirective, 21 | translatableV2: TranslatableV2, 22 | translateTo: TranslateTo, 23 | } 24 | 25 | export const nativeSchemaDirectivesTypeDefs = [ 26 | authDirectiveTypeDefs, 27 | cacheControlDirectiveTypeDefs, 28 | deprecatedDirectiveTypeDefs, 29 | metricDirectiveTypeDefs, 30 | sanitizeDirectiveTypeDefs, 31 | settingsDirectiveTypeDefs, 32 | smartCacheDirectiveTypeDefs, 33 | translatableV2DirectiveTypeDefs, 34 | translateToDirectiveTypeDefs, 35 | ].join('\n\n') 36 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/typeDefs/index.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from 'graphql' 2 | import { GraphQLUpload } from 'graphql-upload' 3 | import { keys, reduce } from 'ramda' 4 | 5 | import { SchemaMetaData } from '../../schema' 6 | 7 | import { resolvers as ioUploadResolvers } from './ioUpload' 8 | import { resolvers as sanitizedStringResolvers } from './sanitizedString' 9 | 10 | export const nativeResolvers = { 11 | 'IOSanitizedString': sanitizedStringResolvers, 12 | 'IOUpload': ioUploadResolvers, 13 | 'Upload': GraphQLUpload as GraphQLScalarType, 14 | } 15 | 16 | export const nativeTypeDefs = (metaData: SchemaMetaData) => reduce( 17 | (acc, scalar) => !metaData[scalar] ? `${acc}\nscalar ${scalar}\n` : acc, 18 | '', 19 | keys(nativeResolvers) 20 | ) 21 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/typeDefs/ioUpload.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from 'graphql' 2 | 3 | const name = 'IOUpload' 4 | 5 | export const scalar = name 6 | 7 | export const resolvers = new GraphQLScalarType({ 8 | description: 'The `IOUpload` scalar type represents a file upload.', 9 | name, 10 | parseValue: (value: any) => value, 11 | parseLiteral() { 12 | throw new Error('‘IOUpload’ scalar literal unsupported.') 13 | }, 14 | serialize() { 15 | throw new Error('‘IOUpload’ scalar serialization unsupported.') 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/schema/typeDefs/sanitizedString.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType, Kind } from 'graphql' 2 | import {filterXSS, IFilterXSSOptions, IWhiteList} from 'xss' 3 | 4 | const defaultName = 'IOSanitizedString' 5 | 6 | export const scalar = defaultName 7 | 8 | const noop = (html: string) => html 9 | 10 | export interface SanitizeOptions { 11 | allowHTMLTags?: boolean 12 | stripIgnoreTag?: boolean 13 | } 14 | 15 | const serialize = (input: string, options?: IFilterXSSOptions) => { 16 | return filterXSS(input, options) 17 | } 18 | 19 | const parseValue = (value: string, options?: IFilterXSSOptions) => { 20 | return filterXSS(value, options) 21 | } 22 | 23 | export class IOSanitizedStringType extends GraphQLScalarType { 24 | constructor(options?: SanitizeOptions) { 25 | const allowHTMLTags = options && options.allowHTMLTags 26 | const stripIgnoreTag = !options || options.stripIgnoreTag !== false 27 | const xssOptions: IFilterXSSOptions = { 28 | stripIgnoreTag, 29 | ...!allowHTMLTags && {whiteList: [] as IWhiteList}, 30 | ...stripIgnoreTag && {escapeHtml: noop}, 31 | } 32 | 33 | super({ 34 | name: options ? `Custom${defaultName}` : defaultName, 35 | parseLiteral: ast => { 36 | switch(ast.kind) { 37 | case Kind.STRING: 38 | return parseValue(ast.value, xssOptions) 39 | default: 40 | return null 41 | } 42 | }, 43 | parseValue: value => parseValue(value, xssOptions), 44 | serialize: value => serialize(value, xssOptions), 45 | }) 46 | } 47 | } 48 | 49 | export const resolvers = new IOSanitizedStringType() 50 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/typings.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, execute, GraphQLSchema } from 'graphql' 2 | 3 | import { IOClients } from '../../../../clients/IOClients' 4 | import { ParamsContext, RecorderState, ServiceContext } from '../typings' 5 | 6 | export interface Query { 7 | variables?: Record 8 | operationName?: string 9 | document: DocumentNode 10 | } 11 | 12 | type TypeFromPromise = T extends Promise ? U : T 13 | 14 | export type GraphQLResponse = TypeFromPromise> 15 | 16 | export interface GraphQLCacheControl { 17 | maxAge: number 18 | scope: 'private' | 'public' | 'segment' 19 | noCache: boolean 20 | noStore: boolean 21 | } 22 | 23 | export interface GraphQLContext extends ParamsContext { 24 | graphql: { 25 | query?: Query 26 | graphqlResponse?: GraphQLResponse 27 | status: 'success' | 'error' 28 | cacheControl: GraphQLCacheControl 29 | } 30 | } 31 | 32 | export type GraphQLServiceContext = ServiceContext 33 | 34 | export interface ExecutableSchema { 35 | schema: GraphQLSchema 36 | hasProvider: boolean 37 | provider?: string 38 | } 39 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/utils/cacheControl.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_DOMAINS } from '../../../../../utils/domain' 2 | import { GraphQLServiceContext } from '../typings' 3 | 4 | const PRIVATE_ROUTE_REGEX = /_v\/graphql\/private\/v*/ 5 | 6 | const linked = !!process.env.VTEX_APP_LINK 7 | 8 | const isPrivateRoute = ({request: {headers}}: GraphQLServiceContext) => PRIVATE_ROUTE_REGEX.test(headers['x-forwarded-path'] || '') 9 | 10 | const publicRegExp = (endpoint: string) => new RegExp(`.*${endpoint.replace('.', '\\.')}.*`) 11 | 12 | const isPublicEndpoint = ({request: {headers}}: GraphQLServiceContext) => { 13 | const host = headers['x-forwarded-host'] 14 | 15 | if (headers.origin || !host) { 16 | return false 17 | } 18 | 19 | return PUBLIC_DOMAINS.some(endpoint => publicRegExp(endpoint).test(host)) 20 | } 21 | 22 | export const cacheControlHTTP = (ctx: GraphQLServiceContext) => { 23 | const { 24 | graphql: { 25 | cacheControl: { 26 | maxAge, 27 | scope: scopeHint, 28 | noCache: noCacheHint, 29 | noStore: noStoreHint, 30 | }, 31 | }, 32 | vtex: { 33 | production, 34 | }, 35 | } = ctx 36 | 37 | const finalHeader = [] 38 | 39 | const noCache = noCacheHint || !production || isPublicEndpoint(ctx) 40 | if (noCache) { 41 | finalHeader.push('no-cache') 42 | } 43 | 44 | const noStore = noStoreHint || linked 45 | if (noStore) { 46 | finalHeader.push('no-store') 47 | } 48 | 49 | if (!noCache && !noStore) { 50 | const scope = scopeHint === 'private' || isPrivateRoute(ctx) ? 'private' : 'public' 51 | finalHeader.push(scope) 52 | 53 | finalHeader.push(`max-age=${maxAge}`) 54 | } 55 | 56 | return finalHeader.join(',') 57 | } 58 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/utils/pathname.ts: -------------------------------------------------------------------------------- 1 | import { filter } from 'ramda' 2 | 3 | export const generatePathName = (rpath: [string | number]) => { 4 | const pathFieldNames = filter(value => typeof value === 'string', rpath) 5 | return pathFieldNames.join('.') 6 | } 7 | -------------------------------------------------------------------------------- /src/service/worker/runtime/graphql/utils/translations.ts: -------------------------------------------------------------------------------- 1 | import { Behavior } from '../../../../../clients' 2 | import { IOContext } from '../../typings' 3 | import { MessagesLoaderV2 } from '../schema/messagesLoaderV2' 4 | 5 | export const CONTEXT_REGEX = /\(\(\((?(.)*)\)\)\)/ 6 | export const FROM_REGEX = /\<\<\<(?(.)*)\>\>\>/ 7 | export const CONTENT_REGEX = /\(\(\((?(.)*)\)\)\)|\<\<\<(?(.)*)\>\>\>/g 8 | 9 | export interface TranslatableMessageV2 { 10 | from?: string 11 | content: string 12 | context?: string 13 | } 14 | 15 | export type TranslationDirectiveType = 'translatableV2' | 'translateTo' 16 | 17 | export const parseTranslatableStringV2 = (rawMessage: string): TranslatableMessageV2 => { 18 | const context = rawMessage.match(CONTEXT_REGEX)?.groups?.context 19 | const from = rawMessage.match(FROM_REGEX)?.groups?.from 20 | const content = rawMessage.replace(CONTENT_REGEX, '') 21 | 22 | return { 23 | content: content?.trim(), 24 | context: context?.trim(), 25 | from: from?.trim(), 26 | } 27 | } 28 | 29 | export const formatTranslatableStringV2 = ({ from, content, context }: TranslatableMessageV2): string => 30 | `${content} ${context ? `(((${context})))` : ''} ${from ? `<<<${from}>>>` : ''}` 31 | 32 | export const handleSingleString = ( 33 | ctx: IOContext, 34 | loader: MessagesLoaderV2, 35 | behavior: Behavior, 36 | directiveName: TranslationDirectiveType 37 | ) => async (rawMessage: string | null) => { 38 | // Messages only know how to process non empty strings. 39 | if (rawMessage == null) { 40 | return rawMessage 41 | } 42 | 43 | const { content, context, from: maybeFrom } = parseTranslatableStringV2(rawMessage) 44 | const { binding, tenant } = ctx 45 | 46 | if (content == null) { 47 | throw new Error( 48 | `@${directiveName} directive needs a content to translate, but received ${JSON.stringify(rawMessage)}` 49 | ) 50 | } 51 | 52 | const from = maybeFrom || binding?.locale || tenant?.locale 53 | 54 | if (from == null) { 55 | throw new Error( 56 | `@${directiveName} directive needs a source language to translate from. You can do this by either setting ${'`ctx.vtex.tenant`'} variable, call this app with the header ${'`x-vtex-tenant`'} or format the string with the ${'`formatTranslatableStringV2`'} function with the ${'`from`'} option set` 57 | ) 58 | } 59 | 60 | return loader.load({ 61 | behavior, 62 | content, 63 | context, 64 | from, 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/authTokens.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../../../clients/IOClients' 2 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 3 | 4 | const JANUS_ENV_COOKIE_KEY = 'vtex-commerce-env' 5 | const VTEX_ID_COOKIE_KEY = 'VtexIdclientAutCookie' 6 | 7 | export async function authTokens < 8 | T extends IOClients, 9 | U extends RecorderState, 10 | V extends ParamsContext 11 | > (ctx: ServiceContext, next: () => Promise) { 12 | const { vtex: { account } } = ctx 13 | 14 | ctx.vtex.adminUserAuthToken = ctx.cookies.get(VTEX_ID_COOKIE_KEY) 15 | ctx.vtex.storeUserAuthToken = ctx.cookies.get(`${VTEX_ID_COOKIE_KEY}_${account}`) 16 | ctx.vtex.janusEnv = ctx.cookies.get(JANUS_ENV_COOKIE_KEY) 17 | 18 | await next() 19 | } 20 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/cancellationToken.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { IOClients } from '../../../../../clients/IOClients' 4 | import { cancellableMethods } from '../../../../../constants' 5 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 6 | 7 | export async function cancellationToken< 8 | T extends IOClients, 9 | U extends RecorderState, 10 | V extends ParamsContext 11 | >(ctx: ServiceContext, next: () => Promise) { 12 | if (cancellableMethods.has(ctx.method.toUpperCase())) { 13 | ctx.vtex.cancellation = { 14 | cancelable: true, 15 | cancelled: false, 16 | source: axios.CancelToken.source(), 17 | } 18 | } 19 | await next() 20 | } 21 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/clients.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClientsImplementation, 3 | IOClients, 4 | } from '../../../../../clients/IOClients' 5 | import { InstanceOptions } from '../../../../../HttpClient' 6 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 7 | 8 | export function clients< 9 | T extends IOClients, 10 | U extends RecorderState, 11 | V extends ParamsContext 12 | >(ClientsImpl: ClientsImplementation, clientOptions: Record) { 13 | return async function withClients(ctx: ServiceContext, next: () => Promise) { 14 | if (ctx.serverTiming){ 15 | ctx.vtex.serverTiming = ctx.serverTiming 16 | } 17 | ctx.clients = new ClientsImpl(clientOptions, ctx.vtex) 18 | await next() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/context.ts: -------------------------------------------------------------------------------- 1 | import { parse as qsParse } from 'querystring' 2 | 3 | import { IOClients } from '../../../../../clients/IOClients' 4 | import { HeaderKeys } from '../../../../../constants' 5 | import { prepareHandlerCtx } from '../../utils/context' 6 | import { 7 | ParamsContext, 8 | RecorderState, 9 | ServiceContext, 10 | ServiceRoute, 11 | } from './../../typings' 12 | 13 | export const createPvtContextMiddleware = ( 14 | routeId: string, 15 | { smartcache }: ServiceRoute 16 | ) => { 17 | return async function pvtContext< 18 | T extends IOClients, 19 | U extends RecorderState, 20 | V extends ParamsContext 21 | >(ctx: ServiceContext, next: () => Promise) { 22 | const { 23 | params, 24 | request: { header }, 25 | } = ctx 26 | ctx.vtex = { 27 | ...prepareHandlerCtx(header, ctx.tracing), 28 | ...(smartcache && { recorder: ctx.state.recorder }), 29 | route: { 30 | id: routeId, 31 | params, 32 | type: 'private', 33 | }, 34 | } 35 | await next() 36 | } 37 | } 38 | 39 | export const createPubContextMiddleware = ( 40 | routeId: string, 41 | { smartcache }: ServiceRoute 42 | ) => { 43 | return async function pubContext< 44 | T extends IOClients, 45 | U extends RecorderState, 46 | V extends ParamsContext 47 | >(ctx: ServiceContext, next: () => Promise) { 48 | const { 49 | request: { header }, 50 | } = ctx 51 | 52 | ctx.vtex = { 53 | ...prepareHandlerCtx(header, ctx.tracing), 54 | ...(smartcache && { recorder: ctx.state.recorder }), 55 | route: { 56 | declarer: header[HeaderKeys.COLOSSUS_ROUTE_DECLARER], 57 | id: routeId, 58 | params: qsParse(header[HeaderKeys.COLOSSUS_PARAMS]), 59 | type: 'public', 60 | }, 61 | } 62 | await next() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import TokenBucket from 'tokenbucket' 2 | import { TooManyRequestsError } from '../../../../../errors' 3 | import { ServiceContext } from '../../typings' 4 | import { createTokenBucket } from '../../utils/tokenBucket' 5 | 6 | const responseMessageConcurrent = 'Rate Exceeded: Too many requests in execution' 7 | const responseMessagePerMinute = 'Rate Exceeded: Too many requests per minute' 8 | 9 | function noopMiddleware(_: ServiceContext, next: () => Promise) { 10 | return next() 11 | } 12 | 13 | export function perMinuteRateLimiter(rateLimit?: number, globalLimiter?: TokenBucket) { 14 | if (!rateLimit && !globalLimiter) { 15 | return noopMiddleware 16 | } 17 | 18 | const tokenBucket: TokenBucket = createTokenBucket(rateLimit, globalLimiter) 19 | 20 | return function perMinuteRateMiddleware(ctx: ServiceContext, next: () => Promise) { 21 | if (!tokenBucket.removeTokensSync(1)) { 22 | throw new TooManyRequestsError(responseMessagePerMinute) 23 | } 24 | return next() 25 | } 26 | } 27 | 28 | export function concurrentRateLimiter(rateLimit?: number) { 29 | if (!rateLimit) { 30 | return noopMiddleware 31 | } 32 | let reqsInExecution = 0 33 | const maxRequests = rateLimit 34 | return async function concurrentRateMiddleware(ctx: ServiceContext, next: () => Promise) { 35 | if (reqsInExecution >= maxRequests) { 36 | throw new TooManyRequestsError(responseMessageConcurrent) 37 | } 38 | reqsInExecution++ 39 | try { 40 | await next() 41 | } finally { 42 | reqsInExecution-- 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/requestStats.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../../../clients/IOClients' 2 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 3 | 4 | export const cancelMessage = 'Request cancelled' 5 | 6 | class IncomingRequestStats { 7 | public aborted = 0 8 | public closed = 0 9 | public total = 0 10 | 11 | public get () { 12 | return { 13 | aborted: this.aborted, 14 | closed: this.closed, 15 | total: this.total, 16 | } 17 | } 18 | 19 | public clear () { 20 | this.aborted = 0 21 | this.closed = 0 22 | this.total = 0 23 | } 24 | } 25 | 26 | export const incomingRequestStats = new IncomingRequestStats() 27 | 28 | const requestClosed = () => { 29 | incomingRequestStats.closed++ 30 | } 31 | const requestAborted = < 32 | T extends IOClients, 33 | U extends RecorderState, 34 | V extends ParamsContext 35 | >(ctx: ServiceContext) => () => { 36 | incomingRequestStats.aborted++ 37 | 38 | if (ctx.vtex.cancellation && ctx.vtex.cancellation.cancelable) { 39 | ctx.vtex.cancellation.source.cancel(cancelMessage) 40 | ctx.vtex.cancellation.cancelled = true 41 | } 42 | } 43 | 44 | export async function trackIncomingRequestStats < 45 | T extends IOClients, 46 | U extends RecorderState, 47 | V extends ParamsContext 48 | > (ctx: ServiceContext, next: () => Promise) { 49 | ctx.req.on('close', requestClosed) 50 | ctx.req.on('aborted', requestAborted(ctx)) 51 | incomingRequestStats.total++ 52 | await next() 53 | } 54 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/setCookie.ts: -------------------------------------------------------------------------------- 1 | import { compose, find, head, isEmpty, map, reduce, split } from 'ramda' 2 | 3 | import { IOClients } from '../../../../../clients/IOClients' 4 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 5 | 6 | const BLACKLISTED_COOKIES = new Set(['checkout.vtex.com']) 7 | 8 | const warnMessage = (keys: string[]) => `Removing set-cookie from response since cache-control has as public scope. 9 | This can be a huge security risk. Please remove either the public scope or set-cookie from your response. 10 | Cookies dropped: 11 | 12 | ${keys.join('\n\t')} 13 | ` 14 | 15 | const findStr = (target: string, set: string[]) => find((a: string) => a.toLocaleLowerCase() === target, set) 16 | 17 | const findScopeInCacheControl = (cacheControl: string | undefined) => { 18 | const splitted = cacheControl && cacheControl.split(/\s*,\s*/g) 19 | const scopePublic = splitted && findStr('public', splitted) 20 | return scopePublic 21 | } 22 | 23 | const cookieKey = (cookie: string) => compose(head, split('='))(cookie) 24 | 25 | const indexCookieByKeys = (setCookie: string[]) => map( 26 | (cookie: string) => [cookieKey(cookie), cookie] as [string, string], 27 | setCookie 28 | ) 29 | 30 | interface CookieAccumulator { 31 | addedPayload: string[] 32 | droppedKeys: string[] 33 | } 34 | 35 | export async function removeSetCookie< 36 | T extends IOClients, 37 | U extends RecorderState, 38 | V extends ParamsContext 39 | > (ctx: ServiceContext, next: () => Promise) { 40 | const { vtex: { logger } } = ctx 41 | 42 | await next() 43 | 44 | const setCookie: string[] | undefined = ctx.response.headers['set-cookie'] 45 | if (!setCookie || isEmpty(setCookie)) { 46 | return 47 | } 48 | 49 | const cacheControl: string | undefined = ctx.response.headers['cache-control'] 50 | const scope = findScopeInCacheControl(cacheControl) 51 | if (scope === 'public') { 52 | const indexedCookies = indexCookieByKeys(setCookie) 53 | const cookies = reduce( 54 | (acc, [key, payload]) => { 55 | if (BLACKLISTED_COOKIES.has(key)) { 56 | acc.droppedKeys.push(key) 57 | } else { 58 | acc.addedPayload.push(payload) 59 | } 60 | return acc 61 | }, 62 | { 63 | addedPayload: [], 64 | droppedKeys: [], 65 | } as CookieAccumulator, 66 | indexedCookies 67 | ) 68 | 69 | if (cookies.droppedKeys.length > 0) { 70 | ctx.set('set-cookie', cookies.addedPayload) 71 | console.warn(warnMessage(cookies.droppedKeys)) 72 | logger!.warn({ 73 | cookieKeys: cookies.droppedKeys, 74 | message: 'Setting cookies in a public route!', 75 | }) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/settings.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto' 2 | import { join, pluck } from 'ramda' 3 | 4 | import { AppMetaInfo, Apps } from '../../../../../clients/infra/Apps' 5 | import { IOClients } from '../../../../../clients/IOClients' 6 | import { APP } from '../../../../../constants' 7 | import { RequestTracingConfig } from '../../../../../HttpClient' 8 | import { Assets } from './../../../../../clients/infra/Assets' 9 | import { appIdToAppAtMajor } from './../../../../../utils/app' 10 | import { 11 | ParamsContext, 12 | RecorderState, 13 | ServiceContext, 14 | } from './../../typings' 15 | 16 | const joinIds = join('') 17 | 18 | const dependsOnApp = (appAtMajor: string) => (a: AppMetaInfo) => { 19 | const [name, major] = appAtMajor.split('@') 20 | const majorInt = major.includes('.') ? major.split('.')[0] : major 21 | const version = a._resolvedDependencies[name] 22 | if (!version) { 23 | return false 24 | } 25 | 26 | const [depMajor] = version.split('.') 27 | return majorInt === depMajor 28 | } 29 | 30 | export const getFilteredDependencies = ( 31 | appAtMajor: string, 32 | dependencies: AppMetaInfo[] 33 | ): AppMetaInfo[] => { 34 | const depends = dependsOnApp(appAtMajor) 35 | return dependencies.filter(depends) 36 | } 37 | 38 | export const getDependenciesHash = (dependencies: AppMetaInfo[]): string => { 39 | const dependingApps = pluck('id', dependencies) 40 | return createHash('md5') 41 | .update(joinIds(dependingApps)) 42 | .digest('hex') 43 | } 44 | 45 | export const getDependenciesSettings = async (apps: Apps, assets: Assets, tracingConfig?: RequestTracingConfig) => { 46 | const appId = APP.ID 47 | const metaInfos = await apps.getAppsMetaInfos(undefined, undefined, tracingConfig) 48 | const appAtMajor = appIdToAppAtMajor(appId) 49 | 50 | return await assets.getSettings(metaInfos, appAtMajor, undefined, tracingConfig) 51 | } 52 | 53 | export const getServiceSettings = () => { 54 | return async function settingsContext< 55 | T extends IOClients, 56 | U extends RecorderState, 57 | V extends ParamsContext 58 | >(ctx: ServiceContext, next: () => Promise) { 59 | const { 60 | clients: { apps, assets }, 61 | } = ctx 62 | 63 | const rootSpan = ctx.tracing?.currentSpan 64 | const dependenciesSettings = await getDependenciesSettings(apps, assets, { tracing: { rootSpan } }) 65 | 66 | // TODO: for now returning all settings, but the ideia is to do merge 67 | ctx.vtex.settings = dependenciesSettings 68 | await next() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/timings.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | import { IOClients } from '../../../../../clients/IOClients' 4 | import { APP, LINKED, PID } from '../../../../../constants' 5 | import { statusLabel } from '../../../../../utils/status' 6 | import { 7 | formatTimingName, 8 | hrToMillis, 9 | reduceTimings, 10 | shrinkTimings, 11 | } from '../../../../../utils/time' 12 | import { 13 | IOContext, 14 | ParamsContext, 15 | RecorderState, 16 | ServiceContext, 17 | } from '../../typings' 18 | 19 | const APP_ELAPSED_TIME_LOCATOR = shrinkTimings(formatTimingName({ 20 | hopNumber: 0, 21 | source: process.env.VTEX_APP_NAME!, 22 | target: '', 23 | })) 24 | 25 | const pid = chalk.magenta('[' + PID + ']') 26 | const formatDate = (date: Date) => chalk.dim('[' + date.toISOString().split('T')[1] + ']') 27 | const formatStatus = (status: number) => status >= 500 ? chalk.red(status.toString()) : (status >=200 && status < 300 ? chalk.green(status.toString()) : status) 28 | const formatMillis = (millis: number) => millis >= 500 ? chalk.red(millis.toString()) : millis >= 200 ? chalk.yellow(millis.toString()) : chalk.green(millis.toString()) 29 | 30 | const log = < 31 | T extends IOClients, 32 | U extends RecorderState, 33 | V extends ParamsContext 34 | >( 35 | { vtex: { account, workspace, route: { id } }, path, method, status }: ServiceContext, 36 | millis: number 37 | ) => 38 | `${formatDate(new Date())}\t${pid}\t${account}/${workspace}:${id}\t${formatStatus(status)}\t${method}\t${path}\t${formatMillis(millis)} ms` 39 | 40 | const logBillingInfo = ( 41 | { account, workspace, production, route: { id, type } }: IOContext, 42 | millis: number 43 | ) => JSON.stringify({ 44 | '__VTEX_IO_BILLING': 'true', 45 | 'account': account, 46 | 'app': APP.ID, 47 | 'handler': id, 48 | 'isLink': LINKED, 49 | 'production': production, 50 | 'routeType': type === 'public' ? 'public_route' : 'private_route', 51 | 'timestamp': Date.now(), 52 | 'type': 'process-time', 53 | 'value': millis, 54 | 'vendor': APP.VENDOR, 55 | 'workspace': workspace, 56 | }) 57 | 58 | export async function timings < 59 | T extends IOClients, 60 | U extends RecorderState, 61 | V extends ParamsContext 62 | > (ctx: ServiceContext, next: () => Promise) { 63 | // Errors will be caught by the next middleware so we don't have to catch. 64 | await next() 65 | 66 | const { status: statusCode, vtex: { route: { id } }, timings: {total}, vtex } = ctx 67 | const totalMillis = hrToMillis(total) 68 | console.log(log(ctx, totalMillis)) 69 | console.log(logBillingInfo(vtex, totalMillis)) 70 | 71 | const status = statusLabel(statusCode) 72 | // Only batch successful responses so metrics don't consider errors 73 | metrics.batch(`http-handler-${id}`, status === 'success' ? total : undefined, { [status]: 1 }) 74 | } 75 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/middlewares/vary.ts: -------------------------------------------------------------------------------- 1 | import { IOClients } from '../../../../../clients/IOClients' 2 | import { 3 | HeaderKeys, 4 | VaryHeaders 5 | } from '../../../../../constants' 6 | import { ParamsContext, RecorderState, ServiceContext } from '../../typings' 7 | 8 | interface CachingStrategy { 9 | forbidden: VaryHeaders[] 10 | path: string 11 | vary: VaryHeaders[] 12 | } 13 | 14 | const cachingStrategies: CachingStrategy[] = [ 15 | { 16 | forbidden: [], 17 | path: '/_v/private/', 18 | vary: [HeaderKeys.SEGMENT, HeaderKeys.SESSION], 19 | }, 20 | { 21 | forbidden: [HeaderKeys.SEGMENT, HeaderKeys.SESSION], 22 | path: '/_v/public/', 23 | vary: [], 24 | }, 25 | { 26 | forbidden: [HeaderKeys.SESSION], 27 | path: '/_v/segment/', 28 | vary: [HeaderKeys.SEGMENT], 29 | }, 30 | ] 31 | 32 | const shouldVaryByHeader = ( 33 | ctx: ServiceContext, 34 | header: VaryHeaders, 35 | strategy?: CachingStrategy 36 | ) => { 37 | if (strategy && strategy.vary.includes(header)) { 38 | return true 39 | } 40 | if (process.env.DETERMINISTIC_VARY) { 41 | return false 42 | } 43 | return !!ctx.get(header) 44 | } 45 | 46 | export async function vary < 47 | T extends IOClients, 48 | U extends RecorderState, 49 | V extends ParamsContext 50 | > (ctx: ServiceContext, next: () => Promise) { 51 | const { method, path } = ctx 52 | const strategy = cachingStrategies.find((cachingStrategy) => path.indexOf(cachingStrategy.path) === 0) 53 | 54 | if (strategy) { 55 | strategy.forbidden.forEach((headerName) => { 56 | delete ctx.headers[headerName] 57 | }) 58 | } 59 | 60 | // We don't need to vary non GET requests, since they are never cached 61 | if (method.toUpperCase() !== 'GET') { 62 | await next() 63 | return 64 | } 65 | 66 | ctx.vary(HeaderKeys.LOCALE) 67 | 68 | if (shouldVaryByHeader(ctx, HeaderKeys.SEGMENT, strategy)) { 69 | ctx.vary(HeaderKeys.SEGMENT) 70 | } 71 | 72 | if (shouldVaryByHeader(ctx, HeaderKeys.SESSION, strategy)) { 73 | ctx.vary(HeaderKeys.SESSION) 74 | } 75 | 76 | await next() 77 | } 78 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/router.ts: -------------------------------------------------------------------------------- 1 | import { HeaderKeys } from '../../../../constants' 2 | import { LogLevel } from '../../../logger' 3 | import { HttpRoute, ServiceContext } from '../typings' 4 | import { logOnceToDevConsole } from './../../../logger/console' 5 | 6 | export const routerFromPublicHttpHandlers = (routes: Record) => { 7 | return async (ctx: ServiceContext, next: () => Promise) => { 8 | const routeId = ctx.get(HeaderKeys.COLOSSUS_ROUTE_ID) 9 | if (!routeId) { 10 | return next() 11 | } 12 | 13 | const handler = routes[routeId]?.handler 14 | if (!handler) { 15 | const msg = `Handler with id '${routeId}' not implemented.` 16 | ctx.status = 501 17 | ctx.body = msg 18 | logOnceToDevConsole(msg, LogLevel.Error) 19 | return 20 | } 21 | 22 | await handler(ctx, next) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/service/worker/runtime/http/routes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ACCOUNT, 3 | APP as APP_ENV, 4 | PUBLIC_ENDPOINT, 5 | WORKSPACE, 6 | } from '../../../../constants' 7 | import { ServiceJSON, ServiceRoute } from '../typings' 8 | 9 | interface PrivateRouteInfo { 10 | protocol?: 'http' | 'https' 11 | vendor: string, 12 | name: string, 13 | major: string | number, 14 | account: string, 15 | workspace: string, 16 | path?: string 17 | } 18 | 19 | export const formatPrivateRoute = ({protocol = 'https', vendor, name, major, account, workspace, path}: PrivateRouteInfo) => 20 | `${protocol}://app.io.vtex.com/${vendor}.${name}/v${major}/${account}/${workspace}${path || ''}` 21 | 22 | export const formatPublicRoute = ({workspace, account, endpoint, path}: {workspace: string, account: string, endpoint: string, path: string}) => 23 | `https://${workspace}--${account}.${endpoint}${path}` 24 | 25 | const getPath = ({ public: publicRoute, path }: ServiceRoute) => publicRoute 26 | ? formatPublicRoute({workspace: WORKSPACE, account: ACCOUNT, endpoint: PUBLIC_ENDPOINT, path}) 27 | : formatPrivateRoute({protocol: 'https', vendor: APP_ENV.VENDOR, name: APP_ENV.NAME, major: APP_ENV.MAJOR, account: ACCOUNT, workspace: WORKSPACE, path}) 28 | 29 | export const logAvailableRoutes = (service: ServiceJSON) => { 30 | const available = Object.values(service.routes || {}).reduce( 31 | (acc, route) => `${acc}\n${getPath(route)}`, 32 | 'Available service routes:' 33 | ) 34 | console.info(available) 35 | } 36 | -------------------------------------------------------------------------------- /src/service/worker/runtime/method.ts: -------------------------------------------------------------------------------- 1 | import { mapObjIndexed } from 'ramda' 2 | 3 | import { IOClients } from '../../../clients/IOClients' 4 | import { 5 | ParamsContext, 6 | RecorderState, 7 | RouteHandler, 8 | ServiceContext, 9 | } from './typings' 10 | import { compose } from './utils/compose' 11 | 12 | type HTTPMethods = 13 | | 'GET' 14 | | 'HEAD' 15 | | 'POST' 16 | | 'PUT' 17 | | 'DELETE' 18 | | 'CONNECT' 19 | | 'OPTIONS' 20 | | 'TRACE' 21 | | 'PATCH' 22 | | 'DEFAULT' 23 | 24 | type MethodOptions< 25 | ClientsT extends IOClients = IOClients, 26 | StateT extends RecorderState = RecorderState, 27 | CustomT extends ParamsContext = ParamsContext 28 | > = Partial< 29 | Record< 30 | HTTPMethods, 31 | | RouteHandler 32 | | Array> 33 | > 34 | > 35 | 36 | const TEN_SECONDS_S = 10 37 | 38 | function methodNotAllowed< 39 | T extends IOClients, 40 | U extends RecorderState, 41 | V extends ParamsContext 42 | >(ctx: ServiceContext) { 43 | ctx.status = 405 44 | ctx.set('cache-control', `public, max-age=${TEN_SECONDS_S}`) 45 | } 46 | 47 | export function method< 48 | T extends IOClients, 49 | U extends RecorderState, 50 | V extends ParamsContext 51 | >(options: MethodOptions) { 52 | const handlers = mapObjIndexed( 53 | handler => compose(Array.isArray(handler) ? handler : [handler]), 54 | options as Record< 55 | string, 56 | | RouteHandler 57 | | Array> 58 | > 59 | ) 60 | 61 | const inner = async function forMethod ( 62 | ctx: ServiceContext, 63 | next: () => Promise 64 | ) { 65 | const verb = ctx.method.toUpperCase() 66 | const handler = handlers[verb] || handlers.DEFAULT || methodNotAllowed 67 | 68 | if (handler) { 69 | await handler(ctx) 70 | } 71 | 72 | await next() 73 | } 74 | 75 | inner.skipTimer = true 76 | 77 | return inner 78 | } 79 | -------------------------------------------------------------------------------- /src/service/worker/runtime/statusTrack.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster' 2 | 3 | import { ACCOUNT, APP, LINKED, PRODUCTION, WORKSPACE } from '../../../constants' 4 | import { ServiceContext } from './typings' 5 | 6 | export type StatusTrack = () => EnvMetric[] 7 | 8 | export interface NamedMetric { 9 | name: string, 10 | [key: string]: any 11 | } 12 | 13 | export interface EnvMetric extends NamedMetric { 14 | production: boolean, 15 | } 16 | 17 | const BROADCAST_STATUS_TRACK = 'broadcastStatusTrack' 18 | const STATUS_TRACK = 'statusTrack' 19 | 20 | export const isStatusTrack = (message: any): message is typeof STATUS_TRACK => 21 | message === STATUS_TRACK 22 | 23 | export const isStatusTrackBroadcast = (message: any): message is typeof BROADCAST_STATUS_TRACK => 24 | message === BROADCAST_STATUS_TRACK 25 | 26 | export const statusTrackHandler = async (ctx: ServiceContext) => { 27 | ctx.tracing?.currentSpan?.setOperationName('builtin:status-track') 28 | if (!LINKED) { 29 | process.send?.(BROADCAST_STATUS_TRACK) 30 | } 31 | ctx.body = [] 32 | return 33 | } 34 | 35 | export const trackStatus = () => { 36 | global.metrics.statusTrack().forEach(status => { 37 | logStatus(status) 38 | }) 39 | } 40 | 41 | export const broadcastStatusTrack = () => Object.values(cluster.workers).forEach( 42 | worker => worker?.send(STATUS_TRACK) 43 | ) 44 | 45 | const logStatus = (status: EnvMetric) => console.log(JSON.stringify({ 46 | __VTEX_IO_LOG: true, 47 | account: ACCOUNT, 48 | app: APP.ID, 49 | isLink: LINKED, 50 | pid: process.pid, 51 | production: PRODUCTION, 52 | status, 53 | type: 'metric/status', 54 | workspace: WORKSPACE, 55 | })) 56 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/compose.ts: -------------------------------------------------------------------------------- 1 | import koaCompose from 'koa-compose' 2 | import { pipe } from 'ramda' 3 | 4 | import { IOClients } from '../../../../clients/IOClients' 5 | import { cancel } from '../../../../utils/cancel' 6 | import { timer, timerForEvents } from '../../../../utils/time' 7 | import { 8 | EventHandler, 9 | ParamsContext, 10 | RecorderState, 11 | RouteHandler, 12 | } from '../typings' 13 | 14 | export const compose = (middlewares: Array>) => 15 | koaCompose(middlewares.map(pipe(timer, cancel))) 16 | 17 | export const composeForEvents = (middlewares: Array>) => 18 | koaCompose(middlewares.map(timerForEvents)) 19 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import uuid from 'uuid/v4' 3 | import { 4 | HeaderKeys, 5 | REGION, 6 | } from '../../../../constants' 7 | import { UserLandTracer } from '../../../../tracing/UserLandTracer' 8 | import { parseTenantHeaderValue } from '../../../../utils/tenant' 9 | import { Logger } from '../../../logger' 10 | import { IOContext, TracingContext } from '../typings' 11 | import { parseBindingHeaderValue } from './../../../../utils/binding' 12 | 13 | type HandlerContext = Omit 14 | 15 | const getPlatform = (account: string): string => { 16 | return account.startsWith('gc-') ? 'gocommerce' : 'vtex' 17 | } 18 | 19 | export const prepareHandlerCtx = (header: Context['request']['header'], tracingContext?: TracingContext): HandlerContext => { 20 | const partialContext = { 21 | account: header[HeaderKeys.ACCOUNT], 22 | authToken: header[HeaderKeys.CREDENTIAL], 23 | binding: header[HeaderKeys.BINDING] ? parseBindingHeaderValue(header[HeaderKeys.BINDING]) : undefined, 24 | host: header[HeaderKeys.FORWARDED_HOST], 25 | locale: header[HeaderKeys.LOCALE], 26 | operationId: header[HeaderKeys.OPERATION_ID] || uuid(), 27 | platform: header[HeaderKeys.PLATFORM] || getPlatform(header[HeaderKeys.ACCOUNT]), 28 | product: header[HeaderKeys.PRODUCT], 29 | production: header[HeaderKeys.WORKSPACE_IS_PRODUCTION]?.toLowerCase() === 'true' || false, 30 | region: REGION, 31 | requestId: header[HeaderKeys.REQUEST_ID], 32 | segmentToken: header[HeaderKeys.SEGMENT], 33 | sessionToken: header[HeaderKeys.SESSION], 34 | tenant: header[HeaderKeys.TENANT] ? parseTenantHeaderValue(header[HeaderKeys.TENANT]) : undefined, 35 | tracer: new UserLandTracer(tracingContext?.tracer!, tracingContext?.currentSpan), 36 | userAgent: process.env.VTEX_APP_ID || '', 37 | workspace: header[HeaderKeys.WORKSPACE], 38 | } 39 | 40 | return { 41 | ...partialContext, 42 | logger: new Logger(partialContext), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/diff.ts: -------------------------------------------------------------------------------- 1 | export interface Snapshot { 2 | usage: NodeJS.CpuUsage, 3 | time: number, 4 | } 5 | 6 | interface CpuUsage { 7 | user: number, 8 | system: number, 9 | } 10 | 11 | export function cpuSnapshot (): Snapshot { 12 | return { 13 | time: microtime(), 14 | usage: process.cpuUsage(), 15 | } 16 | } 17 | 18 | export function snapshotDiff (curr: Snapshot, last: Snapshot): CpuUsage { 19 | const timeDiff = curr.time - last.time 20 | return { 21 | system: (curr.usage.system - last.usage.system) / timeDiff, 22 | user: (curr.usage.user - last.usage.user) / timeDiff, 23 | } 24 | } 25 | 26 | function microtime (): number { 27 | const hrTime = process.hrtime() 28 | return hrTime[0] * 1000000 + hrTime[1] / 1000 29 | } 30 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/recorder.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import { trim } from 'ramda' 3 | import { HeaderKeys } from './../../../../constants' 4 | 5 | const HEADERS = [HeaderKeys.META, HeaderKeys.META_BUCKET] 6 | 7 | export class Recorder { 8 | // tslint:disable-next-line: variable-name 9 | private _record: Record> 10 | 11 | constructor() { 12 | this._record = HEADERS.reduce( 13 | (acc, headerName) => { 14 | acc[headerName] = new Set() 15 | return acc 16 | }, 17 | {} as Record> 18 | ) 19 | } 20 | 21 | public clear () { 22 | HEADERS.forEach(headerName => this._record[headerName].clear()) 23 | } 24 | 25 | public record (headers?: Record) { 26 | HEADERS.forEach( 27 | headerName => { 28 | const h = headers?.[headerName] 29 | if (h) { 30 | h.split(',').map(trim).forEach(hh => this._record[headerName].add(hh)) 31 | } 32 | } 33 | ) 34 | } 35 | 36 | public flush (ctx: Context) { 37 | HEADERS.forEach( 38 | headerName => { 39 | const newValueSet = new Set(this._record[headerName]) 40 | const currentValue = ctx.response.get(headerName) as string | string[] 41 | const parsedCurrentValue = typeof currentValue === 'string' ? currentValue.split(',') : currentValue 42 | parsedCurrentValue.forEach(cur => newValueSet.add(trim(cur))) 43 | const deduped = Array.from(newValueSet).filter(x => !!x) 44 | if (deduped.length > 0) { 45 | ctx.set(headerName, deduped) 46 | } 47 | } 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/toArray.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (x: T | T[]): T[] => Array.isArray(x) ? x : [x] 2 | -------------------------------------------------------------------------------- /src/service/worker/runtime/utils/tokenBucket.ts: -------------------------------------------------------------------------------- 1 | import TokenBucket from 'tokenbucket' 2 | 3 | export function createTokenBucket(rateLimit?: number, globalRateTokenBucket?: TokenBucket){ 4 | return rateLimit ? new TokenBucket({ 5 | interval: 'minute', 6 | parentBucket: globalRateTokenBucket, 7 | size: Math.ceil(rateLimit / 2.0), 8 | spread: true, 9 | tokensToAddPerInterval: rateLimit, 10 | }) : globalRateTokenBucket! 11 | } -------------------------------------------------------------------------------- /src/tracing/LogEvents.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | /** 4 | * This file has all events logged by the runtime's distributed tracing 5 | * instrumentation. In the code you would see something like: 6 | * ``` 7 | * span.log({ event: HttpEvents.CACHE_KEY_CREATE }) 8 | * ``` 9 | */ 10 | 11 | export const enum HttpLogEvents { 12 | /** Event holding the cache key created */ 13 | CACHE_KEY_CREATE = 'cache-key-created', 14 | 15 | /** Event representing that the memoization cache has just saved a response */ 16 | MEMOIZATION_CACHE_SAVED = 'memoization-cache-saved', 17 | 18 | /** Event representing that the memoization cache has just saved a error response */ 19 | MEMOIZATION_CACHE_SAVED_ERROR = 'memoization-cache-saved-error', 20 | 21 | /** Event holding information on a local cache hit that just happened */ 22 | LOCAL_CACHE_HIT_INFO = 'local-cache-hit-info', 23 | 24 | /** Event holding information on the cache config that will be used to decide whether or not to update the local cache */ 25 | CACHE_CONFIG = 'cache-config', 26 | 27 | /** Event informing the decision to not update or save to local cache */ 28 | NO_LOCAL_CACHE_SAVE = 'no-local-cache-save', 29 | 30 | /** Event holding information on the local cache save that just happened */ 31 | LOCAL_CACHE_SAVED = 'local-cache-saved', 32 | 33 | /** A request that is retryable just failed - this event will hold info on the retry that may happen */ 34 | SETUP_REQUEST_RETRY = 'setup-request-retry', 35 | } 36 | 37 | export const enum RuntimeLogEvents { 38 | /** Event representing that userland middlewares are about to start */ 39 | USER_MIDDLEWARES_START = 'user-middlewares-start', 40 | 41 | /** Event representing that userland middlewares just finished */ 42 | USER_MIDDLEWARES_FINISH = 'user-middlewares-finish', 43 | } 44 | -------------------------------------------------------------------------------- /src/tracing/Tags.ts: -------------------------------------------------------------------------------- 1 | import { Tags as OpentracingTags } from 'opentracing' 2 | export { OpentracingTags } 3 | 4 | /* tslint:disable:object-literal-sort-keys */ 5 | 6 | /** 7 | * The following tags are process tags - defined when the tracer is instantiated. 8 | * Those will annotate all spans created 9 | */ 10 | export const enum AppTags { 11 | /** Boolean indicating if the app is linked or not */ 12 | VTEX_APP_LINKED = 'app.linked', 13 | 14 | /** The value of the NODE_ENV environment variable */ 15 | VTEX_APP_NODE_ENV = 'app.node_env', 16 | 17 | /** The @vtex/api version used (e.g. '6.1.2') */ 18 | VTEX_APP_NODE_VTEX_API_VERSION = 'app.node_vtex_api_version', 19 | 20 | /** The value of the VTEX_PRODUCTION environment variable - whether the app is in a production workspace */ 21 | VTEX_APP_PRODUCTION = 'app.production', 22 | 23 | /** The value of the VTEX_REGION environment variable (e.g. 'aws-us-east-1') */ 24 | VTEX_APP_REGION = 'app.region', 25 | 26 | /** The app version (e.g. '1.2.0') */ 27 | VTEX_APP_VERSION = 'app.version', 28 | 29 | /** The workspace in which the app is installed or linked */ 30 | VTEX_APP_WORKSPACE = 'app.workspace', 31 | } 32 | 33 | /** The following tags annotate the entrypoint span on incoming requests */ 34 | export const enum VTEXIncomingRequestTags { 35 | /** The account being served by the request */ 36 | VTEX_ACCOUNT = 'vtex.incoming.account', 37 | 38 | /** The request id header value */ 39 | VTEX_REQUEST_ID = 'vtex.request_id', 40 | 41 | /** The workspace being served by the request */ 42 | VTEX_WORKSPACE = 'vtex.incoming.workspace', 43 | } 44 | 45 | export const enum CustomHttpTags { 46 | HTTP_PATH = 'http.path', 47 | 48 | /** Set to true when the client had no response, probably meaning that there was a client error */ 49 | HTTP_NO_RESPONSE = 'http.no_response', 50 | 51 | /** The HTTP client name (e.g. Apps, Registry, Router) */ 52 | HTTP_CLIENT_NAME = 'http.client.name', 53 | 54 | /** 55 | * CACHE_ENABLED tags indicate if the Cache strategy is enabled 56 | * for the specific request. 57 | */ 58 | HTTP_MEMOIZATION_CACHE_ENABLED = 'http.cache.memoization.enabled', 59 | HTTP_DISK_CACHE_ENABLED = 'http.cache.disk.enabled', 60 | HTTP_MEMORY_CACHE_ENABLED = 'http.cache.memory.enabled', 61 | 62 | /** 63 | * CACHE_RESULT tags indicate the result for that cache strategy 64 | * (HIT or MISS for example). Since there may be many layers 65 | * of cache a ENABLED flag for a strategy may be 'true', but 66 | * the RESULT for that strategy may not be present. 67 | */ 68 | HTTP_MEMORY_CACHE_RESULT = 'http.cache.memory', 69 | HTTP_MEMOIZATION_CACHE_RESULT = 'http.cache.memoization', 70 | HTTP_DISK_CACHE_RESULT = 'http.cache.disk', 71 | HTTP_ROUTER_CACHE_RESULT = 'http.cache.router', 72 | 73 | HTTP_RETRY_ERROR_CODE = 'http.retry.error.code', 74 | HTTP_RETRY_COUNT = 'http.retry.count', 75 | } 76 | 77 | export const UserlandTags = { 78 | ...OpentracingTags, 79 | } 80 | -------------------------------------------------------------------------------- /src/tracing/UserLandTracer.ts: -------------------------------------------------------------------------------- 1 | import { FORMAT_HTTP_HEADERS, Span, SpanContext, SpanOptions, Tracer } from 'opentracing' 2 | import { TracerSingleton } from '../service/tracing/TracerSingleton' 3 | import { getTraceInfo } from './utils' 4 | 5 | export interface IUserLandTracer { 6 | traceId?: string 7 | isTraceSampled: boolean 8 | startSpan: Tracer['startSpan'] 9 | inject: Tracer['inject'] 10 | fallbackSpanContext: () => SpanContext | undefined 11 | } 12 | 13 | export const createTracingContextFromCarrier = ( 14 | newSpanName: string, 15 | carrier: Record 16 | ): { span: Span; tracer: IUserLandTracer } => { 17 | const tracer = TracerSingleton.getTracer() 18 | const rootSpan = tracer.extract(FORMAT_HTTP_HEADERS, carrier) as SpanContext | undefined 19 | if (rootSpan == null) { 20 | throw new Error('Missing span context data on carrier') 21 | } 22 | 23 | const span = tracer.startSpan(newSpanName, { childOf: rootSpan }) 24 | const userlandTracer = new UserLandTracer(tracer, span) 25 | userlandTracer.lockFallbackSpan() 26 | return { span, tracer: userlandTracer } 27 | } 28 | 29 | export class UserLandTracer implements IUserLandTracer { 30 | private tracer: Tracer 31 | private fallbackSpan: Span | undefined 32 | private fallbackSpanLock: boolean 33 | 34 | // tslint:disable-next-line 35 | private _isSampled: boolean 36 | // tslint:disable-next-line 37 | private _traceId?: string 38 | 39 | constructor(tracer: Tracer, fallbackSpan?: Span) { 40 | this.tracer = tracer 41 | this.fallbackSpan = fallbackSpan 42 | this.fallbackSpanLock = false 43 | 44 | const { traceId, isSampled } = getTraceInfo(fallbackSpan) 45 | this._traceId = traceId 46 | this._isSampled = isSampled 47 | } 48 | 49 | get traceId() { 50 | return this._traceId 51 | } 52 | 53 | get isTraceSampled() { 54 | return this._isSampled 55 | } 56 | 57 | public lockFallbackSpan() { 58 | this.fallbackSpanLock = true 59 | } 60 | 61 | public setFallbackSpan(newSpan?: Span) { 62 | if (this.fallbackSpanLock) { 63 | throw new Error(`FallbackSpan is locked, can't change it`) 64 | } 65 | 66 | this.fallbackSpan = newSpan 67 | } 68 | 69 | public startSpan(name: string, options?: SpanOptions) { 70 | if (options && (options.childOf || options.references?.length)) { 71 | return this.tracer.startSpan(name, options) 72 | } 73 | 74 | return this.tracer.startSpan(name, { ...options, childOf: this.fallbackSpan }) 75 | } 76 | 77 | public inject(spanContext: SpanContext | Span, format: string, carrier: any) { 78 | return this.tracer.inject(spanContext, format, carrier) 79 | } 80 | 81 | public fallbackSpanContext(): SpanContext | undefined { 82 | return this.fallbackSpan?.context() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/tracing/index.ts: -------------------------------------------------------------------------------- 1 | import { FORMAT_HTTP_HEADERS, Span } from 'opentracing' 2 | 3 | export { ErrorKindsBase as ErrorKinds } from '@vtex/node-error-report' 4 | export { ErrorReport } from './errorReporting/ErrorReport' 5 | export { createSpanReference } from './spanReference/createSpanReference' 6 | export { SpanReferenceTypes } from './spanReference/SpanReferenceTypes' 7 | export { UserlandTags as TracingTags } from './Tags' 8 | export { createTracingContextFromCarrier, IUserLandTracer } from './UserLandTracer' 9 | export { getTraceInfo, TraceInfo } from './utils' 10 | export { Span, FORMAT_HTTP_HEADERS } 11 | -------------------------------------------------------------------------------- /src/tracing/spanReference/SpanReferenceTypes.ts: -------------------------------------------------------------------------------- 1 | export enum SpanReferenceTypes { 2 | CHILD_OF = 0, 3 | FOLLOWS_FROM = 1, 4 | } -------------------------------------------------------------------------------- /src/tracing/spanReference/createSpanReference.ts: -------------------------------------------------------------------------------- 1 | import { Reference, REFERENCE_CHILD_OF, REFERENCE_FOLLOWS_FROM, Span } from 'opentracing' 2 | import { SpanReferenceTypes } from './SpanReferenceTypes' 3 | 4 | export function createSpanReference(span: Span, type: SpanReferenceTypes) { 5 | return new Reference( 6 | type === SpanReferenceTypes.CHILD_OF ? REFERENCE_CHILD_OF : REFERENCE_FOLLOWS_FROM, 7 | span.context() 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/typings/tar-fs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tar-fs' { 2 | import {Writable} from 'stream' 3 | export function extract (targetPath: string): Writable 4 | } 5 | -------------------------------------------------------------------------------- /src/typings/tokenbucket.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tokenbucket' { 2 | import Promise = require('bluebird') 3 | import redis = require('redis') 4 | 5 | declare interface TokenBucketOptions { 6 | size? : number = 1 7 | tokensToAddPerInterval? : number = 1 8 | interval? : number | string = 1000 9 | lastFill? : number 10 | tokensLeft? : number 11 | spread? : boolean = false 12 | maxWait? : number | string 13 | parentBucket? : TokenBucket 14 | redis? : { 15 | bucketName? : string 16 | redisClient? : redisClient 17 | redisClientConfig: { 18 | port? : number = 6379 19 | host? : string = '127.0.0.1' 20 | unixSocket? : string 21 | options? : string 22 | } 23 | } 24 | } 25 | declare class TokenBucket { 26 | public lastFill: number 27 | 28 | public tokensToAddPerInterval: number 29 | 30 | public tokensLeft: number 31 | 32 | constructor(config?: TokenBucketOptions); 33 | 34 | public removeTokens(tokensToRemove?: number): Promise 35 | 36 | public removeTokensSync(tokensToRemove?: number): boolean 37 | 38 | public save(): Promise 39 | 40 | public loadSaved(): Promise 41 | 42 | } 43 | 44 | export = TokenBucket 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/app.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver' 2 | 3 | import { AppMetaInfo } from '../clients/infra/Apps' 4 | 5 | // Note: The name that composes the part of the appId that precedes the 6 | // character '@' includes the name given to the app and the vendor name. 7 | 8 | export const removeBuild = (id: string): string => id.split('+')[0] 9 | 10 | export const removeVersionFromAppId = (appId: string): string => appId.split('@')[0] 11 | 12 | export const extractVersionFromAppId = (appId: string): string => appId.split('@').slice(-1)[0] 13 | 14 | export const transformToLinkedLocator = (appId: string) => appId.replace(/\+build.*$/, '+linked') 15 | 16 | export const formatLocator = (name: string, versionAndBuild: string): string => 17 | `${name}@${removeBuild(versionAndBuild)}` 18 | 19 | export const isLinkedApp = (app: AppMetaInfo) => app.id.includes('+build') 20 | 21 | export const parseAppId = (appId: string): ParsedLocator => { 22 | const name = removeVersionFromAppId(appId) 23 | const version = extractVersionFromAppId(appId) 24 | const splittedVersion = version.split('+') 25 | return { 26 | build: splittedVersion[1], 27 | locator: formatLocator(name, version), 28 | name, 29 | version: splittedVersion[0], 30 | } 31 | } 32 | 33 | export const formatAppId = ({ locator, build }: ParsedLocator) => (build ? `${locator}+${build}` : locator) 34 | 35 | export const satisfies = (appId: string, version: string): boolean => { 36 | const { version: appVer } = parseAppId(appId) 37 | return semver.satisfies(appVer, version) 38 | } 39 | 40 | export const versionToMajor = (version: string): string => version.split('.')[0] 41 | 42 | export const versionToMajorRange = (version: string): string => `${versionToMajor(version)}.x` 43 | 44 | export const formatMajorLocator = (name: string, version: string): string => { 45 | const majorRange = versionToMajorRange(version) 46 | return `${name}@${majorRange}` 47 | } 48 | 49 | export const appIdToAppAtMajor = (appId: string): string => { 50 | const { name, version } = parseAppId(appId) 51 | const majorRange = versionToMajorRange(version) 52 | return `${name}@${majorRange}` 53 | } 54 | 55 | // SemVer regex from https://github.com/sindresorhus/semver-regex 56 | const APP_ID_REGEX = /^[\w\-]+\.[\w\-]+@(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z\-]+(?:\.[\da-z\-]+)*)?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?$/ 57 | 58 | export const isValidAppIdOrLocator = (appId: string): boolean => { 59 | return APP_ID_REGEX.test(appId) 60 | } 61 | 62 | export const sameMajor = (v1: string, v2: string) => versionToMajor(v1) === versionToMajor(v2) 63 | 64 | export const majorEqualAndGreaterThan = (v1: string, v2: string) => sameMajor(v1, v2) && semver.gt(v1, v2) 65 | 66 | export interface ParsedLocator { 67 | name: string 68 | version: string 69 | locator: string 70 | build?: string 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/appsStaleIfError.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { head } from 'ramda' 3 | import * as semver from 'semver' 4 | 5 | import { AppMetaInfo, Apps } from '..' 6 | import { CacheLayer } from '../caches' 7 | import { Logger } from '../service/logger' 8 | 9 | export const getMetaInfoKey = (account: string, workspace: string) => `${account}-${workspace}-meta-infos` 10 | 11 | const hashMD5 = (text: string) => 12 | crypto 13 | .createHash('md5') 14 | .update(text) 15 | .digest('hex') 16 | 17 | export const updateMetaInfoCache = async (cacheStorage: CacheLayer, account: string, workspace: string, dependencies: AppMetaInfo[], logger: Logger) => { 18 | if (workspace !== 'master') { 19 | return 20 | } 21 | const key = getMetaInfoKey(account, workspace) 22 | const hash = hashMD5(dependencies.toString()) 23 | 24 | try { 25 | const storedDependencies = await cacheStorage.get(key) || '' 26 | if (hash !== hashMD5(storedDependencies.toString())) { 27 | await cacheStorage.set(key, dependencies) 28 | } 29 | } catch (error) { 30 | logger.error({error, message: 'Apps disk cache update failed'}) 31 | } 32 | return 33 | } 34 | 35 | const getFallbackKey = (appName: string, major: string) => `${appName}@${major}` 36 | 37 | export const saveVersion = async (app: string, cacheStorage: CacheLayer) => { 38 | const [appName, version] = app.split('@') 39 | const major = head(version.split('.')) || '' 40 | 41 | const fallbackKey = getFallbackKey(appName, major) 42 | if (cacheStorage.has(fallbackKey)) { 43 | const savedVersion = await cacheStorage.get(fallbackKey) 44 | if (savedVersion && (version === `${major}.x` || semver.gt(version, savedVersion))) { 45 | await cacheStorage.set(fallbackKey, version) 46 | } 47 | } else { 48 | await cacheStorage.set(fallbackKey, version) 49 | } 50 | } 51 | 52 | export const getFallbackFile = async (app: string, path: string, cacheStorage: CacheLayer, apps: Apps): Promise<{data: Buffer, headers: any }> => { 53 | const [appName, version] = app.split('@') 54 | const major = head(version.split('.')) || '' 55 | const fallbackKey = getFallbackKey(appName, major) 56 | 57 | const fallbackVersion = await cacheStorage.get(fallbackKey) 58 | if (fallbackVersion) { 59 | const appFallbackVersion = `${appName}@${fallbackVersion}` 60 | return apps.getAppFile(appFallbackVersion, path) 61 | } 62 | throw Error('Fallback version was not found') 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/utils/billingOptions.ts: -------------------------------------------------------------------------------- 1 | export const isFreeBillingOptions = (billingOptions: BillingOptions): boolean => 2 | billingOptions.type === BILLING_TYPE.FREE 3 | export interface BillingOptions { 4 | type: BILLING_TYPE 5 | support: Support 6 | availableCountries: string[] 7 | plans?: Plan[] 8 | } 9 | 10 | export enum BILLING_TYPE { 11 | FREE = 'free', 12 | BILLABLE = 'billable', 13 | SPONSORED = 'sponsored', 14 | } 15 | 16 | export interface PriceMetric { 17 | id: string 18 | ranges: Range[] 19 | customUrl: string 20 | } 21 | 22 | export interface Range { 23 | exclusiveFrom: number 24 | inclusiveTo?: number 25 | multiplier: number 26 | } 27 | 28 | export interface Plan { 29 | id: string 30 | currency: string 31 | price: Price 32 | } 33 | 34 | export interface Price { 35 | subscription?: number 36 | metrics?: PriceMetric[] 37 | } 38 | 39 | export interface Support { 40 | email: string 41 | url?: string 42 | phone?: string 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/binding.ts: -------------------------------------------------------------------------------- 1 | export interface BindingHeader { 2 | id?: string 3 | rootPath?: string 4 | locale: string 5 | currency?: string 6 | } 7 | 8 | enum BindingHeaderFormat { 9 | webframework0='v0+webframework', 10 | kuberouter0='v0+kuberouter', 11 | } 12 | 13 | export const formatBindingHeaderValue = ( 14 | binding: BindingHeader, 15 | format: BindingHeaderFormat = BindingHeaderFormat.webframework0 16 | ): string => { 17 | if (format === BindingHeaderFormat.webframework0) { 18 | const jsonString = JSON.stringify(binding) 19 | return Buffer.from(jsonString, 'utf8').toString('base64') 20 | } 21 | 22 | if (format === BindingHeaderFormat.kuberouter0) { 23 | return [ 24 | '0', 25 | binding.id || '', 26 | binding.rootPath || '', 27 | binding.locale || '', 28 | binding.currency || '', 29 | ].join(',') 30 | } 31 | 32 | throw new Error(`Unkown binding format: ${format}`) 33 | } 34 | 35 | export const parseBindingHeaderValue = (value: string): BindingHeader => { 36 | if (value[0] === '0' && value[1] === ',') { 37 | // v0+kuberouter 38 | const [, id, rootPath, locale, currency] = value.split(',') 39 | return { 40 | currency: currency || undefined, 41 | id: id || undefined, 42 | locale, 43 | rootPath: rootPath || undefined, 44 | } 45 | } 46 | 47 | // v0+webframework 48 | const jsonString = Buffer.from(value, 'base64').toString('utf8') 49 | return JSON.parse(jsonString) 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/buildFullPath.ts: -------------------------------------------------------------------------------- 1 | // This module is part of the Axios library. 2 | // It is used to build a full URL by combining a base URL with a relative URL. 3 | 4 | // It is originally not intended to be used directly, but rather as a utility function within the Axios library. 5 | // Since Axios 1.0, this function is not exported by default, and the team does not recommend using it directly, 6 | // as it may not be available in future versions. 7 | 8 | // The function takes a base URL and a relative URL as input, and returns the full URL. 9 | // It handles both absolute and relative URLs, and ensures that the resulting URL is properly formatted. 10 | // It is used internally by Axios to construct the full URL for HTTP requests. 11 | 12 | /** 13 | * Creates a new URL by combining the baseURL with the requestedURL, 14 | * only when the requestedURL is not already an absolute URL. 15 | * If the requestURL is absolute, this function returns the requestedURL untouched. 16 | * 17 | * @param {string} baseURL The base URL 18 | * @param {string} requestedURL Absolute or relative URL to combine 19 | * 20 | * @returns {string} The combined full path 21 | */ 22 | export default function(baseURL?: string, requestedURL?: string, allowAbsoluteUrls?: boolean): string | undefined { 23 | let isRelativeUrl = !isAbsoluteURL(requestedURL); 24 | if (baseURL && (isRelativeUrl || allowAbsoluteUrls == false)) { 25 | return combineURLs(baseURL, requestedURL); 26 | } 27 | return requestedURL; 28 | } 29 | 30 | /** 31 | * Determines whether the specified URL is absolute 32 | * 33 | * @param {string} url The URL to test 34 | * 35 | * @returns {boolean} True if the specified URL is absolute, otherwise false 36 | */ 37 | function isAbsoluteURL(url?: string): boolean { 38 | // A URL is considered absolute if it begins with "://" or "//" (protocol-relative URL). 39 | // RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed 40 | // by any combination of letters, digits, plus, period, or hyphen. 41 | return !!url && /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url); 42 | } 43 | 44 | /** 45 | * Creates a new URL by combining the specified URLs 46 | * 47 | * @param {string} baseURL The base URL 48 | * @param {string} relativeURL The relative URL 49 | * 50 | * @returns {string} The combined URL 51 | */ 52 | function combineURLs(baseURL?: string, relativeURL?: string): string | undefined { 53 | return relativeURL && baseURL 54 | ? baseURL.replace(/\/?\/$/, '') + '/' + relativeURL.replace(/^\/+/, '') 55 | : baseURL; 56 | } -------------------------------------------------------------------------------- /src/utils/cancel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParamsContext, 3 | RecorderState, 4 | RouteHandler, 5 | ServiceContext, 6 | } from '../service/worker/runtime/typings' 7 | import { IOClients } from './../clients/IOClients' 8 | 9 | export function cancel< 10 | T extends IOClients, 11 | U extends RecorderState, 12 | V extends ParamsContext 13 | >(middleware: RouteHandler): RouteHandler { 14 | return async (ctx: ServiceContext, next: () => Promise) => { 15 | if(ctx.vtex?.cancellation?.cancelled) { 16 | return 17 | } 18 | await middleware(ctx, next) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/conflicts.mock.ts: -------------------------------------------------------------------------------- 1 | function jsonToBase64(json: object) { 2 | return Buffer.from(JSON.stringify(json)).toString('base64') 3 | } 4 | 5 | export function getMockConflicts(path: string, base: object, mine: object, master: object) { 6 | return { 7 | data: [ 8 | { 9 | base: { 10 | content: jsonToBase64(base), 11 | mimeType: 'application/json', 12 | }, 13 | master: { 14 | content: jsonToBase64(master), 15 | mimeType: 'application/json', 16 | }, 17 | mine: { 18 | content: jsonToBase64(mine), 19 | mimeType: 'application/json', 20 | }, 21 | path, 22 | }, 23 | ], 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/domain.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from 'ramda' 2 | 3 | const VTEX_PUBLIC_ENDPOINT = process.env.VTEX_PUBLIC_ENDPOINT || 'myvtex.com' 4 | 5 | export const PUBLIC_DOMAINS = uniq([VTEX_PUBLIC_ENDPOINT, 'myvtex.com', 'mygocommerce.com', 'vtexsmb.com']) 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app' 2 | export * from './error' 3 | export * from './retry' 4 | export * from './time' 5 | export * from './billingOptions' 6 | export * from './domain' 7 | export * from './tenant' 8 | export * from './binding' 9 | export * from './MineWinsConflictsResolver' 10 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function cleanJson(json: {[k: string]: any}, targetFields: string[]) { 2 | for (const key of Object.keys(json)) { 3 | let deleted = false 4 | for (const field of targetFields) { 5 | if (key.toLowerCase() === field) { 6 | delete json[key] 7 | deleted = true 8 | } 9 | } 10 | 11 | if (!deleted && json[key] && typeof json[key] === 'object') { 12 | json[key] = cleanJson(json[key], targetFields) 13 | } 14 | } 15 | 16 | return json 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import { cleanJson } from './json' 2 | 3 | const SENSITIVE_FIELDS = [ 4 | 'auth', 5 | 'authorization', 6 | 'authtoken', 7 | 'cookie', 8 | 'proxy-authorization', 9 | 'rawheaders', 10 | 'token', 11 | 'vtexidclientautcookie', 12 | 'x-vtex-api-appkey', 13 | 'x-vtex-api-apptoken', 14 | 'x-vtex-credential', 15 | 'x-vtex-session', 16 | ] 17 | 18 | export const cleanLog = (log: any) => { 19 | return cleanJson(log, SENSITIVE_FIELDS) 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/message.ts: -------------------------------------------------------------------------------- 1 | export const PROVIDER_SPACER = '::' 2 | 3 | export interface IOMessage { 4 | id: string 5 | content?: string 6 | description?: string 7 | from?: string 8 | to?: string 9 | behavior?: string 10 | } 11 | 12 | export const providerFromMessage = (message: IOMessage) => { 13 | const {provider} = parseIOMessageId(message) 14 | return provider || 'unknown' 15 | } 16 | 17 | export const parseIOMessageId = ({id}: IOMessage) => { 18 | const splitted = id.split(PROVIDER_SPACER) 19 | if (splitted.length === 2) { 20 | return { 21 | locator: splitted[1], 22 | provider: splitted[0], 23 | } 24 | } 25 | return { 26 | locator: splitted[0], 27 | } 28 | } 29 | 30 | export const removeProviderFromId = (message: IOMessage) => ({ 31 | ...message, 32 | id: parseIOMessageId(message).locator, 33 | }) 34 | -------------------------------------------------------------------------------- /src/utils/renameBy.ts: -------------------------------------------------------------------------------- 1 | import { compose, fromPairs, map, toPairs } from 'ramda' 2 | 3 | export const renameBy = ( 4 | fn: (key: string) => string, 5 | obj: Record 6 | ): Record => 7 | compose, Array<[string, K]>, Array<[string, K]>, Record>( 8 | fromPairs, 9 | map(([key, val]) => [fn(key), val] as [string, K]), 10 | toPairs 11 | )(obj) 12 | -------------------------------------------------------------------------------- /src/utils/retry.ts: -------------------------------------------------------------------------------- 1 | import {isNetworkOrIdempotentRequestError, isSafeRequestError} from 'axios-retry' 2 | 3 | export const TIMEOUT_CODE = 'ProxyTimeout' 4 | 5 | const printLabel = (e: any, message: string) => { 6 | if (!e || !e.config || !e.config.label) { 7 | return 8 | } 9 | console.warn(e.config.label, message) 10 | } 11 | 12 | export const isNetworkErrorOrRouterTimeout = (e: any) => { 13 | if (isNetworkOrIdempotentRequestError(e)) { 14 | printLabel(e, 'Retry from network error') 15 | return true 16 | } 17 | 18 | if (e && isSafeRequestError(e) && e.response && e.response.data && e.response.data.code === TIMEOUT_CODE) { 19 | printLabel(e, 'Retry from timeout') 20 | return true 21 | } 22 | 23 | return false 24 | } 25 | 26 | // Retry on timeout from our end 27 | export const isAbortedOrNetworkErrorOrRouterTimeout = (e: any) => { 28 | if (e && e.code === 'ECONNABORTED') { 29 | printLabel(e, 'Retry from abort') 30 | return true 31 | } 32 | return isNetworkErrorOrRouterTimeout(e) 33 | } 34 | 35 | export {isNetworkOrIdempotentRequestError, exponentialDelay} from 'axios-retry' 36 | -------------------------------------------------------------------------------- /src/utils/status.ts: -------------------------------------------------------------------------------- 1 | export const statusLabel = (status: number) => { 2 | if (status >= 500) { 3 | return 'error' 4 | } 5 | if (status >= 200 && status < 300) { 6 | return 'success' 7 | } 8 | return `${Math.floor(status/100)}xx` 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/tenant.ts: -------------------------------------------------------------------------------- 1 | export interface TenantHeader { 2 | locale: string 3 | } 4 | 5 | export const formatTenantHeaderValue = (tenant: TenantHeader): string => tenant.locale 6 | 7 | export const parseTenantHeaderValue = (value: string): TenantHeader => ({ 8 | locale: value, 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/throwOnGraphQLErrors.ts: -------------------------------------------------------------------------------- 1 | import CustomGraphQLError from '../errors/customGraphQLError' 2 | import { GraphQLResponse } from '../HttpClient/GraphQLClient' 3 | 4 | export function throwOnGraphQLErrors (message: string) { 5 | return function maybeGraphQLResponse (response: GraphQLResponse) { 6 | if (response && response.errors && response.errors.length > 0) { 7 | throw new CustomGraphQLError(message, response.errors) 8 | } 9 | 10 | return response 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "types": ["node", "jest"], 5 | "typeRoots": ["node_modules/@types"], 6 | "target": "es2019", 7 | "lib": [ 8 | "es2019", 9 | "esnext.asynciterable" 10 | ], 11 | "module": "commonjs", 12 | "strict": true, 13 | "declaration": true, 14 | "outDir": "lib", 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "baseUrl": "./", 18 | "paths": { 19 | "axios": ["src/axios.d.ts"] 20 | } 21 | }, 22 | "include": ["src/**/*.ts"], 23 | "exclude": ["**/__tests__", "**/*.test.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-vtex" 3 | } 4 | --------------------------------------------------------------------------------