Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/kind-coats-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hive': minor
---

Add AWS Lambda CDN Artifact Handler.
1 change: 1 addition & 0 deletions .github/workflows/build-and-dockerize.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ jobs:
files:
packages/services/broker-worker/dist/index.worker.mjs
packages/services/cdn-worker/dist/index.worker.mjs
packages/services/cdn-worker/dist/index.lambda.mjs
dest: ${{ inputs.imageTag }}.zip

- name: upload artifact
Expand Down
7 changes: 7 additions & 0 deletions deployment/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as pulumi from '@pulumi/pulumi';
import { deployApp } from './services/app';
import { deployAWSArtifactsLambdaFunction } from './services/aws-artifacts-lambda-function';
import { deployCFBroker } from './services/cf-broker';
import { deployCFCDN } from './services/cf-cdn';
import { deployClickhouse } from './services/clickhouse';
Expand Down Expand Up @@ -89,6 +90,11 @@ const cdn = deployCFCDN({
environment,
});

const lambdaFunction = deployAWSArtifactsLambdaFunction({
s3Mirror,
environment,
});

const broker = deployCFBroker({
environment,
sentry,
Expand Down Expand Up @@ -346,3 +352,4 @@ export const webhooksApiServiceId = webhooks.service.id;
export const appId = app.deployment.id;
export const otelCollectorId = otelCollector.deployment.id;
export const publicIp = proxy.get()!.status.loadBalancer.ingress[0].ip;
export const awsLambdaArtifactsFunctionUrl = lambdaFunction;
1 change: 1 addition & 0 deletions deployment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"dependencies": {
"@lbrlabs/pulumi-grafana": "0.1.0",
"@manypkg/get-packages": "2.2.2",
"@pulumi/aws": "7.12.0",
"@pulumi/cloudflare": "4.16.0",
"@pulumi/command": "1.0.1",
"@pulumi/kubernetes": "4.23.0",
Expand Down
74 changes: 74 additions & 0 deletions deployment/services/aws-artifacts-lambda-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
import { Environment } from './environment';
import { S3 } from './s3';

export function deployAWSArtifactsLambdaFunction(args: {
environment: Environment;
/** Note: We run this mirror only on the AWS S3 Bucket on purpose. */
s3Mirror: S3;
}) {
const lambdaRole = new aws.iam.Role('awsLambdaArtifactsHandlerRole', {
assumeRolePolicy: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { Service: 'lambda.amazonaws.com' },
Action: 'sts:AssumeRole',
},
],
},
});

new aws.iam.RolePolicyAttachment('lambdaBasicExecution', {
role: lambdaRole.name,
policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});

const awsLambdaArtifactsHandler = new aws.lambda.Function('awsLambdaArtifactsHandler', {
name: `hive-artifacts-handler-${args.environment.envName}`,
runtime: aws.lambda.Runtime.NodeJS22dX,
handler: 'index.handler',
packageType: 'Zip',
architectures: ['arm64'],
code: new pulumi.asset.AssetArchive({
'index.mjs': new pulumi.asset.StringAsset(
readFileSync(
process.env.AWS_LAMBDA_ARTIFACT_PATH ||
resolve(__dirname, '../../packages/services/cdn-worker/dist/index.lambda.mjs'),
'utf-8',
),
),
}),
role: lambdaRole.arn,
region: 'us-east-2',
environment: {
variables: {
// This could be done better with secrets manager etc.
// But it adds a lot of complexity and overhead and runtime logic
AWS_S3_ENDPOINT: args.s3Mirror.secret.raw.endpoint,
AWS_S3_BUCKET_NAME: args.s3Mirror.secret.raw.bucket,
AWS_S3_ACCESS_KEY_ID: args.s3Mirror.secret.raw.accessKeyId,
AWS_S3_ACCESSS_KEY_SECRET: args.s3Mirror.secret.raw.secretAccessKey,
Comment on lines +50 to +55
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anyone insists I can do the extra step of storing these in the AWS Secret Manager and reading it within the lambda during function startup from there instead.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be completely fine. And secret manager is expensive

},
},
// 448mb
memorySize: 448,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lambda memory impacts CPU also.
It doesn't seem like your lambda is doing anything too CPU intensive but it may be worth fine-tuning this in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some different tests, and 448mb had the best trade-off in terms of cost and runtime. See https://www.notion.so/theguildoss/AWS-Lambda-CDN-Handler-Fallback-2b0b6b71848a80968ce8ff8d756adf89?source=copy_link#2b0b6b71848a8001be26f7cf15136032 for more context

// 10 seconds
timeout: 10,
});

const example = new aws.lambda.FunctionUrl('awsLambdaArtifactsHandlerUrl', {
functionName: awsLambdaArtifactsHandler.arn,
authorizationType: 'NONE',
invokeMode: 'BUFFERED',
region: 'us-east-2',
});

return {
functionUrl: example.functionUrl,
};
}
2 changes: 1 addition & 1 deletion packages/libraries/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"dependencies": {
"@graphql-hive/signal": "^2.0.0",
"@graphql-tools/utils": "^10.0.0",
"@whatwg-node/fetch": "^0.10.6",
"@whatwg-node/fetch": "^0.10.13",
"async-retry": "^1.3.3",
"events": "^3.3.0",
"js-md5": "0.8.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/libraries/yoga/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@graphql-yoga/plugin-disable-introspection": "2.7.0",
"@graphql-yoga/plugin-graphql-sse": "3.7.0",
"@graphql-yoga/plugin-response-cache": "3.9.0",
"@whatwg-node/fetch": "0.10.6",
"@whatwg-node/fetch": "0.10.13",
"graphql-ws": "5.16.1",
"graphql-yoga": "5.13.3",
"nock": "14.0.10",
Expand Down
2 changes: 1 addition & 1 deletion packages/migrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@types/bcryptjs": "2.4.6",
"@types/node": "22.10.5",
"@types/pg": "8.11.10",
"@whatwg-node/fetch": "0.10.6",
"@whatwg-node/fetch": "0.10.13",
"bcryptjs": "2.4.3",
"copyfiles": "2.4.1",
"date-fns": "4.1.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/services/broker-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"devDependencies": {
"@cloudflare/workers-types": "4.20250913.0",
"@types/service-worker-mock": "2.0.4",
"@whatwg-node/server": "0.10.5",
"@whatwg-node/server": "0.10.17",
"esbuild": "0.25.9",
"itty-router": "4.2.2",
"toucan-js": "4.1.0",
Expand Down
69 changes: 69 additions & 0 deletions packages/services/cdn-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,72 @@ CF_BASE_PATH=http://localhost:4010
```

This way, your local Hive instance will be able to send schema to the locally running CDN Worker.

### Deployment

There is two variants being built that can be deployed independently.

- `Cloudflare Worker`: `dist/index.worker.mjs`
- `AWS Lambda`: `dist/index.lambda.mjs`

#### Cloudflare Worker

THe documentation is work in progress and will be improved in the future.

```
type Env = {
S3_ENDPOINT: string;
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_BUCKET_NAME: string;
S3_SESSION_TOKEN?: string;

S3_MIRROR_ENDPOINT: string;
S3_MIRROR_ACCESS_KEY_ID: string;
S3_MIRROR_SECRET_ACCESS_KEY: string;
S3_MIRROR_BUCKET_NAME: string;
S3_MIRROR_SESSION_TOKEN?: string;

SENTRY_DSN: string;
/**
* Name of the environment, e.g. staging, production
*/
SENTRY_ENVIRONMENT: string;
/**
* Id of the release
*/
SENTRY_RELEASE: string;
/**
* Worker's Analytics Engines
*/
USAGE_ANALYTICS: AnalyticsEngine;
ERROR_ANALYTICS: AnalyticsEngine;
RESPONSE_ANALYTICS: AnalyticsEngine;
R2_ANALYTICS: AnalyticsEngine;
S3_ANALYTICS: AnalyticsEngine;
KEY_VALIDATION_ANALYTICS: AnalyticsEngine;
/**
* Base URL of the KV storage, used to fetch the schema from the KV storage.
* If not provided, the schema will be fetched from default KV storage value.
*
* @default https://key-cache.graphql-hive.com
*/
KV_STORAGE_BASE_URL?: string;
};
```

#### AWS Lambda

**Runtime**: Node.js 22.x

| Name | Required | Description | Example Value |
| --------------------------- | -------- | ------------------------- | ----------------------- |
| `AWS_S3_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` |
| `AWS_S3_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` |
| `AWS_S3_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` |
| `AWS_S3_ACCESSS_KEY_SECRET` | **Yes** | The S3 secret access key. | `minioadmin` |

All other configuration options available for Cloudflare Workers are currently not supported.

We recommend deploying the function to AWS Lambda, create a AWS Lambda Function invocation URL and
then add the function as origin to CloudFront.
18 changes: 18 additions & 0 deletions packages/services/cdn-worker/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ console.log('🚀 Building CDN Worker...');
const __dirname = dirname(fileURLToPath(import.meta.url));
const nodeOutputPath = `${__dirname}/dist/index.nodejs.js`;
const workerOutputPath = `${__dirname}/dist/index.worker.mjs`;
const lambdaOutputPath = `${__dirname}/dist/index.lambda.mjs`;

await Promise.all([
// Build for integration tests, and expect it to run on NodeJS
Expand Down Expand Up @@ -38,4 +39,21 @@ await Promise.all([
console.log(`✅ Built for CloudFlare Worker: "${workerOutputPath}"`);
return result;
}),
build({
entryPoints: [`${__dirname}/src/index-lambda.ts`],
bundle: true,
platform: 'node',
target: 'node22',
format: 'esm',
minify: false,
sourcemap: true,
outfile: lambdaOutputPath,
treeShaking: true,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
}).then(result => {
console.log(`✅ Built for AWS Lambda: "${lambdaOutputPath}"`);
return result;
}),
]);
2 changes: 1 addition & 1 deletion packages/services/cdn-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"devDependencies": {
"@cloudflare/workers-types": "4.20250913.0",
"@types/service-worker-mock": "2.0.4",
"@whatwg-node/server": "0.10.5",
"@whatwg-node/server": "0.10.17",
"bcryptjs": "2.4.3",
"dotenv": "16.4.7",
"esbuild": "0.25.9",
Expand Down
102 changes: 102 additions & 0 deletions packages/services/cdn-worker/src/index-lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { APIGatewayProxyEventV2, APIGatewayProxyResult, Context } from 'aws-lambda';
import { z } from 'zod';
import { createServerAdapter } from '@whatwg-node/server';
import { createArtifactRequestHandler } from './artifact-handler';
import { ArtifactStorageReader } from './artifact-storage-reader';
import { AwsClient } from './aws';
import { createIsAppDeploymentActive } from './is-app-deployment-active';
import { createIsKeyValid } from './key-validation';

const env = z
.object({
AWS_S3_ACCESS_KEY_ID: z.string(),
AWS_S3_ACCESSS_KEY_SECRET: z.string(),
AWS_S3_ENDPOINT: z.string(),
AWS_S3_BUCKET_NAME: z.string(),
})
.parse((globalThis as any).process.env);

const s3 = {
client: new AwsClient({
accessKeyId: env.AWS_S3_ACCESS_KEY_ID,
secretAccessKey: env.AWS_S3_ACCESSS_KEY_SECRET,
service: 's3',
}),
endpoint: env.AWS_S3_ENDPOINT,
bucketName: env.AWS_S3_BUCKET_NAME,
};

const s3Mirror = null;

const artifactStorageReader = new ArtifactStorageReader(s3, s3Mirror, null, null);

const artifactHandler = createArtifactRequestHandler({
isKeyValid: createIsKeyValid({
artifactStorageReader,
analytics: null,
breadcrumb(message: string) {
console.log(message);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this logging?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it gives some context in case there is an exception being thrown in the cloudwatch logs (e.g. what step are we in). breadcrumb is basically a logger.debug. 😄 And we introduced it at some point to figure out what is going wrong in Cloudflare Workers. We could refactor the whole code to actually use more "clean" logging, but that is definetly out of scope here.

},
getCache: null,
waitUntil: null,
captureException() {},
}),
artifactStorageReader,
isAppDeploymentActive: createIsAppDeploymentActive({
artifactStorageReader,
getCache: null,
waitUntil: null,
}),
});

const artifactRouteHandler = createServerAdapter(artifactHandler as any);

export async function handler(
event: APIGatewayProxyEventV2,
lambdaContext: Context,
): Promise<APIGatewayProxyResult> {
console.log(event.requestContext.http.method, event.rawPath);
const url = new URL(event.rawPath, 'http://localhost');
if (event.queryStringParameters != null) {
for (const name in event.queryStringParameters) {
const value = event.queryStringParameters[name];
if (value != null) {
url.searchParams.set(name, value);
}
}
}

const response = await artifactRouteHandler.fetch(
url,
{
method: event.requestContext.http.method,
headers: event.headers as HeadersInit,
body: undefined,
},
{
event,
lambdaContext,
},
);

if (!response) {
return {
statusCode: 404,
body: '',
isBase64Encoded: false,
};
}

const responseHeaders: Record<string, string> = {};

response.headers.forEach((value, name) => {
responseHeaders[name] = value;
});

return {
statusCode: response.status,
headers: responseHeaders,
body: await response.text(),
isBase64Encoded: false,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@apollo/composition": "2.10.4",
"@apollo/federation-internals": "2.9.3",
"@graphql-hive/external-composition": "workspace:*",
"@whatwg-node/server": "0.10.5",
"@whatwg-node/server": "0.10.17",
"dotenv": "16.4.7",
"graphql": "16.9.0",
"lru-cache": "^7.17.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/services/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@theguild/federation-composition": "0.20.2",
"@trpc/client": "10.45.2",
"@trpc/server": "10.45.2",
"@whatwg-node/server": "0.10.5",
"@whatwg-node/server": "0.10.17",
"dotenv": "16.4.7",
"fastify": "4.29.1",
"got": "14.4.7",
Expand Down
Loading
Loading