Pushduck
Pushduck// S3 uploads for any framework

Fresh

Deno-powered file uploads with Fresh using Web Standards - no adapter needed!

🚧 Client-Side In Development: Fresh server-side integration is fully functional with Web Standards APIs. However, Fresh-specific client-side components and hooks are still in development. You can use the standard pushduck client APIs for now.

Using pushduck with Fresh

Fresh is a modern web framework for Deno that uses islands architecture for optimal performance. It uses Web Standards APIs and provides server-side rendering with minimal client-side JavaScript. Since Fresh uses standard Request/Response objects, pushduck handlers work directly without any adapters!

Web Standards Native: Fresh API routes use Web Standard Request/Response objects, making pushduck integration straightforward with zero overhead.

Quick Setup

Install Fresh and pushduck

# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Add pushduck to import_map.json
import_map.json
{
  "imports": {
    "$fresh/": "https://deno.land/x/fresh@1.6.1/",
    "preact": "https://esm.sh/preact@10.19.2",
    "preact/": "https://esm.sh/preact@10.19.2/",
    "pushduck/server": "https://esm.sh/pushduck@latest/server",
    "pushduck/core": "https://esm.sh/pushduck@latest/core"
  }
}
# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Install pushduck via npm (requires Node.js compatibility)
npm install pushduck
# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Install pushduck via yarn (requires Node.js compatibility)
yarn add pushduck
# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Install pushduck via pnpm (requires Node.js compatibility)
pnpm add pushduck
# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Install pushduck via bun (requires Node.js compatibility)
bun add pushduck

Configure upload router

lib/upload.ts
import { createUploadConfig } from 'pushduck/server';

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: Deno.env.get("AWS_ACCESS_KEY_ID")!,
    secretAccessKey: Deno.env.get("AWS_SECRET_ACCESS_KEY")!,
    region: 'auto',
    endpoint: Deno.env.get("AWS_ENDPOINT_URL")!,
    bucket: Deno.env.get("S3_BUCKET_NAME")!,
    accountId: Deno.env.get("R2_ACCOUNT_ID")!,
  })
  .build();

export const uploadRouter = createS3Router({
  imageUpload: s3.image().maxFileSize("5MB"),
  documentUpload: s3.file().maxFileSize("10MB")
});

export type AppUploadRouter = typeof uploadRouter;

Create API route

routes/api/upload/[...path].ts
import { Handlers } from "$fresh/server.ts";
import { uploadRouter } from "../../../lib/upload.ts";

// Direct usage - no adapter needed!
export const handler: Handlers = {
  async GET(req) {
    return uploadRouter.handler(req);
  },
  async POST(req) {
    return uploadRouter.handler(req);
  },
};

Basic Integration

Simple Upload Route

routes/api/upload/[...path].ts
import { Handlers } from "$fresh/server.ts";
import { uploadRouter } from "../../../lib/upload.ts";

// Method 1: Combined handler (recommended)
export const handler: Handlers = {
  async GET(req) {
    return uploadRouter.handler(req);
  },
  async POST(req) {
    return uploadRouter.handler(req);
  },
};

// Method 2: Universal handler
export const handler: Handlers = {
  async GET(req) {
    return uploadRouter.handler(req);
  },
  async POST(req) {
    return uploadRouter.handler(req);
  },
  async OPTIONS(req) {
    return new Response(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
      },
    });
  },
};

With Middleware

routes/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";

export async function handler(
  req: Request,
  ctx: MiddlewareHandlerContext,
) {
  // Add CORS headers for upload routes
  if (ctx.destination === "route" && req.url.includes("/api/upload")) {
    const response = await ctx.next();
    response.headers.set("Access-Control-Allow-Origin", "*");
    response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    response.headers.set("Access-Control-Allow-Headers", "Content-Type");
    return response;
  }
  
  return ctx.next();
}

Advanced Configuration

Authentication with Fresh

lib/upload.ts
import { createUploadConfig } from 'pushduck/server';
import { getCookies } from "https://deno.land/std@0.208.0/http/cookie.ts";

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: Deno.env.get("AWS_ACCESS_KEY_ID")!,
    secretAccessKey: Deno.env.get("AWS_SECRET_ACCESS_KEY")!,
    region: 'auto',
    endpoint: Deno.env.get("AWS_ENDPOINT_URL")!,
    bucket: Deno.env.get("S3_BUCKET_NAME")!,
    accountId: Deno.env.get("R2_ACCOUNT_ID")!,
  })
  .paths({
    prefix: 'uploads',
    generateKey: (file, metadata) => {
      return `${metadata.userId}/${Date.now()}/${file.name}`;
    }
  })
  .build();

export const uploadRouter = createS3Router({
  // Private uploads with cookie-based authentication
  privateUpload: s3
    .image()
    .maxFileSize("5MB")
    .middleware(async ({ req }) => {
      const cookies = getCookies(req.headers);
      const sessionId = cookies.sessionId;
      
      if (!sessionId) {
        throw new Error('Authentication required');
      }
      
      const user = await getUserFromSession(sessionId);
      if (!user) {
        throw new Error('Invalid session');
      }
      
      return {
        userId: user.id,
        username: user.username,
      };
    }),

  // Public uploads (no auth)
  publicUpload: s3
    .image()
    .maxFileSize("2MB")
    // No middleware = public access
});

export type AppUploadRouter = typeof uploadRouter;

// Helper function
async function getUserFromSession(sessionId: string) {
  // Implement your session validation logic
  // This could connect to a database, Deno KV, etc.
  return { id: 'user-123', username: 'demo-user' };
}

Client-Side Usage

Fresh islands run Preact in the browser. pushduck ships no Preact-specific binding, and does not need one — pushduck/core exposes the upload engine directly and wires into Preact hooks in a few lines.

pushduck/core has no framework dependency and no server code. It is the same engine the React, Vue, Svelte, and Solid bindings wrap, so upload behaviour is identical everywhere.

The Engine API

import { createUploadEngine } from "pushduck/core";

const engine = createUploadEngine({
  endpoint: "/api/upload",
  route: "imageUpload",
});

engine.subscribe(() => {
  const { files, progress, isUploading, errors } = engine.getSnapshot();
  // render however your framework prefers
});

await engine.upload([...fileInput.files]);
MemberPurpose
upload(files, metadata?)Start a batch; resolves when it settles
getSnapshot()Current state — stable reference between changes
subscribe(listener)Register a change listener; returns unsubscribe
cancel(fileId)Abort one in-flight file
cancelAll()Abort every in-flight file
reset()Abort everything and clear state

Upload Island

islands/FileUpload.tsx
import { useEffect, useRef, useState } from "preact/hooks";
import {
  createUploadEngine,
  type UploadEngine,
  type UploadEngineState,
} from "pushduck/core";

export default function FileUpload() {
  const engineRef = useRef<UploadEngine | null>(null);
  const [state, setState] = useState<UploadEngineState | null>(null);

  useEffect(() => {
    const engine = createUploadEngine({
      endpoint: "/api/upload",
      route: "imageUpload",
      onError: (error) => console.error("Upload failed:", error),
    });

    engineRef.current = engine;
    setState(engine.getSnapshot());

    // subscribe() returns its own unsubscribe — hand it straight back.
    return engine.subscribe(() => setState(engine.getSnapshot()));
  }, []);

  if (!state) return <p>Loading…</p>;

  return (
    <div class="space-y-4">
      <input
        type="file"
        multiple
        accept="image/*"
        disabled={state.isUploading}
        onChange={(e) =>
          engineRef.current?.upload([
            ...((e.target as HTMLInputElement).files ?? []),
          ])
        }
      />

      {state.isUploading && <progress value={state.progress} max={100} />}

      {state.files.map((file) => (
        <div key={file.id} class="flex gap-2 items-center">
          <span>{file.name}</span>
          <span>
            {file.status} ({file.progress}%)
          </span>
          {file.status === "uploading" && (
            <button onClick={() => engineRef.current?.cancel(file.id)}>
              Cancel
            </button>
          )}
        </div>
      ))}
    </div>
  );
}

Passing Client Metadata

await engine.upload(files, { albumId: "vacation-2026", visibility: "private" });

Client metadata is untrusted user input. Validate and sanitize it in your server middleware, and never trust client-provided identity claims such as userId or role — derive those from the authenticated session instead.

File Management

Server-Side File API

routes/api/files.ts
import { Handlers } from "$fresh/server.ts";

export const handler: Handlers = {
  async GET(req) {
    const url = new URL(req.url);
    const userId = url.searchParams.get('userId');
    
    if (!userId) {
      return new Response(JSON.stringify({ error: 'User ID required' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' }
      });
    }
    
    // Fetch files from database/Deno KV
    const files = await getFilesForUser(userId);
    
    return new Response(JSON.stringify({
      files: files.map(file => ({
        id: file.id,
        name: file.name,
        url: file.url,
        size: file.size,
        uploadedAt: file.createdAt,
      })),
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  },
};

async function getFilesForUser(userId: string) {
  // Example using Deno KV
  const kv = await Deno.openKv();
  const files = [];
  
  for await (const entry of kv.list({ prefix: ["files", userId] })) {
    files.push(entry.value);
  }
  
  return files;
}

File Management Page

routes/files.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import FileUpload from "../islands/FileUpload.tsx";

interface FileData {
  id: string;
  name: string;
  url: string;
  size: number;
  uploadedAt: string;
}

interface PageData {
  files: FileData[];
}

export const handler: Handlers<PageData> = {
  async GET(req, ctx) {
    // Fetch files for current user
    const files = await getFilesForUser("current-user");
    
    return ctx.render({ files });
  },
};

export default function FilesPage({ data }: PageProps<PageData>) {
  function formatFileSize(bytes: number): string {
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    if (bytes === 0) return '0 Bytes';
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
  }

  return (
    <>
      <Head>
        <title>My Files</title>
      </Head>
      <main class="container mx-auto px-4 py-8">
        <h1 class="text-3xl font-bold mb-8">My Files</h1>
        
        <div class="mb-8">
          <FileUpload />
        </div>
        
        <div>
          <h2 class="text-2xl font-semibold mb-4">Uploaded Files</h2>
          
          {data.files.length === 0 ? (
            <p class="text-gray-500">No files uploaded yet.</p>
          ) : (
            <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
              {data.files.map((file) => (
                <div key={file.id} class="border rounded-lg p-4 hover:shadow-md transition-shadow">
                  <h3 class="font-medium truncate" title={file.name}>
                    {file.name}
                  </h3>
                  <p class="text-sm text-gray-500">
                    {formatFileSize(file.size)}
                  </p>
                  <p class="text-sm text-gray-500">
                    {new Date(file.uploadedAt).toLocaleDateString()}
                  </p>
                  <a
                    href={file.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    class="text-blue-500 hover:underline text-sm mt-2 inline-block"
                  >
                    View File
                  </a>
                </div>
              ))}
            </div>
          )}
        </div>
      </main>
    </>
  );
}

async function getFilesForUser(userId: string) {
  // Implementation depends on your storage solution
  return [];
}

Deployment Options

# Deploy to Deno Deploy
deno task build
deployctl deploy --project=my-app --include=. --exclude=node_modules
deno.json
{
  "tasks": {
    "build": "deno run -A dev.ts build",
    "preview": "deno run -A main.ts",
    "start": "deno run -A --watch=static/,routes/ dev.ts",
    "deploy": "deployctl deploy --project=my-app --include=. --exclude=node_modules"
  }
}
Dockerfile
FROM denoland/deno:1.38.0

WORKDIR /app

# Copy dependency files
COPY deno.json deno.lock import_map.json ./

# Cache dependencies
RUN deno cache --import-map=import_map.json main.ts

# Copy source code
COPY . .

# Build the application
RUN deno task build

EXPOSE 8000

CMD ["deno", "run", "-A", "main.ts"]
# Install Deno
curl -fsSL https://deno.land/install.sh | sh

# Clone and run your app
git clone <your-repo>
cd <your-app>
deno task start
/etc/systemd/system/fresh-app.service
[Unit]
Description=Fresh App
After=network.target

[Service]
Type=simple
User=deno
WorkingDirectory=/opt/fresh-app
ExecStart=/home/deno/.deno/bin/deno run -A main.ts
Restart=always

[Install]
WantedBy=multi-user.target

Environment Variables

.env
# AWS Configuration
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_S3_BUCKET=your-bucket-name

# Fresh
PORT=8000

Performance Benefits

Islands Architecture

Only hydrate interactive components, minimal JavaScript

Web Standards

No adapter overhead - direct Request/Response usage

Deno Runtime

Modern runtime with built-in TypeScript and security

Edge Ready

Works on edge runtimes and CDNs

Real-Time Upload Progress

islands/AdvancedUpload.tsx
import { useState } from "preact/hooks";

export default function AdvancedUpload() {
  const [uploadProgress, setUploadProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);

  async function handleFileUpload(event: Event) {
    const target = event.target as HTMLInputElement;
    const files = target.files;
    
    if (!files || files.length === 0) return;
    
    setIsUploading(true);
    setUploadProgress(0);
    
    try {
      // Simulate upload progress
      for (let i = 0; i <= 100; i += 10) {
        setUploadProgress(i);
        await new Promise(resolve => setTimeout(resolve, 100));
      }
      
      alert('Upload completed!');
    } catch (error) {
      console.error('Upload failed:', error);
      alert('Upload failed!');
    } finally {
      setIsUploading(false);
      setUploadProgress(0);
    }
  }

  return (
    <div class="upload-container max-w-md mx-auto">
      <input
        type="file"
        multiple
        onChange={handleFileUpload}
        disabled={isUploading}
        class="w-full p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
      />
      
      {isUploading && (
        <div class="mt-4">
          <div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
            <div 
              class="h-full bg-green-500 transition-all duration-300 ease-out"
              style={{ width: `${uploadProgress}%` }}
            />
          </div>
          <p class="text-center mt-2 text-sm text-gray-600">
            {uploadProgress}% uploaded
          </p>
        </div>
      )}
    </div>
  );
}

Deno KV Integration

lib/storage.ts
// Example using Deno KV for file metadata storage
export class FileStorage {
  private kv: Deno.Kv;

  constructor() {
    this.kv = await Deno.openKv();
  }

  async saveFileMetadata(userId: string, file: {
    id: string;
    name: string;
    url: string;
    size: number;
    type: string;
  }) {
    const key = ["files", userId, file.id];
    await this.kv.set(key, {
      ...file,
      createdAt: new Date().toISOString(),
    });
  }

  async getFilesForUser(userId: string) {
    const files = [];
    for await (const entry of this.kv.list({ prefix: ["files", userId] })) {
      files.push(entry.value);
    }
    return files;
  }

  async deleteFile(userId: string, fileId: string) {
    const key = ["files", userId, fileId];
    await this.kv.delete(key);
  }
}

export const fileStorage = new FileStorage();

Troubleshooting

Common Issues

  1. Route not found: Ensure your route is routes/api/upload/[...path].ts
  2. Import errors: Check your import_map.json configuration
  3. Permissions: Deno requires explicit permissions (-A flag for all permissions)
  4. Environment variables: Use Deno.env.get() instead of process.env

Debug Mode

Enable debug logging:

lib/upload.ts
export const uploadRouter = createS3Router({
  // ... routes
}).middleware(async ({ req, file }) => {
  if (Deno.env.get("DENO_ENV") === "development") {
    console.log("Upload request:", req.url);
    console.log("File:", file.name, file.size);
  }
  return {};
});

Fresh Configuration

fresh.config.ts
import { defineConfig } from "$fresh/server.ts";

export default defineConfig({
  plugins: [],
  // Enable static file serving
  staticDir: "./static",
  // Custom build options
  build: {
    target: ["chrome99", "firefox99", "safari15"],
  },
});

Fresh provides an excellent foundation for building modern web applications with Deno and pushduck, combining the power of islands architecture with Web Standards APIs and Deno's secure runtime environment.