Pushduck
Pushduck// S3 uploads for any framework

Qwik

Edge-optimized file uploads with Qwik using Web Standards - no adapter needed!

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

Using pushduck with Qwik

Qwik is a web framework focused on resumability and edge optimization. It uses Web Standards APIs and provides instant loading with minimal JavaScript. Since Qwik uses standard Request/Response objects, pushduck handlers work directly without any adapters!

Web Standards Native: Qwik server endpoints use Web Standard Request/Response objects, making pushduck integration straightforward with zero overhead and perfect for edge deployment.

Quick Setup

Install dependencies

npm install pushduck

Configure upload router

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

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID!,
    secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY!,
    region: 'auto',
    endpoint: import.meta.env.VITE_AWS_ENDPOINT_URL!,
    bucket: import.meta.env.VITE_S3_BUCKET_NAME!,
    accountId: import.meta.env.VITE_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

src/routes/api/upload/[...path]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';

// Direct usage - no adapter needed!
export const onGet: RequestHandler = async ({ request }) => {
  return uploadRouter.handler(request);
};

export const onPost: RequestHandler = async ({ request }) => {
  return uploadRouter.handler(request);
};

Basic Integration

Simple Upload Route

src/routes/api/upload/[...path]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';

// Method 1: Combined handler (recommended)
export const onRequest: RequestHandler = async ({ request }) => {
  return uploadRouter.handler(request);
};

// Method 2: Separate handlers (if you need method-specific logic)
export const onGet: RequestHandler = async ({ request }) => {
  return uploadRouter.handlers.GET(request);
};

export const onPost: RequestHandler = async ({ request }) => {
  return uploadRouter.handlers.POST(request);
};

With CORS Support

src/routes/api/upload/[...path]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city';
import { uploadRouter } from '~/lib/upload';

export const onRequest: RequestHandler = async ({ request, headers }) => {
  // Handle CORS preflight
  if (request.method === 'OPTIONS') {
    headers.set('Access-Control-Allow-Origin', '*');
    headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    headers.set('Access-Control-Allow-Headers', 'Content-Type');
    return new Response(null, { status: 200 });
  }

  const response = await uploadRouter.handler(request);
  
  // Add CORS headers to actual response
  headers.set('Access-Control-Allow-Origin', '*');
  
  return response;
};

Advanced Configuration

Authentication with Qwik

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

const { s3, createS3Router } = createUploadConfig()
  .provider("cloudflareR2",{
    accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID!,
    secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY!,
    region: 'auto',
    endpoint: import.meta.env.VITE_AWS_ENDPOINT_URL!,
    bucket: import.meta.env.VITE_S3_BUCKET_NAME!,
    accountId: import.meta.env.VITE_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 = req.headers.get('Cookie');
      const sessionId = parseCookie(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 functions
function parseCookie(cookieString: string | null) {
  if (!cookieString) return {};
  return Object.fromEntries(
    cookieString.split('; ').map(c => {
      const [key, ...v] = c.split('=');
      return [key, v.join('=')];
    })
  );
}

async function getUserFromSession(sessionId: string) {
  // Implement your session validation logic
  return { id: 'user-123', username: 'demo-user' };
}

Client-Side Usage

Qwik has no dedicated pushduck binding. It does not need one — pushduck/core exposes the upload engine directly, and Qwik wires it up in a few lines.

The engine holds live state (abort controllers, in-flight transfers) so it is not serializable. Create it inside useVisibleTask$ and wrap it in noSerialize — the standard Qwik pattern for client-only objects.

pushduck/core has no framework dependency and no server code. It is the same engine the React, Vue, Svelte, and Solid bindings use, so upload behaviour is identical across all of them.

Upload Component

src/components/file-upload.tsx
import {
  $,
  component$,
  noSerialize,
  useStore,
  useVisibleTask$,
  type NoSerialize,
} from "@builder.io/qwik";
import {
  createUploadEngine,
  type UploadEngine,
  type UploadEngineState,
} from "pushduck/core";

export const FileUpload = component$(() => {
  // Mirror of engine state — plain serializable data, safe in a Qwik store.
  const state = useStore<UploadEngineState>({
    files: [],
    isUploading: false,
    errors: [],
    progress: 0,
    uploadSpeed: 0,
    eta: 0,
  });

  // The engine itself is not serializable, so it lives behind noSerialize.
  const ref = useStore<{ engine?: NoSerialize<UploadEngine> }>({});

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

    ref.engine = noSerialize(engine);

    // subscribe() returns its own unsubscribe — hand it straight to cleanup.
    cleanup(
      engine.subscribe(() => {
        Object.assign(state, engine.getSnapshot());
      })
    );
  });

  const onSelect = $((event: Event) => {
    const input = event.target as HTMLInputElement;
    ref.engine?.upload([...(input.files ?? [])]);
  });

  return (
    <div class="space-y-4">
      <input
        type="file"
        multiple
        accept="image/*"
        disabled={state.isUploading}
        onChange$={onSelect}
      />

      {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$={() => ref.engine?.cancel(file.id)}>Cancel</button>
          )}
          {file.url && (
            <a href={file.url} target="_blank" rel="noreferrer">
              View
            </a>
          )}
        </div>
      ))}

      {state.errors.map((error) => (
        <p key={error} class="text-red-600">
          {error}
        </p>
      ))}
    </div>
  );
});

Reusable Hook

Extracting the wiring keeps components tidy and gives you one place to change the endpoint:

src/hooks/use-upload.ts
import {
  noSerialize,
  useStore,
  useVisibleTask$,
  type NoSerialize,
} from "@builder.io/qwik";
import {
  createUploadEngine,
  type UploadEngine,
  type UploadEngineState,
} from "pushduck/core";

export function useUpload(route: string, endpoint = "/api/upload") {
  const state = useStore<UploadEngineState>({
    files: [],
    isUploading: false,
    errors: [],
    progress: 0,
    uploadSpeed: 0,
    eta: 0,
  });
  const ref = useStore<{ engine?: NoSerialize<UploadEngine> }>({});

  useVisibleTask$(({ cleanup }) => {
    const engine = createUploadEngine({ endpoint, route });
    ref.engine = noSerialize(engine);
    cleanup(
      engine.subscribe(() => Object.assign(state, engine.getSnapshot()))
    );
  });

  return { state, ref };
}

Passing Client Metadata

ref.engine?.upload([...(input.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 Loader

src/routes/files/index.tsx
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';
import { routeLoader$ } from '@builder.io/qwik-city';
import { FileUpload } from '~/components/file-upload';

export const useFiles = routeLoader$(async (requestEvent) => {
  const userId = 'current-user'; // Get from session/auth
  
  // Fetch files from database
  const files = await getFilesForUser(userId);
  
  return {
    files: files.map(file => ({
      id: file.id,
      name: file.name,
      url: file.url,
      size: file.size,
      uploadedAt: file.createdAt,
    })),
  };
});

export default component$(() => {
  const filesData = useFiles();
  
  const 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 (
    <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>
        
        {filesData.value.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">
            {filesData.value.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>
  );
});

export const head: DocumentHead = {
  title: 'My Files',
};

async function getFilesForUser(userId: string) {
  // Implement your database query logic
  return [];
}

Deployment Options

vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikCloudflarePages } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite';

export default defineConfig(() => {
  return {
    plugins: [
      qwikCity({
        adapter: qwikCloudflarePages(),
      }),
      qwikVite(),
    ],
  };
});
vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikVercel } from '@builder.io/qwik-city/adapters/vercel-edge/vite';

export default defineConfig(() => {
  return {
    plugins: [
      qwikCity({
        adapter: qwikVercel(),
      }),
      qwikVite(),
    ],
  };
});
vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikNetlifyEdge } from '@builder.io/qwik-city/adapters/netlify-edge/vite';

export default defineConfig(() => {
  return {
    plugins: [
      qwikCity({
        adapter: qwikNetlifyEdge(),
      }),
      qwikVite(),
    ],
  };
});
vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import { qwikDeno } from '@builder.io/qwik-city/adapters/deno/vite';

export default defineConfig(() => {
  return {
    plugins: [
      qwikCity({
        adapter: qwikDeno(),
      }),
      qwikVite(),
    ],
  };
});

Environment Variables

.env
# AWS Configuration
VITE_AWS_REGION=us-east-1
VITE_AWS_ACCESS_KEY_ID=your_access_key
VITE_AWS_SECRET_ACCESS_KEY=your_secret_key
VITE_AWS_S3_BUCKET=your-bucket-name

# Qwik
VITE_PUBLIC_UPLOAD_ENDPOINT=http://localhost:5173/api/upload

Performance Benefits

Resumability

Zero hydration - apps resume from server state

Web Standards

No adapter overhead - direct Request/Response usage

Edge Optimized

Perfect for edge deployment and CDNs

Instant Loading

O(1) JavaScript loading regardless of app size

Real-Time Upload Progress

src/components/advanced-upload.tsx
import { component$, useSignal, $ } from '@builder.io/qwik';

export const AdvancedUpload = component$(() => {
  const uploadProgress = useSignal(0);
  const isUploading = useSignal(false);

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

  return (
    <div class="upload-container max-w-md mx-auto">
      <input
        type="file"
        multiple
        onChange$={handleFileUpload}
        disabled={isUploading.value}
        class="w-full p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
      />
      
      {isUploading.value && (
        <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.value}%` }}
            />
          </div>
          <p class="text-center mt-2 text-sm text-gray-600">
            {uploadProgress.value}% uploaded
          </p>
        </div>
      )}
    </div>
  );
});

Qwik City Form Integration

src/routes/upload-form/index.tsx
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';
import { FileUpload } from '~/components/file-upload';

export const useUploadAction = routeAction$(async (data, requestEvent) => {
  // Handle form submission
  // Files are already uploaded via pushduck, just save metadata
  console.log('Form data:', data);
  
  // Redirect to files page
  throw requestEvent.redirect(302, '/files');
}, zod$({
  title: z.string().min(1),
  description: z.string().optional(),
}));

export default component$(() => {
  const uploadAction = useUploadAction();

  return (
    <main class="max-w-2xl mx-auto p-6">
      <h1 class="text-2xl font-bold mb-6">Upload Files</h1>
      
      <Form action={uploadAction} class="space-y-6">
        <div>
          <label for="title" class="block text-sm font-medium mb-2">
            Title
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            class="w-full p-3 border border-gray-300 rounded-lg"
          />
        </div>
        
        <div>
          <label for="description" class="block text-sm font-medium mb-2">
            Description
          </label>
          <textarea
            id="description"
            name="description"
            rows={4}
            class="w-full p-3 border border-gray-300 rounded-lg"
          />
        </div>
        
        <div>
          <label class="block text-sm font-medium mb-2">
            Files
          </label>
          <FileUpload />
        </div>
        
        <button
          type="submit"
          disabled={uploadAction.isRunning}
          class="w-full bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 disabled:opacity-50"
        >
          {uploadAction.isRunning ? "Uploading..." : "Upload Files"}
        </button>
      </Form>
    </main>
  );
});

export const head: DocumentHead = {
  title: 'Upload Form',
};

Troubleshooting

Common Issues

  1. Route not found: Ensure your route is src/routes/api/upload/[...path]/index.ts
  2. Build errors: Check that pushduck is properly installed and configured
  3. Environment variables: Use import.meta.env.VITE_ prefix for client-side variables
  4. Resumability: Remember to use $ suffix for event handlers and functions

Debug Mode

Enable debug logging:

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

Qwik Configuration

vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';

export default defineConfig(() => {
  return {
    plugins: [qwikCity(), qwikVite()],
    preview: {
      headers: {
        'Cache-Control': 'public, max-age=600',
      },
    },
    // Environment variables configuration
    define: {
      'import.meta.env.VITE_AWS_ACCESS_KEY_ID': JSON.stringify(process.env.VITE_AWS_ACCESS_KEY_ID),
    }
  };
});

Qwik provides a modern approach to web development with pushduck, offering instant loading and resumability while maintaining full compatibility with Web Standards APIs for optimal edge performance.