Skip to content

Commit 9f9f473

Browse files
committed
refactor: upgrade to Node.js 20 ESM + TypeScript with security chapter
- Add package.json with type:module for ESM throughout - Add TypeScript 5 strict mode with NodeNext module resolution - Add src/core-apis/fs.ts: modern fs/promises, streams, pipeline - Add src/http-server/server.ts: minimal router from scratch (no frameworks) - Add src/security/security.ts: path traversal, prototype pollution, password hashing - Add tests using Node.js 20 native node:test runner (no Jest/Mocha) - Add GitHub Actions CI testing on Node.js 18, 20, and 22
1 parent 67dba71 commit 9f9f473

7 files changed

Lines changed: 490 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, refactor]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: ['18.x', '20.x', '22.x']
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
cache: 'npm'
21+
- run: npm ci
22+
- run: npm run typecheck
23+
- run: npm run build
24+
- name: Run tests with Node.js native test runner
25+
run: node --test dist/tests/**/*.test.js

package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "node-tutorial",
3+
"version": "2.0.0",
4+
"description": "☺️ Node.js 20 Tutorial — ESM, TypeScript, Core APIs, Fastify, Security",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "tsx watch src/index.ts",
8+
"build": "tsc",
9+
"test": "node --test",
10+
"test:watch": "node --test --watch",
11+
"lint": "eslint src tests --ext .ts",
12+
"typecheck": "tsc --noEmit"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^20.0.0",
16+
"@typescript-eslint/eslint-plugin": "^7.0.0",
17+
"@typescript-eslint/parser": "^7.0.0",
18+
"eslint": "^9.0.0",
19+
"tsx": "^4.7.0",
20+
"typescript": "^5.4.0"
21+
},
22+
"dependencies": {
23+
"fastify": "^4.27.0"
24+
},
25+
"engines": {
26+
"node": ">=20.0.0"
27+
}
28+
}

src/core-apis/fs.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Chapter 1: Core APIs — fs module
3+
* Node.js 20 LTS with ESM and TypeScript
4+
*/
5+
6+
import { readFile, writeFile, readdir, stat, mkdir, rm } from 'node:fs/promises'
7+
import { createReadStream, createWriteStream } from 'node:fs'
8+
import { pipeline } from 'node:stream/promises'
9+
import { join, dirname } from 'node:path'
10+
import { fileURLToPath } from 'node:url'
11+
import { createGzip } from 'node:zlib'
12+
13+
const __dirname = dirname(fileURLToPath(import.meta.url))
14+
const TEMP_DIR = join(__dirname, '../../tmp')
15+
16+
// ─── 1. Basic file operations ─────────────────────────────────────────────────
17+
export async function readTextFile(filePath: string): Promise<string> {
18+
return readFile(filePath, 'utf-8')
19+
}
20+
21+
export async function writeTextFile(filePath: string, content: string): Promise<void> {
22+
await writeFile(filePath, content, 'utf-8')
23+
}
24+
25+
// ─── 2. Directory listing with stats ─────────────────────────────────────────
26+
export interface FileInfo {
27+
name: string
28+
size: number
29+
isDirectory: boolean
30+
modifiedAt: Date
31+
}
32+
33+
export async function listDirectory(dirPath: string): Promise<FileInfo[]> {
34+
const entries = await readdir(dirPath)
35+
const infos = await Promise.all(
36+
entries.map(async (name) => {
37+
const fullPath = join(dirPath, name)
38+
const stats = await stat(fullPath)
39+
return {
40+
name,
41+
size: stats.size,
42+
isDirectory: stats.isDirectory(),
43+
modifiedAt: stats.mtime,
44+
}
45+
})
46+
)
47+
return infos
48+
}
49+
50+
// ─── 3. Stream-based file copy with gzip compression ─────────────────────────
51+
export async function compressFile(
52+
inputPath: string,
53+
outputPath: string
54+
): Promise<void> {
55+
const source = createReadStream(inputPath)
56+
const gzip = createGzip()
57+
const destination = createWriteStream(outputPath)
58+
// pipeline handles backpressure and cleanup automatically
59+
await pipeline(source, gzip, destination)
60+
}
61+
62+
// ─── 4. Recursive directory creation ─────────────────────────────────────────
63+
export async function ensureDir(dirPath: string): Promise<void> {
64+
await mkdir(dirPath, { recursive: true })
65+
}
66+
67+
// ─── Demo runner ──────────────────────────────────────────────────────────────
68+
async function main() {
69+
console.log('=== Node.js 20 fs Module Demo ===\n')
70+
71+
// Create temp directory
72+
await ensureDir(TEMP_DIR)
73+
console.log(`✅ Created temp dir: ${TEMP_DIR}`)
74+
75+
// Write a file
76+
const testFile = join(TEMP_DIR, 'hello.txt')
77+
await writeTextFile(testFile, 'Hello from Node.js 20!\nThis uses ESM + TypeScript.')
78+
console.log(`✅ Written: ${testFile}`)
79+
80+
// Read it back
81+
const content = await readTextFile(testFile)
82+
console.log(`✅ Read content:\n${content}`)
83+
84+
// List directory
85+
const files = await listDirectory(TEMP_DIR)
86+
console.log(`\n✅ Directory listing (${TEMP_DIR}):`)
87+
files.forEach(f => console.log(` ${f.isDirectory ? '📁' : '📄'} ${f.name} (${f.size} bytes)`))
88+
89+
// Compress
90+
const gzFile = join(TEMP_DIR, 'hello.txt.gz')
91+
await compressFile(testFile, gzFile)
92+
console.log(`\n✅ Compressed to: ${gzFile}`)
93+
94+
// Cleanup
95+
await rm(TEMP_DIR, { recursive: true, force: true })
96+
console.log(`\n✅ Cleaned up temp dir`)
97+
}
98+
99+
// Run if this is the entry point
100+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
101+
main().catch(console.error)
102+
}

src/http-server/server.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Chapter 2: HTTP Server from scratch
3+
* Node.js 20 native http module — no frameworks
4+
*/
5+
6+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
7+
import { URL } from 'node:url'
8+
9+
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
10+
11+
interface Route {
12+
method: string
13+
path: string
14+
handler: Handler
15+
}
16+
17+
// ─── Minimal Router ───────────────────────────────────────────────────────────
18+
export class Router {
19+
private routes: Route[] = []
20+
21+
get(path: string, handler: Handler): this {
22+
this.routes.push({ method: 'GET', path, handler })
23+
return this
24+
}
25+
26+
post(path: string, handler: Handler): this {
27+
this.routes.push({ method: 'POST', path, handler })
28+
return this
29+
}
30+
31+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
32+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
33+
const route = this.routes.find(
34+
r => r.method === req.method && r.path === url.pathname
35+
)
36+
37+
if (!route) {
38+
res.writeHead(404, { 'Content-Type': 'application/json' })
39+
res.end(JSON.stringify({ error: 'Not Found' }))
40+
return
41+
}
42+
43+
try {
44+
await route.handler(req, res)
45+
} catch (err) {
46+
res.writeHead(500, { 'Content-Type': 'application/json' })
47+
res.end(JSON.stringify({ error: 'Internal Server Error' }))
48+
}
49+
}
50+
}
51+
52+
// ─── JSON helpers ─────────────────────────────────────────────────────────────
53+
export function json(res: ServerResponse, data: unknown, status = 200): void {
54+
res.writeHead(status, { 'Content-Type': 'application/json' })
55+
res.end(JSON.stringify(data))
56+
}
57+
58+
export async function parseBody<T>(req: IncomingMessage): Promise<T> {
59+
return new Promise((resolve, reject) => {
60+
let body = ''
61+
req.on('data', chunk => { body += chunk })
62+
req.on('end', () => {
63+
try { resolve(JSON.parse(body)) }
64+
catch (e) { reject(new Error('Invalid JSON')) }
65+
})
66+
req.on('error', reject)
67+
})
68+
}
69+
70+
// ─── Demo server ──────────────────────────────────────────────────────────────
71+
interface User {
72+
id: number
73+
name: string
74+
email: string
75+
}
76+
77+
const users: User[] = [
78+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
79+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
80+
]
81+
82+
const router = new Router()
83+
84+
router.get('/health', (_req, res) => {
85+
json(res, { status: 'ok', uptime: process.uptime() })
86+
})
87+
88+
router.get('/users', (_req, res) => {
89+
json(res, users)
90+
})
91+
92+
router.post('/users', async (req, res) => {
93+
const body = await parseBody<Omit<User, 'id'>>(req)
94+
const newUser: User = { id: users.length + 1, ...body }
95+
users.push(newUser)
96+
json(res, newUser, 201)
97+
})
98+
99+
export function startServer(port = 3000): ReturnType<typeof createServer> {
100+
const server = createServer((req, res) => router.handle(req, res))
101+
server.listen(port, () => {
102+
console.log(`🚀 Server running at http://localhost:${port}`)
103+
})
104+
return server
105+
}
106+
107+
// Run if entry point
108+
import { fileURLToPath } from 'node:url'
109+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
110+
startServer(3000)
111+
}

src/security/security.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Chapter 6: Security Best Practices
3+
* Common Node.js security pitfalls and mitigations
4+
*/
5+
6+
import { join, resolve, normalize } from 'node:path'
7+
import { readFile } from 'node:fs/promises'
8+
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
9+
import { promisify } from 'node:util'
10+
11+
const scryptAsync = promisify(scrypt)
12+
13+
// ─── 1. Path Traversal Prevention ────────────────────────────────────────────
14+
const SAFE_BASE_DIR = '/var/www/public'
15+
16+
/**
17+
* Safely resolve a user-provided path within a base directory.
18+
* Prevents path traversal attacks like ../../etc/passwd
19+
*/
20+
export function safeResolvePath(userInput: string, baseDir = SAFE_BASE_DIR): string {
21+
const normalized = normalize(userInput).replace(/^(\.\.(\/|\\|$))+/, '')
22+
const resolved = resolve(join(baseDir, normalized))
23+
24+
if (!resolved.startsWith(resolve(baseDir))) {
25+
throw new Error(`Path traversal detected: ${userInput}`)
26+
}
27+
28+
return resolved
29+
}
30+
31+
// ─── 2. Prototype Pollution Prevention ───────────────────────────────────────
32+
/**
33+
* Safely merge objects without allowing prototype pollution.
34+
* Blocks keys like __proto__, constructor, prototype
35+
*/
36+
export function safeMerge<T extends object>(target: T, source: Record<string, unknown>): T {
37+
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
38+
39+
for (const [key, value] of Object.entries(source)) {
40+
if (BLOCKED_KEYS.has(key)) {
41+
console.warn(`[Security] Blocked prototype pollution attempt: key="${key}"`)
42+
continue
43+
}
44+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
45+
const existing = (target as Record<string, unknown>)[key]
46+
if (existing && typeof existing === 'object') {
47+
safeMerge(existing as object, value as Record<string, unknown>)
48+
} else {
49+
(target as Record<string, unknown>)[key] = safeMerge({}, value as Record<string, unknown>)
50+
}
51+
} else {
52+
(target as Record<string, unknown>)[key] = value
53+
}
54+
}
55+
return target
56+
}
57+
58+
// ─── 3. Secure Password Hashing ──────────────────────────────────────────────
59+
const SALT_LENGTH = 32
60+
const KEY_LENGTH = 64
61+
62+
export async function hashPassword(password: string): Promise<string> {
63+
const salt = randomBytes(SALT_LENGTH).toString('hex')
64+
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH) as Buffer
65+
return `${salt}:${derivedKey.toString('hex')}`
66+
}
67+
68+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
69+
const [salt, storedKey] = hash.split(':')
70+
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH) as Buffer
71+
const storedKeyBuffer = Buffer.from(storedKey, 'hex')
72+
// Use timing-safe comparison to prevent timing attacks
73+
return timingSafeEqual(derivedKey, storedKeyBuffer)
74+
}
75+
76+
// ─── 4. Input Sanitization ────────────────────────────────────────────────────
77+
/**
78+
* Sanitize user input to prevent XSS when rendering HTML
79+
*/
80+
export function escapeHtml(input: string): string {
81+
return input
82+
.replace(/&/g, '&amp;')
83+
.replace(/</g, '&lt;')
84+
.replace(/>/g, '&gt;')
85+
.replace(/"/g, '&quot;')
86+
.replace(/'/g, '&#x27;')
87+
}
88+
89+
/**
90+
* Validate that a string is a safe SQL identifier (no injection)
91+
*/
92+
export function isSafeIdentifier(name: string): boolean {
93+
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)
94+
}
95+
96+
// ─── Demo ─────────────────────────────────────────────────────────────────────
97+
async function main() {
98+
console.log('=== Node.js Security Best Practices Demo ===\n')
99+
100+
// Path traversal
101+
try {
102+
safeResolvePath('../../etc/passwd')
103+
} catch (e) {
104+
console.log(`✅ Path traversal blocked: ${(e as Error).message}`)
105+
}
106+
107+
// Prototype pollution
108+
const obj = { a: 1 }
109+
safeMerge(obj, { '__proto__': { polluted: true }, b: 2 })
110+
console.log(`✅ Prototype pollution blocked. obj.b = ${(obj as Record<string, unknown>).b}`)
111+
console.log(`✅ ({} as any).polluted = ${({} as Record<string, unknown>).polluted}`)
112+
113+
// Password hashing
114+
const hash = await hashPassword('my-secret-password')
115+
const valid = await verifyPassword('my-secret-password', hash)
116+
const invalid = await verifyPassword('wrong-password', hash)
117+
console.log(`\n✅ Password hash: ${hash.slice(0, 30)}...`)
118+
console.log(`✅ Correct password: ${valid}`)
119+
console.log(`✅ Wrong password: ${invalid}`)
120+
121+
// HTML escaping
122+
const userInput = '<script>alert("xss")</script>'
123+
console.log(`\n✅ Escaped HTML: ${escapeHtml(userInput)}`)
124+
}
125+
126+
import { fileURLToPath } from 'node:url'
127+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
128+
main().catch(console.error)
129+
}

0 commit comments

Comments
 (0)