{"collection":"posts","slug":"1733357455673-pure-internet-feral","cid":"bafkreia5amrdy22lvpajhhdacrsb7phhkuj66yxsllywei5yyu4m6yztse","title":"Pure Internet: Feral","excerpt":"A solar-powered Raspberry Pi runs a small Hono app through a Cloudflare Tunnel and disappears when the battery drains.","body":"Next thread in pure internet: feral servers. Non-standard environments shaped by the place they sit in.\n\nThe board is a Raspberry Pi Zero W I had lying around after upgrading another project to a Zero 2 WH. The SOC is too thin for Nginx or Apache. I considered Caddy. Then I realized I didn't need any of it.\n\nThe site I wanted to host was a Hono app. Exposing the dev server directly was the smallest possible setup, and Hono's footprint left plenty of headroom. Cloudflare Tunnels handled the port exposure.\n\nAn Esprit Paradise solar panel powers the Pi. It barely keeps up, with a small battery pack to smooth the gaps. The end goal is fully portable.\n\nWhen it's online: [feral.pure---internet.com](https://feral.pure---internet.com/)\n\n## The app\n\nTwo background services feed a Hono app. The location service fuzzes the coordinates:\n\n```typescript\nexport class LocationService {\n  private cache: LocationData | null = null;\n  private readonly CACHE_FILE = join(__dirname, \"../../../cache/location.json\");\n  private readonly UPDATE_INTERVAL = 60 * 60 * 1000; // 1 hour\n\n  private fuzzLocation(lat: number, lon: number): { latitude: number; longitude: number } {\n    // Generate a random distance up to FUZZ_RADIUS_MILES\n    const radiusMiles = Math.random() * FUZZ_RADIUS_MILES;\n    const angle = Math.random() * 2 * Math.PI;\n    \n    // Convert to angular distance\n    const angularDistance = radiusMiles / EARTH_RADIUS_MILES;\n\n    // Calculate fuzzy position using spherical geometry\n    const lat1 = lat * (Math.PI / 180);\n    const lon1 = lon * (Math.PI / 180);\n\n    const lat2 = Math.asin(\n      Math.sin(lat1) * Math.cos(angularDistance) + \n      Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(angle)\n    );\n\n    // ... coordinate calculations ...\n\n    return {\n      latitude: lat2 * (180 / Math.PI),\n      longitude: ((lon2 * (180 / Math.PI) + 540) % 360) - 180,\n    };\n  }\n}\n```\n\nThe environment service uses those coordinates to build the ecosystem data:\n\n```typescript\nexport class EnvironmentService {\n  private cache: EnvironmentalData | null = null;\n  private readonly CACHE_FILE = join(__dirname, \"../../../cache/ecosystem.json\");\n  private readonly UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes\n\n  private async updateEnvironment(): Promise\u003cEnvironmentalData\u003e {\n    const locationData = await this.locationService.getLocation();\n    \n    // Get weather data from Open-Meteo\n    const weatherData = await this.fetchWeather(locationData);\n    \n    // Get air quality if we have an API key\n    const airQuality = process.env.WAQI_TOKEN \n      ? await this.fetchAirQuality(locationData)\n      : this.defaultAirQuality;\n\n    // Calculate sun and moon data\n    const now = new Date();\n    const times = suncalc.getTimes(now, locationData.latitude, locationData.longitude);\n    const moonIllum = suncalc.getMoonIllumination(now);\n\n    return {\n      location: { /* location data */ },\n      weather: { /* weather data */ },\n      air: airQuality,\n      astronomy: {\n        sunrise: times.sunrise.toLocaleTimeString(),\n        sunset: times.sunset.toLocaleTimeString(),\n        moonPhase: this.getMoonPhase(moonIllum.phase),\n        dayLength: this.formatDayLength(times.sunset - times.sunrise)\n      }\n    };\n  }\n}\n```\n\nBoth feed into the Hono app:\n\n```typescript\nconst app = new Hono();\nconst locationService = new LocationService();\nconst environmentService = new EnvironmentService(locationService);\n\n// Serve the main page with markdown content and ecosystem data\napp.get(\"/\", async (c) =\u003e {\n  const home = readFileSync(join(__dirname, \"content/home.md\"), \"utf-8\");\n  const etc = readFileSync(join(__dirname, \"content/etc.md\"), \"utf-8\");\n  const data = await environmentService.getEnvironment();\n\n  return c.html(layout(`\n    ${marked(home)}\n    ${renderEcosystem(data)}\n    ${marked(etc)}\n  `));\n});\n\n// API endpoints for direct data access\napp.get(\"/api/environment\", async (c) =\u003e c.json(await environmentService.getEnvironment()));\napp.get(\"/api/location\", async (c) =\u003e c.json(await locationService.getLocation()));\n```\n\n## Setup\n\n```bash\n# Clone and install\ngit clone https://github.com/iammatthias/feral-pure-internet.git\ncd feral-pure-internet\npnpm install\n\n# Set up environment\necho \"WAQI_TOKEN=your_token\" \u003e .env\n\n# Configure Cloudflare Tunnel\ncloudflared tunnel create feral\ncloudflared tunnel route dns feral feral.pure---internet.com\n\n# Run with PM2\npm2 start \"pnpm dev\" --name feral\npm2 save\npm2 startup systemd -u pi --hp /home/pi\n```\n\nThe app runs at `localhost:3000`, exposes through the tunnel, and auto-starts on boot.\n\n## Living with the sun\n\nMost servers are always-on, always-available, and disconnected from the world they sit in.\n\nThis one is reachable when the sun is up. When it sets and the battery drains, the site goes dark. No load balancing, no redundancy.\n\nPortability is the next step: a self-contained unit you can drop somewhere with sun. Add a few sensors and it really starts to feel feral.\n\n## Earlier pure internet experiments\n\n- [NFC](/posts/1731984900000-pure-internet-nfc): store a data URL on an NFC tag, load in browser on scan. Top-level navigation rejects data URLs, so the workaround pins minimal HTML on IPFS with JS bootstrapping the data URL from a query param into an iframe.\n\n- [Bluesky](/posts/1732585567703-pure-internet-bluesky): inspired by Daniel Mangum's work. Hosted on the AtProtocol via the PDS and content-addressed blob storage.","tags":["raspberry-pi","solar-powered","cloudflare-tunnel","honey","feral-server","pure-internet","decentralized-web"],"published":true,"createdAt":"2024-12-04T16:10:00Z","updatedAt":"2026-07-16T14:56:30Z"}
