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

refactor: ♻️ quick config fix #101

Merged
merged 7 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 189 additions & 138 deletions apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { chmodSync, copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { chmodSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";

import { Api } from "@bronya.js/api-construct";
import { Api, type ApiConstructProps } 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";
Expand All @@ -13,8 +13,31 @@ import { Architecture, Code, Function as AwsLambdaFunction, Runtime } from "aws-
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 { config } from "dotenv";
import type { BuildOptions } from "esbuild";

/**
* Whether we're executing in CDK.
*
* During development (not in CDK) ...
* - Bronya.js is responsible for handling the development server.
* - The exported `main` function is imported and then called to get the CDK app.
* - No AWS constructs are actually allocated, since this requires certain files to be built that
* may not exist during development.
*
* During deployment (in CDK) ...
* - The `cdk` CLI tool is responsible for deploying the CloudFormation stack.
* - The CLI expects the app to be created at the top level,
* so the main function is also called immediately after its declaration.
* - All the AWS constructs are allocated, and relevant transformations are applied before
* uploading the code via AWS CloudFormation.
*/
const executingInCdk = isCdk();

config({
path: "../../.env",
});

/**
* @see https://github.com/evanw/esbuild/issues/1921#issuecomment-1623640043
*/
Expand All @@ -36,50 +59,87 @@ const projectRoot = process.cwd();
*/
const libsDbDirectory = resolve(projectRoot, "..", "..", "libs", "db");

/**
* Where all Prisma related files are located.
*/
const prismaClientDirectory = resolve(libsDbDirectory, "node_modules", "prisma");

const prismaSchema = resolve(libsDbDirectory, "prisma", "schema.prisma");
/**
* Name of the schema file.
*/
const prismaSchemaFile = "schema.prisma";

/**
* Where the prisma schema is located.
*/
const prismaSchema = resolve(libsDbDirectory, "prisma", prismaSchemaFile);

/**
* Name of the Prisma query engine file that's used on AWS Lambda.
*/
const prismaQueryEngineFile = "libquery_engine-linux-arm64-openssl-1.0.x.so.node";

/**
* The body of a warming request.
*
* TODO: actually recognize warming requests in the route handlers.
*/
const warmingRequestBody = { body: "warming request" };

/**
* Shared ESBuild options.
*/
export const esbuildOptions: BuildOptions = {
format: "esm",
platform: "node",
bundle: true,
minify: true,
banner: { js },

/**
* @remarks
* For Bronya.js: this is specified in order to guarantee that the file is interpreted as ESM.
* However, the framework will continue to assume `handler.js` is the entrypoint.
*
* @RFC What would be the best way to resolve these two values?
*/
outExtension: { ".js": ".mjs" },
plugins: [
{
name: "copy-prisma",
setup(build) {
build.onStart(async () => {
const outDirectory = build.initialOptions.outdir ?? projectRoot;

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"));
});
},
},
],
};

/**
* Shared construct props.
*/
export const constructs: ApiConstructProps = {
functionPlugin: ({ functionProps, handler }, scope) => {
const warmingTarget = new LambdaFunction(handler, {
event: RuleTargetInput.fromObject(warmingRequestBody),
});

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

warmingRule.addTarget(warmingTarget);
},
lambdaUpload: (directory) => {
copyFileSync(
join(prismaClientDirectory, prismaQueryEngineFile),
join(directory, prismaQueryEngineFile),
);

chmodSync(join(directory, prismaQueryEngineFile), 0o755);

copyFileSync(prismaSchema, join(directory, prismaSchemaFile));
},
restApiProps: () => ({ disableExecuteApiEndpoint: true, binaryMediaTypes: ["*/*"] }),
};

/**
* The backend API stack. i.e. AWS Lambda, API Gateway.
*/
class ApiStack extends Stack {
public api: Api;

constructor(scope: App, id: string, stage: string) {
super(scope, id);

Expand All @@ -97,34 +157,15 @@ class ApiStack extends Stack {
},
},
}),
exitPoint: "handler.mjs",
constructs: {
functionPlugin: ({ functionProps, handler }) => {
const warmingTarget = new LambdaFunction(handler, {
event: RuleTargetInput.fromObject({ body: "warming request" }),
});

const warmingRule = new Rule(this, `${functionProps.functionName}-warming-rule`, {
schedule: Schedule.rate(Duration.minutes(5)),
});
/**
* @remarks
* For Bronya.js: although ESBuild specified that "js" -> "mjs", the framework
* still assumes that the entrypoint is `handler.js` unless explicitly specified.
*/
exitPoint: "handler.mjs",

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

if (queryEngines.length === 1) {
return;
}

queryEngines
.filter((x) => x !== "libquery_engine-linux-arm64-openssl-1.0.x.so.node")
.forEach((queryEngineFile) => {
rmSync(join(directory, queryEngineFile));
});
},
restApiProps: () => ({ disableExecuteApiEndpoint: true, binaryMediaTypes: ["*/*"] }),
},
constructs,
environment: {
DATABASE_URL: process.env["DATABASE_URL"] ?? "",
NODE_ENV: process.env["NODE_ENV"] ?? "",
Expand Down Expand Up @@ -156,7 +197,11 @@ function getStage() {
}
}

export async function main() {
/**
* Bronya requires a default export or exported named `main` function that returns an {@link App}
* in order to support development functionality.
*/
export async function main(): Promise<App> {
const id = "peterportal-api-next";

const zoneName = "peterportal.org";
Expand All @@ -167,94 +212,100 @@ export async function main() {

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

const api = stack.api;

await api.init();
await stack.api.init();

if (isCdk()) {
if (stage === "dev") {
throw new Error("Cannot deploy this app in the development environment.");
}
// In development mode, return the app so that Bronya can handle the development server.
// The app is not synthesized, and no AWS resources are allocated.
if (!executingInCdk) {
return app;
}

const result = await api.synth();

/**
* 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.",
}),
},
});
if (stage === "dev") {
throw new Error("Cannot deploy this app in the development environment.");
}

/**
* 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,
// In deployment mode, synthesize all the AWS resources.
const synthesized = await stack.api.synth();

/**
* Add gateway responses for 5xx and 404 errors, so that they remain compliant
* with the {@link `ErrorResponse`} type.
*/
synthesized.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.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 ?? "",
},
});

synthesized.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.",
}),
recordName: `${stage === "prod" ? "" : `${stage}.`}api-next`,
target: RecordTarget.fromAlias(new ApiGateway(result.api)),
});
}
},
});

/**
* 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(synthesized.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,
}),
);

synthesized.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.
synthesized.api.addDomainName(`${id}-${stage}-domain`, {
domainName: `${stage === "prod" ? "" : `${stage}.`}api-next.peterportal.org`,
certificate: Certificate.fromCertificateArn(
synthesized.api,
"peterportal-cert",
process.env.CERTIFICATE_ARN ?? "",
),
});

new ARecord(synthesized.api, `${id}-${stage}-a-record`, {
zone: HostedZone.fromHostedZoneAttributes(synthesized.api, "peterportal-hosted-zone", {
zoneName,
hostedZoneId: process.env.HOSTED_ZONE_ID ?? "",
}),
recordName: `${stage === "prod" ? "" : `${stage}.`}api-next`,
target: RecordTarget.fromAlias(new ApiGateway(synthesized.api)),
});

return app;
}

if (isCdk()) {
if (executingInCdk) {
// Sike, looks like even though we have top-level await, the dev server won't start with it :(
main().then();
}
Loading