diff --git a/src/meshArtifact.js b/src/meshArtifact.js index 5ab34d6..f85f403 100644 --- a/src/meshArtifact.js +++ b/src/meshArtifact.js @@ -70,8 +70,8 @@ function resolveComposerAsTypeScriptModule(data) { * ``` */ function resolveComposerAsJavaScriptModule(data) { - return data.replace( - /"composer":\s*"((?!https:\/\/)[^#]+)#([^"]+)"/, + return data.replaceAll( + /"composer":\s*"((?!https:\/\/)[^#]+)#([^"]+)"/g, `"module": __importStar(require("$1")), "fn": "$2"`, ); } diff --git a/src/server.js b/src/server.js index 1c65ce4..7e9e437 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,7 @@ import { getMesh } from '@graphql-mesh/runtime'; +import { KvStateApiImpl } from './state'; + const { getCorsOptions } = require('./cors'); const { createYoga } = require('graphql-yoga'); const { GraphQLError } = require('graphql/error'); @@ -46,13 +48,14 @@ async function getBuiltMesh(meshArtifacts, meshConfig) { } const buildServer = async (loggerInstance, env, meshArtifacts, meshConfig) => { - const { Secret: secret } = env; + const { SECRETS } = env; const tenantMesh = await getBuiltMesh(meshArtifacts, meshConfig); - const meshSecrets = loadMeshSecrets(loggerInstance, secret); + const meshSecrets = loadMeshSecrets(loggerInstance, SECRETS); return await buildYogaServer(env, tenantMesh, meshConfig, meshSecrets); }; async function buildYogaServer(env, tenantMesh, meshConfig, meshSecrets) { + const stateApi = new KvStateApiImpl(env, meshConfig); const secretsProxy = new Proxy(meshSecrets, getSecretsHandler); return createYoga({ plugins: tenantMesh.plugins, @@ -61,6 +64,7 @@ async function buildYogaServer(env, tenantMesh, meshConfig, meshSecrets) { context: initialContext => ({ ...initialContext, secrets: secretsProxy, + state: stateApi, }), maskedErrors: { maskError: maskError, diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..54cfd58 --- /dev/null +++ b/src/state.js @@ -0,0 +1,155 @@ +import { GraphQLError } from 'graphql/error'; + +/** + * Whether string is empty. + * @param value {string | null | undefined} String value. + */ +const isEmptyString = value => { + return value === '' || value === null || value === undefined; +}; + +/** + * Get byte length of a string. + * @param value {string} String value. + */ +const getByteLength = value => { + return new TextEncoder().encode(value).length; +}; + +/** + * Abstract class for state API implementations. + */ +class StateApiAbstract { + #env; + + constructor(env) { + this.#env = env; + } + + getEnv() { + return this.#env; + } + + /** + * Validate a key. + * @param key {string} Key to validate. + */ + validateKey(key) { + if (isEmptyString(key)) { + throw new Error(`Invalid key: ${key}`); + } + const keyBytes = getByteLength(key); + const env = this.getEnv(); + if (keyBytes > env.MAX_STATE_KEY_SIZE_BYTES) { + throw new Error( + `Key ${key} exceeds maximum key size of ${env.MAX_STATE_KEY_SIZE_BYTES}. Received ${keyBytes} B.`, + ); + } + } + + /** + * Validate a value + * @param key {string} Key. + * @param value {string} Value to validate. + */ + validateValue(key, value) { + if (isEmptyString(value)) { + throw new Error(`Invalid value for key: ${key}`); + } + const valueBytes = getByteLength(value); + const env = this.getEnv(); + if (valueBytes > env.MAX_STATE_VALUE_SIZE_BYTES) { + throw new Error( + `Key ${key} value exceeds maximum key size of ${env.MAX_STATE_VALUE_SIZE_BYTES}. Received ${valueBytes} B.`, + ); + } + } + + /** + * Get the TTL in milliseconds based on the provided config or environment defaults. + * @param config {{ ttl?: number } | undefined} Configuration object that may contain a TTL value. + */ + getTtl(config) { + const env = this.getEnv(); + // Default to maximum ttl if not provided + let useTtl = config?.ttl || env.MAX_STATE_TTL_SECONDS; + // Set TTL to max when over maximum TTL + if (useTtl > env.MAX_STATE_TTL_SECONDS) { + useTtl = env.MAX_STATE_TTL_SECONDS; + } + // Set TTL to min seconds when under minimum TTL + if (useTtl < env.MIN_STATE_TTL_SECONDS) { + useTtl = env.MIN_STATE_TTL_SECONDS; + } + return useTtl; + } +} + +/** + * State API implementation using Cloudflare's KV as the backing store. KV is a distributed key-value store that allows + * for storing and retrieving key-value pairs across different worker instances with eventual consistency. + */ +class KvStateApiImpl extends StateApiAbstract { + meshConfig; + + constructor(env, meshConfig) { + super(env); + this.meshConfig = meshConfig; + } + + /** + * Ensure that the KV namespace is configured in the environment. + * @throws {GraphQLError} when the KV namespace is not configured. + */ + ensureKvConfigured() { + if (!this.getEnv().MESH_KV_NAMESPACE || !this.meshConfig?.state?.enabled) { + throw new GraphQLError( + 'Context state is not configured for this mesh. Please check your mesh configuration and try again.', + { + extensions: { + code: 'ERROR_CONTEXT_STATE_NOT_CONFIGURED', + }, + }, + ); + } + } + + /** + * Get a value by key. + * @param key {key} Key to retrieve. + * @return {Promise} + */ + async get(key) { + this.ensureKvConfigured(); + this.validateKey(key); + return this.getEnv().MESH_KV_NAMESPACE.get(key); + } + + /** + * Put a key-value pair with optional TTL. + * @param key {string} Key to store. + * @param value {string} Value to store. + * @param config {{ ttl?: number } | undefined} Optional configuration object that may contain a TTL value in seconds. + * @return {Promise} + */ + async put(key, value, config) { + this.ensureKvConfigured(); + this.validateKey(key); + this.validateValue(key, value); + const ttl = this.getTtl(config); + return this.getEnv().MESH_KV_NAMESPACE.put(key, value, { expirationTtl: ttl }); + } + + /** + * Delete a key-value pair. + * @param key {string} Key to delete. + * @return {Promise} + */ + async delete(key) { + this.ensureKvConfigured(); + this.validateKey(key); + return this.getEnv().MESH_KV_NAMESPACE.delete(key); + } +} + +module.exports = { KvStateApiImpl }; diff --git a/src/templates/gitignore b/src/templates/gitignore index aa15a7b..52cdab7 100644 --- a/src/templates/gitignore +++ b/src/templates/gitignore @@ -1,5 +1,13 @@ +# Dependencies node_modules + +# Temporary files +.wrangler +.mesh +tenantFiles +tempfiles mesh-artifact meshes -tempfiles -*.txt + +# Template files from @adobe/aio-cli-plugin-api-mesh +wrangler.toml diff --git a/src/templates/wrangler.toml b/src/templates/wrangler.toml index f73ae11..0180614 100644 --- a/src/templates/wrangler.toml +++ b/src/templates/wrangler.toml @@ -1,7 +1,6 @@ # Tenant worker core definition. For platform workers metadata configs are used to set bindings. name = "tenant-worker-core" compatibility_date = "2024-06-03" -node_compat = false find_additional_modules = true base_dir = ".mesh" @@ -9,6 +8,13 @@ rules = [ { type = "CommonJS", globs = ["tenantFiles/**/*.js"] } ] +kv_namespaces = [ + { binding = "MESH_KV_NAMESPACE", id = "local-kv" } +] -[[migrations]] -tag = "v1" +[vars] +# State configuration +MAX_STATE_KEY_SIZE_BYTES="512" # 512 bytes +MAX_STATE_VALUE_SIZE_BYTES="1048576" # 1 MiB +MIN_STATE_TTL_SECONDS="60" # 1 minute +MAX_STATE_TTL_SECONDS="604800" # 7 days diff --git a/src/utils.js b/src/utils.js index 547f7c8..861c205 100644 --- a/src/utils.js +++ b/src/utils.js @@ -227,6 +227,38 @@ function getFilesInMeshConfig(data, meshConfigName) { filesList.push(filename); } } + + if (plugin.hooks.afterAll) { + const composer = plugin.hooks.afterAll.composer; + if (composer && !fileURLRegex.test(composer)) { + const [filename] = composer.split('#'); + filesList.push(filename); + } + } + + if (plugin.hooks.beforeSource) { + Object.values(plugin.hooks.beforeSource).forEach(beforeSourceHooks => { + beforeSourceHooks?.forEach(hook => { + const composer = hook.composer; + if (composer && !fileURLRegex.test(composer)) { + const [filename] = composer.split('#'); + filesList.push(filename); + } + }); + }); + } + + if (plugin.hooks.afterSource) { + Object.values(plugin.hooks.afterSource).forEach(afterSourceHooks => { + afterSourceHooks?.forEach(hook => { + const composer = hook.composer; + if (composer && !fileURLRegex.test(composer)) { + const [filename] = composer.split('#'); + filesList.push(filename); + } + }); + }); + } } }); diff --git a/src/wranglerCli.js b/src/wranglerCli.js index 3244aca..b092247 100644 --- a/src/wranglerCli.js +++ b/src/wranglerCli.js @@ -2,6 +2,7 @@ const { spawn } = require('child_process'); const { readSecretsFile } = require('./serverUtils'); const packageData = require('../package.json'); const { join } = require('node:path'); +const fs = require('node:fs'); /** * Starts the wrangler dev server @@ -13,7 +14,10 @@ const { join } = require('node:path'); const start = (command, port, debug, inspectPort) => { const wranglerPackageNumber = packageData.dependencies.wrangler; const wranglerVersion = `wrangler@${wranglerPackageNumber.replace(/^[\^~]/, '')}`; - const wranglerToml = join(__dirname, '..', 'wrangler.toml'); + // Project wrangler.toml must be used to avoid issues w/ Wrangler. Ensure it is up to date. + const wranglerTomlTemplate = join(__dirname, '..', 'wrangler.toml'); + const wranglerToml = join(process.cwd(), 'wrangler.toml'); + fs.cpSync(wranglerTomlTemplate, wranglerToml); const meshDir = '.mesh'; const secrets = readSecretsFile(meshDir); const entrypoint = join(__dirname, 'worker.js'); @@ -29,7 +33,7 @@ const start = (command, port, debug, inspectPort) => { '--show-interactive-dev-session', 'false', '--var', - `Secret:${JSON.stringify(secrets)}`, + `SECRETS:${JSON.stringify(secrets)}`, '--port', port, '--inspector-port', diff --git a/wrangler.toml b/wrangler.toml index dc0e05a..0180614 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,6 +8,13 @@ rules = [ { type = "CommonJS", globs = ["tenantFiles/**/*.js"] } ] +kv_namespaces = [ + { binding = "MESH_KV_NAMESPACE", id = "local-kv" } +] -[[migrations]] -tag = "v1" +[vars] +# State configuration +MAX_STATE_KEY_SIZE_BYTES="512" # 512 bytes +MAX_STATE_VALUE_SIZE_BYTES="1048576" # 1 MiB +MIN_STATE_TTL_SECONDS="60" # 1 minute +MAX_STATE_TTL_SECONDS="604800" # 7 days diff --git a/yarn.lock b/yarn.lock index 035e24c..f71124b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2378,9 +2378,9 @@ tslib "^2.4.0" "@graphql-tools/utils@^10.8.0": - version "10.8.6" - resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.8.6.tgz#69ef29e408a27919108b2b2227fe8b465acf9e5c" - integrity sha512-Alc9Vyg0oOsGhRapfL3xvqh1zV8nKoFUdtLhXX7Ki4nClaIJXckrA86j+uxEuG3ic6j4jlM1nvcWXRn/71AVLQ== + version "10.9.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-10.9.1.tgz#ff4067256f2080db0c66f4475858f29a8da65ecf" + integrity sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw== dependencies: "@graphql-typed-document-node/core" "^3.1.1" "@whatwg-node/promise-helpers" "^1.0.0" @@ -5696,9 +5696,9 @@ expect@^29.7.0: jest-util "^29.7.0" exsolve@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.4.tgz#7de5c75af82ecd15998328fbf5f2295883be3a39" - integrity sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw== + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== external-editor@^3.0.3: version "3.1.0" @@ -10315,9 +10315,9 @@ ua-parser-js@^1.0.35: integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== ufo@^1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" - integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== + version "1.6.1" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== uglify-js@^3.1.4: version "3.17.4"