docs/STACK.md

STACK.md

Tranzfer v2 Stack

This document describes the intentionally small technical stack for Tranzfer v2.

The goal is not to maximize the number of good libraries we use.

The goal is to have one obvious tool for each job and a codebase that humans and coding agents can understand without archaeological excavation.

The stack is allowed to contain powerful tools.

It is not allowed to become a museum of interesting tools.


Runtime and workspace

Package manager

pnpm

Use a pnpm workspace with root catalogs for shared dependency versions.

text
1apps/*
2infra
3packages/*

Why:

  • mature monorepo behavior
  • deterministic installs
  • workspace protocol
  • dependency catalogs
  • already proven in Store Kit
  • boring in a good way

Do not introduce Turborepo/Nx unless the repository eventually demonstrates an actual need for them.

The monorepo is small enough that package-manager workspaces are sufficient.

Local addresses

Use Portless for named local URLs. From the repo root:

text
1vp run dev

That starts every app dev script through Portless without TLS (no port 443 / local CA). Addresses:

text
1http://tranzfer.localhost apps/web
2http://api.tranzfer.localhost apps/api

Portless assigns each app a port and injects it (PORT, --port, or Wrangler's flags). Do not hard-code localhost:3000 in docs or scripts.

One portless.json at the repo root names the apps. turbo is off; this repo does not use Turborepo.

Direct Vite without Portless still works: vp run --filter @tranzfer/web dev.


Repository shape

text
1tranzfer/
2├── apps/
3│ ├── web/
4│ ├── api/
5│ └── maintenance/
6│
7├── packages/
8│ ├── contracts/
9│ ├── db/
10│ └── upload-core/
11│
12├── infra/
13│
14├── SOUL.md
15├── VISION.md
16├── STACK.md
17├── AGENTS.md
18├── package.json
19└── pnpm-workspace.yaml

That is the starting point.

A future desktop client may eventually add:

text
1apps/desktop/

but do not create it before recurring real-world usage justifies it.

When it exists, it should reuse contracts and transfer behavior instead of importing website implementation code.

Do not create:

text
1packages/common
2packages/shared
3packages/utils
4packages/helpers
5packages/core

as dumping grounds.

New packages require a real architectural boundary.


Frontend

Solid 2

Solid 2 is the application framework.

Pin the exact Solid 2 release used by the repository.

Do not use floating prerelease ranges.

No React compatibility layer.

No React component dependencies.

No Astro.

No SolidStart.

Do not introduce a separate metaframework unless the product eventually proves one is necessary.

The web application is a Solid application.

Solid should be written as Solid rather than React with different syntax.

Use Solid's reactive model directly and keep state close to the feature that owns it.


Vite

Use Vite for the web application.

Keep the build setup boring.

The web Worker is the production server. @cloudflare/vite-plugin owns the ssr Vite environment (viteEnvironment: { name: "ssr" }) so vp dev / vp build / vp preview run the Solid handleRequest export inside workerd. src/worker.ts is the Worker entry: it forwards each request to virtual:solid-ssr-handler. There is no Node server.js.

Client assets are Workers static assets (assets-first: hashed files never hit the Worker). HTML, server functions, and the demo /api/* filesystem routes go through the Worker. Canonical product HTTP still belongs on the Elysia API Worker, not these starter routes.

SESSION_SECRET is still a process env var (the Solid env schema reads process.env at boot). Locally that is apps/web/.env for Vite plus apps/web/.dev.vars for workerd. nodejs_compat is on so that read works in the Worker.

Do not introduce framework layers just to gain conventions that Tranzfer does not need.


Routing

Use the official Solid Router 2 line compatible with the pinned Solid 2 release.

The router owns navigation.

It does not own the domain, data layer, or API contract.


Tailwind CSS

Use Tailwind CSS v4 for styling.

Keep design tokens and global primitives small and deliberate.

Avoid giant component abstraction systems.

Create reusable components when reuse is real.


UI primitives

Prefer native HTML first.

Use Kobalte 2 selectively for accessibility-heavy interactive primitives where implementing correct behavior ourselves would be stupid.

Good examples:

text
1dialog
2popover
3tooltip
4select
5combobox
6dropdown menu
7tabs

Pin the exact Kobalte version.

Wrap unstable headless primitives behind small local UI components so library churn does not spread across the application.

Do not use Kobalte for things that are already trivial HTML:

text
1button
2input
3card
4layout container

Do not install a second headless UI ecosystem unless a concrete missing primitive justifies it.

No Corvu for now.

No React UI ecosystem.

Do not reproduce the dependency soup from Tranzfer v1.


Solid Primitives

Use Solid Primitives à la carte when a primitive clearly saves real implementation work.

Potentially useful examples include:

text
1connectivity
2scheduled
3storage
4event-listener
5resize-observer
6virtual

Do not install primitives speculatively.

Do not let a utility primitive become the owner of transfer state.

Upload mechanics remain Uppy's responsibility.


Forms

Use TanStack Form with Valibot.

Forms are generic state machines that we do not need to hand-roll.

TanStack Form owns:

text
1field state
2touched / dirty state
3validation lifecycle
4submit state
5form errors
6field errors
7reset/default values
8async validation when needed

Valibot owns runtime validation schemas.

Do not build a parallel internal form framework.

Small local field wrappers for consistent labels, descriptions, and errors are fine.

Do not create a giant abstraction layer over TanStack Form.


Remote data

Do not add TanStack Query initially.

Use Solid 2's native async/reactive primitives and direct Eden calls.

Tranzfer does not currently need a second client-side cache system with query keys, invalidation policy, garbage collection, and hydration machinery.

If real product behavior later demonstrates a need for advanced shared remote caching, reconsider it then.


Tables

Do not add TanStack Table initially.

Render ordinary transfer lists and simple tables directly with Solid.

Introduce a table library only if the product genuinely grows into complex sorting/filtering/resizing/pinning/virtualized table behavior.


dismatch

Use dismatch for exhaustive matching of meaningful tagged unions and state machines.

Especially useful for things such as:

ts
1type TransferState =
2 | { tag: "idle" }
3 | { tag: "preparing" }
4 | { tag: "uploading"; progress: Progress }
5 | { tag: "paused"; progress: Progress }
6 | { tag: "retrying"; attempt: number }
7 | { tag: "finalizing" }
8 | { tag: "complete"; transferId: string }
9 | { tag: "failed"; error: UploadError };

Use matching where it makes control flow clearer.

Do not turn every boolean into a tagged union for sport.


Validation and contracts

Valibot

Use Valibot for runtime schemas.

Valibot is the default validation library for:

text
1API request bodies
2API responses where runtime validation matters
3shared public DTOs
4form validation
5serialized boundary data

Prefer schemas that are portable across web, API, and future desktop clients.

Do not introduce a second schema library without a concrete requirement.

No TypeBox.


packages/contracts

Shared contracts contain only things that genuinely cross boundaries.

Examples:

text
1transfer DTOs
2delivery DTOs
3device DTOs
4billing-plan identifiers
5serialized result shapes
6public API request/response models
7shared domain enums/unions
8Valibot schemas used across boundaries

packages/contracts must not import:

text
1Elysia
2Drizzle
3Cloudflare bindings
4application handlers
5UI code

Elysia consumes contracts.

Contracts do not know Elysia exists.

Prefer plain TypeScript where runtime validation is unnecessary.

Do not create parallel copies of the same model in web/api/database packages.

Contracts must not import database implementation details.


Upload engine

Uppy Core

Use:

text
1@uppy/core
2@uppy/aws-s3
3@uppy/drop-target

Uppy owns browser-side upload mechanics.

We own the UI.

Do not use Uppy Dashboard as the product UI.

Build the Solid interface ourselves and bridge Uppy events into Solid state.

Do not split upload ownership between Uppy and a second dropzone/file-upload framework.


Direct client → R2

Actual file bytes should normally travel:

text
1Web / future Desktop
2 │
3 └──────────────→ Cloudflare R2
4 signed upload

Not:

text
1Client → Worker → R2

Workers are the control plane.

R2 is the data plane.

The API authenticates the user, authorizes the operation, owns transfer metadata, and signs storage operations.

The Worker should never proxy hundreds of gigabytes unnecessarily.


Multipart uploads

Large files use R2's S3-compatible multipart API through Uppy.

The upload implementation must support:

  • multipart upload creation
  • dynamic chunk sizing
  • lazy signing
  • retries with backoff
  • ListParts
  • resume reconciliation
  • multipart completion
  • abort
  • persisted upload identity
  • progress
  • throughput
  • ETA
  • refresh recovery
  • network interruption recovery

Chunk sizing must respect the 10,000-part limit.

Do not return to the v1 pattern of hard-coded 25 MB pieces and pre-signing every URL at the beginning.


packages/upload-core

packages/upload-core contains transfer behavior that should not care about Solid.

It owns concepts such as:

text
1UploadSession
2UploadState
3UploadProgress
4MultipartPlan
5RetryPolicy
6UploadError

It must not import:

text
1solid-js
2Elysia
3Drizzle
4Cloudflare bindings
5UI components

Browser-specific behavior belongs in apps/web.

The reason this boundary exists is simple:

A future desktop client should be able to reuse the transfer model without importing the website.

Do not invent plugin systems or transport abstractions before they are needed.


API

Elysia

Use Elysia for the canonical HTTP API.

The API runs on a dedicated Cloudflare Worker.

The web application is a client of that API.

Do not mount Tranzfer's canonical domain API inside the Solid web application.

Elysia owns:

  • routing
  • HTTP validation
  • authentication integration
  • request/response boundaries
  • API composition
  • OpenAPI generation

Elysia does not own the domain.

Business behavior should remain understandable independently of the HTTP framework.

This is especially important because Cloudflare support is a transport/runtime concern, not something that should infect every package.

Use explicit request and response schemas for meaningful API boundaries.

Do not depend on filesystem-based type-generation features that are incompatible with the Worker runtime.


Eden

Use Eden Treaty for first-party TypeScript API clients.

The Elysia application type is the canonical typed HTTP surface for:

text
1web
2future desktop app
3internal TypeScript tooling

Conceptually:

text
1Solid Web ───┐
2 ├── Eden ──► Elysia API
3Desktop ─────┘

Do not introduce a second RPC framework.

Do not add oRPC merely to recreate a contract boundary that Elysia + explicit portable schemas + Eden already provide.

For future non-TypeScript clients, OpenAPI is the interoperability boundary.


Expected errors

Better Result

Use better-result for expected domain failures.

Expected failure is data.

Examples:

text
1TransferExpired
2TransferNotFound
3UploadAlreadyCompleted
4QuotaExceeded
5InvalidMultipartState
6SubscriptionRequired
7PermissionDenied

Prefer:

ts
1Result<T, TransferError>;

over throwing exceptions for normal business outcomes.

Errors should generally be tagged:

ts
1type TransferError =
2 | {
3 _tag: "TransferExpired";
4 transferId: string;
5 }
6 | {
7 _tag: "QuotaExceeded";
8 limit: number;
9 requested: number;
10 };

This combines naturally with dismatch.

Use exceptions for genuinely exceptional programmer/runtime failures.

Do not write Java-style try/catch soup.


Database

Cloudflare D1

Use Cloudflare D1 for relational application state.

D1 stores metadata.

R2 stores files.

Never store large file payloads in D1.


Drizzle ORM

Use Drizzle ORM with D1.

Drizzle owns:

  • schema definitions
  • migrations
  • typed queries
  • relational persistence

packages/db owns database details.

The rest of the application should not scatter raw SQL and table imports everywhere.

Prefer explicit queries over giant repository abstraction layers.

Do not create BaseRepository<T>.

Do not build an ORM on top of the ORM.


IDs

Use standard generated identifiers unless the product demonstrates a reason for something more exotic.

Prefer platform primitives such as:

ts
1crypto.randomUUID();

No TypeID dependency.


Authentication

Better Auth

Use Better Auth.

Keep authentication centralized in the API boundary.

Google OAuth is the initial sign-in path.

Authentication and authorization are different concerns.

Better Auth establishes identity.

Tranzfer decides whether that identity can:

text
1create a transfer
2view a transfer
3manage a workspace
4extend retention
5access billing
6download private content

Do not scatter authorization logic through UI components.


Billing

Polar

Use Polar as Merchant of Record.

Polar owns:

  • checkout
  • subscription billing
  • international payment handling
  • customer-side sales tax / VAT handling
  • subscription lifecycle events

Tranzfer owns:

  • entitlement state
  • feature access
  • plan limits
  • reconciliation with Polar webhooks

Initial product tiers:

text
1Free
2Pro
3Studio
4Enterprise

The product meaning is roughly:

text
1Free prove Tranzfer works
2Pro move huge files reliably
3Studio run a recurring production workflow
4Enterprise negotiated security/admin/compliance needs

Do not encode pricing assumptions deep into business logic.

Use stable internal plan identifiers and map external Polar product IDs through configuration.

Do not turn the pricing model into a matrix of tiny add-ons before customers demand it.


Transfer security and abuse

R2 transfer objects are private.

Download access is mediated by transfer state and authorization.

Initial product constraints:

  • Google OAuth through Better Auth
  • Turnstile on abuse-sensitive entry points
  • production-media / creative-file allowlist
  • no archives
  • no executables
  • validate actual file signatures rather than trusting extensions alone
  • short default retention
  • download-fanout anomaly detection
  • abuse-report path
  • admin transfer disable/delete controls

Tranzfer is delivery infrastructure, not public file hosting.

Do not build a giant moderation platform before real abuse requires it.


Cloudflare

Cloudflare is the deployment platform.

Use:

text
1Workers
2D1
3R2
4Queues where materially useful
5KV where genuinely appropriate
6Cron / scheduled Workers
7Service Bindings
8Turnstile

apps/api

Dedicated Elysia API Worker.

Responsibilities include:

text
1auth
2transfer lifecycle
3multipart signing
4download authorization
5deliveries / inbox
6billing/webhooks
7workspace operations
8future device registration

The API is a modular monolith.

Do not split auth, transfers, billing, inbox, and devices into separate services just because they could be separate Workers.


apps/maintenance

Small scheduled/queue consumer Worker for asynchronous maintenance.

Examples:

text
1expire transfers
2delete expired R2 objects
3abort stale multipart uploads
4reconcile stale database state
5process lightweight asynchronous inspection jobs

Do not turn this into a generic job framework.


R2

Use R2 Standard storage for transfer payloads.

Files are temporary.

Default product behavior should encourage short retention.

Uploads and downloads should normally bypass Worker compute and communicate directly with R2 using scoped signed operations.


Queues

Use Cloudflare Queues when asynchronous work genuinely benefits from decoupling.

Examples may include:

text
1R2 event follow-up
2lightweight media inspection
3moderation pipeline triggers
4cleanup/reconciliation work
5notification fanout

Do not use Queues merely because event-driven architecture sounds sophisticated.


KV

KV is optional.

Only use it for workloads that fit KV semantics:

text
1short-lived cache
2rate-limit metadata
3small globally-read configuration

Do not use KV as a relational database.


Infrastructure

Alchemy v2

Use Alchemy v2 to define Cloudflare infrastructure in TypeScript.

Pin the exact Alchemy v2 version. Current pin: alchemy@2.0.0-beta.77.

Alchemy v2's stack file is an Effect program. Effect is installed only in infra for that. Application packages do not import Effect.

Alchemy is the authoritative source of Cloudflare infrastructure state. apps/api/wrangler.jsonc is a local debug entry (wrangler dev / Portless) with simulated D1 and R2, not a second production source of truth.

The tranzfer stack in infra/alchemy.run.ts currently creates:

text
1D1 database App
2R2 bucket Files (private)
3API Worker tranzfer-api (D1 + R2 bindings)
4web Worker tranzfer-web (service binding API, SESSION_SECRET, custom domain tranzfer.app)
5workers.dev URLs

Queues, KV, scheduled triggers, and R2 CORS wait until a product path needs them.

Do not maintain overlapping infrastructure truth in Wrangler configuration.

Wrangler may still be used for:

text
1local debugging
2diagnostics
3manual inspection
4emergency operations

but it is not a second infrastructure-management system.

Before applying infrastructure changes:

  1. typecheck
  2. run the Alchemy plan
  3. inspect creates/replacements/deletions
  4. do not apply destructive replacements casually

Do not casually rename stable Alchemy resource IDs.

Keep infrastructure readable.

Do not create a generalized internal cloud platform.


Tooling

TypeScript

Strict TypeScript.

Avoid any.

Avoid type gymnastics whose only achievement is impressing TypeScript.

Types exist to make changes safer and behavior easier to understand.


oxlint

Use oxlint for linting.

Fast feedback matters heavily in an agent-driven repository.

Lint violations should fail CI.


oxfmt

Use oxfmt for formatting.

One formatter.

One format.

No discussions.


Testing

No Playwright initially.

The highest-value tests for Tranzfer are not screenshot tests.

Prioritize:

text
1upload-core state tests
2multipart planning tests
3retry behavior
4result/error serialization
5authorization tests
6D1 integration tests
7Worker/API integration tests
8cleanup behavior
9upload reconciliation

Use Cloudflare's Worker testing environment where real Worker/D1/R2 behavior matters.

The real product also requires torture testing that intentionally destroys uploads:

text
110 GB
2100 GB
3350 GB
4
5disconnect network
6expire signed URL
7refresh page
8close page
9sleep machine
10restart router
11fail multipart part
12resume later

Those tests matter more than whether a login button is four pixels too far left.

Browser automation can be introduced later if it begins paying for itself.


Things intentionally not in the stack

Do not add these without a concrete new requirement:

text
1React
2Astro
3Next.js
4SolidStart
5
6TypeBox
7TypeID
8Playwright
9
10TanStack Query
11TanStack Table
12
13Corvu
14additional UI ecosystems
15
16oRPC
17GraphQL
18
19Redux
20Zustand
21
22Kafka
23Redis
24
25Turborepo
26Nx
27
28Effect
29
30microservices
31generic event buses
32dependency injection frameworks
33internal plugin architectures

TanStack Form is intentionally present because forms are a generic state machine we do not want to reimplement.

"No microservices" means:

Do not decompose the Tranzfer domain into independently owned network services without a demonstrated need.

The web Worker, API Worker, and maintenance/queue consumer are deployment boundaries, not permission to build a service mesh.

This list is not ideological.

It exists because every dependency and abstraction increases the amount of context a human or coding agent has to understand.

The repository should remain small enough to fit inside somebody's head.


The architecture in one picture

text
1 ┌───────────────────┐
2 │ Polar │
3 └─────────┬─────────┘
4 │ webhooks
5 ▼
6
7┌──────────────┐ Eden ┌────────────────────┐
8│ ├─────────►│ │
9│ Solid 2 Web │ │ Elysia API │
10│ │ │ Worker │
11└──────┬───────┘ │ │
12 │ ├────────┬───────────┤
13 │ │ │
14 │ signed multipart │ ▼
15 │ │ D1
16 │ │
17 ▼ └──────► Better Auth
18┌──────────────┐
19│ R2 │
20│ huge bytes │
21└──────────────┘
22 ▲
23 │
24 │ cleanup / reconciliation / queue work
25 │
26┌───────┴────────────┐
27│ Maintenance Worker│
28└────────────────────┘
29
30Future, after validation:
31
32┌──────────────────┐
33│ Desktop / Electron│
34└────────┬─────────┘
35 │ Eden
36 ├──────────────► Elysia API Worker
37 │
38 └─ signed bytes ► R2

The most important line in this entire document is:

Client → R2 for bytes. The API is the control plane.

Everything else exists to make that transfer safe, resumable, understandable, and billable.


Installed versions (foundation)

Pins as of the stack-deps change. Bump them on purpose, not by floating ranges.

PackageWhereVersion
solid-js, @solidjs/web, @solidjs/diagnosticsweb2.0.0-rc.6
@solidjs/routerweb2.0.0-next.21
@solidjs/vite-pluginweb3.0.0-next.38
elysiaapi1.4.30
@elysiajs/edenweb1.4.9
alchemyinfra2.0.0-beta.77
effectinfra4.0.0-rc.112
wranglerweb, api4.131.1
@cloudflare/vite-pluginweb1.54.8
tailwindcss, @tailwindcss/viteweb4.3.3
@tanstack/solid-formweb1.33.5
@uppy/coreweb6.0.1
@uppy/aws-s3web6.1.0
@uppy/drop-targetweb5.0.0
dismatchweb2.6.0
better-resultweb, api3.0.1
valibotweb, api1.4.2
better-authapi1.7.4
drizzle-ormapi0.45.2
drizzle-kitapi0.31.10
@polar-sh/sdkapi0.49.0
portlessroot0.15.6

Departures from the rest of this document

Kobalte 2 is not installed. @kobalte/core on npm is still 0.13.x (Solid 1). The Solid 2 port is an unreleased PR. Native HTML until a Solid 2 Kobalte ships.

TanStack Solid Form is installed and unused. @tanstack/solid-store@0.11.1 (a dependency of the form package) still wants solid-js@^1.6.0. Do not import the form helpers until that peer is Solid 2.

Better Auth's optional solid-js@^1 peer is ignored. Auth stays on the API Worker. We are not using a Solid auth UI adapter.

Effect exists only in infra. Alchemy v2's stack file is an Effect program. Application code does not import it. Pinned to 4.0.0-rc.112 because that is alchemy@2.0.0-beta.77's floor (Config.string, Config.redacted, Flag.string). Newer Effect 4 RCs dropped those names.

Solid Primitives were not installed. The list in this document is still à la carte, not a shopping list.

No packages/contracts, packages/db, or packages/upload-core yet. Those folders wait for code that actually has to live there.


Decision rule

When considering a new library, package, abstraction, service, or architectural layer, ask:

Does this make the next 350 GB transfer more reliable, make the code materially easier to maintain, or help us acquire/serve paying customers?

If the answer is no:

don't fucking add it.