Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
**/node_modules
**/.next
.env*
!.env.example
.claude
8 changes: 4 additions & 4 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/sh
# Pre-commit hook: runs lint, type check, build, and tests
# Catches issues before they fail on Railway
# Catches issues before they fail in CI

set -e

Expand All @@ -11,15 +11,15 @@ cd "$(git rev-parse --show-toplevel)"

echo ""
echo "📝 Running lint..."
pnpm lint
bun run lint

echo ""
echo "🔨 Running build..."
pnpm build
bun run build

echo ""
echo "🧪 Running tests..."
pnpm test run
bun run test

echo ""
echo "✅ All checks passed!"
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: bun run build

# Boot the standalone server the way the image does, and render one
# sharp-backed route: the standalone trace has dropped libvips before.
- name: Boot standalone under Bun
working-directory: apps/web
run: |
cp -r public .next/standalone/apps/web/public
cp -r .next/static .next/standalone/apps/web/.next/static
cd .next/standalone
PORT=8080 HOSTNAME=127.0.0.1 SUPABASE_URL=http://127.0.0.1:9 SUPABASE_SERVICE_ROLE_KEY=ci \
bun apps/web/server.js > server.log 2>&1 &
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ || true)
[ "$code" = 200 ] && break
sleep 1
done
set -e
for p in / /about /recent /login /sw.js /manifest.json; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080$p")
echo "$p $code"; [ "$code" = 200 ]
done
ct=$(curl -s -o /dev/null -w '%{http_code} %{content_type}' 'http://127.0.0.1:8080/_next/image?url=%2Fbanner.png&w=640&q=75')
echo "/_next/image $ct"; [ "$ct" = "200 image/png" ]
cat server.log

audit:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- run: bun install --frozen-lockfile
- run: bun audit --audit-level=critical
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ supabase/.temp/
# Claude Code: local-only, machine-specific (keep shared config committed)
**/.claude/settings.local.json
**/.claude/worktrees/
.socket/apply.lock
1 change: 0 additions & 1 deletion .nvmrc

This file was deleted.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ git clone https://github.com/yourusername/icemap.app.git
cd icemap.app

# Install dependencies
pnpm install
bun install

# Set up environment variables
cp .env.example .env.local

# Start development server
pnpm dev
bun run dev
```

## 📖 Documentation
Expand Down
108 changes: 43 additions & 65 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,74 +1,52 @@
# Stage 1: Dependencies
FROM node:24-alpine AS deps
# syntax=docker/dockerfile:1
# icemap.app on Bun. dev2's compose builds THIS path (apps/web/Dockerfile)
# with the repo root as context; keep it here.
#
# Alpine throughout, as under Node: the media upload route shells out to
# ffmpeg and ImageMagick 7, and alpine:3.24 keeps the versions prod ran on
# node:24-alpine (ffmpeg 8.1, ImageMagick 7.1.2). oven/bun:*-alpine is 3.22
# (ffmpeg 6.1) and Debian slim is twice the image, so the runtime stage is
# alpine:3.24 with Bun's musl binary copied in.

# Stage 1: dependencies (whole workspace, so /app is copied as one tree)
FROM oven/bun:1.4.0-alpine AS deps
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

# Copy package files
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
COPY package.json bun.lock bunfig.toml ./
COPY apps/web/package.json ./apps/web/
COPY packages/supabase/package.json ./packages/supabase/

# Install dependencies
RUN pnpm install --frozen-lockfile

# Stage 2: Builder
FROM node:24-alpine AS builder
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

# Build-time arguments (passed from Railway or CI)
# --ignore-scripts: the root postinstall (socket-patch) has no musl build and
# exits 1 here, and it patched nothing in the image anyway (.socket/ was
# never copied in). The trusted deps need no script: bcrypt ships a musl
# prebuild, sharp/esbuild/unrs-resolver use platform packages.
RUN bun install --frozen-lockfile --ignore-scripts

# Stage 2: build
FROM deps AS build
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* are inlined at build time. dev2's compose passes the first
# two; the rest were Railway's and stay declared for parity.
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_VAPID_PUBLIC_KEY
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_DATAFAST_ID
RUN bun run build

# Make build args available as env vars during build
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_DATAFAST_ID=$NEXT_PUBLIC_DATAFAST_ID

# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules

# Copy source files
COPY . .

# Build the application
RUN pnpm --filter @icemap/web build

# Stage 3: Runner
FROM node:24-alpine AS runner
# Stage 3: runtime
FROM alpine:3.24 AS runtime
RUN apk add --no-cache libstdc++ libgcc ffmpeg imagemagick \
&& addgroup -S -g 1000 bun && adduser -S -D -u 1000 -G bun -h /home/bun bun
COPY --from=deps /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app

# Install FFmpeg and ImageMagick for media processing
RUN apk add --no-cache ffmpeg imagemagick

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy built application
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static

USER nextjs

# Railway sets PORT dynamically, default to 8080
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=8080
# Docker sets HOSTNAME to the container id; the standalone server binds to it.
ENV HOSTNAME=0.0.0.0
COPY --from=build --chown=bun:bun /app/apps/web/.next/standalone ./
COPY --from=build --chown=bun:bun /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=bun:bun /app/apps/web/public ./apps/web/public
USER bun
EXPOSE 8080

ENV PORT=8080
ENV HOSTNAME="0.0.0.0"

# Runtime environment variables (set by Railway)
# SUPABASE_URL - Server-side Supabase URL
# SUPABASE_SERVICE_ROLE_KEY - Server-side service key
# NEXT_PUBLIC_* vars are baked in at build time

CMD ["node", "apps/web/server.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD bun -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"
CMD ["bun", "apps/web/server.js"]
3 changes: 3 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactStrictMode: true,
output: 'standalone',
// Workspace root, so the standalone server lands at apps/web/server.js on
// every machine (dev1 would otherwise infer ~ from a stray lockfile).
outputFileTracingRoot: new URL('../../', import.meta.url).pathname,
experimental: {
serverActions: {
bodySizeLimit: '500mb',
Expand Down
10 changes: 6 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p ${PORT:-8080}",
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start -p ${PORT:-8080}",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:vitest": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/lib/time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { getTimeAgo, getTimeUntil } from './time'

const ago = (s: number) => new Date(Date.now() - s * 1000)
const ahead = (s: number) => new Date(Date.now() + s * 1000)

test('getTimeAgo buckets by unit', () => {
assert.equal(getTimeAgo(ago(5)), 'Just now')
assert.equal(getTimeAgo(ago(5 * 60)), '5m ago')
assert.equal(getTimeAgo(ago(3 * 3600)), '3h ago')
assert.equal(getTimeAgo(ago(2 * 86400)), '2d ago')
})

test('getTimeUntil counts down to expiry', () => {
assert.equal(getTimeUntil(ago(1)), 'expired')
assert.equal(getTimeUntil(ahead(30)), 'in less than a minute')
assert.equal(getTimeUntil(ahead(10 * 60 + 5)), 'in 10m')
assert.equal(getTimeUntil(ahead(2 * 3600 + 15 * 60 + 5)), 'in 2h 15m')
assert.equal(getTimeUntil(ahead(86400 + 3600 + 5)), 'in 1d 1h')
})
Loading
Loading