~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
703 lines · 15.4 KB Raw History
# Deno Support Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add Deno runtime support to pds.js by abstracting the SQLite driver and creating a Deno HTTP/WebSocket adapter.

**Architecture:** Create a minimal SQLite driver interface that both `better-sqlite3` (Node) and `node:sqlite` (Deno 2.2+) implement natively. Add a new `@pdsjs/deno` package for the HTTP server adapter using `Deno.serve()`. Reuse `@pdsjs/storage-sqlite` unchanged except for type annotations.

**Tech Stack:** Deno 2.2+, node:sqlite, Deno.serve(), TypeScript/JSDoc

---

## Task 1: Add SQLite Driver Interface Types

**Files:**
- Create: `packages/storage-sqlite/driver.js`

**Step 1: Create the driver interface file**

```javascript
// @pdsjs/storage-sqlite/driver - SQLite driver interface types
// Both better-sqlite3 and node:sqlite implement this interface natively

/**
 * @typedef {Object} SQLiteStatement
 * @property {(...args: unknown[]) => unknown} get - Single row query
 * @property {(...args: unknown[]) => unknown[]} all - Multi-row query
 * @property {(...args: unknown[]) => {changes: number}} run - Execute INSERT/UPDATE/DELETE
 */

/**
 * @typedef {Object} SQLiteDatabase
 * @property {(sql: string) => void} exec - Execute raw SQL (DDL, multiple statements)
 * @property {(sql: string) => SQLiteStatement} prepare - Create prepared statement
 */

export {};
```

**Step 2: Verify file created**

Run: `cat packages/storage-sqlite/driver.js`
Expected: File contents shown

**Step 3: Commit**

```bash
git add packages/storage-sqlite/driver.js
git commit -m "feat(storage-sqlite): add SQLite driver interface types"
```

---

## Task 2: Update Storage Functions to Use Driver Interface

**Files:**
- Modify: `packages/storage-sqlite/index.js` (lines 24-28 and 314-318)

**Step 1: Update createActorStorage type annotation**

Change line 28 from:
```javascript
 * @param {import('better-sqlite3').Database} db
```
to:
```javascript
 * @param {import('./driver.js').SQLiteDatabase} db
```

**Step 2: Update createSharedStorage type annotation**

Change line 318 from:
```javascript
 * @param {import('better-sqlite3').Database} db
```
to:
```javascript
 * @param {import('./driver.js').SQLiteDatabase} db
```

**Step 3: Run existing tests to verify no breakage**

Run: `pnpm test`
Expected: All tests pass

**Step 4: Commit**

```bash
git add packages/storage-sqlite/index.js
git commit -m "feat(storage-sqlite): use driver interface instead of better-sqlite3 types"
```

---

## Task 3: Export Driver Types from Package

**Files:**
- Modify: `packages/storage-sqlite/package.json`

**Step 1: Add driver.js to exports**

Add to the exports field:
```json
{
  "exports": {
    ".": "./index.js",
    "./driver": "./driver.js"
  }
}
```

**Step 2: Verify package.json is valid**

Run: `node -e "console.log(JSON.parse(require('fs').readFileSync('packages/storage-sqlite/package.json')))"`
Expected: No JSON parse errors

**Step 3: Commit**

```bash
git add packages/storage-sqlite/package.json
git commit -m "feat(storage-sqlite): export driver interface types"
```

---

## Task 4: Create @pdsjs/deno Package Structure

**Files:**
- Create: `packages/deno/package.json`
- Create: `packages/deno/deno.json`

**Step 1: Create package.json**

```json
{
  "name": "@pdsjs/deno",
  "version": "0.7.0",
  "type": "module",
  "exports": {
    ".": "./index.js"
  },
  "dependencies": {
    "@pdsjs/core": "workspace:*"
  }
}
```

**Step 2: Create deno.json**

```json
{
  "name": "@pdsjs/deno",
  "version": "0.7.0",
  "exports": "./index.js",
  "imports": {
    "@pdsjs/core": "../core/index.js",
    "@pdsjs/core/": "../core/"
  }
}
```

**Step 3: Commit**

```bash
git add packages/deno/package.json packages/deno/deno.json
git commit -m "feat(deno): create package structure"
```

---

## Task 5: Implement Deno WebSocket Port

**Files:**
- Create: `packages/deno/index.js`

**Step 1: Create the WebSocket port implementation**

```javascript
// @pdsjs/deno - Deno HTTP server adapter

/**
 * Create WebSocket port for Deno
 * @param {Map<Request, {socket: WebSocket, response: Response}>} upgradeMap
 * @returns {import('@pdsjs/core/ports.js').WebSocketPort}
 */
export function createWebSocketPort(upgradeMap) {
  /** @type {Set<WebSocket>} */
  const clients = new Set();

  return {
    isUpgrade(request) {
      return request.headers.get('upgrade')?.toLowerCase() === 'websocket';
    },

    upgrade(request, onConnect) {
      const upgrade = Deno.upgradeWebSocket(request);
      const { socket, response } = upgrade;

      clients.add(socket);
      socket.onclose = () => clients.delete(socket);

      socket.onopen = () => {
        onConnect({
          send(data) {
            if (socket.readyState === WebSocket.OPEN) {
              socket.send(data);
            }
          },
          close() {
            socket.close();
          },
        });
      };

      return response;
    },

    broadcast(data) {
      for (const client of clients) {
        if (client.readyState === WebSocket.OPEN) {
          try {
            client.send(data);
          } catch {
            // Client may have disconnected
          }
        }
      }
    },
  };
}
```

**Step 2: Commit**

```bash
git add packages/deno/index.js
git commit -m "feat(deno): implement WebSocket port"
```

---

## Task 6: Implement Deno HTTP Server

**Files:**
- Modify: `packages/deno/index.js`

**Step 1: Add createServer function**

Append to `packages/deno/index.js`:

```javascript
import { PersonalDataServer } from '@pdsjs/core';

/**
 * @typedef {Object} DenoServerOptions
 * @property {import('@pdsjs/core/ports.js').ActorStoragePort} actorStorage
 * @property {import('@pdsjs/core/ports.js').SharedStoragePort} sharedStorage
 * @property {import('@pdsjs/core/ports.js').BlobPort} blobs
 * @property {string} jwtSecret
 * @property {number} [port=3000]
 * @property {string} [hostname]
 * @property {string} [appviewUrl]
 * @property {string} [appviewDid]
 * @property {string} [relayUrl]
 * @property {string} [password]
 */

/**
 * @typedef {Object} DenoServer
 * @property {PersonalDataServer} pds
 * @property {Deno.HttpServer} server
 * @property {() => void} close
 */

/**
 * Create Deno PDS server
 * @param {DenoServerOptions} options
 * @returns {DenoServer}
 */
export function createServer({
  actorStorage,
  sharedStorage,
  blobs,
  jwtSecret,
  port = 3000,
  hostname,
  appviewUrl,
  appviewDid,
  relayUrl,
  password,
}) {
  /** @type {Map<Request, {socket: WebSocket, response: Response}>} */
  const upgradeMap = new Map();
  const webSocket = createWebSocketPort(upgradeMap);

  const pds = new PersonalDataServer({
    actorStorage,
    sharedStorage,
    blobs,
    webSocket,
    jwtSecret,
    hostname,
    appviewUrl,
    appviewDid,
    relayUrl,
    password,
  });

  const server = Deno.serve({ port }, async (request) => {
    // Handle WebSocket upgrade
    if (webSocket.isUpgrade(request)) {
      return pds.fetch(request);
    }

    // Handle regular HTTP
    return pds.fetch(request);
  });

  // Cleanup interval (every hour)
  const cleanupInterval = setInterval(
    async () => {
      await pds.runBlobCleanup();
      await sharedStorage.cleanupExpiredDpopJtis();
    },
    60 * 60 * 1000,
  );

  return {
    pds,
    server,
    port,
    close() {
      clearInterval(cleanupInterval);
      server.shutdown();
    },
  };
}

/**
 * Create server from environment variables
 * @param {Object} deps - Storage dependencies
 * @param {import('@pdsjs/core/ports.js').ActorStoragePort} deps.actorStorage
 * @param {import('@pdsjs/core/ports.js').SharedStoragePort} deps.sharedStorage
 * @param {import('@pdsjs/core/ports.js').BlobPort} deps.blobs
 * @returns {DenoServer}
 */
export function createServerFromEnv({ actorStorage, sharedStorage, blobs }) {
  return createServer({
    actorStorage,
    sharedStorage,
    blobs,
    jwtSecret: Deno.env.get('JWT_SECRET') || 'development-secret',
    port: parseInt(Deno.env.get('PORT') || '3000', 10),
    hostname: Deno.env.get('HOSTNAME'),
    appviewUrl: Deno.env.get('APPVIEW_URL'),
    appviewDid: Deno.env.get('APPVIEW_DID'),
    relayUrl: Deno.env.get('RELAY_URL'),
    password: Deno.env.get('PDS_PASSWORD'),
  });
}
```

**Step 2: Run type check**

Run: `pnpm exec tsc --noEmit`
Expected: No type errors

**Step 3: Commit**

```bash
git add packages/deno/index.js
git commit -m "feat(deno): implement HTTP server with createServer"
```

---

## Task 7: Create Deno Example

**Files:**
- Create: `examples/deno/main.ts`
- Create: `examples/deno/deno.json`

**Step 1: Create deno.json**

```json
{
  "imports": {
    "@pdsjs/core": "../../packages/core/index.js",
    "@pdsjs/core/": "../../packages/core/",
    "@pdsjs/deno": "../../packages/deno/index.js",
    "@pdsjs/storage-sqlite": "../../packages/storage-sqlite/index.js",
    "@pdsjs/blobs-s3": "../../packages/blobs-s3/index.js"
  }
}
```

**Step 2: Create main.ts**

```typescript
import { DatabaseSync } from 'node:sqlite';
import { createActorStorage, createSharedStorage } from '@pdsjs/storage-sqlite';
import { createServer } from '@pdsjs/deno';

// Initialize SQLite database
const dbPath = Deno.env.get('PDS_DB_PATH') || './pds.db';
const db = new DatabaseSync(dbPath);

// Create storage adapters
const actorStorage = createActorStorage(db);
const sharedStorage = createSharedStorage(db);

// For blobs, use S3 or implement Deno filesystem adapter
// This example uses a minimal in-memory blob store for demo purposes
const blobs = {
  async get(_did: string, _cid: string) {
    return null; // TODO: implement with Deno.readFile or S3
  },
  async put(_did: string, _cid: string, _data: Uint8Array, _mimeType: string) {
    // TODO: implement
  },
  async delete(_did: string, _cid: string) {
    // TODO: implement
  },
};

// Create and start server
const server = createServer({
  actorStorage,
  sharedStorage,
  blobs,
  jwtSecret: Deno.env.get('JWT_SECRET') || 'development-secret',
  port: parseInt(Deno.env.get('PORT') || '3000', 10),
  hostname: Deno.env.get('HOSTNAME'),
  appviewUrl: Deno.env.get('APPVIEW_URL'),
  appviewDid: Deno.env.get('APPVIEW_DID'),
  relayUrl: Deno.env.get('RELAY_URL'),
  password: Deno.env.get('PDS_PASSWORD'),
});

console.log(`PDS running on http://localhost:${server.port}`);

// Handle shutdown
Deno.addSignalListener('SIGINT', () => {
  console.log('Shutting down...');
  server.close();
  db.close();
  Deno.exit(0);
});
```

**Step 3: Commit**

```bash
git add examples/deno/main.ts examples/deno/deno.json
git commit -m "feat(examples): add Deno example"
```

---

## Task 8: Add Deno Filesystem Blobs (Optional)

**Files:**
- Create: `packages/blobs-deno/index.js`
- Create: `packages/blobs-deno/package.json`
- Create: `packages/blobs-deno/deno.json`

**Step 1: Create package.json**

```json
{
  "name": "@pdsjs/blobs-deno",
  "version": "0.7.0",
  "type": "module",
  "exports": {
    ".": "./index.js"
  }
}
```

**Step 2: Create deno.json**

```json
{
  "name": "@pdsjs/blobs-deno",
  "version": "0.7.0",
  "exports": "./index.js"
}
```

**Step 3: Create index.js**

```javascript
// @pdsjs/blobs-deno - Deno filesystem blob storage

import { join, dirname } from 'node:path';

/**
 * Create filesystem blob storage for Deno
 * @param {string} baseDir - Base directory for blob storage
 * @returns {import('@pdsjs/core/ports.js').BlobPort}
 */
export function createDenoBlobs(baseDir) {
  /**
   * Get blob file path
   * @param {string} did
   * @param {string} cid
   */
  function blobPath(did, cid) {
    // Use first 2 chars of CID for sharding
    const shard = cid.slice(0, 2);
    return join(baseDir, did, shard, cid);
  }

  return {
    async get(did, cid) {
      try {
        const path = blobPath(did, cid);
        const data = await Deno.readFile(path);
        const metaPath = `${path}.meta`;
        const meta = JSON.parse(await Deno.readTextFile(metaPath));
        return { data, mimeType: meta.mimeType };
      } catch (e) {
        if (e instanceof Deno.errors.NotFound) {
          return null;
        }
        throw e;
      }
    },

    async put(did, cid, data, mimeType) {
      const path = blobPath(did, cid);
      await Deno.mkdir(dirname(path), { recursive: true });
      await Deno.writeFile(path, data);
      await Deno.writeTextFile(`${path}.meta`, JSON.stringify({ mimeType }));
    },

    async delete(did, cid) {
      try {
        const path = blobPath(did, cid);
        await Deno.remove(path);
        await Deno.remove(`${path}.meta`);
      } catch (e) {
        if (!(e instanceof Deno.errors.NotFound)) {
          throw e;
        }
      }
    },
  };
}
```

**Step 4: Commit**

```bash
git add packages/blobs-deno/
git commit -m "feat(blobs-deno): add Deno filesystem blob storage"
```

---

## Task 9: Update Deno Example to Use Blobs

**Files:**
- Modify: `examples/deno/main.ts`
- Modify: `examples/deno/deno.json`

**Step 1: Update deno.json imports**

Add to imports:
```json
"@pdsjs/blobs-deno": "../../packages/blobs-deno/index.js"
```

**Step 2: Update main.ts to use blobs-deno**

Replace the placeholder blobs with:

```typescript
import { createDenoBlobs } from '@pdsjs/blobs-deno';

const blobsDir = Deno.env.get('PDS_BLOBS_DIR') || './blobs';
const blobs = createDenoBlobs(blobsDir);
```

**Step 3: Commit**

```bash
git add examples/deno/main.ts examples/deno/deno.json
git commit -m "feat(examples): use blobs-deno in Deno example"
```

---

## Task 10: Add README for Deno Example

**Files:**
- Create: `examples/deno/README.md`

**Step 1: Create README**

```markdown
# Deno PDS Example

Minimal PDS server running on Deno.

## Requirements

- Deno 2.2+ (for `node:sqlite` support)

## Quick Start

```bash
cd examples/deno
deno run --allow-net --allow-read --allow-write --allow-env main.ts
```

## Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `PDS_DB_PATH` | SQLite database path | `./pds.db` |
| `PDS_BLOBS_DIR` | Blob storage directory | `./blobs` |
| `JWT_SECRET` | JWT signing secret | `development-secret` |
| `PORT` | Server port | `3000` |
| `HOSTNAME` | PDS hostname | - |
| `APPVIEW_URL` | AppView URL for proxying | - |
| `APPVIEW_DID` | AppView DID for service auth | - |
| `RELAY_URL` | Relay URL for firehose | - |
| `PDS_PASSWORD` | Password for createSession | - |

## Permissions

The server requires these Deno permissions:

- `--allow-net` - HTTP server and outbound requests
- `--allow-read` - Read database and blob files
- `--allow-write` - Write database and blob files
- `--allow-env` - Read environment variables
```

**Step 2: Commit**

```bash
git add examples/deno/README.md
git commit -m "docs(examples): add Deno example README"
```

---

## Task 11: Test Deno Example Manually

**Step 1: Run the Deno example**

```bash
cd examples/deno
deno run --allow-net --allow-read --allow-write --allow-env main.ts
```

Expected: Server starts, prints "PDS running on http://localhost:3000"

**Step 2: Test health endpoint**

```bash
curl http://localhost:3000/xrpc/_health
```

Expected: `{"version":"..."}`

**Step 3: Test describe server**

```bash
curl http://localhost:3000/xrpc/com.atproto.server.describeServer
```

Expected: JSON response with server description

---

## Task 12: Update Root README

**Files:**
- Modify: `README.md`

**Step 1: Add Deno to supported platforms**

Add to the platforms section:
```markdown
### Deno

```bash
cd examples/deno
deno run --allow-net --allow-read --allow-write --allow-env main.ts
```

Requires Deno 2.2+ for `node:sqlite` support.
```

**Step 2: Commit**

```bash
git add README.md
git commit -m "docs: add Deno to supported platforms"
```