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

Commit

Permalink
refactor: ♻️ quick config fix (#101)
Browse files Browse the repository at this point in the history
Co-authored-by: Eddy Chen <[email protected]>
  • Loading branch information
ap0nia and ecxyzzy authored Oct 16, 2023
1 parent fcc1186 commit 4758dd8
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 202 deletions.
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

0 comments on commit 4758dd8

Please sign in to comment.