aws / aws-cdk-rfcs

RFCs for the AWS CDK

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ReactCDK: Add JSX/TSX Support

pfeilbr opened this issue · comments

PR Champion
#258

Description

Express Infrastructure Resources using React JSX/TSX. This is an additional mechanism to compose services/CDK applications at a framework / technology level over a programming language.

Progress

  • Tracking Issue Created
  • RFC PR Created
  • Core Team Member Assigned
  • Initial Approval / Final Comment Period
  • Ready For Implementation
    • implementation issue 1
  • Resolved

Some helpful notes:

Webpack for example uses JSX as configuration https://webpack.js.org/configuration/configuration-languages/#babel-and-jsx

They use this JSX factory https://github.com/developit/jsxobj

commented

+100 on this idea.

JSX is a wonderful language that perfectly represents data-rich components. Component hierarchy is an important component of CDK. Why not represent it in a tree-like structure instead of a bunch of class member reference chains? I've always wondered if CDK could be considered a good example of how not to use OOP.

This should be totally do-able with React and a custom renderer that proxies props through to CDK constructors.

commented

I thought it'd be fun to work backwards from what a ReactCDK app could look like.

import * as ECS from '@aws-cdk/react';

export default function MyApp() {
  return (
    <VPC>
      <ECS.Cluster mode="fargate">
        <ECS.ApplicationLoadBalancedFargateService>
          <ECS.TaskDefinition>
            <ECS.Container fromRegistry="amazon/cloudwatch-agent" />
            <ECS.Container fromRegistry="my/app" />
          </ECS.TaskDefinition>
        </ECS.ApplicationLoadBalancedFargateService>
      </ECS.Cluster>
    </VPC>
  );
};

Obviously this example over-simplifies the number of props you'd need to pass to each JSX element, but you'd be able to abstract that away behind other components. You'll naturally start moving complexity out of the root component and down into descendants, abstracting common branches of your architecture with higher-order concepts. Next comes the magic: your root component file no longer feels as daunting as the first sentence of a Steven King novel. Instead, it looks as simple and easily-understood as a diagram. You've achieved self-documenting code.

export default function MyApp() {
  return (
    <Infrastructure>
      <Services>
        <FooBarService />
        <DataBoiService />
        <SecretProject />
      </Services>
      
      <Ops>
        <Alarms />
        <CICD />
        <Dashboards />
      </Ops>
    </Infrastructure>
  );
};

For referencing constructs between them a ref can be used I think

export default function MyApp() {
  const fooBar = useRef()
  return (
    <Infrastructure>
      <Services>
        <FooBarService ref={fooBar} />
        <DataBoiService />
        <SecretProject fooBar={fooBar} />
      </Services>
    </Infrastructure>
  );
};
commented

Having a hook specifically for creating exports in cross stack references would resolve a pretty serious design flaw in current CDK known as ‘deadly embrace’. The automatic export mechanism when referencing resources across stacks is a dangerous kind of magic and has jammed up my pipelines many times.

Defining your infrastructure in JSX would enforce a unidirectional flow of resource dependencies. Manually opting in to exports using a hook like ‘useStackExport’ is much more predictable and maintainable than the current automatic approach.

More reading on ‘deadly embrace’ here: aws/aws-cdk#12778

This was fun to play around with. Someone who knows React could probably make this slicker, but it's not too hard to get started using jsxobj to make some simple components.

I added the following to my tsconfig.json:

  "compilerOptions": {
    "jsx": "react",

The code was pretty simple, but it's not quite what it should be:

/** @jsx h */
import h from "./jsxobj";
import {CfnElement, Construct, Stack, Environment} from "monocdk";

export class ReactElement extends CfnElement {
    readonly template: any;

    constructor(scope: Construct, id: string, template: any) {
        super(scope, id);
        this.template = {...template}
    }

    _toCloudFormation(): object {
        return {
            // TODO:  Can this be in CdkX.Resources?
            Resources: {
                // TODO:  Is there a way to NOT have to serialize/deserialize?
                ...JSON.parse(JSON.stringify(this.template))
            }
        };
    }
}

export class CdkX {
    // This is not quite what I want.
    static Resources = (props: any) => {
        return {}
    }

    static Resource = (props: {id: string, type: string} & any) => {
        let logicalId = props.id
        let resourceType = props.type
        delete props.id
        delete props.type
        return {
            name: logicalId, // Magic
            Type: resourceType,
            Properties: {
                ...props
            }
        }
    }

    static Bucket = (props: {id: string} & any) => CdkX.Resource({
        type: "AWS::S3::Bucket",
        ...props
    })
}

export class ReactStack extends Stack {
    constructor(scope: Construct, id: string, props: {env: Environment}) {
        super(scope, id, {
            env: props.env
        });
        new ReactElement(this, "ReactCdk", (
            <CdkX.Resources>
                <CdkX.Bucket id="CoolBucket" BucketName="CoolBucketName">
                </CdkX.Bucket>
                <CdkX.Bucket id="OtherBucket" BucketName="OtherBucketName"/>
            </CdkX.Resources>
        ))
    }
}

Resulting template snippet:

  ...
  "Resources": {
    "CoolBucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "BucketName": "CoolBucketName"
      }
    },
    "OtherBucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "BucketName": "OtherBucketName"
      }
    },
    "CDKMetadata": {
      "Type": "AWS::CDK::Metadata",
      "Properties": {...},
      "Metadata": {...}
    }
  },
  ...

Started to do some experiments related to this RFC
https://github.com/iamandrewluca/constructs-jsx/blob/main/index.js
https://github.com/iamandrewluca/constructs-jsx

It basically works, but we need a way to write also imperative code.

ps: I don't have too much knowledge about AWS/CLI/CDK and how it works, just touching the waters

One more attemp creating a custom renderer because we still need to write somehow imperative code. https://github.com/iamandrewluca/react-constructs
https://github.com/iamandrewluca/react-constructs/blob/3762ec26b1112fc835446b09db3ec9abdea68668/lib/hello-cdk-stack.tsx#L23-L42

There are some problems, waiting for an advice from react team.

facebook/react#13006 (comment)

ps: I don'y know why I'm doing this 😄 I never played with AWS CDK, and I think will never have the oportunity soon, but it's interesting 🙂

it seems that a custom renderer it's too compicated, and I't may not work.
I went back to implementing own jsx-runtime, render, useRef, and useLayoutEffect.

https://github.com/iamandrewluca/constructs-jsx/blob/2f2b8adbd763de713156b41f4e7818d5b95889d9/index.js#L98-L140

Will make a POC using default AWS CDK sample app

@iamandrewluca super cool and very exciting.

An update:

Implementing TypeScript support is not fully possible at the moment if we want to use directly Construct as a component.

To tell TS how to infer a Construct props, I have to declare an interface with a single property in JSX namespace that will tell where to look for props type e.g.

declare namespace JSX {
	interface ElementAttributesProperty {
		props; // specify the property name to use
	}
}

This means that TS will look at Construct props field to know the props types.
If this interface is not declared, TS will take first parameter of constructor as props.

So first, we don't have a field on the Construct with his props.
And second, constructor first parameter is scope: Construct and props autocompletion is for Construct fields.

If I go for example in Stack types and add props field, everything works perfectly.

class Stack {
	/* ... */
	props: StackOptions
}

If we had the Construct options as a generic parameter, maybe that would simplify things.
If for example constructs would introduce such a change. And all the libraries should follow the generics, and in some future major release remove generic default value, and make every construct TS compatible with JSX/React
Actually I think that this adjustments can be done very fast for all aws k8s terraform cdks

export class Construct<Options extends ConstructOptions = {}> implements IConstruct {
	options: Options;
	constructor(scope: Construct, id: string, options: Options) {
		// this is optional, we just need the type above
		this.options = options;
		// ...
	}
}

One more solution (that I don't like) instead of using directly a Construct as a component. We can generate "fake libraries" that will export constructs as strings and map back to Constructs. e.g.

@aws-cdk/core/react or react-cdk/@aws-cdk/core that is generated automatically

import { StackProps, StageProps } from '@aws-cdk/core'

export const Stack = '@aws-cdk/core/Stack'
export const Stage = '@aws-cdk/core/Stage'
// ...

declare global {
	declare namespace JSX {
		// For string types TS looks at this interface to infer props
		interface IntrinsicElements {
			[Stack]: StackProps
			[Stage]: StageProps
			// ...
		}
	}
}

user code

import { Stack } from '@aws-cdk/core/react'

const element = <Stack />

in renederer something like this

function render(element, parent) {
	const [user, package, construct] = element.type.split('/')
	const Construct = require(`${user}/${package}`)[construct]
	return new Construct(parent, element.key, element.props)
}

@iamandrewluca For AWS CDK L2s we might be able to add a "props" property to all types. We have awslint which can enforce that. Is this something you might be interested to contribute?

We'll need to work with @rix0rrr to flush the details.

@eladb yes, I'm in. I will need some giudance.

One more thing. Using JSX there is one limitation. Because key and ref are special props, they cannot be in the options, because they are removed from props before construct creation.

  • key is used as id on Construct (defaults to Construct name and order index in parent Construct
  • ref is used to get a reference to an actual Construct

One way to avoid this is to have props as a prop e.g.

<Stack key="unique-id" ref={stack} props={{ /* ... */ }} />

Infering props complicates with this method

The simplest solution would seem to be generating JSX.IntrinsicElements declarations for all constructs and publishing those as a separate library, right? Then it's easy to add in key and ref parameters.

Then it's easy to add in key and ref parameters

The problem with key and ref props is that they shadow these fields in Construct options object

https://github.com/iamandrewluca/react-cdk/blob/9e7bc23fe21876faa6112cc658e68fc080c9bf5d/src/constructs-jsx.tsx#L42-L56

Then it's easy to add in key and ref parameters.

key and ref can be configured using JSX namespace

https://github.com/iamandrewluca/react-cdk/blob/9e7bc23fe21876faa6112cc658e68fc080c9bf5d/jsx/jsx-dev-runtime.tsx#L30-L36

The simplest solution would seem to be generating JSX.IntrinsicElements

Yes, doing this way, nothing should be changed in constructs. Only problem that can be is namespacing in JSX.IntrinsicElements, but if using @user/package/Construct should cover this case.

But with this solution you would need to generate this react "fake libraries" for each cdk library

key and ref shadowing still exists.

Picking up this topic, I've started experimenting a bit with this idea. The following is cdk synthable:
https://github.com/dbartholomae/jsx-cdk/blob/main/examples/example.tsx

I will work on it a bit more to figure out the restrictions and what they would mean for an RFC.

I've experimented a bit more, and it would not be a problem to add a JavaScript layer which allows the following code (without need for custom wrappers for each construct, note that Stack and Queue are directly imported from aws-cdk-lib):

/* @jsx h */
import h, { attachToApp } from "../src";
import { App, Duration, Stack, StackProps } from "aws-cdk-lib";
import { Queue } from "aws-cdk-lib/aws-sqs";

const CdkExampleStack = (props: StackProps & { id: string }) => {
  return <Stack id={props.id} env={props.env}>
    <Queue id="CdkExampleQueue" visibilityTimeout={Duration.seconds(300)} />
  </Stack>;
};

void attachToApp(
  <CdkExampleStack
    env={{ account: "111111111", region: "eu-central-1" }}
    id="CdkExampleStack"
  />,
  new App()
);

Unfortunately, TypeScript currently wouldn't allow it, as it limits which kind of classes can be used as JSX Elements. I've opened an issue with TypeScript, but I do not expect this to be of any priority to them, as JSX so far is used almost exclusively for React or compatible libraries. Without that, we could create workarounds like the following:

/* @jsx h */
import h, { attachToApp, C } from "../src";
import { App, Duration, Stack, StackProps } from "aws-cdk-lib";
import { Queue } from "aws-cdk-lib/aws-sqs";

const CdkExampleStack = (props: StackProps & { id: string }) => {
  return <C construct={Stack} id={props.id} env={props.env}>
    <C construct={Queue} id="CdkExampleQueue" visibilityTimeout={Duration.seconds(300)} />
  </C>;
};

void attachToApp(
  <CdkExampleStack
    env={{ account: "111111111", region: "eu-central-1" }}
    id="CdkExampleStack"
  />,
  new App()
);

I don't think, this would cut it, though, and therefore I have decided to drop this until there has been some movement on the TypeScript front, or a non-class interface that is supported by CDK.

The ticket for TypeScript is now in "Awaiting more feedback", so if this would also help you, please write about your use case there.

Nice. Also we may approach this problem from both sides.

@iamandrewluca For AWS CDK L2s we might be able to add a "props" property to all types. We have awslint which can enforce that. Is this something you might be interested to contribute?
by @eladb

Is there a common parent class where this might best be implemented? E. g. we could add a

export abstract class PropsResource<Props = unknown> extends Resource {
  public readonly props: Props;
  constructor(scope: Construct, id: string, props: Props & ResourceProps) {
    super(scope, id, props);
    this.props = props
  }
}

and then change all L2 constructs to rely on this instead of Resource. But I'm not sure whether all L2 constructs are Resources - this is more a mix-in than something that would be inherited.

If this is a way forward, I'm happy to contribute it. What would be the next step?

To add it there would be a breaking change, though, as the constructor would need to receive the props. I don't think this is an option.

I'm just aware how JSX works, I don't have real experience with how AWS CDK works
Doing this just for learning purposes

I supposed to make this props optional from the beginning. 🤔
And later in a major version to make it required 👀

I think the use of Refs in https://github.com/iamandrewluca/react-cdk/blob/main/lib/constructs-jsx-stack.tsx makes sense, but it also makes it clear that using JSX/React paradigms for CDK is not going to be that ergonomic without changes to the way CDK models its component tree and APIs. CDK is fundamentally highly imperative by design, and I don't really see that changing any time soon.

I do sometimes wish that CDK had less boiler-plate for constructing the component tree, but my opinion is that the JSX/React model is too far away from the current CDK model. JSX trees use postorder traversal, CDK trees use preorder traversal. React tries to make you use a unidirectional data flow where CDK APIs tend to be all about imperative child -> parent interaction e.g. calling methods on children.

That said, is the push here to make CDK more declarative, or to reduce CDK boilerplate using a DSL? One of the reasons that React embraced declarative, unidirectional data flow was to make reasoning about application state easier. This choice obviously comes with tradeoffs, so it feels like you might be signing up for all the cons of unidirectional data flow and none of the pros, since CDK apps don't have state and render just once. So then, maybe the real objective should be a better DSL for building CDK trees? It should be possible to create some DSL / syntactic sugar for CDK stacks that compliments CDK's constraints rather than working against them. I think it's a good idea to work backwards from the constrains / programming model to arrive at the DSL you want rather than trying to shoehorn CDK into a different paradigm. A really nice DSL for CDK:

  • Does not enshrine 'props' (CDK constructs are pretty fast and loose about their constructor signature so props should not be relied upon)
  • natively supports child -> parent imperative interactions, maybe makes them a bit more standard?
  • Takes full advantage of the 'render once', 'stateless' model (obviously tokens / IResolvable slightly challenge this)
  • has full type support w/IntelliSense
  • Maybe uses more implicit parent declaration (no more constructor boilerplate) based on scope
  • Reduces construct id boilerplate (perhaps by deriving ID automatically from naming/order, although I know that has its own issues)

As for ergonomics, JSX may or may not still be preferable but a non JSX dsl might look like this:

stack({props}) {
    queue()
    topic()
}.do(siblings => {
    siblings.topic.addSubscription(subscription(siblings.queue))
})

Where do is a function that exposes the construct subtree it's called on, so you could do

queue().do(({queue}) => subscription(queue))

Not that confident do could actually be strongly typed, and obviously you would need to use construct IDs pretty quickly to differentiate between siblings to the point where inference might not be worth it.

Edit:

The other thing this DSL should do is allow you to write constructs like this:

const MyBucketConstruct = (props) => bucket(props).do(bucket => bucket.addRead /* etc */ )

Function constructs could be invoked within the dsl and have their underlying constructs' constructors automatically called with the correct scope and id