├── api └── index.ts ├── favicon.svg ├── src ├── routes │ ├── stats.ts │ ├── track.ts │ └── widget.ts ├── middleware │ └── cors.ts ├── lib │ ├── prisma.ts │ └── constants.ts ├── server.ts ├── types │ └── index.ts ├── app.ts └── controllers │ ├── trackController.ts │ ├── statsController.ts │ └── widgetController.ts ├── .gitignore ├── vercel.json ├── .env.example ├── tsconfig.json ├── prisma └── schema.prisma ├── LICENSE ├── eslint.config.js ├── package.json ├── CONTRIBUTING.md └── README.md /api/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "../src/app.js"; 2 | 3 | // Create the Express app 4 | const app = createApp(); 5 | 6 | // Export for Vercel serverless function 7 | export default app; 8 | -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/routes/stats.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { statsController } from "../controllers/statsController.js"; 3 | 4 | const router = Router(); 5 | 6 | router.get("/", statsController); 7 | 8 | export { router as statsRoute }; 9 | -------------------------------------------------------------------------------- /src/routes/track.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { trackController } from "../controllers/trackController.js"; 3 | 4 | const router = Router(); 5 | 6 | router.post("/", trackController); 7 | 8 | export { router as trackRoute }; 9 | -------------------------------------------------------------------------------- /src/routes/widget.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { widgetController } from "../controllers/widgetController.js"; 3 | 4 | const router = Router(); 5 | 6 | router.get("/", widgetController); 7 | 8 | export { router as widgetRoute }; 9 | -------------------------------------------------------------------------------- /src/middleware/cors.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors"; 2 | 3 | export const corsMiddleware = cors({ 4 | origin: "*", // Allow all origins for analytics widget embedding 5 | credentials: false, // No credentials needed for analytics 6 | optionsSuccessStatus: 200, 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Environment variables 5 | .env 6 | .env.local 7 | 8 | # Build output 9 | dist/ 10 | *.tsbuildinfo 11 | 12 | # Logs 13 | *.log 14 | 15 | # IDE 16 | .vscode/ 17 | .idea/ 18 | 19 | # OS 20 | .DS_Store 21 | 22 | # Vercel 23 | .vercel 24 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const globalForPrisma = globalThis as unknown as { 4 | prisma: PrismaClient | undefined; 5 | }; 6 | 7 | export const prisma = globalForPrisma.prisma ?? new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") { 10 | globalForPrisma.prisma = prisma; 11 | } 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "buildCommand": "npm run build", 4 | "builds": [ 5 | { 6 | "src": "api/index.ts", 7 | "use": "@vercel/node" 8 | } 9 | ], 10 | "routes": [ 11 | { 12 | "src": "/(.*)", 13 | "dest": "api/index.ts" 14 | } 15 | ], 16 | "env": { 17 | "NODE_ENV": "production" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "./app.js"; 2 | 3 | const app = createApp(); 4 | const port = process.env.PORT || 3210; 5 | 6 | app.listen(port, () => { 7 | console.log(`🚀 here/now API server running on port ${port}`); 8 | console.log(`📊 Widget available at: http://localhost:${port}/widget.js`); 9 | console.log(`💻 API docs: http://localhost:${port}`); 10 | }); 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface StatsResult { 2 | here: number; 3 | now: number; 4 | domain: string; 5 | path: string; 6 | } 7 | 8 | export interface QueryResult { 9 | here_count: bigint; 10 | now_count: bigint; 11 | } 12 | 13 | export interface TrackingRequest { 14 | domain: string; 15 | path: string; 16 | user_id?: string; 17 | session_id?: string; 18 | } 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database (Supabase) 2 | DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:6543/postgres?pgbouncer=true" 3 | DIRECT_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:5432/postgres" 4 | 5 | # Domain Configuration 6 | ALLOWED_DOMAINS="localhost,yourdomain.com,yourotherdomain.com" 7 | 8 | # API Base URL (auto-detected if not set, hardcoded to HTTPS) 9 | API_BASE_URL="http://localhost:3210" 10 | 11 | # Node Environment 12 | NODE_ENV="development" 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "sourceMap": true, 15 | "incremental": true, 16 | "declaration": true, 17 | "removeComments": true 18 | }, 19 | "include": ["src/**/*", "api/**/*"], 20 | "exclude": ["node_modules", "dist", "**/*.test.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | directUrl = env("DIRECT_URL") 9 | } 10 | 11 | model PageEvent { 12 | id String @id @default(cuid()) 13 | domain String 14 | path String 15 | userId String @map("user_id") 16 | sessionId String @map("session_id") 17 | userAgent String @map("user_agent") 18 | timestamp DateTime @default(now()) 19 | metadata Json? 20 | migrationStatus String? @map("migration_status") 21 | 22 | @@index([domain, timestamp]) 23 | @@index([domain, userId]) 24 | @@index([migrationStatus]) 25 | @@map("page_events") 26 | } 27 | 28 | model User { 29 | id String @id @default(cuid()) 30 | email String @unique 31 | domain String 32 | reason String 33 | whitelistedAt DateTime? @map("whitelisted_at") 34 | createdAt DateTime @default(now()) @map("created_at") 35 | 36 | @@map("users") 37 | } 38 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { trackRoute } from "./routes/track.js"; 3 | import { statsRoute } from "./routes/stats.js"; 4 | import { widgetRoute } from "./routes/widget.js"; 5 | import { corsMiddleware } from "./middleware/cors.js"; 6 | 7 | export const createApp = () => { 8 | const app = express(); 9 | 10 | // Middleware 11 | app.use(corsMiddleware); 12 | app.use(express.json()); 13 | 14 | // Health check endpoint 15 | app.get("/health", (req, res) => { 16 | res.json({ status: "ok", service: "here-now-api" }); 17 | }); 18 | 19 | // API routes 20 | app.use("/api/track", trackRoute); 21 | app.use("/api/stats", statsRoute); 22 | app.use("/widget.js", widgetRoute); 23 | 24 | // Root endpoint info 25 | app.get("/", (req, res) => { 26 | res.json({ 27 | name: "here/now analytics API", 28 | version: "0.1.0", 29 | endpoints: { 30 | track: "POST /api/track", 31 | stats: "GET /api/stats", 32 | widget: "GET /widget.js", 33 | health: "GET /health", 34 | }, 35 | docs: "https://github.com/fredrivett/here-now", 36 | }); 37 | }); 38 | 39 | return app; 40 | }; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International 2 | 3 | Copyright (c) 2025 Jotmake Limited 4 | 5 | This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 6 | 7 | You are free to: 8 | - Share — copy and redistribute the material in any medium or format 9 | - Adapt — remix, transform, and build upon the material 10 | 11 | Under the following terms: 12 | - Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 13 | - NonCommercial — You may not use the material for commercial purposes. This includes offering this software as a paid service or SaaS platform. 14 | - ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. 15 | 16 | No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. 17 | 18 | To view a copy of this license, visit: 19 | https://creativecommons.org/licenses/by-nc-sa/4.0/ 20 | 21 | For commercial licensing, please contact: fred@fredrivett.com 22 | -------------------------------------------------------------------------------- /src/controllers/trackController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { prisma } from "../lib/prisma.js"; 3 | import { isDomainAllowed } from "../lib/constants.js"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | import { TrackingRequest } from "../types/index.js"; 6 | 7 | export const trackController = async (req: Request, res: Response) => { 8 | try { 9 | const { domain, path, user_id, session_id }: TrackingRequest = req.body; 10 | 11 | // Validate required parameters 12 | if (!domain) { 13 | return res.status(400).json({ 14 | error: "Missing required parameter: domain", 15 | }); 16 | } 17 | 18 | if (!path) { 19 | return res.status(400).json({ 20 | error: "Missing required parameter: path", 21 | }); 22 | } 23 | 24 | // Check domain whitelist 25 | if (!isDomainAllowed(domain)) { 26 | return res.status(403).json({ 27 | error: "Domain not allowed", 28 | }); 29 | } 30 | 31 | const userAgent = req.headers["user-agent"] || ""; 32 | 33 | // Insert tracking event 34 | const event = await prisma.pageEvent.create({ 35 | data: { 36 | domain, 37 | path, 38 | userId: user_id || uuidv4(), 39 | sessionId: session_id || uuidv4(), 40 | userAgent, 41 | }, 42 | }); 43 | 44 | res.json({ success: true, event_id: event.id }); 45 | } catch (error) { 46 | console.error("Tracking error:", error); 47 | res.status(500).json({ 48 | error: "Failed to track event", 49 | }); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import typescript from "@typescript-eslint/eslint-plugin"; 3 | import typescriptParser from "@typescript-eslint/parser"; 4 | 5 | export default [ 6 | // Apply to TypeScript and JavaScript files 7 | { 8 | files: ["**/*.{ts,js}"], 9 | languageOptions: { 10 | parser: typescriptParser, 11 | parserOptions: { 12 | ecmaVersion: "latest", 13 | sourceType: "module", 14 | }, 15 | globals: { 16 | console: "readonly", 17 | process: "readonly", 18 | Buffer: "readonly", 19 | __dirname: "readonly", 20 | __filename: "readonly", 21 | global: "readonly", 22 | }, 23 | }, 24 | plugins: { 25 | "@typescript-eslint": typescript, 26 | }, 27 | rules: { 28 | // ESLint recommended rules 29 | ...js.configs.recommended.rules, 30 | 31 | // TypeScript specific rules 32 | "@typescript-eslint/no-unused-vars": [ 33 | "error", 34 | { 35 | argsIgnorePattern: "^_", 36 | varsIgnorePattern: "^_", 37 | }, 38 | ], 39 | "@typescript-eslint/no-explicit-any": "warn", 40 | 41 | // General code quality 42 | "no-console": "off", // Allow console.log for server logging 43 | "prefer-const": "error", 44 | "no-var": "error", 45 | eqeqeq: ["error", "always"], 46 | curly: "error", 47 | 48 | // Import/export 49 | "no-duplicate-imports": "error", 50 | }, 51 | }, 52 | 53 | // Ignore certain files 54 | { 55 | ignores: ["node_modules/**", "dist/**", "**/*.js.map", ".env*"], 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "here-now", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "description": "A simple, privacy-focused visitor tracking API", 6 | "main": "dist/server.js", 7 | "scripts": { 8 | "dev": "tsx watch src/server.ts", 9 | "dev:vercel": "vercel dev", 10 | "build": "prisma generate && tsc", 11 | "start": "node dist/server.js", 12 | "postinstall": "prisma generate", 13 | "db:generate": "prisma generate", 14 | "db:push": "prisma db push", 15 | "db:migrate": "prisma migrate dev", 16 | "fix": "npm run format && npm run lint:fix", 17 | "lint": "eslint", 18 | "lint:all": "bunx concurrently --success=all -n ESLint,Prettier,TS -c cyan,green,magenta \"bun run lint:quiet\" \"bun run format:check\" \"bun run ts:check\"", 19 | "lint:fix": "eslint --fix --quiet", 20 | "format": "prettier . --write" 21 | }, 22 | "dependencies": { 23 | "@prisma/client": "^6.16.0", 24 | "cors": "^2.8.5", 25 | "express": "^4.18.2", 26 | "uuid": "^13.0.0", 27 | "zod": "^4.1.7" 28 | }, 29 | "devDependencies": { 30 | "@types/cors": "^2.8.17", 31 | "@types/express": "^4.17.21", 32 | "@types/node": "^20.0.0", 33 | "@types/uuid": "^10.0.0", 34 | "@typescript-eslint/eslint-plugin": "^8.43.0", 35 | "@typescript-eslint/parser": "^8.43.0", 36 | "@vercel/node": "^3.0.0", 37 | "eslint": "^9.35.0", 38 | "prisma": "^6.16.0", 39 | "tsx": "^4.0.0", 40 | "typescript": "^5.3.0" 41 | }, 42 | "keywords": [ 43 | "analytics", 44 | "visitor-tracking", 45 | "privacy", 46 | "self-hosted" 47 | ], 48 | "author": "Fred Rivett", 49 | "license": "CC-BY-NC-SA-4.0" 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | // Activity threshold - used for both heartbeat interval and "now" window 2 | export const ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes 3 | 4 | // Domain whitelist - configurable via environment variable 5 | function getAllowedDomains(): string[] { 6 | const envDomains = process.env.ALLOWED_DOMAINS; 7 | if (envDomains) { 8 | return envDomains.split(",").map((d) => d.trim()); 9 | } 10 | 11 | // Default domains for development 12 | return ["localhost"]; 13 | } 14 | 15 | export const ALLOWED_DOMAINS = getAllowedDomains(); 16 | 17 | export function isDomainAllowed(domain: string): boolean { 18 | // Check exact match first 19 | if (ALLOWED_DOMAINS.includes(domain)) { 20 | return true; 21 | } 22 | 23 | // Check if domain starts with 'www.' and the non-www version is allowed 24 | if (domain.startsWith("www.")) { 25 | const nonWwwDomain = domain.substring(4); 26 | return ALLOWED_DOMAINS.includes(nonWwwDomain); 27 | } 28 | 29 | return false; 30 | } 31 | 32 | // JavaScript version for the widget (same logic, no TypeScript) 33 | export function getDomainCheckJS(): string { 34 | return ` 35 | function isDomainAllowed(domain) { 36 | const ALLOWED_DOMAINS = [${ALLOWED_DOMAINS.map((d) => `'${d}'`).join(", ")}]; 37 | 38 | // Check exact match first 39 | if (ALLOWED_DOMAINS.includes(domain)) { 40 | return true; 41 | } 42 | 43 | // Check if domain starts with 'www.' and the non-www version is allowed 44 | if (domain.startsWith('www.')) { 45 | const nonWwwDomain = domain.substring(4); 46 | return ALLOWED_DOMAINS.includes(nonWwwDomain); 47 | } 48 | 49 | return false; 50 | }`; 51 | } 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to here/now 2 | 3 | Thanks for your interest in contributing to here/now! This document provides guidelines for contributing to the project. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork locally 9 | 3. Install dependencies: `npm install` 10 | 4. Set up your environment variables (see `.env.example`) 11 | 5. Start the development server: `npm run dev` 12 | 13 | ## Development Setup 14 | 15 | ### Prerequisites 16 | - Node.js 18+ 17 | - A Supabase database (or compatible PostgreSQL database) 18 | 19 | ### Environment Setup 20 | 1. Copy `.env.example` to `.env` 21 | 2. Fill in your database connection details 22 | 3. Set `ALLOWED_DOMAINS` to include your development domains 23 | 24 | ### Database Setup 25 | ```bash 26 | npm run db:push # Push schema to database 27 | npm run db:generate # Generate Prisma client 28 | ``` 29 | 30 | ## Code Style 31 | 32 | - We use ESLint for code linting and Prettier for formatting 33 | - Run `npm run fix` to auto-format and fix linting issues 34 | - Run `npm run lint` to check for any remaining issues 35 | - Follow existing code patterns and conventions 36 | 37 | ## Making Changes 38 | 39 | 1. Create a new branch from `main`: 40 | ```bash 41 | git checkout -b feature/your-feature-name 42 | ``` 43 | 44 | 2. Make your changes following these guidelines: 45 | - Write clear, descriptive commit messages 46 | - Keep changes focused and atomic 47 | - Add tests if applicable 48 | - Update documentation if needed 49 | 50 | 3. Test your changes: 51 | ```bash 52 | npm run fix # Format code and fix linting issues 53 | npm run build # Ensure the project builds 54 | ``` 55 | 56 | 4. Push to your fork and create a pull request 57 | 58 | ## Pull Request Guidelines 59 | 60 | - Provide a clear description of what your PR does 61 | - Reference any related issues 62 | - Include screenshots for UI changes 63 | - Make sure all checks pass 64 | - Keep PRs focused on a single feature/fix 65 | 66 | ## Reporting Issues 67 | 68 | When reporting bugs, please include: 69 | - Steps to reproduce 70 | - Expected vs actual behavior 71 | - Environment details (OS, Node version, etc.) 72 | - Relevant logs or error messages 73 | 74 | ## Feature Requests 75 | 76 | - Check existing issues before creating new ones 77 | - Clearly describe the use case and benefit 78 | - Consider if the feature aligns with the project's goals 79 | 80 | ## Code of Conduct 81 | 82 | - Be respectful and inclusive 83 | - Focus on constructive feedback 84 | - Help maintain a welcoming environment for all contributors 85 | 86 | ## Questions? 87 | 88 | Feel free to open an issue for questions about contributing or join discussions in existing issues. 89 | -------------------------------------------------------------------------------- /src/controllers/statsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { prisma } from "../lib/prisma.js"; 3 | import { isDomainAllowed, ACTIVITY_THRESHOLD_MS } from "../lib/constants.js"; 4 | import { StatsResult, QueryResult } from "../types/index.js"; 5 | 6 | // Simple in-memory cache to reduce database load 7 | const statsCache = new Map(); 8 | const CACHE_TTL = 30 * 1000; // 30 seconds cache 9 | 10 | export const statsController = async (req: Request, res: Response) => { 11 | const domain = req.query.domain as string; 12 | const path = req.query.path as string; 13 | 14 | try { 15 | // Validate required parameters 16 | if (!domain) { 17 | return res.status(400).json({ 18 | error: "Missing required parameter: domain", 19 | }); 20 | } 21 | 22 | if (!path) { 23 | return res.status(400).json({ 24 | error: "Missing required parameter: path", 25 | }); 26 | } 27 | 28 | // Check domain whitelist 29 | if (!isDomainAllowed(domain)) { 30 | return res.status(403).json({ 31 | error: "Domain not allowed", 32 | }); 33 | } 34 | 35 | // Check cache first to reduce database load 36 | const cacheKey = `${domain}:${path}`; 37 | const cached = statsCache.get(cacheKey); 38 | const now = Date.now(); 39 | 40 | if (cached && now - cached.timestamp < CACHE_TTL) { 41 | return res.json(cached.data); 42 | } 43 | 44 | // Use single raw SQL query for maximum performance with large datasets 45 | const activityThresholdAgo = new Date(Date.now() - ACTIVITY_THRESHOLD_MS); 46 | 47 | // Get both counts in a single query to reduce database load and connection usage 48 | const queryResult = await prisma.$queryRaw` 49 | SELECT 50 | COUNT(DISTINCT user_id) as here_count, 51 | COUNT(DISTINCT CASE WHEN timestamp >= ${activityThresholdAgo} THEN user_id END) as now_count 52 | FROM page_events 53 | WHERE domain = ${domain} AND path = ${path} 54 | `; 55 | 56 | const queryData = (queryResult as QueryResult[])[0]; 57 | const here = Number(queryData?.here_count || 0); 58 | const nowCount = Number(queryData?.now_count || 0); 59 | 60 | const result = { 61 | here, 62 | now: nowCount, 63 | domain, 64 | path, 65 | }; 66 | 67 | // Cache the result to reduce database load 68 | statsCache.set(cacheKey, { 69 | data: result, 70 | timestamp: Date.now(), 71 | }); 72 | 73 | // Clean up old cache entries periodically 74 | if (statsCache.size > 100) { 75 | const cutoff = Date.now() - CACHE_TTL * 2; 76 | for (const [key, value] of statsCache.entries()) { 77 | if (value.timestamp < cutoff) { 78 | statsCache.delete(key); 79 | } 80 | } 81 | } 82 | 83 | res.json(result); 84 | } catch (error) { 85 | console.error("Stats error details:", error); 86 | console.error("Error stack:", error instanceof Error ? error.stack : error); 87 | console.error("Domain:", domain, "Path:", path); 88 | res.status(500).json({ 89 | error: "Failed to get stats", 90 | details: error instanceof Error ? error.message : String(error), 91 | domain, 92 | path, 93 | }); 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # here/now logo here/now — modern minimal webpage hit counter 2 | 3 | A minimal, self-hosted visitor tracking API that shows both **total visitor count** and **real-time visitor counts** per webpage. 4 | 5 | Hosted original and example available at [herenow.fyi](https://www.herenow.fyi). 6 | 7 | ## ✨ Features 8 | 9 | - **Real-time visitor tracking** - See current active visitors on a page 10 | - **Total visitor counts** - Track all-time unique visitors on a page 11 | - **Self-hosted** - Full control over your data 12 | - **Lightweight widget** - Single script tag integration 13 | - **Dark/light theme detection** - Automatic theme matching 14 | - **SPA support** - Works with React, Vue, Next.js, etc. 15 | - **CORS enabled** - Works from any website (domain filtering via allowlist) 16 | 17 | Keeping a link to [herenow.fyi](https://herenow.fyi) in your implementation is appreciated but not required, as this helps others discover how to implement here/now. 18 | 19 | ## 🚀 Quick Start 20 | 21 | ### 1. Clone and Install 22 | 23 | ```bash 24 | git clone https://github.com/fredrivett/here-now.git 25 | cd here-now 26 | npm install 27 | ``` 28 | 29 | ### 2. Set up Database 30 | 31 | _You can use any database setup you choose, this guide works with Supabase (postgres)._ 32 | 33 | Copy the environment variables: 34 | 35 | ```bash 36 | cp .env.example .env 37 | ``` 38 | 39 | Set up your database: 40 | 41 | 1. Create a free PostgreSQL database at [supabase.com](https://supabase.com) 42 | 2. Go to Connect → Connection String and copy both connection strings 43 | 3. Update `.env` with your Supabase URLs: 44 | ```bash 45 | DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:6543/postgres?pgbouncer=true" # Transaction Pooler 46 | DIRECT_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-DB].supabase.co:5432/postgres" # Direct connection 47 | ``` 48 | 49 | Initialize database: 50 | 51 | ```bash 52 | npm run db:generate # Generates types 53 | npm run db:push # Creates tables in your database 54 | ``` 55 | 56 | ### 3. Configure Domains 57 | 58 | Add your allowed domains to `.env`: 59 | 60 | ```bash 61 | ALLOWED_DOMAINS="localhost,yourdomain.com,yourotherdomain.com" 62 | ``` 63 | 64 | ### 4. Start the Server 65 | 66 | ```bash 67 | npm run dev 68 | ``` 69 | 70 | Your API will be available at `http://localhost:3210` 71 | 72 | ### 5. Add to Your Website 73 | 74 | Add this single line to any webpage where you wish the widget to display: 75 | 76 | ```html 77 |
78 | ``` 79 | 80 | Then include the script before the closing `` tag: 81 | 82 | ```html 83 | 84 | ``` 85 | 86 | ## 📁 Project Structure 87 | 88 | ``` 89 | here-now/ 90 | ├── api/ 91 | │ └── index.ts # Vercel serverless entry point 92 | ├── src/ 93 | │ ├── app.ts # Express app configuration 94 | │ ├── server.ts # Standalone server entry point 95 | │ ├── controllers/ # Request handlers 96 | │ │ ├── trackController.ts 97 | │ │ ├── statsController.ts 98 | │ │ └── widgetController.ts 99 | │ ├── routes/ # Route definitions 100 | │ │ ├── track.ts 101 | │ │ ├── stats.ts 102 | │ │ └── widget.ts 103 | │ ├── middleware/ # Custom middleware 104 | │ │ └── cors.ts 105 | │ ├── lib/ # Utilities & external services 106 | │ │ ├── prisma.ts 107 | │ │ └── constants.ts 108 | │ └── types/ # TypeScript type definitions 109 | │ └── index.ts 110 | ├── prisma/ 111 | │ └── schema.prisma # Database schema 112 | ├── package.json 113 | ├── tsconfig.json 114 | ├── vercel.json # Vercel deployment config 115 | └── .env.example 116 | ``` 117 | 118 | ## 🔌 API Endpoints 119 | 120 | The widget automatically calls these on page load so you don't need to implement them, but these are the API endpoints available: 121 | 122 | ### Track Visitor 123 | 124 | ```http 125 | POST /api/track 126 | Content-Type: application/json 127 | 128 | { 129 | "domain": "yourdomain.com", 130 | "path": "/blog/post-1", 131 | "user_id": "optional-user-id", 132 | "session_id": "optional-session-id" 133 | } 134 | ``` 135 | 136 | ### Get Stats 137 | 138 | ```http 139 | GET /api/stats?domain=yourdomain.com&path=/blog/post-1 140 | ``` 141 | 142 | Response: 143 | 144 | ```json 145 | { 146 | "here": 42, 147 | "now": 3, 148 | "domain": "yourdomain.com", 149 | "path": "/blog/post-1" 150 | } 151 | ``` 152 | 153 | ### Widget Script 154 | 155 | ```http 156 | GET /widget.js 157 | ``` 158 | 159 | Returns the JavaScript widget code. 160 | 161 | ## ☁️ Vercel Deployment 162 | 163 | This project is configured for Vercel deployment, but you can deploy how you wish. 164 | 165 | Vercel instructions: 166 | 167 | 1. Push to GitHub 168 | 2. Connect to Vercel 169 | 3. Set environment variables 170 | 4. Deploy 171 | 172 | The `vercel.json` and `api/index.ts` files handle the serverless configuration. 173 | 174 | ## ⚙️ Environment Variables 175 | 176 | | Variable | Required | Description | 177 | | ----------------- | -------- | ---------------------------------------------------------- | 178 | | `DATABASE_URL` | ✅ | PostgreSQL connection string | 179 | | `DIRECT_URL` | ✅ | Direct database connection (for migrations) | 180 | | `ALLOWED_DOMAINS` | ✅ | Comma-separated list of allowed domains | 181 | | `API_BASE_URL` | ❌ | Base URL for widget API calls (auto-detected from request) | 182 | 183 | ## 🤝 Contributing 184 | 185 | Contributions welcome! Please read our [contributing guidelines](CONTRIBUTING.md) and submit pull requests. 186 | 187 | ## 📄 License 188 | 189 | MIT License - see LICENSE file for details. 190 | 191 | ## 🔗 Links 192 | 193 | - **Hosted Version**: [herenow.fyi](https://herenow.fyi) 194 | - **Issues**: [GitHub Issues](https://github.com/fredrivett/here-now/issues) 195 | -------------------------------------------------------------------------------- /src/controllers/widgetController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { ACTIVITY_THRESHOLD_MS, getDomainCheckJS } from "../lib/constants.js"; 3 | 4 | export const widgetController = async (req: Request, res: Response) => { 5 | // Get API base URL from environment or request 6 | const host = req.get("host"); 7 | const protocol = host?.includes("localhost") ? "http" : "https"; 8 | const apiBaseUrl = process.env.API_BASE_URL || `${protocol}://${host}`; 9 | 10 | const widgetScript = ` 11 | (function() { 12 | 'use strict'; 13 | 14 | // Check if we're in a browser environment (not SSR) 15 | if (typeof window === 'undefined' || typeof document === 'undefined') { 16 | return; 17 | } 18 | 19 | // Configuration 20 | const DEBUG = document.documentElement.hasAttribute('data-herenow-debug'); 21 | const NAVIGATION_TIMEOUT = 100; // ms delay for SPA navigation handling 22 | 23 | // Track in-flight initializations to prevent race conditions 24 | // Using WeakSet because it automatically cleans up when DOM elements 25 | // are removed during SPA navigation (no memory leaks) 26 | const initializingElements = new WeakSet(); 27 | 28 | // Shared logging function with DRY prefix 29 | const PREFIX = '[herenow]'; 30 | 31 | function log(message, ...args) { 32 | if (DEBUG) { 33 | console.log(PREFIX, message, ...args); 34 | } 35 | } 36 | 37 | function warn(message, ...args) { 38 | console.warn(PREFIX, message, ...args); 39 | } 40 | 41 | function error(message, ...args) { 42 | console.error(PREFIX, message, ...args); 43 | } 44 | 45 | log('Debug mode enabled'); 46 | const HERENOW_API = '${apiBaseUrl}'; 47 | 48 | ${getDomainCheckJS()} 49 | 50 | // Check if we're on the allowed domain 51 | if (!isDomainAllowed(window.location.hostname)) { 52 | warn('Domain not allowed:', window.location.hostname); 53 | return; 54 | } 55 | 56 | log('Widget loading on domain:', window.location.hostname); 57 | 58 | const DOMAIN = window.location.hostname === 'localhost' ? 'localhost' : 59 | window.location.hostname.includes('vercel.app') ? 'herenow.fyi' : 60 | window.location.hostname; 61 | 62 | // Detect dark mode from multiple sources 63 | function isDarkMode() { 64 | // 1. Check for Tailwind's dark class on html 65 | if (document.documentElement.classList.contains('dark')) { 66 | return true; 67 | } 68 | 69 | // 2. Check for light class (explicit light mode) 70 | if (document.documentElement.classList.contains('light')) { 71 | return false; 72 | } 73 | 74 | // 3. Check for data-theme attribute 75 | const theme = document.documentElement.getAttribute('data-theme'); 76 | if (theme === 'dark') return true; 77 | if (theme === 'light') return false; 78 | 79 | // 4. Fallback to system preference 80 | return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 81 | } 82 | 83 | // Generate or get user ID (UUID in localStorage) 84 | function getUserId() { 85 | let userId = localStorage.getItem('herenow_user_id'); 86 | if (!userId) { 87 | // Generate a simple UUID-like string 88 | userId = 'user_' + Math.random().toString(36).substring(2) + '_' + Date.now().toString(36); 89 | localStorage.setItem('herenow_user_id', userId); 90 | log('Generated new user ID:', userId); 91 | } 92 | return userId; 93 | } 94 | 95 | // Generate or get session ID 96 | function getSessionId() { 97 | let sessionId = sessionStorage.getItem('herenow_session_id'); 98 | if (!sessionId) { 99 | sessionId = crypto.randomUUID(); 100 | sessionStorage.setItem('herenow_session_id', sessionId); 101 | } 102 | return sessionId; 103 | } 104 | 105 | // Activity tracking 106 | let lastActivity = 0; 107 | const ACTIVITY_THRESHOLD = ${ACTIVITY_THRESHOLD_MS}; 108 | 109 | // Track page visit 110 | async function trackVisit() { 111 | log('Tracking visit for path:', window.location.pathname); 112 | try { 113 | const response = await fetch(HERENOW_API + '/api/track', { 114 | method: 'POST', 115 | headers: { 116 | 'Content-Type': 'application/json', 117 | }, 118 | body: JSON.stringify({ 119 | domain: DOMAIN, 120 | path: window.location.pathname, 121 | user_id: getUserId(), 122 | session_id: getSessionId(), 123 | }), 124 | }); 125 | 126 | if (!response.ok) { 127 | const errorData = await response.json(); 128 | error('Tracking failed with status:', response.status, errorData); 129 | } else { 130 | const result = await response.json(); 131 | log('Visit tracked successfully:', result); 132 | lastActivity = Date.now(); 133 | } 134 | } catch (err) { 135 | error('Tracking failed:', err); 136 | } 137 | } 138 | 139 | // Send activity update only if user is visible 140 | async function sendActivityUpdate() { 141 | if (document.hidden) { 142 | log('User not visible, skipping activity update'); 143 | return; 144 | } 145 | 146 | log('Sending activity update for continued presence'); 147 | await trackVisit(); 148 | 149 | // Refresh stats immediately to show updated count 150 | try { 151 | const updatedStats = await fetchStats(); 152 | updateWidgetNumbers(updatedStats); 153 | log('Updated stats after activity update:', updatedStats); 154 | } catch (err) { 155 | error('Failed to refresh stats after activity update:', err); 156 | } 157 | } 158 | 159 | // Handle visibility changes 160 | function handleVisibilityChange() { 161 | if (!document.hidden) { 162 | const timeSinceLastActivity = Date.now() - lastActivity; 163 | if (timeSinceLastActivity >= ACTIVITY_THRESHOLD) { 164 | log('User returned to tab after', Math.round(timeSinceLastActivity / 1000 / 60), 'minutes'); 165 | sendActivityUpdate(); 166 | } else { 167 | log('User returned to tab, but recent activity exists - skipping'); 168 | } 169 | } 170 | } 171 | 172 | // Fetch stats 173 | async function fetchStats() { 174 | const currentPath = window.location.pathname; 175 | try { 176 | const response = await fetch(HERENOW_API + '/api/stats?domain=' + DOMAIN + '&path=' + encodeURIComponent(currentPath)); 177 | if (response.ok) { 178 | return await response.json(); 179 | } 180 | } catch (error) { 181 | error('Stats failed:', error); 182 | } 183 | return { here: 0, now: 0, domain: DOMAIN, path: currentPath }; 184 | } 185 | 186 | // Create skeleton widget HTML with placeholder dashes 187 | function createSkeletonWidget() { 188 | const dark = isDarkMode(); 189 | const colors = dark ? { 190 | bg: '#000', 191 | border: '#fff', 192 | numberText: '#fff', 193 | labelText: '#a1a1aa', 194 | pulse: '#fff', 195 | hoverBg: '#fff', 196 | hoverText: '#000' 197 | } : { 198 | bg: '#fff', 199 | border: '#000', 200 | numberText: '#000', 201 | labelText: '#374151', 202 | pulse: '#000', 203 | hoverBg: '#000', 204 | hoverText: '#fff' 205 | }; 206 | 207 | const widget = document.createElement('div'); 208 | widget.id = 'herenow-widget'; 209 | widget.style.cssText = \` 210 | display: inline-flex; 211 | align-items: stretch; 212 | font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; 213 | font-size: 14px; 214 | border: 1px solid \${colors.border}; 215 | background: \${colors.bg}; 216 | \`; 217 | 218 | widget.innerHTML = \` 219 |
220 | 221 | here 222 |
223 | 224 |
225 |
226 |
227 | 228 |
229 | now 230 |
231 | 232 | 236 | fyi 237 | 238 | 239 | 240 | 241 | 242 | 243 | \`; 244 | 245 | return widget; 246 | } 247 | 248 | // Update skeleton widget with real numbers 249 | function updateWidgetNumbers(stats) { 250 | const hereSpan = document.querySelector('.herenow-here-count'); 251 | const nowSpan = document.querySelector('.herenow-now-count'); 252 | 253 | if (hereSpan) { 254 | hereSpan.textContent = stats.here; 255 | log('Updated here count:', stats.here); 256 | } 257 | if (nowSpan) { 258 | nowSpan.textContent = stats.now; 259 | log('Updated now count:', stats.now); 260 | } 261 | } 262 | 263 | // Update widget appearance 264 | function updateWidget(stats) { 265 | const existingWidget = document.getElementById('herenow-widget'); 266 | if (existingWidget) { 267 | const newWidget = createSkeletonWidget(); 268 | existingWidget.replaceWith(newWidget); 269 | // Update numbers after replacement 270 | updateWidgetNumbers(stats); 271 | } 272 | } 273 | 274 | // Initialize widget 275 | async function init() { 276 | // Find target element 277 | const target = document.querySelector('[data-herenow]'); 278 | if (!target) { 279 | warn('No element with data-herenow attribute found'); 280 | return; 281 | } 282 | 283 | log('Found data-herenow element on initial page load'); 284 | 285 | // Use unified initialization function (handles tracking + widget creation) 286 | let currentStats = await initializeElement(target); 287 | if (!currentStats) return; 288 | 289 | // Watch for theme changes on document.documentElement 290 | const themeObserver = new MutationObserver((mutations) => { 291 | mutations.forEach((mutation) => { 292 | if (mutation.type === 'attributes' && 293 | (mutation.attributeName === 'class' || mutation.attributeName === 'data-theme')) { 294 | updateWidget(currentStats); 295 | } 296 | }); 297 | }); 298 | 299 | themeObserver.observe(document.documentElement, { 300 | attributes: true, 301 | attributeFilter: ['class', 'data-theme'] 302 | }); 303 | 304 | // Initialize individual element - unified function for both tracking and widget creation 305 | async function initializeElement(element) { 306 | if (!element || element.hasAttribute('data-herenow-initialized')) { 307 | log('Element already initialized or invalid, skipping'); 308 | return; 309 | } 310 | 311 | if (initializingElements.has(element)) { 312 | log('Element already being initialized, skipping'); 313 | return; 314 | } 315 | 316 | initializingElements.add(element); 317 | log('Initializing widget for element:', element); 318 | 319 | try { 320 | // Show skeleton immediately (replace any existing content) 321 | element.innerHTML = ''; 322 | const skeletonWidget = createSkeletonWidget(); 323 | element.appendChild(skeletonWidget); 324 | log('Skeleton widget shown, loading data...'); 325 | 326 | // Track the visit first (for both initial load and SPA navigation) 327 | await trackVisit(); 328 | 329 | // Get stats and update skeleton 330 | const currentStats = await fetchStats(); 331 | log('Got stats for element:', currentStats); 332 | updateWidgetNumbers(currentStats); 333 | 334 | // Only mark as initialized if everything succeeded 335 | element.setAttribute('data-herenow-initialized', 'true'); 336 | log('Widget initialized successfully'); 337 | 338 | return currentStats; // Return for potential theme setup 339 | } catch (err) { 340 | error('Failed to initialize widget:', err); 341 | // Don't mark as initialized so it can be retried 342 | } finally { 343 | // Remove from in-flight tracking (success or failure) 344 | initializingElements.delete(element); 345 | } 346 | } 347 | 348 | // Handle SPA navigation efficiently 349 | function reinitializeWidgets() { 350 | log('SPA navigation detected, scanning for new widgets'); 351 | // Find any new [data-herenow] elements that haven't been initialized 352 | const newElements = document.querySelectorAll('[data-herenow]:not([data-herenow-initialized])'); 353 | log('Found', newElements.length, 'new elements to initialize'); 354 | newElements.forEach(element => { 355 | initializeElement(element); 356 | }); 357 | } 358 | 359 | // Listen for browser back/forward navigation 360 | window.addEventListener('popstate', () => { 361 | log('popstate event detected'); 362 | reinitializeWidgets(); 363 | }); 364 | 365 | // Intercept pushState and replaceState (used by most SPA frameworks) 366 | const originalPushState = history.pushState; 367 | const originalReplaceState = history.replaceState; 368 | 369 | history.pushState = function() { 370 | log('pushState intercepted, args:', arguments); 371 | originalPushState.apply(this, arguments); 372 | setTimeout(() => { 373 | log('pushState timeout triggered'); 374 | reinitializeWidgets(); 375 | }, NAVIGATION_TIMEOUT); 376 | }; 377 | 378 | history.replaceState = function() { 379 | log('replaceState intercepted, args:', arguments); 380 | originalReplaceState.apply(this, arguments); 381 | setTimeout(() => { 382 | log('replaceState timeout triggered'); 383 | reinitializeWidgets(); 384 | }, NAVIGATION_TIMEOUT); 385 | }; 386 | 387 | // Listen for custom rescan events (gives users control) 388 | document.addEventListener('herenow-rescan', () => { 389 | log('Custom rescan event received'); 390 | reinitializeWidgets(); 391 | }); 392 | 393 | // Framework-specific hooks (if available) 394 | if (window.next?.router?.events?.on) { 395 | log('Next.js router detected, adding event listener'); 396 | window.next.router.events.on('routeChangeComplete', () => { 397 | log('Next.js routeChangeComplete event'); 398 | reinitializeWidgets(); 399 | }); 400 | } 401 | 402 | // Set up activity tracking 403 | document.addEventListener('visibilitychange', handleVisibilityChange); 404 | 405 | // Send heartbeat every 5 minutes (only if user is visible) 406 | setInterval(() => { 407 | sendActivityUpdate(); 408 | }, ACTIVITY_THRESHOLD); 409 | 410 | // Update stats every 30 seconds 411 | setInterval(async () => { 412 | currentStats = await fetchStats(); 413 | updateWidget(currentStats); 414 | }, 30000); 415 | } 416 | 417 | // Add pulse animation CSS if not already added 418 | if (!document.getElementById('herenow-styles')) { 419 | const style = document.createElement('style'); 420 | style.id = 'herenow-styles'; 421 | style.textContent = \` 422 | @keyframes pulse { 423 | 0%, 100% { 424 | opacity: 1; 425 | } 426 | 50% { 427 | opacity: .5; 428 | } 429 | } 430 | \`; 431 | document.head.appendChild(style); 432 | } 433 | 434 | // Initialize with hydration safety 435 | function safeInit() { 436 | // Wait a frame to ensure React hydration is complete 437 | requestAnimationFrame(() => { 438 | init(); 439 | }); 440 | } 441 | 442 | // Handle different loading states with React hydration consideration 443 | const HYDRATION_DELAY = 500; // Give React time to hydrate before widget initialization 444 | 445 | if (document.readyState === 'loading') { 446 | document.addEventListener('DOMContentLoaded', () => setTimeout(safeInit, HYDRATION_DELAY)); 447 | } else if (document.readyState === 'interactive') { 448 | // DOM loaded but resources still loading - wait for hydration 449 | setTimeout(safeInit, HYDRATION_DELAY); 450 | } else { 451 | // DOM fully loaded - still wait for potential hydration 452 | setTimeout(safeInit, HYDRATION_DELAY); 453 | } 454 | })(); 455 | `; 456 | 457 | res.set({ 458 | "Content-Type": "application/javascript", 459 | "Cache-Control": "public, max-age=3600", // Cache for 1 hour 460 | }); 461 | 462 | res.send(widgetScript); 463 | }; 464 | --------------------------------------------------------------------------------