Initial commit
This commit is contained in:
commit
dd3482c11d
11
.env.sample
Normal file
11
.env.sample
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export PORT=8080
|
||||||
|
|
||||||
|
export DB_HOST=localhost
|
||||||
|
export DB_USER=postgres
|
||||||
|
export DB_PASSWORD=password
|
||||||
|
export DB_NAME=db_name
|
||||||
|
export DB_PORT=5432
|
||||||
|
|
||||||
|
export DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
export SESSION_KEY="1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
src/server/public/dist
|
||||||
|
.env
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
// "deno.enable": true
|
||||||
|
}
|
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Installs Node image
|
||||||
|
FROM node:18-alpine as base
|
||||||
|
|
||||||
|
# sets the working directory for any RUN, CMD, COPY command
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DB_HOST=postgres
|
||||||
|
ENV DB_USER=postgres
|
||||||
|
ENV DB_NAME=db_name
|
||||||
|
ENV DB_PASSWORD=password
|
||||||
|
ENV DB_PORT=5432
|
||||||
|
ENV DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
|
|
||||||
|
# Copies stuff to cache for install
|
||||||
|
COPY ./package.json ./package-lock.json tsconfig.json ./
|
||||||
|
|
||||||
|
RUN echo "npm install"
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copies everything in the src directory to WORKDIR/src
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY ./scripts ./scripts
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
RUN echo "npm run prod-start"
|
||||||
|
CMD ["npm", "run", "prod-start"]
|
40
README.md
Normal file
40
README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Firstack
|
||||||
|
|
||||||
|
Firstack is a template repo for a tech stack. This stack includes
|
||||||
|
|
||||||
|
- [react](https://react.dev/)
|
||||||
|
- [emotion](https://emotion.sh/)
|
||||||
|
- [fastify](https://fastify.dev/)
|
||||||
|
- [postgres](https://www.postgresql.org/)
|
||||||
|
- [typescript](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Before you can run this locally, you must install:
|
||||||
|
|
||||||
|
- node (version 18) - I recommend installing through [nvm](https://github.com/nvm-sh/nvm#nvmrc)
|
||||||
|
- [docker desktop](https://www.docker.com/)
|
||||||
|
|
||||||
|
It might also be helpful to install [pgAdmin4](https://www.pgadmin.org/) and some helpful api app like postman (I use [HTTPie](https://httpie.io/)).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running it!
|
||||||
|
|
||||||
|
To run the application locally...
|
||||||
|
|
||||||
|
In one tab,
|
||||||
|
```sh
|
||||||
|
npm run dev-watch-client
|
||||||
|
```
|
||||||
|
|
||||||
|
In another,
|
||||||
|
```sh
|
||||||
|
npm run dev-docker
|
||||||
|
npm run dev-server
|
||||||
|
```
|
15
TODO.md
Normal file
15
TODO.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
TODO
|
||||||
|
|
||||||
|
- [ ] User Auth
|
||||||
|
- [x] Component Pack
|
||||||
|
- [x] Typescript Pack
|
||||||
|
- [ ] Can this be used in a way where it doesn't need to be imported everywhere (lib?)
|
||||||
|
- [ ] Consider making more parts into Packs
|
||||||
|
- [ ] tsconfig with extends
|
||||||
|
- [ ] server with wrapper around fastify
|
||||||
|
- [ ] scripts from package.json
|
||||||
|
- [ ] Streamline spinning up a server
|
||||||
|
- [ ] Should public be top level?
|
||||||
|
- [ ] Clean up .env variable names
|
||||||
|
- [ ] use Temporal (polyfill)?
|
||||||
|
- [ ] Do I want to add testing infrastructure?
|
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
version: '3.9'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
container_name: app
|
||||||
|
image: node
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: base
|
||||||
|
env_file:
|
||||||
|
.env
|
||||||
|
ports:
|
||||||
|
- ${PORT}:${PORT}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
profiles: ["prod"]
|
||||||
|
|
||||||
|
db:
|
||||||
|
container_name: postgres
|
||||||
|
image: postgres
|
||||||
|
env_file:
|
||||||
|
.env
|
||||||
|
ports:
|
||||||
|
- '5432:${DB_PORT}'
|
||||||
|
volumes:
|
||||||
|
- data:/data/db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
profiles: ["dev", "prod"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data: {}
|
7475
package-lock.json
generated
Normal file
7475
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "firstack",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Firstack is a template repo for a tech stack.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev-docker": "docker compose --profile dev up -d",
|
||||||
|
"dev-server": "echo \"starting server\" && npm run dev-ts ./src/server/index.ts",
|
||||||
|
"dev-ts": "nodemon --require 'dotenv/config'",
|
||||||
|
"dev-watch-client": "ts-node ./scripts/watch.ts",
|
||||||
|
"dev-migrate": "source ./.env && pg-migrations apply --directory ./src/database/migrations",
|
||||||
|
"prod-migrate": "pg-migrations apply --directory ./src/database/migrations",
|
||||||
|
"prod-build-client": "ts-node ./scripts/build.ts",
|
||||||
|
"prod-docker": "docker compose --profile prod up -d",
|
||||||
|
"prod-start": "echo \"building frontend\" && npm run prod-build-client && echo \"running migrations\" && npm run prod-migrate && echo \"starting server\" && npm run prod-ts ./src/server/index.ts",
|
||||||
|
"prod-ts": "ts-node --require 'dotenv/config'",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.playbox.link/dylan/firstack.git"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@databases/pg": "^5.4.1",
|
||||||
|
"@fastify/cookie": "^9.0.4",
|
||||||
|
"@fastify/static": "^6.10.2",
|
||||||
|
"@firebox/components": "^0.1.7",
|
||||||
|
"@firebox/tsutil": "^0.1.2",
|
||||||
|
"@sinclair/typebox": "^0.31.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"fastify": "^4.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@databases/pg-migrations": "^5.0.2",
|
||||||
|
"@emotion/css": "^11.11.2",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@types/react": "^18.2.21",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"esbuild": "^0.19.2",
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^4.10.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
9
scripts/build.ts
Normal file
9
scripts/build.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import esbuild from 'esbuild';
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
entryPoints: ['src/client/index.ts'],
|
||||||
|
format: "esm",
|
||||||
|
outfile: 'src/server/public/dist/index.js',
|
||||||
|
bundle: true,
|
||||||
|
})
|
12
scripts/watch.ts
Normal file
12
scripts/watch.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import esbuild from 'esbuild';
|
||||||
|
|
||||||
|
const buildContext = await esbuild
|
||||||
|
.context({
|
||||||
|
entryPoints: ['src/client/index.ts'],
|
||||||
|
format: "esm",
|
||||||
|
outfile: 'src/server/public/dist/index.js',
|
||||||
|
bundle: true,
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
await buildContext.watch();
|
34
src/client/app.tsx
Normal file
34
src/client/app.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { css } from "@emotion/css";
|
||||||
|
import { Center, Cover, Stack } from "@firebox/components";
|
||||||
|
|
||||||
|
const App = (props: { name: string }) => {
|
||||||
|
const {name} = props;
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<div className={css`background-color: floralwhite;`}>
|
||||||
|
<Cover gap pad>
|
||||||
|
<Center>
|
||||||
|
<Stack gap={-1}>
|
||||||
|
<h1>Hello, {name}!</h1>
|
||||||
|
<p>Welcome to a website with a certain design philosophy. Tell me how it's working out! I want to see this text wrap a few times. Hopefully this sentence will help.</p>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
<Cover.Footer>A page by Dylan Pizzo</Cover.Footer>
|
||||||
|
</Cover>
|
||||||
|
</div>
|
||||||
|
<div className={css`background-color: aliceblue;`}>
|
||||||
|
<Cover gap pad>
|
||||||
|
<Center>
|
||||||
|
<Stack gap={-1}>
|
||||||
|
<h1>Hello, {name}!</h1>
|
||||||
|
<p>Welcome to a website with a certain design philosophy. Tell me how it's working out! I want to see this text wrap a few times. Hopefully this sentence will help.</p>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
<Cover.Footer>A page by Dylan Pizzo</Cover.Footer>
|
||||||
|
</Cover>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const app = <App name="World" />;
|
6
src/client/index.ts
Normal file
6
src/client/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { app } from "./app.js";
|
||||||
|
|
||||||
|
const domNode = document.getElementById("root")!;
|
||||||
|
const root = createRoot(domNode);
|
||||||
|
root.render(app);
|
28
src/database/db.ts
Normal file
28
src/database/db.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import createConnectionPool, {ConnectionPool, ConnectionPoolConfig, sql} from '@databases/pg';
|
||||||
|
|
||||||
|
export {sql};
|
||||||
|
|
||||||
|
const portString = process.env["DB_PORT"];
|
||||||
|
const portNumber = portString ? parseInt(portString) : undefined;
|
||||||
|
|
||||||
|
const clientConfig: ConnectionPoolConfig = {
|
||||||
|
host: process.env["DB_HOST"],
|
||||||
|
user: process.env["DB_USER"],
|
||||||
|
database: process.env["DB_NAME"],
|
||||||
|
password: process.env["DB_PASSWORD"],
|
||||||
|
port: portNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const db: ConnectionPool = createConnectionPool({
|
||||||
|
connectionString: false,
|
||||||
|
...clientConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
process.once('SIGTERM', () => {
|
||||||
|
db.dispose().catch((ex) => {
|
||||||
|
console.error(ex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {db};
|
4
src/database/migrations/1-first-migration.sql
Normal file
4
src/database/migrations/1-first-migration.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE testtable (
|
||||||
|
id text,
|
||||||
|
somecolumn text
|
||||||
|
)
|
11
src/server/api.ts
Normal file
11
src/server/api.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type { RouteList } from "./routelist.ts"
|
||||||
|
|
||||||
|
type RouteUrl = RouteList[number]["url"];
|
||||||
|
|
||||||
|
type HttpMethod = RouteList[number]["method"];
|
||||||
|
|
||||||
|
type Route<M extends HttpMethod, U extends RouteUrl> = Extract<RouteList[number], {url: U, method: M}>;
|
||||||
|
|
||||||
|
export type RoutePayload<M extends HttpMethod, U extends RouteUrl> = Parameters<Route<M, U>["handler"]>[0]["payload"];
|
||||||
|
|
||||||
|
export type RouteResponse<M extends HttpMethod, U extends RouteUrl> = Awaited<ReturnType<Route<M, U>["handler"]>>;
|
18
src/server/api/echo.ts
Normal file
18
src/server/api/echo.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { FirRouteInput, FirRouteOptions } from "../util/routewrap.js";
|
||||||
|
|
||||||
|
const method = "GET";
|
||||||
|
const url = "/echo";
|
||||||
|
|
||||||
|
const payloadT = Type.Any();
|
||||||
|
|
||||||
|
const handler = ({payload}: FirRouteInput<typeof payloadT>) => {
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
payloadT,
|
||||||
|
handler,
|
||||||
|
} as const satisfies FirRouteOptions<typeof payloadT>;
|
1
src/server/dbal/dbal.ts
Normal file
1
src/server/dbal/dbal.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Database Access Layer stuff goes here
|
31
src/server/index.ts
Normal file
31
src/server/index.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// Import the framework and instantiate it
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import fastifyStatic from '@fastify/static'
|
||||||
|
import { routeList } from "./routelist.ts";
|
||||||
|
import { route } from "./util/routewrap.ts";
|
||||||
|
|
||||||
|
console.log(process.env["DATABASE_URL"]);
|
||||||
|
|
||||||
|
const server = Fastify({
|
||||||
|
logger: true
|
||||||
|
});
|
||||||
|
|
||||||
|
server.register(fastifyStatic, {
|
||||||
|
root: new URL('public', import.meta.url).toString().slice("file://".length),
|
||||||
|
prefix: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
routeList.forEach(firRoute => {
|
||||||
|
server.route(route(firRoute));
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run the server!
|
||||||
|
try {
|
||||||
|
// Note: host needs to be 0.0.0.0 rather than omitted or localhost, otherwise
|
||||||
|
// it always returns an empty reply when used inside docker...
|
||||||
|
// See: https://github.com/fastify/fastify/issues/935
|
||||||
|
await server.listen({ port: parseInt(process.env["PORT"] ?? "3000"), host: "0.0.0.0" })
|
||||||
|
} catch (err) {
|
||||||
|
server.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
48
src/server/public/index.html
Normal file
48
src/server/public/index.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="dist/index.js" type="module"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--measure: 64ch;
|
||||||
|
--ratio: 1.618;
|
||||||
|
|
||||||
|
--s-5: calc(var(--s-4) / var(--ratio));
|
||||||
|
--s-4: calc(var(--s-3) / var(--ratio));
|
||||||
|
--s-3: calc(var(--s-2) / var(--ratio));
|
||||||
|
--s-2: calc(var(--s-1) / var(--ratio));
|
||||||
|
--s-1: calc(var(--s0) / var(--ratio));
|
||||||
|
--s0: 1rem;
|
||||||
|
--s1: calc(var(--s0) * var(--ratio));
|
||||||
|
--s2: calc(var(--s1) * var(--ratio));
|
||||||
|
--s3: calc(var(--s2) * var(--ratio));
|
||||||
|
--s4: calc(var(--s3) * var(--ratio));
|
||||||
|
--s5: calc(var(--s4) * var(--ratio));
|
||||||
|
|
||||||
|
--border-radius: 0.5rem;
|
||||||
|
|
||||||
|
font-size: calc(1rem + 0.15vw);
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
max-inline-size: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
div,
|
||||||
|
header,
|
||||||
|
nav,
|
||||||
|
main,
|
||||||
|
footer {
|
||||||
|
max-inline-size: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
7
src/server/routelist.ts
Normal file
7
src/server/routelist.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import echo from "./api/echo.ts";
|
||||||
|
|
||||||
|
export const routeList = [
|
||||||
|
echo,
|
||||||
|
];
|
||||||
|
|
||||||
|
export type RouteList = typeof routeList;
|
47
src/server/util/routewrap.ts
Normal file
47
src/server/util/routewrap.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Static, TSchema } from "@sinclair/typebox";
|
||||||
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
import { HTTPMethods } from "fastify"
|
||||||
|
import { RouteOptions } from "fastify/types/route.js";
|
||||||
|
|
||||||
|
type URLString = string;
|
||||||
|
|
||||||
|
export type FirRouteInput<TPayloadSchema extends TSchema> = {
|
||||||
|
payload: Static<TPayloadSchema>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FirRouteOptions<TIn extends TSchema = TSchema, TOut extends TSchema = TSchema> = {
|
||||||
|
method: HTTPMethods,
|
||||||
|
url: URLString,
|
||||||
|
payloadT: TIn,
|
||||||
|
responseT?: TOut,
|
||||||
|
handler: (input: FirRouteInput<TIn>) => Static<TOut> | Promise<Static<TOut>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const route = <TIn extends TSchema, TOut extends TSchema>(routeOptions: FirRouteOptions<TIn, TOut>): RouteOptions => {
|
||||||
|
const {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
payloadT,
|
||||||
|
handler,
|
||||||
|
} = routeOptions;
|
||||||
|
|
||||||
|
const augmentedHandler = (request: Parameters<RouteOptions["handler"]>[0]) => {
|
||||||
|
const {
|
||||||
|
body,
|
||||||
|
query,
|
||||||
|
} = request;
|
||||||
|
const payload = body ?? query;
|
||||||
|
if (Value.Check(payloadT, payload)) {
|
||||||
|
return handler({payload});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
throw new Error("Payload wrong shape.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
handler: augmentedHandler,
|
||||||
|
}
|
||||||
|
}
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true,
|
||||||
|
"experimentalSpecifierResolution": "node",
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user