Skip to content
This repository has been archived by the owner on Oct 18, 2024. It is now read-only.

Commit

Permalink
feat: ✨ restore deployment feature parity (#99)
Browse files Browse the repository at this point in the history
Co-authored-by: Aponia <[email protected]>
  • Loading branch information
ecxyzzy and ap0nia authored Oct 15, 2023
1 parent 9b65441 commit f7f7db9
Show file tree
Hide file tree
Showing 15 changed files with 400 additions and 228 deletions.
4 changes: 2 additions & 2 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const config = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
project: ["./tsconfig.json", "./apps/docs/tsconfig.json", "./apps/docs/cdk/tsconfig.json"],
project: ["./tsconfig.json", "./apps/docs/tsconfig.json"],
sourceType: "module",
},
plugins: ["import", "@typescript-eslint"],
Expand Down Expand Up @@ -38,7 +38,7 @@ const config = {
},
],
},
ignorePatterns: ["*.config.*", "*.cjs"],
ignorePatterns: ["*.cjs"],
env: {
es2020: true,
node: true,
Expand Down
251 changes: 173 additions & 78 deletions apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import fs from "node:fs";
import path from "node:path";
import { App, Stack, Duration } from "aws-cdk-lib/core";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { RuleTargetInput, Rule, Schedule } from "aws-cdk-lib/aws-events";
import { isCdk } from "@bronya.js/core";
import { chmodSync, copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";

import { Api } from "@bronya.js/api-construct";
import { createApiCliPlugins } from "@bronya.js/api-construct/plugins/cli";
import { isCdk } from "@bronya.js/core";
import { logger } from "@libs/lambda";
import { LambdaIntegration, ResponseType } from "aws-cdk-lib/aws-apigateway";
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
import { RuleTargetInput, Rule, Schedule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { Architecture, Code, Function as AwsLambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
import { ARecord, HostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
import { ApiGateway } from "aws-cdk-lib/aws-route53-targets";
import { App, Stack, Duration } from "aws-cdk-lib/core";
import type { BuildOptions } from "esbuild";

/**
* @see https://github.com/evanw/esbuild/issues/1921#issuecomment-1623640043
Expand All @@ -27,24 +34,61 @@ const projectRoot = process.cwd();
/**
* Where @libs/db is located.
*/
const libsDbDirectory = path.resolve(projectRoot, "..", "..", "libs", "db");
const libsDbDirectory = resolve(projectRoot, "..", "..", "libs", "db");

const prismaClientDirectory = resolve(libsDbDirectory, "node_modules", "prisma");

const prismaClientDirectory = path.resolve(libsDbDirectory, "node_modules", "prisma");
const prismaSchema = resolve(libsDbDirectory, "prisma", "schema.prisma");

const prismaSchema = path.resolve(libsDbDirectory, "prisma", "schema.prisma");
export const esbuildOptions: BuildOptions = {
format: "esm",
platform: "node",
bundle: true,
minify: true,
banner: { js },
outExtension: { ".js": ".mjs" },
plugins: [
{
name: "copy-prisma",
setup(build) {
build.onStart(async () => {
const outDirectory = build.initialOptions.outdir ?? projectRoot;

class MyStack extends Stack {
mkdirSync(outDirectory, { recursive: true });

const queryEngines = readdirSync(prismaClientDirectory).filter((file) =>
file.endsWith(".so.node"),
);

queryEngines.forEach((queryEngineFile) =>
copyFileSync(
join(prismaClientDirectory, queryEngineFile),
join(outDirectory, queryEngineFile),
),
);

queryEngines.forEach((queryEngineFile) =>
chmodSync(join(outDirectory, queryEngineFile), 0o755),
);

copyFileSync(prismaSchema, join(outDirectory, "schema.prisma"));
});
},
},
],
};

class ApiStack extends Stack {
public api: Api;
constructor(scope: App, id: string) {
constructor(scope: App, id: string, stage: string) {
super(scope, id);

this.api = new Api(this, `${id}-api`, {
this.api = new Api(this, id, {
directory: "src/routes",
plugins: createApiCliPlugins({
dev: {
hooks: {
transformExpressParams(params) {
const { req } = params;
transformExpressParams: ({ req }) => {
logger.info(`Path params: ${JSON.stringify(req.params)}`);
logger.info(`Query: ${JSON.stringify(req.query)}`);
logger.info(`Body: ${JSON.stringify(req.body)}`);
Expand All @@ -55,21 +99,19 @@ class MyStack extends Stack {
}),
exitPoint: "handler.mjs",
constructs: {
functionPlugin(functionResources) {
const { functionProps, handler } = functionResources;

functionPlugin: ({ functionProps, handler }) => {
const warmingTarget = new LambdaFunction(handler, {
event: RuleTargetInput.fromObject({ body: "warming request" }),
});

const warmingRule = new Rule(scope, `${id}-${functionProps.functionName}-warming-rule`, {
const warmingRule = new Rule(this, `${functionProps.functionName}-warming-rule`, {
schedule: Schedule.rate(Duration.minutes(5)),
});

warmingRule.addTarget(warmingTarget);
},
lambdaUpload(directory) {
const queryEngines = fs.readdirSync(directory).filter((x) => x.endsWith(".so.node"));
lambdaUpload: (directory) => {
const queryEngines = readdirSync(directory).filter((x) => x.endsWith(".so.node"));

if (queryEngines.length === 1) {
return;
Expand All @@ -78,88 +120,141 @@ class MyStack extends Stack {
queryEngines
.filter((x) => x !== "libquery_engine-linux-arm64-openssl-1.0.x.so.node")
.forEach((queryEngineFile) => {
fs.rmSync(path.join(directory, queryEngineFile));
rmSync(join(directory, queryEngineFile));
});
},
restApiProps: () => ({ disableExecuteApiEndpoint: true, binaryMediaTypes: ["*/*"] }),
},
environment: { DATABASE_URL: process.env["DATABASE_URL"] ?? "" },
esbuild: {
format: "esm",
platform: "node",
bundle: true,
minify: true,
banner: { js },
outExtension: { ".js": ".mjs" },
plugins: [
{
name: "copy-graphql-schema",
setup(build) {
build.onStart(async () => {
if (!build.initialOptions.outdir?.endsWith("graphql")) return;

fs.mkdirSync(build.initialOptions.outdir, { recursive: true });

fs.cpSync(
path.resolve(projectRoot, "src/routes/v1/graphql/schema"),
path.join(build.initialOptions.outdir, "schema"),
{ recursive: true },
);
});
},
},
{
name: "copy-prisma",
setup(build) {
build.onStart(async () => {
const outDirectory = build.initialOptions.outdir ?? projectRoot;

fs.mkdirSync(outDirectory, { recursive: true });

const queryEngines = fs
.readdirSync(prismaClientDirectory)
.filter((file) => file.endsWith(".so.node"));

queryEngines.forEach((queryEngineFile) =>
fs.copyFileSync(
path.join(prismaClientDirectory, queryEngineFile),
path.join(outDirectory, queryEngineFile),
),
);

queryEngines.forEach((queryEngineFile) =>
fs.chmodSync(path.join(outDirectory, queryEngineFile), 0o755),
);

fs.copyFileSync(prismaSchema, path.join(outDirectory, "schema.prisma"));
});
},
},
],
environment: {
DATABASE_URL: process.env["DATABASE_URL"] ?? "",
NODE_ENV: process.env["NODE_ENV"] ?? "",
STAGE: stage,
},
esbuild: esbuildOptions,
});
}
}

function getStage() {
if (!process.env.NODE_ENV) {
throw new Error("NODE_ENV not set.");
}
switch (process.env.NODE_ENV) {
case "production":
return "prod";
case "staging":
if (!process.env.PR_NUM) {
throw new Error("NODE_ENV was set to staging, but a PR number was not provided.");
}
return `staging-${process.env.PR_NUM}`;
case "development":
return "dev";
default:
throw new Error(
"Invalid NODE_ENV specified. Valid values are 'production', 'staging', and 'development'.",
);
}
}

export async function main() {
const id = "peterportal-api-next";

const zoneName = "peterportal.org";

const app = new App();

const stack = new MyStack(app, "peterportal-api-canary");
const stage = getStage();

const stack = new ApiStack(app, `${id}-${stage}`, stage);

const api = stack.api;

await api.init();

if (isCdk()) {
if (stage === "dev") {
throw new Error("Cannot deploy this app in the development environment.");
}

const result = await api.synth();

result.api.addGatewayResponse;
/**
* Add gateway responses for 5xx and 404 errors, so that they remain compliant
* with the {@link `ErrorResponse`} type.
*/
result.api.addGatewayResponse(`${id}-${stage}-5xx`, {
type: ResponseType.DEFAULT_5XX,
statusCode: "500",
templates: {
"application/json": JSON.stringify({
timestamp: "$context.requestTime",
requestId: "$context.requestId",
statusCode: 500,
error: "Internal Server Error",
message: "An unknown error has occurred. Please try again.",
}),
},
});
result.api.addGatewayResponse(`${id}-${stage}-404`, {
type: ResponseType.MISSING_AUTHENTICATION_TOKEN,
statusCode: "404",
templates: {
"application/json": JSON.stringify({
timestamp: "$context.requestTime",
requestId: "$context.requestId",
statusCode: 404,
error: "Not Found",
message: "The requested resource could not be found.",
}),
},
});

result.functions;
/**
* Define the CORS response headers integration and add it to all endpoints.
* This is necessary since we hacked API Gateway to be able to serve binary data.
*/
const corsIntegration = new LambdaIntegration(
new AwsLambdaFunction(result.api, `${id}-${stage}-options-handler`, {
code: Code.fromInline(
// language=JavaScript
'exports.h=async _=>({headers:{"Access-Control-Allow-Origin": "*","Access-Control-Allow-Headers": "Apollo-Require-Preflight,Content-Type","Access-Control-Allow-Methods": "GET,POST,OPTIONS"},statusCode:204})',
),
handler: "index.h",
runtime: Runtime.NODEJS_18_X,
architecture: Architecture.ARM_64,
}),
);
result.api.methods.forEach((apiMethod) => {
try {
apiMethod.resource.addMethod("OPTIONS", corsIntegration);
} catch {
// no-op
}
});

// Set up the custom domain name and A record for the API.
result.api.addDomainName(`${id}-${stage}-domain`, {
domainName: `${stage === "prod" ? "" : `${stage}.`}api-next.peterportal.org`,
certificate: Certificate.fromCertificateArn(
result.api,
"peterportal-cert",
process.env.CERTIFICATE_ARN ?? "",
),
});
new ARecord(result.api, `${id}-${stage}-a-record`, {
zone: HostedZone.fromHostedZoneAttributes(result.api, "peterportal-hosted-zone", {
zoneName,
hostedZoneId: process.env.HOSTED_ZONE_ID ?? "",
}),
recordName: `${stage === "prod" ? "" : `${stage}.`}api-next`,
target: RecordTarget.fromAlias(new ApiGateway(result.api)),
});
}

return app;
}

if (isCdk()) {
main();
// Sike, looks like even though we have top-level await, the dev server won't start with it :(
main().then();
}
6 changes: 3 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
},
"license": "MIT",
"type": "module",
"main": "bronya.config.ts",
"scripts": {
"cdk-app": "cdk --app 'npx tsx bronya.config.ts' --require-approval never",
"dev": "bunny dev-api"
Expand All @@ -39,12 +38,13 @@
"zod": "3.22.2"
},
"devDependencies": {
"@bronya.js/api-construct": "0.10.9",
"@bronya.js/core": "0.10.9",
"@bronya.js/api-construct": "0.11.3",
"@bronya.js/core": "0.11.3",
"@types/aws-lambda": "8.10.119",
"aws-cdk": "2.93.0",
"dotenv": "16.3.1",
"dotenv-cli": "7.3.0",
"esbuild": "0.19.2",
"tsx": "3.12.7"
},
"//": "the CDK configuration in this config file is used in the deployment package, @tools/cdk"
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/routes/v1/graphql/+config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { cpSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";

import { ApiPropsOverride } from "@bronya.js/api-construct";

import { esbuildOptions } from "../../../../bronya.config";

export const overrides: ApiPropsOverride = {
esbuild: {
...esbuildOptions,
plugins: [
{
name: "copy-graphql-schema",
setup(build) {
build.onStart(async () => {
mkdirSync(build.initialOptions.outdir!, { recursive: true });

cpSync(
resolve("src/routes/v1/graphql/schema"),
join(build.initialOptions.outdir!, "schema"),
{
recursive: true,
},
);
});
},
},
],
},
};
Loading

0 comments on commit f7f7db9

Please sign in to comment.