Skip to content

Commit

Permalink
Serverless LiveViewJS example code and workspace
Browse files Browse the repository at this point in the history
  • Loading branch information
floodfx committed Jan 8, 2023
1 parent 48c09a2 commit 8caf3fb
Show file tree
Hide file tree
Showing 22 changed files with 2,463 additions and 408 deletions.
15 changes: 15 additions & 0 deletions apps/lambda-examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
public/*.png
public/*.gif
public/*.jpg
public/*.jpeg
public/*.pdf
public/js/

*.js
!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out
6 changes: 6 additions & 0 deletions apps/lambda-examples/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.ts
!*.d.ts

# CDK asset staging directory
.cdk.staging
cdk.out
1 change: 1 addition & 0 deletions apps/lambda-examples/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
8 changes: 8 additions & 0 deletions apps/lambda-examples/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.cache/
.env
dist/
.parcel-cache/
.DS_Store
coverage/
package-lock.json
6 changes: 6 additions & 0 deletions apps/lambda-examples/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"bracketSameLine": true
}
33 changes: 33 additions & 0 deletions apps/lambda-examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 🖼 LiveViewJS for AWS Lambda (NodeJS)

The is a proof-of-concept that shows how you can host a "serverless" LiveViewJS application on AWS Lambda and API Gateway. The project loads the examples from the [LiveViewJS](https://liveviewjs.com) but you can adapt this to host your own LiveViewJS application.

## Status
This is a proof-of-concept and is NOT production ready for large volumes. In particular, the following issues need to be addressed:
- API Gateway Websocket requests may not be handled in order that they were received (thus race conditions can occur)
- LiveViewJS currently keeps LiveView state in memory which may break in the
case multiple lambda functions attempt to handle requests from the same LiveView

We can address these issue by storing LiveView state in a different data store (e.g. DynamoDB) and using a queue to ensure that requests are handled in order.

## Summary AWS Architecture
The set of AWS resources that are created are pretty simple:
- API Gateway Websocket API passing requests to a Single Lambda function
- API Gateway HTTP API passing requests to a Single Lambda function

## Pre-requisites
You should have an AWS account and have the AWS CLI installed and configured with your credentials.

Run `npm install` to install dependencies.

## Deploy
This project uses AWS CDK to setup the infrastructure and deploy the code.

Deploy to AWS Lambda using `cdk deploy [--profile YOUR_AWS_PROFILE]`.

When the

## Teardown
After you are done with the project you can remove the stack from your AWS account by running:

`cdk destroy [--profile YOUR_AWS_PROFILE]`
18 changes: 18 additions & 0 deletions apps/lambda-examples/bin/liveviewjs-lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import "source-map-support/register";
import { LiveViewJSLambdaStack } from "../lib/liveviewjs-lambda-stack";

const app = new cdk.App();
new LiveViewJSLambdaStack(app, "LiveViewJSLambdaStack", {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */
/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },
/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
42 changes: 42 additions & 0 deletions apps/lambda-examples/cdk.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"app": "npx ts-node --prefer-ts-exts bin/liveviewjs-lambda.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
"@aws-cdk/core:stackRelativeExports": true,
"@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
"@aws-cdk/aws-lambda:recognizeVersionProps": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
]
}
}
87 changes: 87 additions & 0 deletions apps/lambda-examples/lib/liveviewjs-lambda-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { HttpApi, HttpMethod, WebSocketApi, WebSocketStage } from "@aws-cdk/aws-apigatewayv2-alpha";
import { HttpLambdaIntegration, WebSocketLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha";
import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";

/**
* The CDK Stack for LiveViewJS that deploys the HTTP and WebSocket API Gateway endpoints
* along with the Lambda functions that handle the requests.
*/
export class LiveViewJSLambdaStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);

// Lambda function for Websocket requests
const websocketLambdaFn = new NodejsFunction(this, "LiveViewJS WS Fn", {
entry: "src/lambdas/websocket.ts",
runtime: Runtime.NODEJS_16_X,
});

// API Gateway for Websocket requests
const webSocketApi = new WebSocketApi(this, "LiveViewJS WS APIG", {
connectRouteOptions: { integration: new WebSocketLambdaIntegration("connect", websocketLambdaFn) },
disconnectRouteOptions: { integration: new WebSocketLambdaIntegration("disconnect", websocketLambdaFn) },
defaultRouteOptions: { integration: new WebSocketLambdaIntegration("default", websocketLambdaFn) },
});
// grant permissions for the Lambda function to send messages back to the client
webSocketApi.grantManageConnections(websocketLambdaFn);

// The stage for the Websocket API Gateway
new WebSocketStage(this, "LiveViewJS WS APIG Stage", {
webSocketApi,
// the stageName must be "websocket" because that is part of the
// URL that the LiveViewJS client will connect to
stageName: "websocket", //
autoDeploy: true,
});

// Lambda function for HTTP requests
const httpLambdaFn = new NodejsFunction(this, "LiveViewJS HTTP Fn", {
entry: "src/lambdas/http.ts",
runtime: Runtime.NODEJS_16_X,
bundling: {
commandHooks: {
beforeBundling(inputDir: string, outputDir: string) {
return [
// run esbuild to bundle the client-side JS
`npm run build:client -w apps/lambda-examples`,
// copy public folder to outputDir
`cp -r ${inputDir}/apps/lambda-examples/public ${outputDir}`,
];
},
afterBundling() {
// no-op
return [];
},
beforeInstall() {
// no-op
return [];
},
},
},
});
const arnToReadApiGateways = this.formatArn({
service: "apigateway",
resource: "/apis",
account: "", // arn requires empty account for some reason
});
httpLambdaFn.addToRolePolicy(
new PolicyStatement({ actions: ["apigateway:GET"], resources: [arnToReadApiGateways] })
);

// API Gateway for HTTP requests
const httpApi = new HttpApi(this, "LiveViewJS HTTP APIG");
// Route all requests to the Lambda function
httpApi.addRoutes({
path: "/{proxy+}",
methods: [HttpMethod.ANY],
integration: new HttpLambdaIntegration("http", httpLambdaFn),
});

// print out the LiveViewJS App URL
new CfnOutput(this, "URL", { value: httpApi.url! });
}
}
54 changes: 54 additions & 0 deletions apps/lambda-examples/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@liveviewjs/lambda-examples",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "ts-node ./src/example/autorun.ts",
"build": "npm run build:client && npm run build:server",
"build:client": "esbuild ./src/client/index.ts --bundle --outdir=public/js --platform=browser --format=esm --minify --sourcemap",
"build:server": "esbuild ./src/example/index.ts --bundle --outdir=build --platform=node --format=cjs --minify --sourcemap",
"clean": "rm -rf build; rm -rf dist",
"dist": "npm run build",
"format": "prettier --write '**/*.{ts,js,json,html,css}'"
},
"bin": {
"cdk": "bin/liveviewjs-lambda.ts"
},
"keywords": [
"liveviewjs",
"liveview",
"phoenix",
"typescript",
"javascript",
"lambda",
"aws"
],
"author": "Donnie Flood <[email protected]>",
"license": "MIT",
"dependencies": {
"@liveviewjs/examples": "*",
"@liveviewjs/express": "*",
"liveviewjs": "*",
"nanoid": "^3.2.0"
},
"devDependencies": {
"@aws-cdk/aws-apigatewayv2-alpha": "^2.46.0-alpha.0",
"@aws-cdk/aws-apigatewayv2-integrations-alpha": "^2.46.0-alpha.0",
"@types/aws-lambda": "^8.10.108",
"@types/node": "^18.7.8",
"@types/nprogress": "^0.2.0",
"@types/phoenix": "^1.5.4",
"@types/phoenix_live_view": "^0.15.1",
"aws-cdk": "^2.43.1",
"aws-cdk-lib": "^2.43.1",
"aws-lambda": "^1.0.7",
"aws-sdk": "^2.1236.0",
"esbuild": "^0.14.53",
"nprogress": "^0.2.0",
"phoenix": "^1.6.12",
"phoenix_html": "^3.2.0",
"phoenix_live_view": "^0.18.0",
"ts-node": "^10.9.1",
"typescript": "^4.5.4"
}
}
Binary file added apps/lambda-examples/public/favicon.ico
Binary file not shown.
41 changes: 41 additions & 0 deletions apps/lambda-examples/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import NProgress from "nprogress";
import { Socket } from "phoenix";
import "phoenix_html";
import { LiveSocket, ViewHook } from "phoenix_live_view";

/**
* Define custom LiveView Hooks that can tap into browser events.
* See: https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook
*/
let Hooks = {
/**
* This hook can be used by an input element to prevent input other than numbers.
* e.g. <input type="text" phx-hook="NumberInput" />
*/
NumberInput: {
mounted() {
this.el.addEventListener("input", () => {
// replace all non-numeric characters with empty string
this.el.value = this.el.value.replace(/\D/g, "");
});
},
} as ViewHook,
};

// WS_APIG_ID in the URL below will be replaced with the API Gateway ID at runtime
// See: src/lambdas/http.ts route handler for "/js/index.js"
const url = "wss://WS_APIG_ID.execute-api.us-west-2.amazonaws.com";
let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content");
let liveSocket = new LiveSocket(url, Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks });

// Show progress bar on live navigation and form submits
window.addEventListener("phx:page-loading-start", (info) => NProgress.start());
window.addEventListener("phx:page-loading-stop", (info) => NProgress.done());

// connect if there are any LiveViews on the page
liveSocket.connect();

// expose liveSocket on window for web console debug logs and latency simulation:
// liveSocket.enableDebug();
// liveSocket.enableLatencySim(1000)
(window as any).liveSocket = liveSocket;
55 changes: 55 additions & 0 deletions apps/lambda-examples/src/example/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
autocompleteLiveView,
booksLiveView,
counterLiveView,
dashboardLiveView,
decarbLiveView,
helloToggleEmojiLiveView,
jsCmdsLiveView,
paginateLiveView,
photosLiveView,
printLiveView,
rtCounterLiveView,
searchLiveView,
serversLiveView,
sortLiveView,
volumeLiveView,
volunteerLiveView,
xkcdLiveView,
} from "@liveviewjs/examples";
import { LiveViewRouter } from "liveviewjs";
import { LambdaLiveViewServer } from "../lambda/server";
import { htmlPageTemplate, wrapperTemplate } from "./liveTemplates";

// LiveViewRouter that maps the path to the LiveView
const router: LiveViewRouter = {
"/autocomplete": autocompleteLiveView,
"/decarbonize": decarbLiveView,
"/prints": printLiveView,
"/volume": volumeLiveView,
"/paginate": paginateLiveView,
"/dashboard": dashboardLiveView,
"/search": searchLiveView,
"/servers": serversLiveView,
"/sort": sortLiveView,
"/volunteers": volunteerLiveView,
"/counter": counterLiveView,
"/jscmds": jsCmdsLiveView,
"/photos": photosLiveView,
"/xkcd": xkcdLiveView,
"/rtcounter": rtCounterLiveView,
"/books": booksLiveView,
"/helloToggle": helloToggleEmojiLiveView,
};

// Configure the LambdaLiveViewServer which generates handlers for HTTP
// and WebSocket requests from API Gateway and AWS Lambda
export const liveView = new LambdaLiveViewServer(
router,
htmlPageTemplate,
{ title: "Lambda Demo", suffix: " · LiveViewJS" },
{
serDeSigningSecret: "signingSecret",
wrapperTemplate,
}
);
Loading

0 comments on commit 8caf3fb

Please sign in to comment.