···11-22-Default to using Bun instead of Node.js.
33-44-- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
55-- Use `bun test` instead of `jest` or `vitest`
66-- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
77-- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
88-- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
99-- Use `bunx <package> <command>` instead of `npx <package> <command>`
1010-- Bun automatically loads .env, so don't use dotenv.
1111-1212-## APIs
1313-1414-- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
1515-- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
1616-- `Bun.redis` for Redis. Don't use `ioredis`.
1717-- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
1818-- `WebSocket` is built-in. Don't use `ws`.
1919-- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
2020-- Bun.$`ls` instead of execa.
2121-2222-## Testing
2323-2424-Use `bun test` to run tests.
2525-2626-```ts#index.test.ts
2727-import { test, expect } from "bun:test";
2828-2929-test("hello world", () => {
3030- expect(1).toBe(1);
3131-});
3232-```
3333-3434-## Frontend
3535-3636-Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
3737-3838-Server:
3939-4040-```ts#index.ts
4141-import index from "./index.html"
4242-4343-Bun.serve({
4444- routes: {
4545- "/": index,
4646- "/api/users/:id": {
4747- GET: (req) => {
4848- return new Response(JSON.stringify({ id: req.params.id }));
4949- },
5050- },
5151- },
5252- // optional websocket support
5353- websocket: {
5454- open: (ws) => {
5555- ws.send("Hello, world!");
5656- },
5757- message: (ws, message) => {
5858- ws.send(message);
5959- },
6060- close: (ws) => {
6161- // handle close
6262- }
6363- },
6464- development: {
6565- hmr: true,
6666- console: true,
6767- }
6868-})
6969-```
7070-7171-HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
7272-7373-```html#index.html
7474-<html>
7575- <body>
7676- <h1>Hello, world!</h1>
7777- <script type="module" src="./frontend.tsx"></script>
7878- </body>
7979-</html>
8080-```
8181-8282-With the following `frontend.tsx`:
8383-8484-```tsx#frontend.tsx
8585-import React from "react";
8686-import { createRoot } from "react-dom/client";
8787-8888-// import .css files directly and it works
8989-import './index.css';
9090-9191-const root = createRoot(document.body);
9292-9393-export default function Frontend() {
9494- return <h1>Hello, world!</h1>;
9595-}
9696-9797-root.render(<Frontend />);
9898-```
9999-100100-Then, run index.ts
101101-102102-```sh
103103-bun --hot ./index.ts
104104-```
105105-106106-For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+56-5
cli/commands/deploy.ts
···99 estimateDirectorySize,
1010 findLargeDirectories,
1111 replaceDirectoryWithSubfs,
1212+ splitDirectoryIntoChunks,
1213 countFilesInDirectory,
1314 type UploadedFile,
1415 type FileUploadResult
···281282 const subfsRkeys: string[] = [];
282283 let currentDir = directory;
283284 let iteration = 0;
285285+ let chunkCounter = 0;
284286285287 while (
286288 (estimateDirectorySize(currentDir) > MAX_MANIFEST_SIZE ||
···297299 if (largeDirs.length === 0) break;
298300299301 const largest = largeDirs[0]!;
300300- const subfsRkey = `${siteRkey}-subfs-${iteration}`;
301301-302302 spinner.text = `Creating subfs ${iteration} for ${largest.path} (${formatBytes(largest.size)})`;
303303304304- // Create subfs record for this directory
305305- const subfsUri = await createSubfsRecord(agent, did, largest.directory, subfsRkey);
306306- subfsRkeys.push(subfsRkey);
304304+ let subfsUri: string;
305305+306306+ // Check if directory is too large for a single subfs record
307307+ if (largest.size > MAX_SUBFS_SIZE) {
308308+ // Split into chunks
309309+ console.log(pc.dim(`\n → Directory too large (${formatBytes(largest.size)}), splitting into chunks...`));
310310+ const chunks = splitDirectoryIntoChunks(largest.directory, MAX_SUBFS_SIZE);
311311+ console.log(pc.dim(` → Created ${chunks.length} chunks`));
312312+313313+ // Upload each chunk as a subfs record
314314+ const chunkUris: string[] = [];
315315+ for (let i = 0; i < chunks.length; i++) {
316316+ const chunk = chunks[i]!;
317317+ const chunkRkey = `${siteRkey}-chunk-${chunkCounter++}`;
318318+ const chunkSize = estimateDirectorySize(chunk);
319319+ const chunkFileCount = countFilesInDirectory(chunk);
320320+321321+ console.log(pc.dim(` → Uploading chunk ${i + 1}/${chunks.length} (${chunkFileCount} files, ${formatBytes(chunkSize)})...`));
322322+323323+ const chunkUri = await createSubfsRecord(agent, did, chunk, chunkRkey);
324324+ chunkUris.push(chunkUri);
325325+ subfsRkeys.push(chunkRkey);
326326+ }
327327+328328+ // Create parent subfs that references all chunks with flat: true
329329+ console.log(pc.dim(` → Creating parent subfs with ${chunkUris.length} chunk references...`));
330330+331331+ const parentEntries = chunkUris.map((uri, i) => ({
332332+ name: `chunk${i}`,
333333+ node: {
334334+ $type: 'place.wisp.fs#subfs' as const,
335335+ type: 'subfs' as const,
336336+ subject: uri,
337337+ flat: true // Merge chunk contents into parent
338338+ }
339339+ }));
340340+341341+ const parentDirectory: Directory = {
342342+ $type: 'place.wisp.fs#directory' as const,
343343+ type: 'directory' as const,
344344+ entries: parentEntries
345345+ };
346346+347347+ const parentRkey = `${siteRkey}-subfs-${iteration}`;
348348+ subfsUri = await createSubfsRecord(agent, did, parentDirectory, parentRkey);
349349+ subfsRkeys.push(parentRkey);
350350+351351+ console.log(pc.green(` ✓ Created parent subfs with ${chunks.length} chunks`));
352352+ } else {
353353+ // Directory fits in a single subfs record
354354+ const subfsRkey = `${siteRkey}-subfs-${iteration}`;
355355+ subfsUri = await createSubfsRecord(agent, did, largest.directory, subfsRkey);
356356+ subfsRkeys.push(subfsRkey);
357357+ }
307358308359 // Replace directory with subfs reference
309360 currentDir = replaceDirectoryWithSubfs(currentDir, largest.path, subfsUri);
+3-3
cli/commands/pull.ts
···33import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
44import { extractBlobCid } from '@wisp/atproto-utils';
55import { sanitizePath } from '@wisp/fs-utils';
66-import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'fs';
66+import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readFileSync } from 'fs';
77import { dirname, join } from 'path';
88import { gunzipSync } from 'zlib';
99import { createSpinner, formatBytes, pc } from '../lib/progress.ts';
···364364365365 if (existsSync(srcPath)) {
366366 mkdirSync(dirname(destPath), { recursive: true });
367367- const content = Bun.file(srcPath).arrayBuffer();
368368- writeFileSync(destPath, Buffer.from(await content));
367367+ const content = readFileSync(srcPath);
368368+ writeFileSync(destPath, content);
369369 }
370370 }
371371 }
+80-38
cli/commands/serve.ts
···11import { AtpAgent } from '@atproto/api';
22import { IdResolver } from '@atproto/identity';
33-import { BunFirehose } from '../lib/firehose';
33+import { Firehose } from '@atproto/sync';
44+import { Hono } from 'hono';
55+import { serve as honoNodeServe } from '@hono/node-server';
46import type { Record as SettingsRecord } from '@wisp/lexicons/types/place/wisp/settings';
57import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
68import { join, extname } from 'path';
···810import { pull } from './pull.ts';
911import { createSpinner, pc } from '../lib/progress.ts';
1012import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '../lib/redirects.ts';
1313+import { isBun } from '../lib/runtime.ts';
1414+import { BunFirehose } from '../lib/firehose.ts';
11151216export interface ServeOptions {
1317 site: string;
···308312 redirectRules
309313 };
310314311311- // 5. Start HTTP server
312312- const server = Bun.serve({
313313- port,
314314- fetch(req) {
315315- return handleRequest(req, state);
316316- }
315315+ // 5. Start HTTP server with Hono (works on both Bun and Node)
316316+ const app = new Hono();
317317+318318+ app.all('*', (c) => {
319319+ const req = c.req.raw;
320320+ return handleRequest(req, state);
317321 });
318322323323+ let serverHandle: { close: () => void };
324324+325325+ if (isBun) {
326326+ // @ts-ignore - Bun global
327327+ const bunServer = Bun.serve({
328328+ port,
329329+ fetch: app.fetch,
330330+ });
331331+ serverHandle = { close: () => bunServer.stop() };
332332+ } else {
333333+ const nodeServer = honoNodeServe({
334334+ fetch: app.fetch,
335335+ port,
336336+ });
337337+ serverHandle = { close: () => nodeServer.close() };
338338+ }
339339+319340 console.log(pc.green(`\n✓ Server running at http://localhost:${port}\n`));
320341 console.log(pc.dim('Watching for updates via firehose...\n'));
321342322322- // 6. Connect to firehose for live updates
343343+ // 6. Connect to firehose for live updates (runtime-aware)
323344 const idResolver = new IdResolver();
324324- const firehose = new BunFirehose({
325325- idResolver,
326326- service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'),
327327- filterCollections: ['place.wisp.fs', 'place.wisp.settings'],
328328- handleEvent: async (evt) => {
329329- // Only handle commit events for this DID
330330- if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return;
331331- if (evt.did !== did) return;
332332- if (evt.rkey !== site) return;
333345334334- if (evt.collection === 'place.wisp.fs') {
335335- console.log(pc.yellow('\nSite updated, re-pulling...\n'));
336336- await pull(identifier, { site, path: outputPath });
346346+ const firehoseHandleEvent = async (evt: any) => {
347347+ // Only handle commit events for this DID
348348+ if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return;
349349+ if (evt.did !== did) return;
350350+ if (evt.rkey !== site) return;
337351338338- // Reload redirects
339339- state.redirectRules = loadRedirectRules(outputPath);
340340- console.log(pc.green('✓ Site reloaded\n'));
341341- } else if (evt.collection === 'place.wisp.settings') {
342342- console.log(pc.yellow('\nSettings updated...\n'));
343343- state.settings = await fetchSettings(pdsEndpoint, did, site);
344344- console.log(pc.green('✓ Settings reloaded\n'));
345345- }
346346- },
347347- onError: (err: Error) => {
348348- console.error(pc.red('Firehose error:'), err.message);
349349- if (err.cause) {
350350- console.error(pc.red(' Cause:'), err.cause);
351351- }
352352+ if (evt.collection === 'place.wisp.fs') {
353353+ console.log(pc.yellow('\nSite updated, re-pulling...\n'));
354354+ await pull(identifier, { site, path: outputPath });
355355+356356+ // Reload redirects
357357+ state.redirectRules = loadRedirectRules(outputPath);
358358+ console.log(pc.green('✓ Site reloaded\n'));
359359+ } else if (evt.collection === 'place.wisp.settings') {
360360+ console.log(pc.yellow('\nSettings updated...\n'));
361361+ state.settings = await fetchSettings(pdsEndpoint, did, site);
362362+ console.log(pc.green('✓ Settings reloaded\n'));
352363 }
353353- });
364364+ };
354365355355- firehose.start();
366366+ const firehoseOnError = (err: Error) => {
367367+ console.error(pc.red('Firehose error:'), err.message);
368368+ if (err.cause) {
369369+ console.error(pc.red(' Cause:'), err.cause);
370370+ }
371371+ };
372372+373373+ let firehoseHandle: { destroy: () => void };
374374+375375+ if (isBun) {
376376+ // Use BunFirehose for Bun (native WebSocket)
377377+ const bunFirehose = new BunFirehose({
378378+ idResolver,
379379+ service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'),
380380+ filterCollections: ['place.wisp.fs', 'place.wisp.settings'],
381381+ handleEvent: firehoseHandleEvent,
382382+ onError: firehoseOnError,
383383+ });
384384+ bunFirehose.start();
385385+ firehoseHandle = { destroy: () => bunFirehose.destroy() };
386386+ } else {
387387+ // Use @atproto/sync Firehose for Node.js (uses ws library)
388388+ const nodeFirehose = new Firehose({
389389+ idResolver,
390390+ service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'),
391391+ filterCollections: ['place.wisp.fs', 'place.wisp.settings'],
392392+ handleEvent: firehoseHandleEvent,
393393+ onError: firehoseOnError,
394394+ });
395395+ nodeFirehose.start();
396396+ firehoseHandle = { destroy: () => nodeFirehose.destroy() };
397397+ }
356398357399 // Handle shutdown
358400 process.on('SIGINT', () => {
359401 console.log(pc.dim('\nShutting down...'));
360360- firehose.destroy();
361361- server.stop();
402402+ firehoseHandle.destroy();
403403+ serverHandle.close();
362404 process.exit(0);
363405 });
364406
+43-27
cli/lib/auth.ts
···11import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node";
22import { Agent, CredentialSession } from "@atproto/api";
33+import { Hono } from "hono";
44+import { serve as honoNodeServe } from "@hono/node-server";
35import open from "open";
46import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
57import { dirname, join } from "path";
68import { homedir } from "os";
99+import { isBun } from "./runtime";
710811// OAuth scope for CLI
912const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*';
···148151149152 // Create loopback server to receive callback
150153 const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => {
151151- const server = Bun.serve({
152152- port: LOOPBACK_PORT,
153153- hostname: LOOPBACK_HOST,
154154- fetch(req) {
155155- const url = new URL(req.url);
154154+ const app = new Hono();
155155+ let serverHandle: { close: () => void } | null = null;
156156157157- if (url.pathname === '/oauth/callback') {
158158- const params = new URLSearchParams(url.search);
157157+ const successHtml = `
158158+ <html>
159159+ <head><title>Wisp CLI - Authentication Successful</title></head>
160160+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
161161+ <div style="text-align: center;">
162162+ <h1>Authentication Successful</h1>
163163+ <p>You can close this window and return to the CLI.</p>
164164+ </div>
165165+ </body>
166166+ </html>
167167+ `;
159168160160- // Close server after receiving callback
161161- setTimeout(() => server.stop(), 100);
169169+ app.get('/oauth/callback', (c) => {
170170+ const params = new URLSearchParams(c.req.url.split('?')[1] || '');
162171163163- resolve({ params });
172172+ // Close server after receiving callback
173173+ setTimeout(() => serverHandle?.close(), 100);
164174165165- return new Response(`
166166- <html>
167167- <head><title>Wisp CLI - Authentication Successful</title></head>
168168- <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
169169- <div style="text-align: center;">
170170- <h1>Authentication Successful</h1>
171171- <p>You can close this window and return to the CLI.</p>
172172- </div>
173173- </body>
174174- </html>
175175- `, {
176176- headers: { 'Content-Type': 'text/html' }
177177- });
178178- }
175175+ resolve({ params });
179176180180- return new Response('Not found', { status: 404 });
181181- },
177177+ return c.html(successHtml);
182178 });
183179180180+ app.all('*', (c) => c.text('Not found', 404));
181181+182182+ // Start server based on runtime
183183+ if (isBun) {
184184+ // @ts-ignore - Bun global
185185+ const bunServer = Bun.serve({
186186+ port: LOOPBACK_PORT,
187187+ hostname: LOOPBACK_HOST,
188188+ fetch: app.fetch,
189189+ });
190190+ serverHandle = { close: () => bunServer.stop() };
191191+ } else {
192192+ const nodeServer = honoNodeServe({
193193+ fetch: app.fetch,
194194+ port: LOOPBACK_PORT,
195195+ hostname: LOOPBACK_HOST,
196196+ });
197197+ serverHandle = { close: () => nodeServer.close() };
198198+ }
199199+184200 // Timeout after 5 minutes
185201 setTimeout(() => {
186186- server.stop();
202202+ serverHandle?.close();
187203 reject(new Error('OAuth callback timeout'));
188204 }, 5 * 60 * 1000);
189205 });