Compare commits

...

4 Commits

Author SHA1 Message Date
4847424d9b use codebuild 2025-06-21 09:56:46 +09:00
48d86e4479 add cognito 2025-06-21 07:42:57 +09:00
fab8c22e0c replace react-js with react-ts 2025-06-21 07:32:06 +09:00
890087a572 improve database schema 2025-06-19 22:29:18 +09:00
20 changed files with 972 additions and 218 deletions

View File

@ -9,16 +9,12 @@ export const main: APIGatewayProxyHandlerV2 = async (event) => {
endpoint: `https://${domainName}/${stage}`, endpoint: `https://${domainName}/${stage}`,
}); });
console.log("Received event: ", JSON.stringify(event, null, 2));
try { try {
switch (routeKey) { switch (routeKey) {
case '$connect': case '$connect':
console.log(`New connection: ${connectionId}`);
return { statusCode: 200, body: 'Connected' }; return { statusCode: 200, body: 'Connected' };
case '$disconnect': case '$disconnect':
console.log(`Disconnected: ${connectionId}`);
return { statusCode: 200, body: 'Disconnected' }; return { statusCode: 200, body: 'Disconnected' };
default: default:

29
buildspec.yml Normal file
View File

@ -0,0 +1,29 @@
version: 0.2
phases:
install:
runtime-versions:
nodejs: 18
commands:
- echo cloning repository...
- git clone https://git.falsy.cat/falsycat/imbusy/ .
- find
- echo installing dependencies...
- cd frontend && npm ci && cd ..
- cd backend && npm ci && cd ..
build:
commands:
- echo building frontend...
- cd frontend && npm run build && cd ..
- echo building backend...
- cd backend && npm run build && cd ..
post_build:
commands:
- echo deploying frontend...
- aws s3 sync frontend/dist/ s3://$IMBUSY_FE_BUCKET --delete
- echo deploying backend...
- cd backend/dist && zip -r ../../backend.zip . && cd ../..
- aws s3 cp backend.zip s3://$IMBUSY_BE_BUCKET

View File

@ -1,15 +1,54 @@
imbusy-fe # React + TypeScript + Vite
====
imbusy frontend webui This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
## Commands - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
```bash ## Expanding the ESLint configuration
# starting dev server
npm run dev
# building If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
npm run build
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
``` ```

View File

@ -2,32 +2,27 @@ import js from '@eslint/js'
import globals from 'globals' import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default [ export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist'] },
{ {
files: ['**/*.{js,jsx}'], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
}, },
plugins: { plugins: {
'react-hooks': reactHooks, 'react-hooks': reactHooks,
'react-refresh': reactRefresh, 'react-refresh': reactRefresh,
}, },
rules: { rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': [
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
}, },
}, },
] )

View File

@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title> <title>Vite + React + TS</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
@ -22,6 +22,8 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5"
} }
} }

View File

@ -22,7 +22,7 @@ function App() {
count is {count} count is {count}
</button> </button>
<p> <p>
Edit <code>src/App.jsx</code> and save to test HMR Edit <code>src/App.tsx</code> and save to test HMR
</p> </p>
</div> </div>
<p className="read-the-docs"> <p className="read-the-docs">

View File

@ -1,9 +1,9 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.tsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -7,18 +7,11 @@ import * as common from "./common";
const tags = common.tags; const tags = common.tags;
const prefix = `${common.prefix}-backend`; const prefix = `${common.prefix}-backend`;
console.log("building backend...");
esbuild.buildSync({
entryPoints: ["../backend/src/index.ts"],
outfile: "../backend/dist/index.js",
bundle: true,
platform: "node",
target: "node18",
minify: true,
});
// ---- program bucket ----
export const bucket = new aws.s3.Bucket(`${prefix}-bucket`);
// ---- Lambda ---- // ---- api lambda ----
const lambdaRole = new aws.iam.Role(`${prefix}-lambda-role`, { const lambdaRole = new aws.iam.Role(`${prefix}-lambda-role`, {
tags, tags,
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
@ -35,9 +28,8 @@ const lambda = new aws.lambda.Function(`${prefix}-lambda`, {
runtime: "nodejs18.x", runtime: "nodejs18.x",
handler: "index.main", handler: "index.main",
role: lambdaRole.arn, role: lambdaRole.arn,
code: new pulumi.asset.AssetArchive({ s3Bucket: bucket.arn,
".": new pulumi.asset.FileArchive("../backend/dist/"), s3Key: "lambda.zip",
}),
}); });
new aws.cloudwatch.LogGroup(`${prefix}-lambda-log-group`, { new aws.cloudwatch.LogGroup(`${prefix}-lambda-log-group`, {
name: pulumi.interpolate`/aws/lambda/${lambda.name}`, name: pulumi.interpolate`/aws/lambda/${lambda.name}`,
@ -45,8 +37,22 @@ new aws.cloudwatch.LogGroup(`${prefix}-lambda-log-group`, {
}); });
// ---- database ----
new aws.dynamodb.Table(`${prefix}-db`, {
tags,
name: "objects",
attributes: [
{ name: "userId", type: "S", },
{ name: "sortKey", type: "S", },
],
hashKey: "userId",
rangeKey: "sortKey",
billingMode: "PAY_PER_REQUEST",
});
// ---- API Gateway ---- // ---- API Gateway ----
const api = new aws.apigatewayv2.Api(`${prefix}-api`, { export const api = new aws.apigatewayv2.Api(`${prefix}-api`, {
tags, tags,
protocolType: "WEBSOCKET", protocolType: "WEBSOCKET",
routeSelectionExpression: "$request.body.action", routeSelectionExpression: "$request.body.action",
@ -91,4 +97,19 @@ new aws.iam.RolePolicy(`${prefix}-api-role-policy`, {
}`, }`,
}); });
export const endpoint = pulumi.interpolate`${api.apiEndpoint}`;
// ---- cognito ----
const userPool = new aws.cognito.UserPool(`${prefix}-cognito-userpool`, {
tags,
autoVerifiedAttributes: ["email"],
usernameAttributes: ["email"],
passwordPolicy: {
minimumLength: 8,
requireSymbols: true,
requireNumbers: true,
},
});
new aws.cognito.UserPoolClient(`${prefix}-cognito-userpool-cli`, {
userPoolId: userPool.id,
generateSecret: false,
});

View File

@ -1,4 +1,5 @@
import * as pulumi from "@pulumi/pulumi"; import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
export const tags = { export const tags = {
project: pulumi.getProject(), project: pulumi.getProject(),
@ -6,3 +7,5 @@ export const tags = {
}; };
export const prefix = `${tags.project}-${tags.env}`; export const prefix = `${tags.project}-${tags.env}`;
export const logGroupPrefix = `/falsycat/${tags.project}/${tags.env}/`;

View File

@ -1,17 +0,0 @@
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import * as common from "./common";
const tags = common.tags;
const prefix = `${common.prefix}-database`;
new aws.dynamodb.Table(`${prefix}-users`, {
tags,
name: "Users", // これがDynamoDBのTableNameになる
attributes: [
{ name: "id", type: "S", },
],
hashKey: "id",
billingMode: "PAY_PER_REQUEST",
});

79
infra/deployment.ts Normal file
View File

@ -0,0 +1,79 @@
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as fs from "fs";
import * as common from "./common";
import * as backend from "./backend";
import * as frontend from "./frontend";
const tags = common.tags;
const prefix = `${common.prefix}-deployment`;
// ---- role ----
const role = new aws.iam.Role(`${prefix}-role`, {
tags,
assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: "codebuild.amazonaws.com" }),
});
new aws.iam.RolePolicyAttachment(`${prefix}-policy-codebuild`, {
role: role.name,
policyArn: aws.iam.ManagedPolicies.AWSCodeBuildDeveloperAccess,
});
new aws.iam.RolePolicy(`${prefix}-role-policy`, {
role: role.name,
policy: pulumi.all([backend.bucket.arn, frontend.bucket.arn]).apply(([be, fe]) => JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
Resource: [ `${be}/*`, `${fe}/*`, ],
},
{
Effect: "Allow",
Action: [
"s3:ListBucket",
],
Resource: [ be, fe, ],
},
{
Effect: "Allow",
Action: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
Resource: "*",
},
]
})),
});
// ---- codebuild ----
const codebuild = new aws.codebuild.Project(`${prefix}-codebuild`, {
tags,
source: {
type: "NO_SOURCE",
buildspec: fs.readFileSync("../buildspec.yml", "utf-8"),
},
environment: {
computeType: "BUILD_GENERAL1_SMALL",
image: "aws/codebuild/standard:7.0",
type: "LINUX_CONTAINER",
environmentVariables: [
{ name: "IMBUSY_BE_BUCKET", value: backend.bucket.bucket, },
{ name: "IMBUSY_FE_BUCKET", value: frontend.bucket.bucket, },
],
},
serviceRole: role.arn,
artifacts: {
type: "NO_ARTIFACTS",
},
});

View File

@ -10,33 +10,18 @@ import * as common from "./common";
const tags = common.tags; const tags = common.tags;
const prefix = `${common.prefix}-frontend`; const prefix = `${common.prefix}-frontend`;
// ---- infra resouces ---- // ---- bucket ----
const bucket = new aws.s3.Bucket(`${prefix}-bucket`, {tags}); export const bucket = new aws.s3.Bucket(`${prefix}-bucket`, {tags});
const bucketOai = new aws.cloudfront.OriginAccessIdentity(`${prefix}-oai`);
const oai = new aws.cloudfront.OriginAccessIdentity(`${prefix}-oai`);
const bucketPolicy = new aws.s3.BucketPolicy(`${prefix}-bucket-policy`, {
bucket: bucket.bucket,
policy: pulumi.all([oai.iamArn, bucket.bucket]).apply(([a, b]) => JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Principal: {
AWS: a,
},
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${b}/*`],
}],
})),
});
// ---- cloudfront ----
export const cloudfront = new aws.cloudfront.Distribution(`${prefix}-cloudfront`, { export const cloudfront = new aws.cloudfront.Distribution(`${prefix}-cloudfront`, {
tags, tags,
origins: [{ origins: [{
domainName: bucket.bucketRegionalDomainName, domainName: bucket.bucketRegionalDomainName,
originId: bucket.arn, originId: bucket.arn,
s3OriginConfig: { s3OriginConfig: {
originAccessIdentity: oai.cloudfrontAccessIdentityPath, originAccessIdentity: bucketOai.cloudfrontAccessIdentityPath,
}, },
}], }],
enabled: true, enabled: true,
@ -51,6 +36,18 @@ export const cloudfront = new aws.cloudfront.Distribution(`${prefix}-cloudfront`
cookies: { forward: "none" }, cookies: { forward: "none" },
}, },
}, },
customErrorResponses: [
{
errorCode: 403,
responseCode: 200,
responsePagePath: "/index.html",
},
{
errorCode: 404,
responseCode: 200,
responsePagePath: "/index.html",
},
],
priceClass: "PriceClass_100", priceClass: "PriceClass_100",
restrictions: { restrictions: {
geoRestriction: { geoRestriction: {
@ -61,23 +58,3 @@ export const cloudfront = new aws.cloudfront.Distribution(`${prefix}-cloudfront`
cloudfrontDefaultCertificate: true, cloudfrontDefaultCertificate: true,
}, },
}); });
// ---- file uploads ----
(async () => {
const cwd = "../frontend/dist/";
const stats = await fs.stat(cwd);
if (!stats.isDirectory()) {
throw new Error("build the frontend firstly");
}
const files = await fg("**/*", {cwd, dot: true});
for (const file of files) {
new aws.s3.BucketObject(file, {
bucket: bucket,
source: new pulumi.asset.FileAsset(cwd+file),
contentType: mime.getType(file) || "application/octet-stream",
key: file,
});
}
})();

View File

@ -4,7 +4,7 @@ import * as aws from "@pulumi/aws";
import * as backend from "./backend"; import * as backend from "./backend";
import * as frontend from "./frontend"; import * as frontend from "./frontend";
import "./database"; import "./deployment";
export const frontendDomain = frontend.cloudfront.domainName; export const frontendDomain = frontend.cloudfront.domainName;
export const backendEndpoint = backend.endpoint; export const backendEndpoint = backend.api.apiEndpoint;