diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 9cb1335a8..e23790043 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -308,6 +308,7 @@ export default defineConfig({
{ label: 'ts#project', link: '/guides/typescript-project' },
{ label: 'ts#infra', link: '/guides/typescript-infrastructure' },
{ label: 'ts#trpc-api', link: '/guides/trpc' },
+ { label: 'ts#trpc-api (ECS)', link: '/guides/ecs-trpc' },
{ label: 'ts#smithy-api', link: '/guides/ts-smithy-api' },
{
label: 'ts#react-website',
diff --git a/docs/src/content/docs/en/guides/ecs-trpc.mdx b/docs/src/content/docs/en/guides/ecs-trpc.mdx
new file mode 100644
index 000000000..4b6d36a27
--- /dev/null
+++ b/docs/src/content/docs/en/guides/ecs-trpc.mdx
@@ -0,0 +1,258 @@
+---
+title: ECS tRPC
+description: Reference documentation for ECS tRPC
+---
+import { FileTree } from '@astrojs/starlight/components';
+import Link from '@components/link.astro';
+import RunGenerator from '@components/run-generator.astro';
+import GeneratorParameters from '@components/generator-parameters.astro';
+import NxCommands from '@components/nx-commands.astro';
+import Snippet from '@components/snippet.astro';
+
+[tRPC](https://trpc.io/) is a framework for building APIs in TypeScript with end-to-end type safety. Using tRPC, updates to API operation inputs and outputs are immediately reflected in client code and are visible in your IDE without the need to rebuild your project.
+
+The ECS tRPC API generator creates a new tRPC API deployed as a containerised [AWS ECS Fargate](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html) service, fronted by an [API Gateway HTTP API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) via a VPC Link. The generated backend uses [Fastify](https://fastify.dev/) as the HTTP server and includes schema validation using [Zod](https://zod.dev/).
+
+:::tip
+ECS Fargate is a good fit when your API has long-running requests, needs consistent low-latency responses without cold starts, or has steady traffic patterns that benefit from always-on compute. If your API is lightweight, has variable or bursty traffic, and can tolerate occasional cold starts, a serverless Lambda deployment may be simpler and more cost-effective. See the `ts#trpc-api` Lambda guide for the Lambda-based alternative.
+:::
+
+## Usage
+
+### Generate an ECS tRPC API
+
+You can generate a new ECS tRPC API in two ways:
+
+
+
+Select `EcsFargate` as the `computeType` when prompted.
+
+### Options
+
+
+
+## Generator Output
+
+The generator will create the following project structure in the `/` directory:
+
+
+ - src
+ - init.ts Backend tRPC initialisation
+ - server.ts Fastify server entrypoint
+ - local-server.ts Fastify server entrypoint for local development with CORS enabled
+ - router.ts tRPC router definition
+ - index.ts Exports for the router and schemas
+ - schema Schema definitions using Zod
+ - echo.ts Example definitions for the input and output of the "echo" procedure
+ - index.ts Schema barrel export
+ - procedures Procedures (or operations) exposed by your API
+ - echo.ts Example procedure
+ - Dockerfile Dockerfile for building the container image
+ - tsconfig.json TypeScript configuration
+ - project.json Project configuration and build targets
+
+
+### Infrastructure
+
+
+
+For deploying your API, the following files are generated:
+
+
+ - packages/common/constructs/src
+ - app
+ - ecs-apis
+ - \.ts CDK construct for deploying your ECS API
+ - core
+ - ecs
+ - ecs-api.ts Core CDK construct for ECS Fargate services fronted by API Gateway
+ - api
+ - http-api.ts CDK construct for the API Gateway HTTP API
+ - utils.ts Utilities for the API constructs
+
+
+## Implementing your ECS tRPC API
+
+At a high-level, tRPC APIs consist of a router which delegates requests to specific procedures. Each procedure has an input and output, defined as a Zod schema. The ECS variant runs your tRPC router inside a Fastify HTTP server, packaged as a Docker container.
+
+### Schema
+
+
+
+### Router and Procedures
+
+Your tRPC router is defined in `src/router.ts`, which registers all procedures. The Fastify server entry point is in `src/server.ts`, which registers the tRPC router as a Fastify plugin and exposes a `/health` endpoint for load balancer health checks.
+
+
+
+### Fastify Server
+
+The `src/server.ts` file sets up a [Fastify](https://fastify.dev/) HTTP server that hosts your tRPC router. It registers the tRPC adapter as a Fastify plugin under the `/trpc` prefix and exposes a `/health` endpoint used by the Application Load Balancer for health checks.
+
+```ts
+import Fastify from 'fastify';
+import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
+import { appRouter } from './router.js';
+
+const server = Fastify({ logger: true });
+
+void server.register(fastifyTRPCPlugin, {
+ prefix: '/trpc',
+ trpcOptions: { router: appRouter },
+});
+
+server.get('/health', async () => ({ status: 'ok' }));
+```
+
+## Customising your ECS tRPC API
+
+### Errors
+
+
+
+### Organising Your Operations
+
+
+
+### Implementing Custom Middleware
+
+You can add additional values to the context provided to procedures by implementing middleware in `src/init.ts`.
+
+As an example, let's implement some middleware to add request logging:
+
+```ts
+import { initTRPC } from '@trpc/server';
+
+export const t = initTRPC.create();
+
+const loggerMiddleware = t.middleware(async (opts) => {
+ const start = Date.now();
+ const result = await opts.next();
+ const duration = Date.now() - start;
+ console.log(`${opts.type} ${opts.path} - ${duration}ms`);
+ return result;
+});
+
+export const publicProcedure = t.procedure.use(loggerMiddleware);
+```
+
+## Deploying your ECS tRPC API
+
+The ECS tRPC API generator creates CDK infrastructure as code. You can use this to deploy your API.
+
+:::note
+ECS infrastructure is currently only supported with CDK.
+:::
+
+The CDK construct for deploying your API is in the `common/constructs` folder. You can consume this in a CDK application, for example:
+
+```ts {6-8}
+import { MyApi } from ':my-scope/common-constructs';
+
+export class ExampleStack extends Stack {
+ constructor(scope: Construct, id: string) {
+ // Add the api to your stack
+ const api = new MyApi(this, 'MyApi');
+ }
+}
+```
+
+This sets up your API infrastructure, including:
+
+- A VPC with private subnets
+- An ECS Fargate cluster and service running your containerised API
+- An internal Application Load Balancer
+- An API Gateway HTTP API connected to the ALB via a VPC Link
+- CloudWatch log groups for the container and API access logs
+- Authentication based on your chosen `auth` method
+
+You can customise the construct by passing optional properties:
+
+```ts
+const api = new MyApi(this, 'MyApi', {
+ cpu: 512,
+ memoryLimitMiB: 1024,
+ desiredCount: 2,
+ environment: {
+ MY_ENV_VAR: 'my-value',
+ },
+});
+```
+
+:::note
+If you selected to use `Cognito` authentication, you will need to supply the `identity` property to the API construct:
+
+```ts {9-12}
+import { MyApi, UserIdentity } from ':my-scope/common-constructs';
+
+export class ExampleStack extends Stack {
+ constructor(scope: Construct, id: string) {
+ const identity = new UserIdentity(this, 'Identity');
+
+ const api = new MyApi(this, 'MyApi', {
+ identity: {
+ userPool: identity.userPool,
+ userPoolClient: identity.userPoolClient,
+ },
+ });
+ }
+}
+```
+
+The `UserIdentity` construct can be generated using the `ts#react-website-auth` generator
+:::
+
+### Granting Access (IAM Only)
+
+If you selected to use `IAM` authentication, you can grant access to your API:
+
+```ts
+api.grantInvokeAccess(myIdentityPool.authenticatedRole);
+```
+
+### CORS Configuration
+
+By default, the API allows all origins. You can restrict CORS to specific origins:
+
+```ts
+api.restrictCorsTo('https://myapp.com', 'https://staging.myapp.com');
+```
+
+### Bundle Target
+
+
+
+## Local Development Server
+
+You can use the `serve` target to run a local development server for your API:
+
+
+
+This starts a local development server using `tsx --watch`, which automatically reloads when you make changes to your source code. The entry point for the local server is `src/local-server.ts`.
+
+## Invoking your ECS tRPC API
+
+You can create a tRPC client to invoke your API in a type-safe manner. If you are calling your tRPC API from another backend, you can use the client exported from your API package:
+
+```ts
+import { createTRPCClient, httpBatchLink } from '@trpc/client';
+import type { AppRouter } from ':my-scope/my-api';
+
+const client = createTRPCClient({
+ links: [
+ httpBatchLink({
+ url: 'https://my-api-url.example.com/trpc',
+ }),
+ ],
+});
+
+await client.echo.query({ message: 'Hello world!' });
+```
+
+If you are calling your API from a React website, consider using the Connection generator to configure the client.
+
+## More Information
+
+For more information about tRPC, please refer to the [tRPC documentation](https://trpc.io/docs).
+
+For more information about ECS Fargate, please refer to the [AWS ECS documentation](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html).
diff --git a/docs/src/content/docs/en/guides/trpc.mdx b/docs/src/content/docs/en/guides/trpc.mdx
index e4d6cf760..598f6fab3 100644
--- a/docs/src/content/docs/en/guides/trpc.mdx
+++ b/docs/src/content/docs/en/guides/trpc.mdx
@@ -36,6 +36,10 @@ Select `ServerlessApiGatewayRestApi` (default) as your `computeType` if you woul
The `integrationPattern` option defaults to `isolated`, which creates one Lambda per tRPC procedure. Select `shared` if you would prefer a single shared Lambda handler for the whole API, with optional per-procedure overrides.
:::
+:::tip
+Select `EcsFargate` as your `computeType` to deploy your API as a containerised ECS Fargate service instead of Lambda. This is a good fit for long-running requests, consistent low-latency without cold starts, or steady traffic patterns. See the ECS tRPC guide for details.
+:::
+
## Generator Output
The generator will create the following project structure in the `/` directory:
@@ -75,73 +79,17 @@ At a high-level, tRPC APIs consist of a router which delegates requests to speci
### Schema
-The `src/schema` directory contains the types that are shared between your client and server code. In this package, these types are defined using [Zod](https://zod.dev/), a TypeScript-first schema declaration and validation library.
-
-An example schema might look as follows:
-
-```ts
-import { z } from 'zod';
-
-// Schema definition
-export const UserSchema = z.object({
- name: z.string(),
- height: z.number(),
- dateOfBirth: z.string().datetime(),
-});
-
-// Corresponding TypeScript type
-export type User = z.TypeOf;
-```
-
-Given the above schema, the `User` type is equivalent to the following TypeScript:
-
-```ts
-interface User {
- name: string;
- height: number;
- dateOfBirth: string;
-}
-```
-
-Schemas are shared by both server and client code, providing a single place to update when making changes to the structures used in your API.
-
-Schemas are automatically validated by your tRPC API at runtime, which saves hand-crafting custom validation logic in your backend.
-
-Zod provides powerful utilities to combine or derive schemas such as `.merge`, `.pick`, `.omit` and more. You can find more information on the [Zod documentation website](https://zod.dev/?id=basic-usage).
+
### Router and Procedures
Your tRPC router is defined in `src/router.ts`, which registers all procedures. Each procedure defines the expected input, output, and implementation. The Lambda handler entry point is in `src/handler.ts`, which forwards requests to your router.
-The sample router generated for you has a single operation, called `echo`:
-
-```ts
-import { echo } from './procedures/echo.js';
-
-export const appRouter = router({
- echo,
-});
-```
-
-The example `echo` procedure is generated for you in `src/procedures/echo.ts`:
-
-```ts
-export const echo = publicProcedure
- .input(EchoInputSchema)
- .output(EchoOutputSchema)
- .query((opts) => ({ result: opts.input.message }));
-```
-
-To break down the above:
-
-- `publicProcedure` defines a public method on the API, including the middleware set up in `src/middleware`. This middleware includes AWS Lambda Powertools integration for logging, tracing and metrics.
-- `input` accepts a Zod schema which defines the expected input for the operation. Requests sent for this operation are automatically validated against this schema.
-- `output` accepts a Zod schema which defines the expected output for the operation. You will see type errors in your implementation if you don't return an output which conforms to the schema.
-- `query` accepts a function which defines the implementation for your API. This implementation receives `opts`, which contains the `input` passed to your operation, as well as other context set up by middleware, available in `opts.ctx`. The function passed to `query` must return an output which conforms to the `output` schema.
+
-The use of `query` to define the implementation indicates that the operation is not mutative. Use this to define methods to retrieve data. To implement a mutative operation, use the `mutation` method instead.
-
-If you add a new procedure, make sure you register it by adding it to the router in `src/router.ts`.
+:::note
+In the Lambda variant, `publicProcedure` also includes the middleware set up in `src/middleware`, which provides AWS Lambda Powertools integration for logging, tracing and metrics.
+:::
### Subscriptions (Streaming)
@@ -191,39 +139,11 @@ The generated infrastructure uses a streaming Lambda handler with `ResponseTrans
### Errors
-In your implementation, you can return error responses to clients by throwing a `TRPCError`. These accept a `code` which indicates the type of error, for example:
-
-```ts
-throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'The requested resource could not be found',
-});
-```
+
### Organising Your Operations
-As your API grows, you may wish to group related operations together.
-
-You can group operations together using nested routers, for example:
-
-```ts
-import { getUser } from './procedures/users/get.js';
-import { listUsers } from './procedures/users/list.js';
-
-const appRouter = router({
- users: router({
- get: getUser,
- list: listUsers,
- }),
- ...
-})
-```
-
-Clients then receive this grouping of operations, for example invoking the `listUsers` operation in this case might look as follows:
-
-```ts
-client.users.list.query();
-```
+
### Logging
diff --git a/docs/src/content/docs/en/snippets/trpc/echo-procedure.mdx b/docs/src/content/docs/en/snippets/trpc/echo-procedure.mdx
new file mode 100644
index 000000000..1683ceb08
--- /dev/null
+++ b/docs/src/content/docs/en/snippets/trpc/echo-procedure.mdx
@@ -0,0 +1,32 @@
+---
+title: tRPC Echo Procedure
+---
+The sample router generated for you has a single operation, called `echo`:
+
+```ts
+import { echo } from './procedures/echo.js';
+
+export const appRouter = router({
+ echo,
+});
+```
+
+The example `echo` procedure is generated for you in `src/procedures/echo.ts`:
+
+```ts
+export const echo = publicProcedure
+ .input(EchoInputSchema)
+ .output(EchoOutputSchema)
+ .query((opts) => ({ result: opts.input.message }));
+```
+
+To break down the above:
+
+- `publicProcedure` defines a public method on the API, including any middleware configured in `src/init.ts`.
+- `input` accepts a Zod schema which defines the expected input for the operation. Requests sent for this operation are automatically validated against this schema.
+- `output` accepts a Zod schema which defines the expected output for the operation. You will see type errors in your implementation if you don't return an output which conforms to the schema.
+- `query` accepts a function which defines the implementation for your API. This implementation receives `opts`, which contains the `input` passed to your operation, as well as other context set up by middleware, available in `opts.ctx`. The function passed to `query` must return an output which conforms to the `output` schema.
+
+The use of `query` to define the implementation indicates that the operation is not mutative. Use this to define methods to retrieve data. To implement a mutative operation, use the `mutation` method instead.
+
+If you add a new procedure, make sure you register it by adding it to the router in `src/router.ts`.
diff --git a/docs/src/content/docs/en/snippets/trpc/errors.mdx b/docs/src/content/docs/en/snippets/trpc/errors.mdx
new file mode 100644
index 000000000..4b8c420cf
--- /dev/null
+++ b/docs/src/content/docs/en/snippets/trpc/errors.mdx
@@ -0,0 +1,11 @@
+---
+title: tRPC Errors
+---
+In your implementation, you can return error responses to clients by throwing a `TRPCError`. These accept a `code` which indicates the type of error, for example:
+
+```ts
+throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'The requested resource could not be found',
+});
+```
diff --git a/docs/src/content/docs/en/snippets/trpc/organising-operations.mdx b/docs/src/content/docs/en/snippets/trpc/organising-operations.mdx
new file mode 100644
index 000000000..284880dee
--- /dev/null
+++ b/docs/src/content/docs/en/snippets/trpc/organising-operations.mdx
@@ -0,0 +1,25 @@
+---
+title: tRPC Organising Operations
+---
+As your API grows, you may wish to group related operations together.
+
+You can group operations together using nested routers, for example:
+
+```ts
+import { getUser } from './procedures/users/get.js';
+import { listUsers } from './procedures/users/list.js';
+
+const appRouter = router({
+ users: router({
+ get: getUser,
+ list: listUsers,
+ }),
+ ...
+})
+```
+
+Clients then receive this grouping of operations, for example invoking the `listUsers` operation in this case might look as follows:
+
+```ts
+client.users.list.query();
+```
diff --git a/docs/src/content/docs/en/snippets/trpc/schema.mdx b/docs/src/content/docs/en/snippets/trpc/schema.mdx
new file mode 100644
index 000000000..07ffc8a35
--- /dev/null
+++ b/docs/src/content/docs/en/snippets/trpc/schema.mdx
@@ -0,0 +1,36 @@
+---
+title: tRPC Schema
+---
+The `src/schema` directory contains the types that are shared between your client and server code. These types are defined using [Zod](https://zod.dev/), a TypeScript-first schema declaration and validation library.
+
+An example schema might look as follows:
+
+```ts
+import { z } from 'zod';
+
+// Schema definition
+export const UserSchema = z.object({
+ name: z.string(),
+ height: z.number(),
+ dateOfBirth: z.string().datetime(),
+});
+
+// Corresponding TypeScript type
+export type User = z.TypeOf;
+```
+
+Given the above schema, the `User` type is equivalent to the following TypeScript:
+
+```ts
+interface User {
+ name: string;
+ height: number;
+ dateOfBirth: string;
+}
+```
+
+Schemas are shared by both server and client code, providing a single place to update when making changes to the structures used in your API.
+
+Schemas are automatically validated by your tRPC API at runtime, which saves hand-crafting custom validation logic in your backend.
+
+Zod provides powerful utilities to combine or derive schemas such as `.merge`, `.pick`, `.omit` and more. You can find more information on the [Zod documentation website](https://zod.dev/?id=basic-usage).
diff --git a/packages/nx-plugin/src/trpc/backend/__snapshots__/generator.spec.ts.snap b/packages/nx-plugin/src/trpc/backend/__snapshots__/generator.spec.ts.snap
index 571fd3145..566943f14 100644
--- a/packages/nx-plugin/src/trpc/backend/__snapshots__/generator.spec.ts.snap
+++ b/packages/nx-plugin/src/trpc/backend/__snapshots__/generator.spec.ts.snap
@@ -1,5 +1,679 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`trpc backend generator > EcsFargate computeType > should generate bundle target with correct rolldown config > rolldown.config.ts 1`] = `
+"import { defineConfig } from 'rolldown';
+
+// Disables tree-shaking for the given module patterns
+const disableTreeShake = (patterns: RegExp[]) => ({
+ name: 'disable-treeshake',
+ transform: (code, id) => {
+ if (patterns.some((p) => p.test(id))) {
+ return { code, map: null, moduleSideEffects: 'no-treeshake' };
+ }
+ return null;
+ },
+});
+
+export default defineConfig([
+ {
+ tsconfig: 'tsconfig.lib.json',
+ input: 'src/server.ts',
+ output: {
+ file: '../../dist/packages/demo-ecs-api/bundle/index.js',
+ format: 'cjs',
+ inlineDynamicImports: true,
+ },
+ platform: 'node',
+ plugins: [disableTreeShake([/@aws-sdk\\/.*/])],
+ },
+]);
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/Dockerfile 1`] = `
+"FROM node:22-slim AS base
+WORKDIR /app
+
+COPY dist/apps/test-api/bundle/index.js ./index.js
+
+EXPOSE 3000
+
+CMD ["node", "index.js"]
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/index.ts 1`] = `
+"export type { AppRouter } from './router.js';
+export { appRouter } from './router.js';
+export * from './schema/index.js';
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/init.ts 1`] = `
+"import { initTRPC } from '@trpc/server';
+
+export const t = initTRPC.create();
+
+export const publicProcedure = t.procedure;
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/local-server.ts 1`] = `
+"import Fastify from 'fastify';
+import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
+import { appRouter } from './router.js';
+
+const PORT = 3000;
+
+const server = Fastify({ logger: true });
+
+// Enable CORS for local development
+server.addHook('onRequest', async (request, reply) => {
+ reply.header('Access-Control-Allow-Origin', '*');
+ reply.header('Access-Control-Allow-Methods', '*');
+ reply.header('Access-Control-Allow-Headers', '*');
+ if (request.method === 'OPTIONS') {
+ return reply.status(204).send();
+ }
+});
+
+void server.register(fastifyTRPCPlugin, {
+ prefix: '/trpc',
+ trpcOptions: { router: appRouter },
+});
+
+server.get('/health', async () => ({ status: 'ok' }));
+
+const start = async () => {
+ await server.listen({ port: PORT, host: '0.0.0.0' });
+};
+
+void start();
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/procedures/echo.ts 1`] = `
+"import { publicProcedure } from '../init.js';
+import { EchoInputSchema, EchoOutputSchema } from '../schema/index.js';
+
+export const echo = publicProcedure
+ .input(EchoInputSchema)
+ .output(EchoOutputSchema)
+ .query((opts) => ({ result: opts.input.message }));
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/router.ts 1`] = `
+"import { echo } from './procedures/echo.js';
+import { t } from './init.js';
+
+export const router = t.router;
+
+export const appRouter = router({
+ echo,
+});
+
+export type AppRouter = typeof appRouter;
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/schema/echo.ts 1`] = `
+"import { z } from 'zod';
+
+export const EchoInputSchema = z.object({
+ message: z.string(),
+});
+
+export type IEchoInput = z.TypeOf;
+
+export const EchoOutputSchema = z.object({
+ result: z.string(),
+});
+
+export type IEchoOutput = z.TypeOf;
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/schema/index.ts 1`] = `
+"export * from './echo.js';
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate the project > apps/test-api/src/server.ts 1`] = `
+"import Fastify from 'fastify';
+import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
+import { appRouter } from './router.js';
+
+const PORT = 3000;
+
+const server = Fastify({ logger: true });
+
+void server.register(fastifyTRPCPlugin, {
+ prefix: '/trpc',
+ trpcOptions: { router: appRouter },
+});
+
+server.get('/health', async () => ({ status: 'ok' }));
+
+const start = async () => {
+ await server.listen({ port: PORT, host: '0.0.0.0' });
+};
+
+void start();
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate with Cognito auth > packages/common/constructs/src/app/ecs-apis/test-api.ts 1`] = `
+"import { Construct } from 'constructs';
+import * as url from 'url';
+import { EcsApi, type EcsApiProps } from '../../core/ecs/ecs-api.js';
+import { HttpUserPoolAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
+import { IUserPool, IUserPoolClient } from 'aws-cdk-lib/aws-cognito';
+
+/**
+ * Properties for creating a TestApi ECS API
+ */
+export interface TestApiProps extends Partial<
+ Pick<
+ EcsApiProps,
+ 'vpc' | 'cpu' | 'memoryLimitMiB' | 'desiredCount' | 'environment'
+ >
+> {
+ /**
+ * Identity details for Cognito Authentication
+ */
+ readonly identity: {
+ readonly userPool: IUserPool;
+ readonly userPoolClient: IUserPoolClient;
+ };
+}
+
+/**
+ * A CDK construct that creates an ECS Fargate service for TestApi
+ * fronted by API Gateway HTTP API with VPC Link.
+ */
+export class TestApi extends EcsApi {
+ constructor(scope: Construct, id: string, props: TestApiProps = {}) {
+ super(scope, id, {
+ apiName: 'TestApi',
+ dockerContextPath: url.fileURLToPath(
+ new URL('../../../../../../', import.meta.url),
+ ),
+ dockerfilePath: 'apps/test-api/Dockerfile',
+ dockerExclude: [
+ '**/node_modules',
+ '**/.nx',
+ '**/src',
+ '**/.git',
+ '**/cdk.out',
+ ],
+ containerPort: 3000,
+ authorizer: new HttpUserPoolAuthorizer(
+ 'TestApiAuthorizer',
+ props.identity!.userPool,
+ {
+ userPoolClients: [props.identity!.userPoolClient],
+ },
+ ),
+ ...props,
+ });
+ }
+}
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate with IAM auth > packages/common/constructs/src/app/ecs-apis/test-api.ts 1`] = `
+"import { Construct } from 'constructs';
+import * as url from 'url';
+import { EcsApi, type EcsApiProps } from '../../core/ecs/ecs-api.js';
+import { HttpIamAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
+import { Grant, IGrantable } from 'aws-cdk-lib/aws-iam';
+
+/**
+ * Properties for creating a TestApi ECS API
+ */
+export type TestApiProps = Partial<
+ Pick<
+ EcsApiProps,
+ 'vpc' | 'cpu' | 'memoryLimitMiB' | 'desiredCount' | 'environment'
+ >
+>;
+
+/**
+ * A CDK construct that creates an ECS Fargate service for TestApi
+ * fronted by API Gateway HTTP API with VPC Link.
+ */
+export class TestApi extends EcsApi {
+ constructor(scope: Construct, id: string, props: TestApiProps = {}) {
+ super(scope, id, {
+ apiName: 'TestApi',
+ dockerContextPath: url.fileURLToPath(
+ new URL('../../../../../../', import.meta.url),
+ ),
+ dockerfilePath: 'apps/test-api/Dockerfile',
+ dockerExclude: [
+ '**/node_modules',
+ '**/.nx',
+ '**/src',
+ '**/.git',
+ '**/cdk.out',
+ ],
+ containerPort: 3000,
+ authorizer: new HttpIamAuthorizer(),
+ ...props,
+ });
+ }
+
+ /**
+ * Grants IAM permissions to invoke any method on this API.
+ */
+ public grantInvokeAccess(grantee: IGrantable) {
+ Grant.addToPrincipal({
+ grantee,
+ actions: ['execute-api:Invoke'],
+ resourceArns: [this.api.arnForExecuteApi('*', '/*', '*')],
+ });
+ }
+}
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should generate with no auth > packages/common/constructs/src/app/ecs-apis/test-api.ts 1`] = `
+"import { Construct } from 'constructs';
+import * as url from 'url';
+import { EcsApi, type EcsApiProps } from '../../core/ecs/ecs-api.js';
+import { HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
+
+/**
+ * Properties for creating a TestApi ECS API
+ */
+export type TestApiProps = Partial<
+ Pick<
+ EcsApiProps,
+ 'vpc' | 'cpu' | 'memoryLimitMiB' | 'desiredCount' | 'environment'
+ >
+>;
+
+/**
+ * A CDK construct that creates an ECS Fargate service for TestApi
+ * fronted by API Gateway HTTP API with VPC Link.
+ */
+export class TestApi extends EcsApi {
+ constructor(scope: Construct, id: string, props: TestApiProps = {}) {
+ super(scope, id, {
+ apiName: 'TestApi',
+ dockerContextPath: url.fileURLToPath(
+ new URL('../../../../../../', import.meta.url),
+ ),
+ dockerfilePath: 'apps/test-api/Dockerfile',
+ dockerExclude: [
+ '**/node_modules',
+ '**/.nx',
+ '**/src',
+ '**/.git',
+ '**/cdk.out',
+ ],
+ containerPort: 3000,
+ authorizer: new HttpNoneAuthorizer(),
+ ...props,
+ });
+ }
+}
+"
+`;
+
+exports[`trpc backend generator > EcsFargate computeType > should snapshot the core ECS construct > ecs-api.ts 1`] = `
+"import { Construct } from 'constructs';
+import { CfnOutput, Duration, RemovalPolicy } from 'aws-cdk-lib';
+import {
+ Vpc,
+ SubnetType,
+ SecurityGroup,
+ Port,
+ IVpc,
+} from 'aws-cdk-lib/aws-ec2';
+import {
+ Cluster,
+ ContainerImage,
+ ContainerInsights,
+ CpuArchitecture,
+ FargateService,
+ FargateTaskDefinition,
+ LogDrivers,
+ OperatingSystemFamily,
+ Protocol,
+} from 'aws-cdk-lib/aws-ecs';
+import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
+import {
+ ApplicationLoadBalancer,
+ ApplicationProtocol,
+ ApplicationTargetGroup,
+ TargetType,
+} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
+import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
+import {
+ HttpApi as _HttpApi,
+ HttpMethod,
+ HttpStage,
+ CorsHttpMethod,
+ CfnApi,
+ IHttpRouteAuthorizer,
+ LogGroupLogDestination,
+} from 'aws-cdk-lib/aws-apigatewayv2';
+import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
+import { VpcLink } from 'aws-cdk-lib/aws-apigatewayv2';
+import { RuntimeConfig } from '../runtime-config.js';
+import { suppressRules } from '../checkov.js';
+
+/**
+ * Properties for creating an EcsApi construct
+ */
+export interface EcsApiProps {
+ /**
+ * Unique name for the API
+ */
+ readonly apiName: string;
+
+ /**
+ * Path to the Docker build context directory
+ */
+ readonly dockerContextPath: string;
+
+ /**
+ * Path to the Dockerfile relative to the Docker build context
+ */
+ readonly dockerfilePath?: string;
+
+ /**
+ * Glob patterns to exclude from the Docker build context
+ */
+ readonly dockerExclude?: string[];
+
+ /**
+ * Container port the application listens on
+ */
+ readonly containerPort: number;
+
+ /**
+ * Optional VPC to deploy into. If not provided, a new VPC is created.
+ */
+ readonly vpc?: IVpc;
+
+ /**
+ * Optional CPU units for the Fargate task (default: 256)
+ */
+ readonly cpu?: number;
+
+ /**
+ * Optional memory in MiB for the Fargate task (default: 512)
+ */
+ readonly memoryLimitMiB?: number;
+
+ /**
+ * Optional desired count of tasks (default: 1)
+ */
+ readonly desiredCount?: number;
+
+ /**
+ * Optional environment variables for the container
+ */
+ readonly environment?: Record;
+
+ /**
+ * Optional authorizer for the API routes
+ */
+ readonly authorizer?: IHttpRouteAuthorizer;
+}
+
+/**
+ * A CDK construct that creates an ECS Fargate service fronted by
+ * an internal ALB and API Gateway HTTP API via VPC Link.
+ */
+export class EcsApi extends Construct {
+ /** The API Gateway HTTP API */
+ public readonly api: _HttpApi;
+
+ /** The default stage of the HTTP API */
+ public readonly defaultStage: HttpStage;
+
+ /** The ECS Fargate service */
+ public readonly service: FargateService;
+
+ /** The VPC */
+ public readonly vpc: IVpc;
+
+ /** The internal ALB */
+ public readonly loadBalancer: ApplicationLoadBalancer;
+
+ constructor(scope: Construct, id: string, props: EcsApiProps) {
+ super(scope, id);
+
+ // VPC
+ this.vpc =
+ props.vpc ??
+ new Vpc(this, 'Vpc', {
+ maxAzs: 2,
+ natGateways: 1,
+ });
+
+ // ECS Cluster
+ const cluster = new Cluster(this, 'Cluster', {
+ vpc: this.vpc,
+ containerInsightsV2: ContainerInsights.ENHANCED,
+ });
+
+ // Task Definition
+ const taskDefinition = new FargateTaskDefinition(this, 'TaskDef', {
+ cpu: props.cpu ?? 256,
+ memoryLimitMiB: props.memoryLimitMiB ?? 512,
+ runtimePlatform: {
+ cpuArchitecture: CpuArchitecture.ARM64,
+ operatingSystemFamily: OperatingSystemFamily.LINUX,
+ },
+ });
+
+ const logGroup = new LogGroup(this, 'LogGroup', {
+ retention: RetentionDays.ONE_WEEK,
+ removalPolicy: RemovalPolicy.DESTROY,
+ });
+ suppressRules(
+ logGroup,
+ ['CKV_AWS_158'],
+ 'Using default CloudWatch log encryption',
+ );
+
+ taskDefinition.addContainer('AppContainer', {
+ image: ContainerImage.fromAsset(props.dockerContextPath, {
+ file: props.dockerfilePath,
+ exclude: props.dockerExclude,
+ platform: Platform.LINUX_ARM64,
+ }),
+ portMappings: [
+ { containerPort: props.containerPort, protocol: Protocol.TCP },
+ ],
+ logging: LogDrivers.awsLogs({
+ streamPrefix: props.apiName,
+ logGroup,
+ }),
+ environment: props.environment,
+ });
+
+ // Security Groups
+ const albSg = new SecurityGroup(this, 'AlbSg', {
+ vpc: this.vpc,
+ allowAllOutbound: true,
+ });
+ suppressRules(
+ albSg,
+ ['CKV_AWS_260'],
+ 'Internal ALB only accessible via VPC Link from API Gateway',
+ );
+
+ const serviceSg = new SecurityGroup(this, 'ServiceSg', {
+ vpc: this.vpc,
+ allowAllOutbound: true,
+ });
+ serviceSg.addIngressRule(
+ albSg,
+ Port.tcp(props.containerPort),
+ 'Allow ALB to reach container',
+ );
+
+ // Fargate Service
+ this.service = new FargateService(this, 'Service', {
+ cluster,
+ taskDefinition,
+ desiredCount: props.desiredCount ?? 1,
+ assignPublicIp: false,
+ securityGroups: [serviceSg],
+ vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
+ });
+
+ // Internal ALB
+ this.loadBalancer = new ApplicationLoadBalancer(this, 'Alb', {
+ vpc: this.vpc,
+ internetFacing: false,
+ securityGroup: albSg,
+ });
+ suppressRules(
+ this.loadBalancer,
+ ['CKV_AWS_91'],
+ 'ALB access logging not required for internal ALB',
+ );
+ suppressRules(
+ this.loadBalancer,
+ ['CKV_AWS_131'],
+ 'Drop invalid headers not required for internal ALB',
+ );
+
+ const targetGroup = new ApplicationTargetGroup(this, 'TargetGroup', {
+ vpc: this.vpc,
+ port: props.containerPort,
+ protocol: ApplicationProtocol.HTTP,
+ targetType: TargetType.IP,
+ healthCheck: {
+ path: '/health',
+ interval: Duration.seconds(30),
+ },
+ });
+ this.service.attachToApplicationTargetGroup(targetGroup);
+
+ const httpListener = this.loadBalancer.addListener('HttpListener', {
+ port: 80,
+ defaultTargetGroups: [targetGroup],
+ });
+ suppressRules(
+ httpListener,
+ ['CKV_AWS_2', 'CKV_AWS_103'],
+ 'Internal ALB does not require HTTPS - traffic is within VPC via VPC Link',
+ );
+
+ // VPC Link + API Gateway HTTP API
+ const vpcLink = new VpcLink(this, 'VpcLink', {
+ vpc: this.vpc,
+ subnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
+ securityGroups: [albSg],
+ });
+
+ this.api = new _HttpApi(this, 'Api', {
+ createDefaultStage: false,
+ corsPreflight: {
+ allowOrigins: ['*'],
+ allowMethods: [CorsHttpMethod.ANY],
+ allowHeaders: [
+ 'authorization',
+ 'content-type',
+ 'x-amz-content-sha256',
+ 'x-amz-date',
+ 'x-amz-security-token',
+ ],
+ },
+ });
+
+ const accessLogGroup = new LogGroup(this, 'ApiAccessLogs', {
+ retention: RetentionDays.ONE_WEEK,
+ removalPolicy: RemovalPolicy.DESTROY,
+ });
+ suppressRules(
+ accessLogGroup,
+ ['CKV_AWS_158'],
+ 'Using default CloudWatch log encryption',
+ );
+
+ this.defaultStage = new HttpStage(this, 'DefaultStage', {
+ httpApi: this.api,
+ autoDeploy: true,
+ accessLogSettings: {
+ destination: new LogGroupLogDestination(accessLogGroup),
+ },
+ });
+
+ const albIntegration = new HttpAlbIntegration(
+ 'AlbIntegration',
+ this.loadBalancer.listeners[0],
+ { vpcLink },
+ );
+
+ this.api.addRoutes({
+ path: '/{proxy+}',
+ methods: [
+ HttpMethod.GET,
+ HttpMethod.POST,
+ HttpMethod.PUT,
+ HttpMethod.PATCH,
+ HttpMethod.DELETE,
+ HttpMethod.HEAD,
+ ],
+ integration: albIntegration,
+ ...(props.authorizer ? { authorizer: props.authorizer } : {}),
+ });
+
+ new CfnOutput(this, \`\${props.apiName}Url\`, {
+ value: this.defaultStage.url!,
+ });
+
+ // Register the API URL in runtime configuration
+ const rc = RuntimeConfig.ensure(this);
+ rc.set('connection', 'apis', {
+ ...rc.get('connection').apis,
+ [props.apiName]: this.defaultStage.url!,
+ });
+ }
+
+ /**
+ * Return the API url
+ */
+ public get url() {
+ return this.defaultStage.url;
+ }
+
+ /**
+ * Restricts CORS to the provided origins
+ */
+ public restrictCorsTo(...origins: string[]) {
+ const cfnApi = this.api.node.defaultChild;
+ if (!(cfnApi instanceof CfnApi)) {
+ throw new Error(
+ 'Unable to configure CORS: API default child is not a CfnApi instance',
+ );
+ }
+
+ cfnApi.corsConfiguration = {
+ allowOrigins: origins,
+ allowMethods: [CorsHttpMethod.ANY],
+ allowHeaders: [
+ 'authorization',
+ 'content-type',
+ 'x-amz-content-sha256',
+ 'x-amz-date',
+ 'x-amz-security-token',
+ ],
+ };
+ }
+}
+"
+`;
+
exports[`trpc backend generator > should generate the project > apps/test-api/src/client/index.ts 1`] = `
"import { createTRPCClient, httpLink, HTTPLinkOptions } from '@trpc/client';
import { AwsClient } from 'aws4fetch';
diff --git a/packages/nx-plugin/src/trpc/backend/files-ecs/Dockerfile.template b/packages/nx-plugin/src/trpc/backend/files-ecs/Dockerfile.template
new file mode 100644
index 000000000..ef2b8a0e1
--- /dev/null
+++ b/packages/nx-plugin/src/trpc/backend/files-ecs/Dockerfile.template
@@ -0,0 +1,8 @@
+FROM node:22-slim AS base
+WORKDIR /app
+
+COPY dist/<%= backendRoot %>/bundle/index.js ./index.js
+
+EXPOSE <%= port %>
+
+CMD ["node", "index.js"]
diff --git a/packages/nx-plugin/src/trpc/backend/files-ecs/src/index.ts.template b/packages/nx-plugin/src/trpc/backend/files-ecs/src/index.ts.template
new file mode 100644
index 000000000..1c7b71bd8
--- /dev/null
+++ b/packages/nx-plugin/src/trpc/backend/files-ecs/src/index.ts.template
@@ -0,0 +1,3 @@
+export type { AppRouter } from './router.js';
+export { appRouter } from './router.js';
+export * from './schema/index.js';
diff --git a/packages/nx-plugin/src/trpc/backend/files-ecs/src/init.ts.template b/packages/nx-plugin/src/trpc/backend/files-ecs/src/init.ts.template
new file mode 100644
index 000000000..7a57bbec7
--- /dev/null
+++ b/packages/nx-plugin/src/trpc/backend/files-ecs/src/init.ts.template
@@ -0,0 +1,5 @@
+import { initTRPC } from '@trpc/server';
+
+export const t = initTRPC.create();
+
+export const publicProcedure = t.procedure;
diff --git a/packages/nx-plugin/src/trpc/backend/files-ecs/src/local-server.ts.template b/packages/nx-plugin/src/trpc/backend/files-ecs/src/local-server.ts.template
new file mode 100644
index 000000000..292664acf
--- /dev/null
+++ b/packages/nx-plugin/src/trpc/backend/files-ecs/src/local-server.ts.template
@@ -0,0 +1,30 @@
+import Fastify from 'fastify';
+import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
+import { appRouter } from './router.js';
+
+const PORT = <%- port %>;
+
+const server = Fastify({ logger: true });
+
+// Enable CORS for local development
+server.addHook('onRequest', async (request, reply) => {
+ reply.header('Access-Control-Allow-Origin', '*');
+ reply.header('Access-Control-Allow-Methods', '*');
+ reply.header('Access-Control-Allow-Headers', '*');
+ if (request.method === 'OPTIONS') {
+ return reply.status(204).send();
+ }
+});
+
+void server.register(fastifyTRPCPlugin, {
+ prefix: '/trpc',
+ trpcOptions: { router: appRouter },
+});
+
+server.get('/health', async () => ({ status: 'ok' }));
+
+const start = async () => {
+ await server.listen({ port: PORT, host: '0.0.0.0' });
+};
+
+void start();
diff --git a/packages/nx-plugin/src/trpc/backend/files-ecs/src/server.ts.template b/packages/nx-plugin/src/trpc/backend/files-ecs/src/server.ts.template
new file mode 100644
index 000000000..efb157f4f
--- /dev/null
+++ b/packages/nx-plugin/src/trpc/backend/files-ecs/src/server.ts.template
@@ -0,0 +1,20 @@
+import Fastify from 'fastify';
+import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
+import { appRouter } from './router.js';
+
+const PORT = <%- port %>;
+
+const server = Fastify({ logger: true });
+
+void server.register(fastifyTRPCPlugin, {
+ prefix: '/trpc',
+ trpcOptions: { router: appRouter },
+});
+
+server.get('/health', async () => ({ status: 'ok' }));
+
+const start = async () => {
+ await server.listen({ port: PORT, host: '0.0.0.0' });
+};
+
+void start();
diff --git a/packages/nx-plugin/src/trpc/backend/files/src/schema/index.ts.template b/packages/nx-plugin/src/trpc/backend/files/src/schema/index.ts.template
deleted file mode 100644
index eed60014e..000000000
--- a/packages/nx-plugin/src/trpc/backend/files/src/schema/index.ts.template
+++ /dev/null
@@ -1 +0,0 @@
-export * from './echo.js';
\ No newline at end of file
diff --git a/packages/nx-plugin/src/trpc/backend/generator.spec.ts b/packages/nx-plugin/src/trpc/backend/generator.spec.ts
index f28522818..edecdff81 100644
--- a/packages/nx-plugin/src/trpc/backend/generator.spec.ts
+++ b/packages/nx-plugin/src/trpc/backend/generator.spec.ts
@@ -977,4 +977,389 @@ describe('trpc backend generator', () => {
expect(tree.exists('packages/apis/src')).toBeTruthy();
expect(tree.exists('packages/apis/src/index.ts')).toBeTruthy();
});
+
+ describe('EcsFargate computeType', () => {
+ it('should generate the project', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ expect(tree.exists('apps/test-api')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/index.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/server.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/local-server.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/router.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/init.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/procedures/echo.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/src/schema/echo.ts')).toBeTruthy();
+ expect(tree.exists('apps/test-api/Dockerfile')).toBeTruthy();
+
+ snapshotTreeDir(tree, 'apps/test-api/src');
+ snapshotTreeDir(tree, 'apps/test-api/Dockerfile');
+ });
+
+ it('should set up project configuration correctly', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const backendProjectConfig = JSON.parse(
+ tree.read('apps/test-api/project.json', 'utf-8')!,
+ );
+
+ expect(backendProjectConfig.metadata).toEqual({
+ apiName: 'TestApi',
+ apiType: 'trpc',
+ auth: 'IAM',
+ computeType: 'EcsFargate',
+ generator: TRPC_BACKEND_GENERATOR_INFO.id,
+ port: 3000,
+ ports: [3000],
+ });
+ });
+
+ it('should add required dependencies', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const packageJson = JSON.parse(tree.read('package.json', 'utf-8')!);
+
+ expect(packageJson.dependencies['@trpc/server']).toBeDefined();
+ expect(packageJson.dependencies['@trpc/client']).toBeDefined();
+ expect(packageJson.dependencies['zod']).toBeDefined();
+ expect(packageJson.dependencies['fastify']).toBeDefined();
+ expect(packageJson.dependencies['aws-cdk-lib']).toBeDefined();
+ expect(packageJson.dependencies['constructs']).toBeDefined();
+ expect(packageJson.devDependencies['tsx']).toBeDefined();
+ });
+
+ it('should add a serve target', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const projectConfig = readProjectConfiguration(tree, '@proj/test-api');
+ expect(projectConfig.targets).toHaveProperty('serve');
+ expect(projectConfig.targets!.serve!.executor).toBe('nx:run-commands');
+ expect(projectConfig.targets!.serve!.options!.commands).toEqual([
+ 'tsx --watch src/local-server.ts',
+ ]);
+ });
+
+ it('should add generator metric', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ expectHasMetricTags(tree, TRPC_BACKEND_GENERATOR_INFO.metric);
+ });
+
+ it('should increment ports when running generator multiple times', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'FirstApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ await tsTrpcApiGenerator(tree, {
+ name: 'SecondApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const firstApiConfig = JSON.parse(
+ tree.read('apps/first-api/project.json', 'utf-8')!,
+ );
+ const secondApiConfig = JSON.parse(
+ tree.read('apps/second-api/project.json', 'utf-8')!,
+ );
+
+ expect(firstApiConfig.metadata.ports).toEqual([3000]);
+ expect(secondApiConfig.metadata.ports).toEqual([3001]);
+
+ const firstServer = tree.read(
+ 'apps/first-api/src/local-server.ts',
+ 'utf-8',
+ );
+ const secondServer = tree.read(
+ 'apps/second-api/src/local-server.ts',
+ 'utf-8',
+ );
+
+ expect(firstServer).toContain('const PORT = 3000;');
+ expect(secondServer).toContain('const PORT = 3001;');
+ });
+
+ it('should generate a Dockerfile with the correct port', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const dockerfile = tree.read('apps/test-api/Dockerfile', 'utf-8');
+ expect(dockerfile).toContain('EXPOSE 3000');
+ expect(dockerfile).toContain('node:22-slim');
+ });
+
+ it('should use fastify adapter in server.ts', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const serverContent = tree.read('apps/test-api/src/server.ts', 'utf-8');
+ expect(serverContent).toContain('fastifyTRPCPlugin');
+ expect(serverContent).toContain("from 'fastify'");
+ expect(serverContent).toContain('/health');
+ });
+
+ it('should set up shared constructs with ECS infra', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ expect(
+ tree.exists('packages/common/constructs/src/app/ecs-apis/index.ts'),
+ ).toBeTruthy();
+ expect(
+ tree.exists('packages/common/constructs/src/app/ecs-apis/test-api.ts'),
+ ).toBeTruthy();
+ expect(
+ tree.exists('packages/common/constructs/src/core/ecs/ecs-api.ts'),
+ ).toBeTruthy();
+
+ expect(
+ tree.read(
+ 'packages/common/constructs/src/app/ecs-apis/index.ts',
+ 'utf-8',
+ ),
+ ).toContain("export * from './test-api.js'");
+ expect(
+ tree.read('packages/common/constructs/src/app/index.ts', 'utf-8'),
+ ).toContain("export * from './ecs-apis/index.js'");
+ });
+
+ it('should generate with IAM auth', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ const appApiContent = tree.read(
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ 'utf-8',
+ );
+ expect(appApiContent).toContain('HttpIamAuthorizer');
+ expect(appApiContent).toContain('grantInvokeAccess');
+
+ snapshotTreeDir(
+ tree,
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ );
+ });
+
+ it('should generate with Cognito auth', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'Cognito',
+ iacProvider: 'CDK',
+ });
+
+ const appApiContent = tree.read(
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ 'utf-8',
+ );
+ expect(appApiContent).toContain('HttpUserPoolAuthorizer');
+
+ snapshotTreeDir(
+ tree,
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ );
+ });
+
+ it('should generate with no auth', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'None',
+ iacProvider: 'CDK',
+ });
+
+ const appApiContent = tree.read(
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ 'utf-8',
+ );
+ expect(appApiContent).toContain('HttpNoneAuthorizer');
+
+ snapshotTreeDir(
+ tree,
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ );
+ });
+
+ it.each([
+ { directory: 'packages', backendRoot: 'packages/test-api' },
+ { directory: 'apps', backendRoot: 'apps/test-api' },
+ ])(
+ 'should have consistent Docker build paths when directory=$directory',
+ async ({ directory, backendRoot }) => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory,
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ // Verify Dockerfile COPY path references the correct bundle location
+ const dockerfile = tree.read(`${backendRoot}/Dockerfile`, 'utf-8');
+ expect(dockerfile).toContain(
+ `COPY dist/${backendRoot}/bundle/index.js ./index.js`,
+ );
+
+ // Verify rolldown config outputs to the matching path
+ const rolldownConfig = tree.read(
+ `${backendRoot}/rolldown.config.ts`,
+ 'utf-8',
+ );
+ expect(rolldownConfig).toContain(`dist/${backendRoot}/bundle/index.js`);
+
+ // Verify the construct sets dockerfilePath to the correct Dockerfile
+ const constructContent = tree.read(
+ 'packages/common/constructs/src/app/ecs-apis/test-api.ts',
+ 'utf-8',
+ );
+ expect(constructContent).toContain(
+ `dockerfilePath: '${backendRoot}/Dockerfile'`,
+ );
+
+ // Verify the construct Docker context resolves to workspace root (6 levels up)
+ expect(constructContent).toContain(
+ "new URL('../../../../../../', import.meta.url)",
+ );
+
+ // Verify shared constructs build depends on the API bundle
+ const sharedConstructsConfig = JSON.parse(
+ tree.read('packages/common/constructs/project.json', 'utf-8')!,
+ );
+ expect(sharedConstructsConfig.targets.build.dependsOn).toContainEqual(
+ `@proj/test-api:bundle`,
+ );
+
+ // Verify the API build depends on bundle
+ const apiConfig = JSON.parse(
+ tree.read(`${backendRoot}/project.json`, 'utf-8')!,
+ );
+ expect(apiConfig.targets.build.dependsOn).toContain('bundle');
+
+ // Verify the bundle target outputs to the correct location
+ expect(apiConfig.targets.bundle.outputs).toEqual([
+ `{workspaceRoot}/dist/{projectRoot}/bundle`,
+ ]);
+ },
+ );
+
+ it('should snapshot the core ECS construct', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ expect(
+ tree.read(
+ 'packages/common/constructs/src/core/ecs/ecs-api.ts',
+ 'utf-8',
+ ),
+ ).toMatchSnapshot('ecs-api.ts');
+ });
+
+ it('should generate bundle target with correct rolldown config', async () => {
+ await tsTrpcApiGenerator(tree, {
+ name: 'DemoEcsApi',
+ directory: 'packages',
+ computeType: 'EcsFargate',
+ auth: 'None',
+ iacProvider: 'CDK',
+ });
+
+ const rolldownConfig = tree.read(
+ 'packages/demo-ecs-api/rolldown.config.ts',
+ 'utf-8',
+ );
+ expect(rolldownConfig).toMatchSnapshot('rolldown.config.ts');
+
+ // Verify compile outputs are scoped to tsc subdirectory only
+ const apiConfig = JSON.parse(
+ tree.read('packages/demo-ecs-api/project.json', 'utf-8')!,
+ );
+ expect(apiConfig.targets.compile.outputs).toEqual([
+ '{workspaceRoot}/dist/{projectRoot}/tsc',
+ ]);
+ // Verify bundle outputs are scoped to bundle subdirectory only
+ expect(apiConfig.targets.bundle.outputs).toEqual([
+ '{workspaceRoot}/dist/{projectRoot}/bundle',
+ ]);
+ });
+
+ it('should throw for unsupported integration pattern', async () => {
+ await expect(
+ tsTrpcApiGenerator(tree, {
+ name: 'TestApi',
+ directory: 'apps',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ integrationPattern: 'shared',
+ iacProvider: 'CDK',
+ }),
+ ).rejects.toThrow(
+ 'Invalid tRPC computeType/integrationPattern combination: EcsFargate + shared.',
+ );
+
+ expect(tree.exists('apps/test-api')).toBeFalsy();
+ });
+ });
});
diff --git a/packages/nx-plugin/src/trpc/backend/generator.ts b/packages/nx-plugin/src/trpc/backend/generator.ts
index 1cf7e55f4..d10b75aa3 100644
--- a/packages/nx-plugin/src/trpc/backend/generator.ts
+++ b/packages/nx-plugin/src/trpc/backend/generator.ts
@@ -29,6 +29,7 @@ import {
} from '../../utils/nx';
import { addGeneratorMetricsIfApplicable } from '../../utils/metrics';
import { addApiGatewayInfra } from '../../utils/api-constructs/api-constructs';
+import { addEcsInfra } from '../../utils/ecs-constructs/ecs-constructs';
import { assignPort } from '../../utils/port';
import { resolveIacProvider } from '../../utils/iac';
import { addTypeScriptBundleTarget } from '../../utils/bundle/bundle';
@@ -41,8 +42,12 @@ const VALID_TRPC_INTEGRATION_PERMUTATIONS = new Set([
'ServerlessApiGatewayRestApi::shared',
'ServerlessApiGatewayHttpApi::isolated',
'ServerlessApiGatewayHttpApi::shared',
+ 'EcsFargate::isolated',
]);
+const isEcsFargate = (options: TsTrpcApiGeneratorSchema) =>
+ options.computeType === 'EcsFargate';
+
export async function tsTrpcApiGenerator(
tree: Tree,
options: TsTrpcApiGeneratorSchema,
@@ -74,7 +79,8 @@ export async function tsTrpcApiGenerator(
);
const backendRoot = projectConfig.root;
- const port = assignPort(tree, projectConfig, 2022);
+ const defaultPort = isEcsFargate(options) ? 3000 : 2022;
+ const port = assignPort(tree, projectConfig, defaultPort);
const enhancedOptions = {
backendProjectName,
@@ -83,26 +89,41 @@ export async function tsTrpcApiGenerator(
apiNameClassName,
backendRoot,
pkgMgrCmd: getPackageManagerDisplayCommands().exec,
- apiGatewayEventType: getApiGatewayEventType(options),
+ apiGatewayEventType: isEcsFargate(options)
+ ? undefined
+ : getApiGatewayEventType(options),
port,
...options,
};
- await addApiGatewayInfra(tree, {
- apiProjectName: backendProjectName,
- apiNameClassName,
- apiNameKebabCase,
- constructType:
- options.computeType === 'ServerlessApiGatewayHttpApi' ? 'http' : 'rest',
- backend: {
- type: 'trpc',
- projectAlias: enhancedOptions.backendProjectAlias,
- bundleOutputDir: joinPathFragments('dist', backendRoot, 'bundle'),
- integrationPattern: getIntegrationPattern(options),
- },
- auth: options.auth,
- iacProvider,
- });
+ if (isEcsFargate(options)) {
+ addEcsInfra(tree, {
+ apiProjectName: backendProjectName,
+ apiNameClassName,
+ apiNameKebabCase,
+ backendProjectAlias: enhancedOptions.backendProjectAlias,
+ backendRoot,
+ port,
+ auth: options.auth,
+ iacProvider,
+ });
+ } else {
+ await addApiGatewayInfra(tree, {
+ apiProjectName: backendProjectName,
+ apiNameClassName,
+ apiNameKebabCase,
+ constructType:
+ options.computeType === 'ServerlessApiGatewayHttpApi' ? 'http' : 'rest',
+ backend: {
+ type: 'trpc',
+ projectAlias: enhancedOptions.backendProjectAlias,
+ bundleOutputDir: joinPathFragments('dist', backendRoot, 'bundle'),
+ integrationPattern: getIntegrationPattern(options),
+ },
+ auth: options.auth,
+ iacProvider,
+ });
+ }
projectConfig.metadata = {
...projectConfig.metadata,
@@ -110,7 +131,9 @@ export async function tsTrpcApiGenerator(
apiType: 'trpc',
auth: options.auth,
computeType: options.computeType,
- integrationPattern: getIntegrationPattern(options),
+ ...(isEcsFargate(options)
+ ? { port }
+ : { integrationPattern: getIntegrationPattern(options) }),
} as unknown;
projectConfig.targets.serve = {
@@ -122,10 +145,16 @@ export async function tsTrpcApiGenerator(
continuous: true,
};
- await addTypeScriptBundleTarget(tree, projectConfig, {
- targetFilePath: 'src/handler.ts',
- external: [/@aws-sdk\/.*/], // lambda runtime provides aws sdk
- });
+ if (isEcsFargate(options)) {
+ await addTypeScriptBundleTarget(tree, projectConfig, {
+ targetFilePath: 'src/server.ts',
+ });
+ } else {
+ await addTypeScriptBundleTarget(tree, projectConfig, {
+ targetFilePath: 'src/handler.ts',
+ external: [/@aws-sdk\/.*/], // lambda runtime provides aws sdk
+ });
+ }
addDependencyToTargetIfNotPresent(projectConfig, 'build', 'bundle');
@@ -133,9 +162,34 @@ export async function tsTrpcApiGenerator(
updateProjectConfiguration(tree, projectConfig.name, projectConfig);
+ if (isEcsFargate(options)) {
+ // Generate ECS-specific template files
+ generateFiles(
+ tree,
+ joinPathFragments(__dirname, 'files-ecs'),
+ backendRoot,
+ enhancedOptions,
+ {
+ overwriteStrategy: OverwriteStrategy.Overwrite,
+ },
+ );
+ } else {
+ // Generate Lambda-specific template files
+ generateFiles(
+ tree,
+ joinPathFragments(__dirname, 'files'),
+ backendRoot,
+ enhancedOptions,
+ {
+ overwriteStrategy: OverwriteStrategy.Overwrite,
+ },
+ );
+ }
+
+ // Generate shared tRPC template files (router, procedures, schema)
generateFiles(
tree,
- joinPathFragments(__dirname, 'files'),
+ joinPathFragments(__dirname, '../../utils/files/trpc'),
backendRoot,
enhancedOptions,
{
@@ -145,30 +199,38 @@ export async function tsTrpcApiGenerator(
tree.delete(joinPathFragments(backendRoot, 'src', 'lib'));
- // Remove streaming schema helper for HTTP APIs (API Gateway HTTP API doesn't support streaming)
+ // Remove streaming schema helper for HTTP APIs and ECS (API Gateway HTTP API doesn't support streaming)
if (options.computeType !== 'ServerlessApiGatewayRestApi') {
tree.delete(
joinPathFragments(backendRoot, 'src', 'schema', 'z-async-iterable.ts'),
);
}
- addDependenciesToPackageJson(
- tree,
- withVersions([
- 'aws-xray-sdk-core',
- 'zod',
- '@aws-lambda-powertools/logger',
- '@aws-lambda-powertools/metrics',
- '@aws-lambda-powertools/parameters',
- '@aws-lambda-powertools/tracer',
- '@aws-sdk/client-appconfigdata',
- '@trpc/server',
- '@trpc/client',
- 'aws4fetch',
- '@aws-sdk/credential-providers',
- ]),
- withVersions(['@types/aws-lambda', 'tsx', 'cors', '@types/cors']),
- );
+ if (isEcsFargate(options)) {
+ addDependenciesToPackageJson(
+ tree,
+ withVersions(['zod', '@trpc/server', '@trpc/client', 'fastify']),
+ withVersions(['tsx']),
+ );
+ } else {
+ addDependenciesToPackageJson(
+ tree,
+ withVersions([
+ 'aws-xray-sdk-core',
+ 'zod',
+ '@aws-lambda-powertools/logger',
+ '@aws-lambda-powertools/metrics',
+ '@aws-lambda-powertools/parameters',
+ '@aws-lambda-powertools/tracer',
+ '@aws-sdk/client-appconfigdata',
+ '@trpc/server',
+ '@trpc/client',
+ 'aws4fetch',
+ '@aws-sdk/credential-providers',
+ ]),
+ withVersions(['@types/aws-lambda', 'tsx', 'cors', '@types/cors']),
+ );
+ }
tree.delete(joinPathFragments(backendRoot, 'package.json'));
addGeneratorMetadata(tree, backendName, TRPC_BACKEND_GENERATOR_INFO);
diff --git a/packages/nx-plugin/src/trpc/backend/schema.d.ts b/packages/nx-plugin/src/trpc/backend/schema.d.ts
index 89c53eed5..34efdfe47 100644
--- a/packages/nx-plugin/src/trpc/backend/schema.d.ts
+++ b/packages/nx-plugin/src/trpc/backend/schema.d.ts
@@ -8,7 +8,10 @@ import { TsProjectGeneratorSchema } from '../../ts/lib/schema';
export interface TsTrpcApiGeneratorSchema {
name: string;
- computeType: 'ServerlessApiGatewayRestApi' | 'ServerlessApiGatewayHttpApi';
+ computeType:
+ | 'ServerlessApiGatewayRestApi'
+ | 'ServerlessApiGatewayHttpApi'
+ | 'EcsFargate';
integrationPattern?: 'isolated' | 'shared';
auth: 'IAM' | 'Cognito' | 'None';
directory?: TsProjectGeneratorSchema['directory'];
diff --git a/packages/nx-plugin/src/trpc/backend/schema.json b/packages/nx-plugin/src/trpc/backend/schema.json
index 78c4f70e1..53bf32acb 100644
--- a/packages/nx-plugin/src/trpc/backend/schema.json
+++ b/packages/nx-plugin/src/trpc/backend/schema.json
@@ -15,9 +15,13 @@
},
"computeType": {
"type": "string",
- "description": "The type of compute to use to deploy this API. Choose between ServerlessApiGatewayRestApi (default) or ServerlessApiGatewayHttpApi.",
+ "description": "The type of compute to use to deploy this API. Choose between ServerlessApiGatewayRestApi (default), ServerlessApiGatewayHttpApi, or EcsFargate.",
"default": "ServerlessApiGatewayRestApi",
- "enum": ["ServerlessApiGatewayRestApi", "ServerlessApiGatewayHttpApi"],
+ "enum": [
+ "ServerlessApiGatewayRestApi",
+ "ServerlessApiGatewayHttpApi",
+ "EcsFargate"
+ ],
"x-prompt": "What compute type would you like to deploy your API with?",
"x-priority": "important"
},
@@ -66,7 +70,7 @@
},
"iacProvider": {
"type": "string",
- "description": "The preferred IaC provider. By default this is inherited from your initial selection.",
+ "description": "The preferred IaC provider. By default this is inherited from your initial selection. EcsFargate only supports CDK.",
"enum": ["Inherit", "CDK", "Terraform"],
"x-priority": "important",
"default": "Inherit",
diff --git a/packages/nx-plugin/src/trpc/react/__snapshots__/generator.spec.ts.snap b/packages/nx-plugin/src/trpc/react/__snapshots__/generator.spec.ts.snap
index 39ea2a5c4..e41b12843 100644
--- a/packages/nx-plugin/src/trpc/react/__snapshots__/generator.spec.ts.snap
+++ b/packages/nx-plugin/src/trpc/react/__snapshots__/generator.spec.ts.snap
@@ -1,5 +1,179 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`ECS tRPC API > should generate client provider for ECS > TestApiClientProvider-ECS.tsx 1`] = `
+"import { AppRouter } from 'backend';
+import { useQueryClient } from '@tanstack/react-query';
+import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
+import { createContext, FC, PropsWithChildren, useMemo } from 'react';
+import { useRuntimeConfig } from '../hooks/useRuntimeConfig';
+import {
+ HTTPLinkOptions,
+ TRPCClient,
+ createTRPCClient,
+ httpLink,
+} from '@trpc/client';
+
+interface TestApiTRPCContextValue {
+ optionsProxy: ReturnType>;
+ client: TRPCClient;
+}
+
+export const TestApiTRPCContext = createContext(
+ null,
+);
+
+export const TestApiClientProvider: FC = ({ children }) => {
+ const queryClient = useQueryClient();
+ const runtimeConfig = useRuntimeConfig();
+ const apiUrl = runtimeConfig.apis.TestApi;
+
+ const container = useMemo(() => {
+ const linkOptions: HTTPLinkOptions = {
+ url: \`\${apiUrl}trpc\`,
+ };
+
+ const client = createTRPCClient({
+ links: [httpLink(linkOptions)],
+ });
+
+ const optionsProxy = createTRPCOptionsProxy({
+ client,
+ queryClient,
+ });
+
+ return { optionsProxy, client };
+ }, [apiUrl, queryClient]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default TestApiClientProvider;
+"
+`;
+
+exports[`ECS tRPC API > should generate client provider for ECS with Cognito auth > TestApiClientProvider-ECS-Cognito.tsx 1`] = `
+"import { AppRouter } from 'backend';
+import { useQueryClient } from '@tanstack/react-query';
+import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
+import { createContext, FC, PropsWithChildren, useMemo } from 'react';
+import { useRuntimeConfig } from '../hooks/useRuntimeConfig';
+import {
+ HTTPLinkOptions,
+ TRPCClient,
+ createTRPCClient,
+ httpLink,
+} from '@trpc/client';
+import { useAuth } from 'react-oidc-context';
+
+interface TestApiTRPCContextValue {
+ optionsProxy: ReturnType>;
+ client: TRPCClient;
+}
+
+export const TestApiTRPCContext = createContext(
+ null,
+);
+
+export const TestApiClientProvider: FC = ({ children }) => {
+ const queryClient = useQueryClient();
+ const runtimeConfig = useRuntimeConfig();
+ const apiUrl = runtimeConfig.apis.TestApi;
+ const auth = useAuth();
+ const user = auth?.user;
+
+ const container = useMemo(() => {
+ const linkOptions: HTTPLinkOptions = {
+ url: \`\${apiUrl}trpc\`,
+ headers: {
+ Authorization: \`Bearer \${user?.id_token}\`,
+ },
+ };
+
+ const client = createTRPCClient({
+ links: [httpLink(linkOptions)],
+ });
+
+ const optionsProxy = createTRPCOptionsProxy({
+ client,
+ queryClient,
+ });
+
+ return { optionsProxy, client };
+ }, [apiUrl, queryClient, user]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default TestApiClientProvider;
+"
+`;
+
+exports[`ECS tRPC API > should generate client provider for ECS with IAM auth > TestApiClientProvider-ECS-IAM.tsx 1`] = `
+"import { AppRouter } from 'backend';
+import { useQueryClient } from '@tanstack/react-query';
+import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
+import { createContext, FC, PropsWithChildren, useMemo } from 'react';
+import { useRuntimeConfig } from '../hooks/useRuntimeConfig';
+import {
+ HTTPLinkOptions,
+ TRPCClient,
+ createTRPCClient,
+ httpLink,
+} from '@trpc/client';
+import { useSigV4 } from '../hooks/useSigV4';
+
+interface TestApiTRPCContextValue {
+ optionsProxy: ReturnType>;
+ client: TRPCClient;
+}
+
+export const TestApiTRPCContext = createContext(
+ null,
+);
+
+export const TestApiClientProvider: FC = ({ children }) => {
+ const queryClient = useQueryClient();
+ const runtimeConfig = useRuntimeConfig();
+ const apiUrl = runtimeConfig.apis.TestApi;
+ const sigv4Client = useSigV4();
+
+ const container = useMemo(() => {
+ const linkOptions: HTTPLinkOptions = {
+ url: \`\${apiUrl}trpc\`,
+ fetch: sigv4Client.fetch,
+ };
+
+ const client = createTRPCClient({
+ links: [httpLink(linkOptions)],
+ });
+
+ const optionsProxy = createTRPCOptionsProxy({
+ client,
+ queryClient,
+ });
+
+ return { optionsProxy, client };
+ }, [apiUrl, queryClient, sigv4Client]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default TestApiClientProvider;
+"
+`;
+
exports[`trpc react generator > REST API (ServerlessApiGatewayRestApi) > should generate REST API client provider with splitLink for Cognito auth > TestApiClientProvider-REST-Cognito.tsx 1`] = `
"import { AppRouter } from 'backend';
import { useQueryClient } from '@tanstack/react-query';
diff --git a/packages/nx-plugin/src/trpc/react/files/src/components/__apiNameClassName__ClientProvider.tsx.template b/packages/nx-plugin/src/trpc/react/files/src/components/__apiNameClassName__ClientProvider.tsx.template
index 3319d34c9..36da234c7 100644
--- a/packages/nx-plugin/src/trpc/react/files/src/components/__apiNameClassName__ClientProvider.tsx.template
+++ b/packages/nx-plugin/src/trpc/react/files/src/components/__apiNameClassName__ClientProvider.tsx.template
@@ -87,7 +87,11 @@ export const <%= apiNameClassName %>ClientProvider: FC = ({
});
<%_ } else { _%>
const linkOptions: HTTPLinkOptions = {
+<%_ if (isEcs) { _%>
+ url: `${apiUrl}trpc`,
+<%_ } else { _%>
url: apiUrl,
+<%_ } _%>
<%_ if (auth === 'IAM') { _%>
fetch: sigv4Client.fetch,
<%_ } else if (auth === 'Cognito') { _%>
diff --git a/packages/nx-plugin/src/trpc/react/generator.spec.ts b/packages/nx-plugin/src/trpc/react/generator.spec.ts
index 6f0ecf9c0..f5435a4cc 100644
--- a/packages/nx-plugin/src/trpc/react/generator.spec.ts
+++ b/packages/nx-plugin/src/trpc/react/generator.spec.ts
@@ -301,6 +301,112 @@ export function Main() {
});
});
+describe('ECS tRPC API', () => {
+ let tree: Tree;
+
+ beforeEach(() => {
+ tree = createTreeUsingTsSolutionSetup();
+ tree.write(
+ 'apps/frontend/project.json',
+ JSON.stringify({
+ name: 'frontend',
+ root: 'apps/frontend',
+ sourceRoot: 'apps/frontend/src',
+ }),
+ );
+ tree.write(
+ 'apps/backend/project.json',
+ JSON.stringify({
+ name: 'backend',
+ root: 'apps/backend',
+ sourceRoot: 'apps/backend/src',
+ metadata: {
+ apiName: 'TestApi',
+ apiType: 'trpc',
+ computeType: 'EcsFargate',
+ auth: 'None',
+ port: 3000,
+ },
+ }),
+ );
+ tree.write(
+ 'apps/frontend/src/main.tsx',
+ `
+import { RouterProvider } from '@tanstack/react-router';
+
+const App = () => ;
+
+export function Main() {
+ return ;
+}
+`,
+ );
+ });
+
+ it('should generate client provider for ECS', async () => {
+ await reactGenerator(tree, {
+ frontendProjectName: 'frontend',
+ backendProjectName: 'backend',
+ });
+
+ const clientProvider = tree.read(
+ 'apps/frontend/src/components/TestApiClientProvider.tsx',
+ 'utf-8',
+ );
+
+ expect(clientProvider).toContain('url: `${apiUrl}trpc`');
+ expect(clientProvider).toMatchSnapshot('TestApiClientProvider-ECS.tsx');
+ });
+
+ it('should generate client provider for ECS with IAM auth', async () => {
+ updateJson(tree, 'apps/backend/project.json', (config) => ({
+ ...config,
+ metadata: {
+ ...config.metadata,
+ auth: 'IAM',
+ },
+ }));
+
+ await reactGenerator(tree, {
+ frontendProjectName: 'frontend',
+ backendProjectName: 'backend',
+ });
+
+ const clientProvider = tree.read(
+ 'apps/frontend/src/components/TestApiClientProvider.tsx',
+ 'utf-8',
+ );
+
+ expect(clientProvider).toContain('url: `${apiUrl}trpc`');
+ expect(clientProvider).toMatchSnapshot('TestApiClientProvider-ECS-IAM.tsx');
+ });
+
+ it('should generate client provider for ECS with Cognito auth', async () => {
+ updateJson(tree, 'apps/backend/project.json', (config) => ({
+ ...config,
+ metadata: {
+ ...config.metadata,
+ auth: 'Cognito',
+ },
+ }));
+
+ await reactGenerator(tree, {
+ frontendProjectName: 'frontend',
+ backendProjectName: 'backend',
+ });
+
+ const clientProvider = tree.read(
+ 'apps/frontend/src/components/TestApiClientProvider.tsx',
+ 'utf-8',
+ );
+
+ expect(clientProvider).toContain('url: `${apiUrl}trpc`');
+ expect(clientProvider).toMatchSnapshot(
+ 'TestApiClientProvider-ECS-Cognito.tsx',
+ );
+ });
+});
+
describe('trpc react generator with unqualified names', () => {
let tree: Tree;
@@ -490,3 +596,42 @@ describe('trpc react generator with real react and trpc projects', () => {
expect(runtimeConfigContent).toContain('http://localhost:2023/');
});
});
+
+describe('trpc react generator with real ECS tRPC project', () => {
+ let tree: Tree;
+
+ beforeEach(async () => {
+ tree = createTreeUsingTsSolutionSetup();
+
+ // Generate a React website
+ await tsReactWebsiteGenerator(tree, {
+ name: 'frontend',
+ skipInstall: true,
+ iacProvider: 'CDK',
+ });
+ });
+
+ it('should generate client provider with trpc url suffix when using real ECS tRPC generator', async () => {
+ // Generate an ECS tRPC backend using the real generator
+ await tsTrpcApiGenerator(tree, {
+ name: 'DemoEcsApi',
+ directory: 'packages',
+ computeType: 'EcsFargate',
+ auth: 'IAM',
+ iacProvider: 'CDK',
+ });
+
+ // Connect the frontend to the ECS tRPC backend
+ await reactGenerator(tree, {
+ frontendProjectName: 'frontend',
+ backendProjectName: 'demo-ecs-api',
+ });
+
+ const clientProvider = tree.read(
+ 'frontend/src/components/DemoEcsApiClientProvider.tsx',
+ 'utf-8',
+ );
+
+ expect(clientProvider).toContain('url: `${apiUrl}trpc`');
+ });
+});
diff --git a/packages/nx-plugin/src/trpc/react/generator.ts b/packages/nx-plugin/src/trpc/react/generator.ts
index 4fb29e4c0..1a0c99a78 100644
--- a/packages/nx-plugin/src/trpc/react/generator.ts
+++ b/packages/nx-plugin/src/trpc/react/generator.ts
@@ -46,6 +46,7 @@ export async function reactGenerator(
const auth = metadata.auth ?? 'IAM';
const port = metadata.port ?? metadata.ports?.[0] ?? 2022;
const isRestApi = metadata.computeType === 'ServerlessApiGatewayRestApi';
+ const isEcs = metadata.computeType === 'EcsFargate';
const apiNameClassName = toClassName(apiName);
const backendProjectAlias = toScopeAlias(backendProjectConfig.name);
@@ -59,6 +60,7 @@ export async function reactGenerator(
...options,
auth,
isRestApi,
+ isEcs,
backendProjectAlias,
},
{
diff --git a/packages/nx-plugin/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap b/packages/nx-plugin/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap
index 10a9973a0..bafe00629 100644
--- a/packages/nx-plugin/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap
+++ b/packages/nx-plugin/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap
@@ -2419,10 +2419,13 @@ export default defineConfig(() => ({
host: 'localhost',
},
plugins: [react(), tailwindcss(), tsconfigPaths()],
+<<<<<<< HEAD
// Uncomment this if you are using workers.
// worker: {
// plugins: [],
// },
+=======
+>>>>>>> 83c13289 (fix(ts#react-website): revert tsconfigPaths config that broke scoped alias resolution)
build: {
outDir: '../dist/test-app',
emptyOutDir: true,
diff --git a/packages/nx-plugin/src/utils/api-constructs/api-constructs.ts b/packages/nx-plugin/src/utils/api-constructs/api-constructs.ts
index c93d38e45..3add3d036 100644
--- a/packages/nx-plugin/src/utils/api-constructs/api-constructs.ts
+++ b/packages/nx-plugin/src/utils/api-constructs/api-constructs.ts
@@ -87,13 +87,11 @@ export const addApiGatewayInfra = async (
};
/**
- * Add an API CDK construct, and update the Runtime Config type to export its url
+ * Generate core API CDK files (utils, trpc, http, rest) into the shared constructs directory.
+ * Shared between API Gateway and ECS constructs.
*/
-const addApiGatewayCdkConstructs = async (
- tree: Tree,
- options: AddApiGatewayConstructOptions,
-) => {
- const generateCoreApiFile = (name: string) => {
+export const generateCoreApiCdkFiles = (tree: Tree, names: string[]) => {
+ for (const name of names) {
generateFiles(
tree,
joinPathFragments(__dirname, 'files', 'cdk', 'core', 'api', name),
@@ -109,14 +107,22 @@ const addApiGatewayCdkConstructs = async (
overwriteStrategy: OverwriteStrategy.KeepExisting,
},
);
- };
+ }
+};
+/**
+ * Add an API CDK construct, and update the Runtime Config type to export its url
+ */
+const addApiGatewayCdkConstructs = async (
+ tree: Tree,
+ options: AddApiGatewayConstructOptions,
+) => {
// Generate relevant core CDK construct and utilities
- generateCoreApiFile(options.constructType);
- generateCoreApiFile('utils');
- if (options.backend.type === 'trpc') {
- generateCoreApiFile('trpc');
- }
+ generateCoreApiCdkFiles(tree, [
+ options.constructType,
+ 'utils',
+ ...(options.backend.type === 'trpc' ? ['trpc'] : []),
+ ]);
// Generate app specific CDK construct
generateFiles(
diff --git a/packages/nx-plugin/src/utils/ecs-constructs/ecs-constructs.ts b/packages/nx-plugin/src/utils/ecs-constructs/ecs-constructs.ts
new file mode 100644
index 000000000..40d99d8bb
--- /dev/null
+++ b/packages/nx-plugin/src/utils/ecs-constructs/ecs-constructs.ts
@@ -0,0 +1,124 @@
+/**
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+ generateFiles,
+ joinPathFragments,
+ OverwriteStrategy,
+ ProjectConfiguration,
+ Tree,
+ updateJson,
+} from '@nx/devkit';
+import {
+ PACKAGES_DIR,
+ SHARED_CONSTRUCTS_DIR,
+} from '../shared-constructs-constants';
+import { addStarExport } from '../ast';
+import { IacProvider } from '../iac';
+import { generateCoreApiCdkFiles } from '../api-constructs/api-constructs';
+
+export interface AddEcsInfraOptions {
+ apiProjectName: string;
+ apiNameClassName: string;
+ apiNameKebabCase: string;
+ backendProjectAlias: string;
+ backendRoot: string;
+ port: number;
+ auth: 'IAM' | 'Cognito' | 'None';
+}
+
+export const addEcsInfra = (
+ tree: Tree,
+ options: AddEcsInfraOptions & { iacProvider: IacProvider },
+) => {
+ if (options.iacProvider === 'CDK') {
+ addEcsCdkConstructs(tree, options);
+ } else {
+ throw new Error(
+ `ECS infrastructure is currently only supported with CDK, not ${options.iacProvider}`,
+ );
+ }
+
+ updateJson(
+ tree,
+ joinPathFragments(PACKAGES_DIR, SHARED_CONSTRUCTS_DIR, 'project.json'),
+ (config: ProjectConfiguration) => {
+ if (!config.targets) {
+ config.targets = {};
+ }
+ if (!config.targets.build) {
+ config.targets.build = {};
+ }
+ config.targets.build.dependsOn = [
+ ...(config.targets.build.dependsOn ?? []),
+ `${options.apiProjectName}:bundle`,
+ ];
+ return config;
+ },
+ );
+};
+
+const addEcsCdkConstructs = (tree: Tree, options: AddEcsInfraOptions) => {
+ // Generate core ECS construct
+ generateFiles(
+ tree,
+ joinPathFragments(__dirname, 'files', 'cdk', 'core', 'ecs'),
+ joinPathFragments(
+ PACKAGES_DIR,
+ SHARED_CONSTRUCTS_DIR,
+ 'src',
+ 'core',
+ 'ecs',
+ ),
+ {},
+ {
+ overwriteStrategy: OverwriteStrategy.KeepExisting,
+ },
+ );
+
+ // Generate shared core API CDK files (utils, trpc, http)
+ generateCoreApiCdkFiles(tree, ['utils', 'trpc', 'http']);
+
+ // Generate app-specific ECS construct
+ generateFiles(
+ tree,
+ joinPathFragments(__dirname, 'files', 'cdk', 'app', 'ecs-apis'),
+ joinPathFragments(
+ PACKAGES_DIR,
+ SHARED_CONSTRUCTS_DIR,
+ 'src',
+ 'app',
+ 'ecs-apis',
+ ),
+ options,
+ {
+ overwriteStrategy: OverwriteStrategy.KeepExisting,
+ },
+ );
+
+ // Export app-specific ECS construct
+ addStarExport(
+ tree,
+ joinPathFragments(
+ PACKAGES_DIR,
+ SHARED_CONSTRUCTS_DIR,
+ 'src',
+ 'app',
+ 'ecs-apis',
+ 'index.ts',
+ ),
+ `./${options.apiNameKebabCase}.js`,
+ );
+ addStarExport(
+ tree,
+ joinPathFragments(
+ PACKAGES_DIR,
+ SHARED_CONSTRUCTS_DIR,
+ 'src',
+ 'app',
+ 'index.ts',
+ ),
+ './ecs-apis/index.js',
+ );
+};
diff --git a/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/app/ecs-apis/__apiNameKebabCase__.ts.template b/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/app/ecs-apis/__apiNameKebabCase__.ts.template
new file mode 100644
index 000000000..ad072ea4a
--- /dev/null
+++ b/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/app/ecs-apis/__apiNameKebabCase__.ts.template
@@ -0,0 +1,73 @@
+import { Construct } from 'constructs';
+import * as url from 'url';
+import { EcsApi, type EcsApiProps } from '../../core/ecs/ecs-api.js';
+<%_ if (auth === 'IAM') { _%>
+import { HttpIamAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
+import { Grant, IGrantable } from 'aws-cdk-lib/aws-iam';
+<%_ } else if (auth === 'Cognito') { _%>
+import { HttpUserPoolAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
+import { IUserPool, IUserPoolClient } from 'aws-cdk-lib/aws-cognito';
+<%_ } else { _%>
+import { HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
+<%_ } _%>
+
+<%_ if (auth === 'Cognito') { _%>
+/**
+ * Properties for creating a <%= apiNameClassName %> ECS API
+ */
+export interface <%= apiNameClassName %>Props extends Partial> {
+ /**
+ * Identity details for Cognito Authentication
+ */
+ readonly identity: {
+ readonly userPool: IUserPool;
+ readonly userPoolClient: IUserPoolClient;
+ };
+}
+<%_ } else { _%>
+/**
+ * Properties for creating a <%= apiNameClassName %> ECS API
+ */
+export type <%= apiNameClassName %>Props = Partial>;
+<%_ } _%>
+
+/**
+ * A CDK construct that creates an ECS Fargate service for <%= apiNameClassName %>
+ * fronted by API Gateway HTTP API with VPC Link.
+ */
+export class <%= apiNameClassName %> extends EcsApi {
+ constructor(scope: Construct, id: string, props: <%= apiNameClassName %>Props = {}) {
+ super(scope, id, {
+ apiName: '<%= apiNameClassName %>',
+ dockerContextPath: url.fileURLToPath(
+ new URL('../../../../../../', import.meta.url),
+ ),
+ dockerfilePath: '<%= backendRoot %>/Dockerfile',
+ dockerExclude: ['**/node_modules', '**/.nx', '**/src', '**/.git', '**/cdk.out'],
+ containerPort: <%= port %>,
+ <%_ if (auth === 'IAM') { _%>
+ authorizer: new HttpIamAuthorizer(),
+ <%_ } else if (auth === 'Cognito') { _%>
+ authorizer: new HttpUserPoolAuthorizer('<%= apiNameClassName %>Authorizer', props.identity!.userPool, {
+ userPoolClients: [props.identity!.userPoolClient],
+ }),
+ <%_ } else { _%>
+ authorizer: new HttpNoneAuthorizer(),
+ <%_ } _%>
+ ...props,
+ });
+ }
+ <%_ if (auth === 'IAM') { _%>
+
+ /**
+ * Grants IAM permissions to invoke any method on this API.
+ */
+ public grantInvokeAccess(grantee: IGrantable) {
+ Grant.addToPrincipal({
+ grantee,
+ actions: ['execute-api:Invoke'],
+ resourceArns: [this.api.arnForExecuteApi('*', '/*', '*')],
+ });
+ }
+ <%_ } _%>
+}
diff --git a/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/core/ecs/ecs-api.ts.template b/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/core/ecs/ecs-api.ts.template
new file mode 100644
index 000000000..33bfa1987
--- /dev/null
+++ b/packages/nx-plugin/src/utils/ecs-constructs/files/cdk/core/ecs/ecs-api.ts.template
@@ -0,0 +1,348 @@
+import { Construct } from 'constructs';
+import { CfnOutput, Duration, RemovalPolicy } from 'aws-cdk-lib';
+import {
+ Vpc,
+ SubnetType,
+ SecurityGroup,
+ Port,
+ IVpc,
+} from 'aws-cdk-lib/aws-ec2';
+import {
+ Cluster,
+ ContainerImage,
+ ContainerInsights,
+ CpuArchitecture,
+ FargateService,
+ FargateTaskDefinition,
+ LogDrivers,
+ OperatingSystemFamily,
+ Protocol,
+} from 'aws-cdk-lib/aws-ecs';
+import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
+import {
+ ApplicationLoadBalancer,
+ ApplicationProtocol,
+ ApplicationTargetGroup,
+ TargetType,
+} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
+import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
+import {
+ HttpApi as _HttpApi,
+ HttpMethod,
+ HttpStage,
+ CorsHttpMethod,
+ CfnApi,
+ IHttpRouteAuthorizer,
+ LogGroupLogDestination,
+} from 'aws-cdk-lib/aws-apigatewayv2';
+import { HttpAlbIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
+import { VpcLink } from 'aws-cdk-lib/aws-apigatewayv2';
+import { RuntimeConfig } from '../runtime-config.js';
+import { suppressRules } from '../checkov.js';
+
+/**
+ * Properties for creating an EcsApi construct
+ */
+export interface EcsApiProps {
+ /**
+ * Unique name for the API
+ */
+ readonly apiName: string;
+
+ /**
+ * Path to the Docker build context directory
+ */
+ readonly dockerContextPath: string;
+
+ /**
+ * Path to the Dockerfile relative to the Docker build context
+ */
+ readonly dockerfilePath?: string;
+
+ /**
+ * Glob patterns to exclude from the Docker build context
+ */
+ readonly dockerExclude?: string[];
+
+ /**
+ * Container port the application listens on
+ */
+ readonly containerPort: number;
+
+ /**
+ * Optional VPC to deploy into. If not provided, a new VPC is created.
+ */
+ readonly vpc?: IVpc;
+
+ /**
+ * Optional CPU units for the Fargate task (default: 256)
+ */
+ readonly cpu?: number;
+
+ /**
+ * Optional memory in MiB for the Fargate task (default: 512)
+ */
+ readonly memoryLimitMiB?: number;
+
+ /**
+ * Optional desired count of tasks (default: 1)
+ */
+ readonly desiredCount?: number;
+
+ /**
+ * Optional environment variables for the container
+ */
+ readonly environment?: Record;
+
+ /**
+ * Optional authorizer for the API routes
+ */
+ readonly authorizer?: IHttpRouteAuthorizer;
+}
+
+/**
+ * A CDK construct that creates an ECS Fargate service fronted by
+ * an internal ALB and API Gateway HTTP API via VPC Link.
+ */
+export class EcsApi extends Construct {
+ /** The API Gateway HTTP API */
+ public readonly api: _HttpApi;
+
+ /** The default stage of the HTTP API */
+ public readonly defaultStage: HttpStage;
+
+ /** The ECS Fargate service */
+ public readonly service: FargateService;
+
+ /** The VPC */
+ public readonly vpc: IVpc;
+
+ /** The internal ALB */
+ public readonly loadBalancer: ApplicationLoadBalancer;
+
+ constructor(scope: Construct, id: string, props: EcsApiProps) {
+ super(scope, id);
+
+ // VPC
+ this.vpc =
+ props.vpc ??
+ new Vpc(this, 'Vpc', {
+ maxAzs: 2,
+ natGateways: 1,
+ });
+
+ // ECS Cluster
+ const cluster = new Cluster(this, 'Cluster', {
+ vpc: this.vpc,
+ containerInsightsV2: ContainerInsights.ENHANCED,
+ });
+
+ // Task Definition
+ const taskDefinition = new FargateTaskDefinition(this, 'TaskDef', {
+ cpu: props.cpu ?? 256,
+ memoryLimitMiB: props.memoryLimitMiB ?? 512,
+ runtimePlatform: {
+ cpuArchitecture: CpuArchitecture.ARM64,
+ operatingSystemFamily: OperatingSystemFamily.LINUX,
+ },
+ });
+
+ const logGroup = new LogGroup(this, 'LogGroup', {
+ retention: RetentionDays.ONE_WEEK,
+ removalPolicy: RemovalPolicy.DESTROY,
+ });
+ suppressRules(
+ logGroup,
+ ['CKV_AWS_158'],
+ 'Using default CloudWatch log encryption',
+ );
+
+ taskDefinition.addContainer('AppContainer', {
+ image: ContainerImage.fromAsset(props.dockerContextPath, {
+ file: props.dockerfilePath,
+ exclude: props.dockerExclude,
+ platform: Platform.LINUX_ARM64,
+ }),
+ portMappings: [
+ { containerPort: props.containerPort, protocol: Protocol.TCP },
+ ],
+ logging: LogDrivers.awsLogs({
+ streamPrefix: props.apiName,
+ logGroup,
+ }),
+ environment: props.environment,
+ });
+
+ // Security Groups
+ const albSg = new SecurityGroup(this, 'AlbSg', {
+ vpc: this.vpc,
+ allowAllOutbound: true,
+ });
+ suppressRules(
+ albSg,
+ ['CKV_AWS_260'],
+ 'Internal ALB only accessible via VPC Link from API Gateway',
+ );
+
+ const serviceSg = new SecurityGroup(this, 'ServiceSg', {
+ vpc: this.vpc,
+ allowAllOutbound: true,
+ });
+ serviceSg.addIngressRule(
+ albSg,
+ Port.tcp(props.containerPort),
+ 'Allow ALB to reach container',
+ );
+
+ // Fargate Service
+ this.service = new FargateService(this, 'Service', {
+ cluster,
+ taskDefinition,
+ desiredCount: props.desiredCount ?? 1,
+ assignPublicIp: false,
+ securityGroups: [serviceSg],
+ vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
+ });
+
+ // Internal ALB
+ this.loadBalancer = new ApplicationLoadBalancer(this, 'Alb', {
+ vpc: this.vpc,
+ internetFacing: false,
+ securityGroup: albSg,
+ });
+ suppressRules(
+ this.loadBalancer,
+ ['CKV_AWS_91'],
+ 'ALB access logging not required for internal ALB',
+ );
+ suppressRules(
+ this.loadBalancer,
+ ['CKV_AWS_131'],
+ 'Drop invalid headers not required for internal ALB',
+ );
+
+ const targetGroup = new ApplicationTargetGroup(this, 'TargetGroup', {
+ vpc: this.vpc,
+ port: props.containerPort,
+ protocol: ApplicationProtocol.HTTP,
+ targetType: TargetType.IP,
+ healthCheck: {
+ path: '/health',
+ interval: Duration.seconds(30),
+ },
+ });
+ this.service.attachToApplicationTargetGroup(targetGroup);
+
+ const httpListener = this.loadBalancer.addListener('HttpListener', {
+ port: 80,
+ defaultTargetGroups: [targetGroup],
+ });
+ suppressRules(
+ httpListener,
+ ['CKV_AWS_2', 'CKV_AWS_103'],
+ 'Internal ALB does not require HTTPS - traffic is within VPC via VPC Link',
+ );
+
+ // VPC Link + API Gateway HTTP API
+ const vpcLink = new VpcLink(this, 'VpcLink', {
+ vpc: this.vpc,
+ subnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
+ securityGroups: [albSg],
+ });
+
+ this.api = new _HttpApi(this, 'Api', {
+ createDefaultStage: false,
+ corsPreflight: {
+ allowOrigins: ['*'],
+ allowMethods: [CorsHttpMethod.ANY],
+ allowHeaders: [
+ 'authorization',
+ 'content-type',
+ 'x-amz-content-sha256',
+ 'x-amz-date',
+ 'x-amz-security-token',
+ ],
+ },
+ });
+
+ const accessLogGroup = new LogGroup(this, 'ApiAccessLogs', {
+ retention: RetentionDays.ONE_WEEK,
+ removalPolicy: RemovalPolicy.DESTROY,
+ });
+ suppressRules(
+ accessLogGroup,
+ ['CKV_AWS_158'],
+ 'Using default CloudWatch log encryption',
+ );
+
+ this.defaultStage = new HttpStage(this, 'DefaultStage', {
+ httpApi: this.api,
+ autoDeploy: true,
+ accessLogSettings: {
+ destination: new LogGroupLogDestination(accessLogGroup),
+ },
+ });
+
+ const albIntegration = new HttpAlbIntegration(
+ 'AlbIntegration',
+ this.loadBalancer.listeners[0],
+ { vpcLink },
+ );
+
+ this.api.addRoutes({
+ path: '/{proxy+}',
+ methods: [
+ HttpMethod.GET,
+ HttpMethod.POST,
+ HttpMethod.PUT,
+ HttpMethod.PATCH,
+ HttpMethod.DELETE,
+ HttpMethod.HEAD,
+ ],
+ integration: albIntegration,
+ ...(props.authorizer ? { authorizer: props.authorizer } : {}),
+ });
+
+ new CfnOutput(this, `${props.apiName}Url`, {
+ value: this.defaultStage.url!,
+ });
+
+ // Register the API URL in runtime configuration
+ const rc = RuntimeConfig.ensure(this);
+ rc.set('connection', 'apis', {
+ ...rc.get('connection').apis,
+ [props.apiName]: this.defaultStage.url!,
+ });
+ }
+
+ /**
+ * Return the API url
+ */
+ public get url() {
+ return this.defaultStage.url;
+ }
+
+ /**
+ * Restricts CORS to the provided origins
+ */
+ public restrictCorsTo(...origins: string[]) {
+ const cfnApi = this.api.node.defaultChild;
+ if (!(cfnApi instanceof CfnApi)) {
+ throw new Error(
+ 'Unable to configure CORS: API default child is not a CfnApi instance',
+ );
+ }
+
+ cfnApi.corsConfiguration = {
+ allowOrigins: origins,
+ allowMethods: [CorsHttpMethod.ANY],
+ allowHeaders: [
+ 'authorization',
+ 'content-type',
+ 'x-amz-content-sha256',
+ 'x-amz-date',
+ 'x-amz-security-token',
+ ],
+ };
+ }
+}
diff --git a/packages/nx-plugin/src/trpc/backend/files/src/procedures/echo.ts.template b/packages/nx-plugin/src/utils/files/trpc/src/procedures/echo.ts.template
similarity index 99%
rename from packages/nx-plugin/src/trpc/backend/files/src/procedures/echo.ts.template
rename to packages/nx-plugin/src/utils/files/trpc/src/procedures/echo.ts.template
index f6e9988f2..6046dfd81 100644
--- a/packages/nx-plugin/src/trpc/backend/files/src/procedures/echo.ts.template
+++ b/packages/nx-plugin/src/utils/files/trpc/src/procedures/echo.ts.template
@@ -1,7 +1,6 @@
import { publicProcedure } from '../init.js';
import { EchoInputSchema, EchoOutputSchema } from '../schema/index.js';
-
export const echo = publicProcedure
.input(EchoInputSchema)
.output(EchoOutputSchema)
diff --git a/packages/nx-plugin/src/trpc/backend/files/src/router.ts.template b/packages/nx-plugin/src/utils/files/trpc/src/router.ts.template
similarity index 100%
rename from packages/nx-plugin/src/trpc/backend/files/src/router.ts.template
rename to packages/nx-plugin/src/utils/files/trpc/src/router.ts.template
diff --git a/packages/nx-plugin/src/trpc/backend/files/src/schema/echo.ts.template b/packages/nx-plugin/src/utils/files/trpc/src/schema/echo.ts.template
similarity index 100%
rename from packages/nx-plugin/src/trpc/backend/files/src/schema/echo.ts.template
rename to packages/nx-plugin/src/utils/files/trpc/src/schema/echo.ts.template
diff --git a/packages/nx-plugin/src/utils/files/trpc/src/schema/index.ts.template b/packages/nx-plugin/src/utils/files/trpc/src/schema/index.ts.template
new file mode 100644
index 000000000..74fdef51f
--- /dev/null
+++ b/packages/nx-plugin/src/utils/files/trpc/src/schema/index.ts.template
@@ -0,0 +1 @@
+export * from './echo.js';
diff --git a/packages/nx-plugin/src/utils/versions.ts b/packages/nx-plugin/src/utils/versions.ts
index bf725789b..cf79ecada 100644
--- a/packages/nx-plugin/src/utils/versions.ts
+++ b/packages/nx-plugin/src/utils/versions.ts
@@ -66,6 +66,7 @@ export const TS_VERSIONS = {
'@typescript-eslint/parser': '8.58.1',
'eslint-plugin-prettier': '5.5.5',
express: '5.2.1',
+ fastify: '5.3.3',
'jsonc-eslint-parser': '3.1.0',
'typescript-eslint': '8.58.1',
'make-dir-cli': '4.0.0',