DEV Community

Cover image for DIY SvelteKit CDK adapter
Juhani Ränkimies
Juhani Ränkimies

Posted on • Edited on

DIY SvelteKit CDK adapter

Update on 2021-10-30

You should also check out my SvelteKit-CDK adapter.
It covers everything discussed in this article. While not even close to stable, it's cleaner and has better structure. And has a wrapper for Lambda@Edge, too.

Below are notes of me putting together sveltekit and AWS CDK. Explanations are minimal. Familiarity with both SvelteKit and CDK is probably required to follow.

At the moment @sveltejs/kit version is 1.0.0-next.71, and the adapter interface is not stable, so this solution is likely to break as time passes.

Alt Text

1. Init SvelteKit project

~$ mkdir sveltekit-cdk
~$ cd sveltekit-cdk/
~/sveltekit-cdk$ npm init svelte@next
   ...
   (I chose typescript/CSS/no eslint/no prettier)
   ...
~/sveltekit-cdk$ npm install
~/sveltekit-cdk$ git init
~/sveltekit-cdk$ git add .
~/sveltekit-cdk$ git commit -m "init svelte"
Enter fullscreen mode Exit fullscreen mode

2. Init CDK project for adapter

~/sveltekit-cdk$ mkdir adapter
~/sveltekit-cdk$ cd adapter
~/sveltekit-cdk/adapter$ npx cdk init --language typescript
~/sveltekit-cdk/adapter$ git add .
~/sveltekit-cdk/adapter$ git commit -m "init CDK"
Enter fullscreen mode Exit fullscreen mode

3. Replace node adapter with a dummy adapter

This is our dummy adapter (adapter/adapter.ts)

import type { Adapter } from '@sveltejs/kit'

export const adapter: Adapter = {
    name: 'MAGIC',
    async adapt(utils): Promise<void> {
        console.log('TODO...')
    }
}
Enter fullscreen mode Exit fullscreen mode

To be able to use typescript for the adapter without extra compilation steps, lets add a little ts-node wrapper (adapter/index.js)

require('ts-node').register()
module.exports = require('./adapter.ts').adapter
Enter fullscreen mode Exit fullscreen mode

Finally, node adapter is replaced in svelte.config.js with our adapter

- const node = require('@sveltejs/adapter-node');
+ const cdkAdapter = require('./adapter/index.js')


-       // By default, `npm run build` will create a standard Node app.
-       // You can create optimized builds for different platforms by
-       // specifying a different adapter
-       adapter: node(),
+       adapter: cdkAdapter,
Enter fullscreen mode Exit fullscreen mode

To make ts-node wrapper work, I had to comment out "module": "es2020", from tsconfig.json. Full commit here.

And try it out

$ npm run build

> sveltekit-cdk@0.0.1 build /workspaces/sveltekit-cdk
> svelte-kit build

vite v2.1.5 building for production...
✓ 18 modules transformed.
.svelte/output/client/_app/manifest.json                            0.67kb
.svelte/output/client/_app/assets/start-d4cd1237.css                0.29kb / brotli: 0.18kb
.svelte/output/client/_app/assets/pages/index.svelte-27172613.css   0.69kb / brotli: 0.26kb
.svelte/output/client/_app/pages/index.svelte-f28bc36b.js           1.58kb / brotli: 0.68kb
.svelte/output/client/_app/chunks/vendor-57a96aae.js                5.14kb / brotli: 2.00kb
.svelte/output/client/_app/start-ff890ac9.js                        15.52kb / brotli: 5.29kb
vite v2.1.5 building SSR bundle for production...
✓ 16 modules transformed.
.svelte/output/server/app.js   70.57kb

Run npm start to try your app locally.

> Using MAGIC
TODO...
  ✔ done
Enter fullscreen mode Exit fullscreen mode

4. Capture Svelte output

Svelte files are copied to a place that is easy to include to CDK stack.

export const adapter: Adapter = {
    name: 'MAGIC',
    async adapt(utils): Promise<void> {
-        console.log('TODO...')
+        const contentPath = path.join(__dirname, 'content')
+        rmRecursive(contentPath)
+        const serverPath = path.join(contentPath, 'server')
+        const staticPath = path.join(contentPath, 'static')
+        utils.copy_server_files(serverPath)
+        utils.copy_client_files(staticPath)
+        utils.copy_static_files(staticPath)
    }
}
Enter fullscreen mode Exit fullscreen mode

Svelte kit provides nice utilities for storing the files to desired folders. Here, the SSR implementation is put to server folder, and will be later deployed to a lambda function. And client and static files are stored to static folder, and will be later deployed to S3.

Alt Text

Full commit

5. Create a Lambda wrapper for SSR

This lambda handler maps the API gateway request and response to what SSR implementation expects.

import { URLSearchParams } from 'url';
import { render } from '../content/server/app.js'

export async function handler(event) {
    const { path, headers, multiValueQueryStringParameters } = event;

    const query = new URLSearchParams();
    if (multiValueQueryStringParameters) {
        Object.keys(multiValueQueryStringParameters).forEach(k => {
            const vs = multiValueQueryStringParameters[k]
            vs.forEach(v => {
                query.append(k, v)
            })
        })
    }

    const rendered = await render({
        host: event.requestContext.domainName,
        method: event.httpMethod,
        body: JSON.parse(event.body), // TODO: other payload types
        headers,
        query,
        path,
    })

    if (rendered) {
        const resp = {
            headers: {},
            multiValueHeaders: {},
            body: rendered.body,
            statusCode: rendered.status
        }
        Object.keys(rendered.headers).forEach(k => {
            const v = rendered.headers[k]
            if (v instanceof Array) {
                resp.multiValueHeaders[k] = v
            } else {
                resp.headers[k] = v
            }
        })
        return resp
    }
    return {
        statusCode: 404,
        body: 'Not found.'
    }
}
Enter fullscreen mode Exit fullscreen mode

And it needs to be bundled for deployment to lambda


export const adapter: Adapter = {
    name: 'MAGIC',
    async adapt(utils): Promise<void> {
        const contentPath = path.join(__dirname, 'content')
        rmRecursive(contentPath)
        const serverPath = path.join(contentPath, 'server')
        const staticPath = path.join(contentPath, 'static')
        utils.copy_server_files(serverPath)
        utils.copy_client_files(staticPath)
        utils.copy_static_files(staticPath)
+  
+       const bundler = new ParcelBundler(
+            [path.join(__dirname, 'lambda', 'index.js')],
+            {
+                outDir: path.join(contentPath, 'server-bundle'),
+                bundleNodeModules: true,
+                target: 'node',
+                sourceMaps: false,
+                minify: false,
+            },
+        )
+        await bundler.bundle()

    }
}
Enter fullscreen mode Exit fullscreen mode

Full commit here.

6. Create CDK Stack

This is a barebones stack for deploying the site. The only non-trivial parts are the routing configuration that is generated from the contents of the static folder, and that I configured CDN to pass session cookies through to SSR handler.

import * as cdk from '@aws-cdk/core'
import * as lambda  from '@aws-cdk/aws-lambda'
import * as gw from '@aws-cdk/aws-apigatewayv2'
import * as s3  from '@aws-cdk/aws-s3'
import * as s3depl from '@aws-cdk/aws-s3-deployment'
import { LambdaProxyIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'
import * as cdn from '@aws-cdk/aws-cloudfront'
import * as fs from 'fs';
import * as path from 'path';


interface AdapterProps extends cdk.StackProps {
  serverPath: string
  staticPath: string
}

export class AdapterStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: AdapterProps) {
    super(scope, id, props);

    const handler = new lambda.Function(this, 'handler', {
      code: new lambda.AssetCode(props?.serverPath),
      handler: 'index.handler',
      runtime: lambda.Runtime.NODEJS_14_X,
    })

    const api = new gw.HttpApi(this, 'api')
    api.addRoutes({
      path: '/{proxy+}',
      methods: [gw.HttpMethod.ANY],
      integration: new LambdaProxyIntegration({
        handler,
        payloadFormatVersion: gw.PayloadFormatVersion.VERSION_1_0,
      })
    })

    const staticBucket = new s3.Bucket(this, 'staticBucket')
    const staticDeployment = new s3depl.BucketDeployment(this, 'staticDeployment', {
      destinationBucket: staticBucket,
      sources: [s3depl.Source.asset(props.staticPath)]
    })

    const staticID = new cdn.OriginAccessIdentity(this, 'staticID')
    staticBucket.grantRead(staticID)

    const distro = new cdn.CloudFrontWebDistribution(this, 'distro', {
      priceClass: cdn.PriceClass.PRICE_CLASS_100,
      defaultRootObject: '',
      originConfigs: [
        {
          customOriginSource: {
            domainName: cdk.Fn.select(1, cdk.Fn.split('://', api.apiEndpoint)),
            originProtocolPolicy: cdn.OriginProtocolPolicy.HTTPS_ONLY,
          },
          behaviors: [
            {
              allowedMethods: cdn.CloudFrontAllowedMethods.ALL,
              forwardedValues: {
                queryString: false,
                cookies: {
                  forward: 'whitelist',
                  whitelistedNames: ['sid', 'sid.sig']
                }
              },
              isDefaultBehavior: true
            }
          ]
        },
        {
          s3OriginSource: {
            s3BucketSource: staticBucket,
            originAccessIdentity: staticID,
          },
          behaviors: mkStaticRoutes(props.staticPath)
        }
      ]
    })

  }
}

function mkStaticRoutes(staticPath: string): cdn.Behavior[] {
  return fs.readdirSync(staticPath).map(f => {
    const fullPath = path.join(staticPath, f)
    const stat = fs.statSync(fullPath)
    if (stat.isDirectory()) {
      return {
        pathPattern: `/${f}/*`,
      }
    }
    return { pathPattern: `/${f}` }
  })
}
Enter fullscreen mode Exit fullscreen mode

Check full commit to see how cdk deploy is invoked and parameters passed to the stack

$ export ACCOUNT=234......23
$ export REGION=eu-north-1
$ npm run build
> sveltekit-cdk@0.0.1 build /workspaces/sveltekit-cdk> svelte-kit build

vite v2.1.5 building for production...
✓ 18 modules transformed.
.svelte/output/client/_app/manifest.json                            0.67kb
.svelte/output/client/_app/assets/start-d4cd1237.css                0.29kb / brotli: 0.18kb
.svelte/output/client/_app/assets/pages/index.svelte-27172613.css   0.69kb / brotli: 0.26kb
.svelte/output/client/_app/pages/index.svelte-f28bc36b.js           1.58kb / brotli: 0.68kb
.svelte/output/client/_app/chunks/vendor-57a96aae.js                5.14kb / brotli: 2.00kb
.svelte/output/client/_app/start-ff890ac9.js                        15.52kb / brotli: 5.29kb
vite v2.1.5 building SSR bundle for production...
✓ 16 modules transformed.
.svelte/output/server/app.js   70.57kb

Run npm start to try your app locally.

> Using MAGIC
✨  Built in 1.45s.

adapter/content/server-bundle/index.js    83.97 KB    1.14s
  ✔ done
AdapterStack: deploying...
[0%] start: Publishing 77fee73b537c2786a56a5ae730eecc3d1121be2512b210c0ad7f92a87ba52125:current
[25%] success: Published 77fee73b537c2786a56a5ae730eecc3d1121be2512b210c0ad7f92a87ba52125:current
[25%] start: Publishing e9882ab123687399f934da0d45effe675ecc8ce13b40cb946f3e1d6141fe8d68:current
[50%] success: Published e9882ab123687399f934da0d45effe675ecc8ce13b40cb946f3e1d6141fe8d68:current
[50%] start: Publishing c24b999656e4fe6c609c31bae56a1cf4717a405619c3aa6ba1bc686b8c2c86cf:current
[75%] success: Published c24b999656e4fe6c609c31bae56a1cf4717a405619c3aa6ba1bc686b8c2c86cf:current
[75%] start: Publishing 9ab01d8ba7648b72beed50ec3fad310aef1e1af2bf56f3912d53a56e03579ece:current
[100%] success: Published 9ab01d8ba7648b72beed50ec3fad310aef1e1af2bf56f3912d53a56e03579ece:current
AdapterStack: creating CloudFormation changeset...
  0/18 | 11:07:37 PM | REVIEW_IN_PROGRESS   | AWS::CloudFormation::Stack                      | AdapterStack User Initiated
  0/18 | 11:07:43 PM | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack                      | AdapterStack User Initiated
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role                                  | handler/ServiceRole (handlerServiceRole187D5A5A) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::S3::Bucket                                 | staticBucket (staticBucket49CE0992) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role                                  | handler/ServiceRole (handlerServiceRole187D5A5A) Resource creation Initiated
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::CloudFront::CloudFrontOriginAccessIdentity | staticID (staticID76F07208) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Api                          | api (apiC8550315) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata                              | CDKMetadata/Default (CDKMetadata) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role                                  | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265) 
  1/18 | 11:08:16 PM | CREATE_IN_PROGRESS   | AWS::Lambda::LayerVersion                       | staticDeployment/AwsCliLayer (staticDeploymentAwsCliLayerCF83B634) 
  1/18 | 11:08:17 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role                                  | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265) Resource creation Initiated
  1/18 | 11:08:17 PM | CREATE_IN_PROGRESS   | AWS::S3::Bucket                                 | staticBucket (staticBucket49CE0992) Resource creation Initiated
  1/18 | 11:08:18 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata                              | CDKMetadata/Default (CDKMetadata) Resource creation Initiated
  1/18 | 11:08:18 PM | CREATE_COMPLETE      | AWS::CDK::Metadata                              | CDKMetadata/Default (CDKMetadata) 
  1/18 | 11:08:18 PM | CREATE_IN_PROGRESS   | AWS::CloudFront::CloudFrontOriginAccessIdentity | staticID (staticID76F07208) Resource creation Initiated
  4/18 | 11:08:18 PM | CREATE_COMPLETE      | AWS::CloudFront::CloudFrontOriginAccessIdentity | staticID (staticID76F07208) 
  4/18 | 11:08:18 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Api                          | api (apiC8550315) Resource creation Initiated
  4/18 | 11:08:18 PM | CREATE_COMPLETE      | AWS::ApiGatewayV2::Api                          | api (apiC8550315) 
  4/18 | 11:08:20 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Stage                        | api/DefaultStage (apiDefaultStage04B80AC9) 
  4/18 | 11:08:22 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Stage                        | api/DefaultStage (apiDefaultStage04B80AC9) Resource creation Initiated
  4/18 | 11:08:22 PM | CREATE_COMPLETE      | AWS::ApiGatewayV2::Stage                        | api/DefaultStage (apiDefaultStage04B80AC9) 
  5/18 | 11:08:32 PM | CREATE_IN_PROGRESS   | AWS::Lambda::LayerVersion                       | staticDeployment/AwsCliLayer (staticDeploymentAwsCliLayerCF83B634) Resource creation Initiated
  5/18 | 11:08:33 PM | CREATE_COMPLETE      | AWS::Lambda::LayerVersion                       | staticDeployment/AwsCliLayer (staticDeploymentAwsCliLayerCF83B634) 
  9/18 | 11:08:34 PM | CREATE_COMPLETE      | AWS::IAM::Role                                  | handler/ServiceRole (handlerServiceRole187D5A5A) 
  9/18 | 11:08:35 PM | CREATE_COMPLETE      | AWS::IAM::Role                                  | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265) 
  9/18 | 11:08:37 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function                           | handler (handlerE1533BD5) 
  9/18 | 11:08:37 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function                           | handler (handlerE1533BD5) Resource creation Initiated
  9/18 | 11:08:37 PM | CREATE_COMPLETE      | AWS::Lambda::Function                           | handler (handlerE1533BD5) 
  9/18 | 11:08:38 PM | CREATE_COMPLETE      | AWS::S3::Bucket                                 | staticBucket (staticBucket49CE0992) 
 12/18 | 11:08:39 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Permission                         | api/ANY--{proxy+}/AdapterStackapiANYproxy1E757BCE-Permission (apiANYproxyAdapterStackapiANYproxy1E757BCEPermission4DD3BE97) 
 12/18 | 11:08:39 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Integration                  | api/ANY--{proxy+}/HttpIntegration-addabd80f5f992d479db94f5bda52ee5 (apiANYproxyHttpIntegrationaddabd80f5f992d479db94f5bda52ee5449897FD) 
 12/18 | 11:08:40 PM | CREATE_IN_PROGRESS   | AWS::IAM::Policy                                | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF) 
 12/18 | 11:08:40 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Permission                         | api/ANY--{proxy+}/AdapterStackapiANYproxy1E757BCE-Permission (apiANYproxyAdapterStackapiANYproxy1E757BCEPermission4DD3BE97) Resource creation Initiated
 12/18 | 11:08:40 PM | CREATE_IN_PROGRESS   | AWS::S3::BucketPolicy                           | staticBucket/Policy (staticBucketPolicyA47383C0) 
 12/18 | 11:08:41 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Integration                  | api/ANY--{proxy+}/HttpIntegration-addabd80f5f992d479db94f5bda52ee5 (apiANYproxyHttpIntegrationaddabd80f5f992d479db94f5bda52ee5449897FD) Resource creation Initiated
 12/18 | 11:08:41 PM | CREATE_COMPLETE      | AWS::ApiGatewayV2::Integration                  | api/ANY--{proxy+}/HttpIntegration-addabd80f5f992d479db94f5bda52ee5 (apiANYproxyHttpIntegrationaddabd80f5f992d479db94f5bda52ee5449897FD) 
 12/18 | 11:08:41 PM | CREATE_IN_PROGRESS   | AWS::IAM::Policy                                | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF) Resource creation Initiated
 12/18 | 11:08:41 PM | CREATE_IN_PROGRESS   | AWS::S3::BucketPolicy                           | staticBucket/Policy (staticBucketPolicyA47383C0) Resource creation Initiated
 12/18 | 11:08:41 PM | CREATE_COMPLETE      | AWS::S3::BucketPolicy                           | staticBucket/Policy (staticBucketPolicyA47383C0) 
 12/18 | 11:08:43 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Route                        | api/ANY--{proxy+} (apiANYproxy1413EA65) 
 12/18 | 11:08:43 PM | CREATE_IN_PROGRESS   | AWS::ApiGatewayV2::Route                        | api/ANY--{proxy+} (apiANYproxy1413EA65) Resource creation Initiated
 12/18 | 11:08:44 PM | CREATE_COMPLETE      | AWS::ApiGatewayV2::Route                        | api/ANY--{proxy+} (apiANYproxy1413EA65) 
 13/18 | 11:08:50 PM | CREATE_COMPLETE      | AWS::Lambda::Permission                         | api/ANY--{proxy+}/AdapterStackapiANYproxy1E757BCE-Permission (apiANYproxyAdapterStackapiANYproxy1E757BCEPermission4DD3BE97) 
 14/18 | 11:08:58 PM | CREATE_IN_PROGRESS   | AWS::CloudFront::Distribution                   | distro/CFDistribution (distroCFDistributionB272DD5C) 
 14/18 | 11:08:58 PM | CREATE_COMPLETE      | AWS::IAM::Policy                                | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF) 
 14/18 | 11:09:00 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function                           | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536) 
 14/18 | 11:09:02 PM | CREATE_IN_PROGRESS   | AWS::CloudFront::Distribution                   | distro/CFDistribution (distroCFDistributionB272DD5C) Resource creation Initiated
 15/18 | 11:09:04 PM | CREATE_IN_PROGRESS   | AWS::Lambda::Function                           | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536) Resource creation Initiated
 15/18 | 11:09:05 PM | CREATE_COMPLETE      | AWS::Lambda::Function                           | Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C (CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536) 
 15/18 | 11:09:07 PM | CREATE_IN_PROGRESS   | Custom::CDKBucketDeployment                     | staticDeployment/CustomResource/Default (staticDeploymentCustomResource41B995BA) 
 16/18 | 11:09:39 PM | CREATE_IN_PROGRESS   | Custom::CDKBucketDeployment                     | staticDeployment/CustomResource/Default (staticDeploymentCustomResource41B995BA) Resource creation Initiated
 16/18 | 11:09:39 PM | CREATE_COMPLETE      | Custom::CDKBucketDeployment                     | staticDeployment/CustomResource/Default (staticDeploymentCustomResource41B995BA) 
16/18 Currently in progress: AdapterStack, distroCFDistributionB272DD5C
 18/18 | 11:11:19 PM | CREATE_COMPLETE      | AWS::CloudFront::Distribution                   | distro/CFDistribution (distroCFDistributionB272DD5C) 
 18/18 | 11:11:20 PM | CREATE_COMPLETE      | AWS::CloudFormation::Stack                      | AdapterStack 

 ✅  AdapterStack

Stack ARN:
arn:aws:cloudformation:eu-north-1:3123....123:stack/AdapterStack/b653b540-9663-11eb-b7b2-0eab76d49478
Enter fullscreen mode Exit fullscreen mode

Hurrah!!

image

GitHub logo juranki / diy-sveltekit-cdk-adapter

An exercise on deploying SvelteKit with CDK


Cover photo by Alistair MacRobert on Unsplash

Top comments (2)

Collapse
 
koleok profile image
Kyle Chamberlain

Fantastic this was really helpful for me today thank you 👏

Collapse
 
juslin03 profile image
Juslin

Sir, you saved me.