Initial commit

This commit is contained in:
2025-10-13 23:17:05 -05:00
commit c9715d22f6
17 changed files with 2354 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
docker-cache/
node_modules/
distribution/
assets/css/
assets/js/
assets/images/webp/
assets/images/avif/
docker-compose.yml

View File

@@ -0,0 +1,99 @@
name: App Image CI
on:
push:
branches:
- master
tags:
- 'v*'
pull_request:
branches:
- master
jobs:
build_app_image:
name: Build app image
runs-on: ubuntu-latest
env:
DH_REGISTRY: docker.io
GH_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
REPOSITORY: ${{ github.event.repository.name }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- name: Login to DockerHub
if: startsWith(github.ref, 'refs/tags/v')
uses: docker/login-action@v3
with:
registry: ${{ env.DH_REGISTRY }}
username: ${{ env.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ env.GITHUB_SERVER_URL == 'https://github.com' }}
uses: docker/login-action@v3
with:
registry: ${{ env.GH_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for release Docker image
if: startsWith(github.ref, 'refs/tags/v')
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_USERNAME }}/${{ env.REPOSITORY }}
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Extract metadata for develop Docker image
if: "!startsWith(github.ref, 'refs/tags/v')"
id: meta-develop
uses: docker/metadata-action@v5
with:
images: |
${{ env.GH_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
- name: Build and push release Docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: docker/build-push-action@v6
with:
target: deploy
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
- name: Build and push develop Docker image
if: "!startsWith(github.ref, 'refs/tags/v')"
uses: docker/build-push-action@v6
with:
target: deploy
push: ${{ env.GITHUB_SERVER_URL == 'https://github.com' }}
tags: ${{ steps.meta-develop.outputs.tags }}
labels: ${{ steps.meta-develop.outputs.labels }}
platforms: linux/amd64,linux/arm64

27
.github/workflows/npm-audit.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: NPM Audit Check
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '0 16 * * 5'
jobs:
npm_audit:
name: Check NPM audit
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: true
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: NPM Audit
run: npm audit

110
.gitignore vendored Normal file
View File

@@ -0,0 +1,110 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
assets/css/
assets/js/
assets/webp/
config/
distribution/

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/distribution/index.js",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": ["${workspaceFolder}/distribution/**/*.js"]
}
]
}

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:lts-alpine AS base
FROM base AS build-env
WORKDIR /usr/src/app
RUN apk add --no-cache libwebp libwebp-tools libavif-apps
COPY package*.json ./
RUN npm install
COPY . .
RUN npx tsc && npm run-script build && \
npm ci --only=production && \
ln -sf /usr/share/fonts assets/ && \
chown -R node .
FROM base AS deploy
HEALTHCHECK --timeout=3s \
CMD curl --fail http://localhost:8080/healthcheck || exit 1
EXPOSE 8080
WORKDIR /usr/src/app
RUN apk add --no-cache curl
COPY --from=build-env /usr/src/app /usr/src/app
USER node
CMD [ "node", "distribution/index.js"]

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Node Web Template

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
version: '2'
services:
nodejs-web:
container_name: nodejs-web
build:
context: ./
dockerfile: Dockerfile
restart: "no"
ports:
- 8080:8080

1761
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "nodejs-web-template",
"version": "0.0.1",
"description": "A nodejs web project",
"keywords": [
"web",
"template"
],
"homepage": "https://github.com/CorySanin/nodejs-web-template#readme",
"bugs": {
"url": "https://github.com/CorySanin/nodejs-web-template/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/CorySanin/nodejs-web-template.git"
},
"license": "MIT",
"author": "Cory Sanin",
"type": "module",
"main": "./distribution/index.js",
"files": [
"distribution",
"assets",
"views"
],
"dependencies": {
"ejs": "3.1.10",
"express": "5.1.0",
"express-session": "1.18.2",
"json5": "2.2.3",
"ky": "1.11.0"
},
"devDependencies": {
"@sindresorhus/tsconfig": "8.0.1",
"@types/express": "^5.0.3",
"@types/express-session": "^1.18.2",
"@types/node": "^24.7.0",
"forking-build-shit": "1.0.4",
"typescript": "5.9.3"
},
"scripts": {
"build": "npx build-shit",
"watch": "npx build-shit --watch"
}
}

103
src/Web.ts Normal file
View File

@@ -0,0 +1,103 @@
import * as http from "http";
import crypto from 'crypto';
import express from 'express';
import session from 'express-session';
import ky from 'ky';
import bodyParser from 'body-parser';
import type { Express } from 'express';
interface WebConfig {
sessionSecret?: string;
port?: number;
secure?: boolean;
}
/**
* I still hate typescript.
*/
function notStupidParseInt(v: string | undefined): number {
return v === undefined ? NaN : parseInt(v);
}
class Web {
private _webserver: http.Server | null = null;
private app: Express | null = null;
private port: number;
private options: WebConfig;
constructor(options: WebConfig = {}) {
this.options = options;
this.port = notStupidParseInt(process.env['PORT']) || options['port'] as number || 8080;
}
initialize = async () => {
const options = this.options;
const sessionSecret = process.env['SESSIONSECRET'] || options.sessionSecret;
const app: Express = this.app = express();
if(!sessionSecret) {
console.error('sessionSecret is required.');
throw new Error('sessionSecret is required.');
}
app.set('trust proxy', 1);
app.set('view engine', 'ejs');
app.set('view options', { outputFunctionName: 'echo' });
app.use('/assets', express.static('assets', { maxAge: '30 days' }));
app.use(session({
name: 'sessionId',
secret: sessionSecret,
resave: true,
saveUninitialized: false,
store: undefined,
cookie: {
maxAge: notStupidParseInt(process.env['COOKIETTL']) || 1000 * 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
secure: !!options.secure
}
}));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use((_req, res, next) => {
crypto.randomBytes(32, (err, randomBytes) => {
if (err) {
console.error(err);
next(err);
} else {
res.locals['cspNonce'] = randomBytes.toString("hex");
next();
}
});
});
app.get('/healthcheck', (_, res) => {
res.send('Healthy');
});
app.get('/', (_, res) => {
res.render('index', {
page: {
title: 'Web',
titlesuffix: 'Home',
description: 'Homepage'
}
});
});
app.get('/ky', async (_, res) => {
res.send(await (await ky.get('https://sanin.dev')).text());
});
this._webserver = this.app.listen(this.port, () => console.log(`archery is running on port ${this.port}`));
}
close = () => {
if (this._webserver) {
this._webserver.close();
}
}
}
export default Web;
export { Web, notStupidParseInt };
export type { WebConfig };

28
src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import fs from 'fs';
import path from 'path';
import JSON5 from 'json5';
import { Web, type WebConfig } from './Web.js';
interface compositeConfig {
web?: WebConfig
}
async function readConfig(): Promise<compositeConfig> {
try {
return JSON5.parse(await fs.promises.readFile(process.env['config'] || process.env['CONFIG'] || path.join(process.cwd(), 'config', 'config.jsonc'), 'utf-8'));
}
catch (err) {
console.error('No config file found, using default config');
console.error(err);
return {};
}
}
const config: compositeConfig = await readConfig();
const web = new Web(config.web);
await web.initialize();
process.on('SIGTERM', () => {
web.close();
});

48
styles/00-reset.css Normal file
View File

@@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"sourceMap": true
},
"include": [
"src"
]
}

22
views/head.ejs Normal file
View File

@@ -0,0 +1,22 @@
<title><%= page.title %> | <%= page.titlesuffix %></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="og:title" content="<%= page.title%> | <%= page.titlesuffix%>" />
<% if (page.description) { %>
<meta name="description" content="<%= page.description%>" />
<meta name="og:description" content="<%= page.description%>" />
<% } %>
<% if (page.image) { %>
<meta name="og:image" content="<%= page.image%>"/>
<link rel="image_src" href="<%= page.image%>"/>
<% } %>
<% if (page.canonical) { %>
<link rel="canonical" href="<%= page.canonical%>"/>
<% } %>
<link rel="shortcut icon" href="/assets/svg/favicon.svg">
<link rel="stylesheet" href="/assets/css/styles.css?v4">
<script nonce="<%= cspNonce %>">
document.addEventListener("DOMContentLoaded", function() {
document.body.classList.remove('preload');
});
</script>

14
views/index.ejs Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<%- include("head", locals) %>
</head>
<body class="preload">
<%- include("navigation", locals) %>
<div class="content">
<h1>Hello World</h1>
</div>
</body>
</html>

11
views/navigation.ejs Normal file
View File

@@ -0,0 +1,11 @@
<div class="navigation">
<div class="nav_logo">
<a href="/"><img src="/assets/svg/logo.svg" alt="Logo" /></a>
</div>
<nav>
<ul class="nav_links">
<li><a href="/">Home</a></li>
<li><a href="/ky">Away</a></li>
</ul>
</nav>
</div>