@saastemly/voidcommerce
v0.24.0
Published
Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.
Readme
voidcommerce
Void, with a shop in it.
vc init # void init, then a form for the shop
vc --help # void's help, with vc's commands merged in
vc dev # everything else is void's, verbatimvc extends void; it replaces nothing
Every command vc does not name goes to void untouched — same arguments,
same terminal, same exit code. The named ones run void's version and add vc's
part after it:
| | void's part | then vc's |
|---|---|---|
| vc init | void init, when there is no Void app here yet | the shop form |
| vc --help | void --help | a shop group merged into the Commands box |
| vc init --help | void init --help | a second box, vc init |
| vc --version | void's version | vc's |
| vc dev, vc build, vc preview | — void has no such commands | the app's own script, where the app is |
vc init --github and void's other partial modes are void's alone. vc help
db execute is void's. When void is not installed, vc says so and stops —
except for help, which it prints alone and says why. dev, build and
preview are the scripts void init writes into the app's package.json;
vc only decides where they run — api/ from a monorepo root, .vc/app at
a strict root.
The first question: one app, or api + frontend
| | where it runs | what vc init does |
|---|---|---|
| One app | one Void app on Cloudflare Workers at <domain> and www — the API and the generated storefront and panel together | void init here, then the form |
| Monorepo | api/ on Workers at api.<domain> with the panel; frontend/ a static Void site on GitHub Pages at <domain> | void init in api/ (the D1 starter), void init in frontend/ (Static Pages), then the form |
| Strict (experimental) | api.<domain> on Workers; the storefront on <domain> | no void init at all: the repository root IS the storefront, and the worker is generated under .vc/app |
The layout decides what the domain means, so it is asked before anything
else, and it cannot change after init — moving files is not a regeneration.
vc init --layout app|monorepo|strict skips the question for scripts.
The domain need not be a registrable apex. shop.example.com is a shop inside
the zone example.com, and the two are different questions: the domain is
where the shop answers, the zone is where records go and which zone Cloudflare
needs for the worker's custom domain. The wizard asks for the zone only when
the domain is not one. A subdomain has no www, so it does not get a route
nobody would type.
Every layout puts the worker on a Cloudflare custom domain, which Cloudflare creates in its own zone — so the zone must be on Cloudflare, and DNS at Simply is refused with that reason rather than generating a shop with no hostname. Where Cloudflare also does the mail, there are no records left for the DNS plugin to write and the shop needs no DNS token at all.
In a monorepo the Pages host (<owner>.github.io) is required, the frontend
gets the custom storefront kit, and a workflow publishes it: every push builds
frontend/, prerenders it, and force-pushes the tree to a static branch with
the shop's domain in CNAME. Pages serves that branch from /.
Strict: a monorepo turned inside out
A monorepo has api/ and frontend/ side by side. Strict has the same two
halves and the same hosting, but only one of them is a directory you can see:
voidcommerce.json the answers — the worker is generated from this
package.json ONE package.json, shared by the storefront and the worker
pages/ lib/ the STOREFRONT. Yours, and what the repository looks like
vite.config.ts its Vite config; void.json sets output: "static"
data/ the catalogue, pushed by vc import
content/ faqs.json and posts.json — launch content, snapshotted in
branding/ static assets, copied to public/branding/
migrations/ what production has already applied — see below
patches/ a void fix that has not shipped upstream
.env local values, shared by both halves
.vc/app/ the WORKER. Generated, gitignored, nobody's to editSo the repository is, in technicality, a frontend app that builds a backend
app — and in practice a monorepo without the ceremony of being one. What you
edit is the shop front; the API, the panel, the cron, the queue and the live
stream are generated underneath it from the manifest and rewritten by
vc generate. A hand edit to a generated app drifts from the thing it exists
to describe, and strict is the mode that refuses to let it. If you need a file
the manifest cannot produce, that is the signal to use the one-app layout.
Two branches come out of one push:
| branch | what is on it | who serves it |
|---|---|---|
| void-dist | the worker, as a self-contained Void app | Cloudflare Workers Builds |
| frontend-static | the prerendered storefront | GitHub Pages, or Cloudflare Pages |
Locally, bun run dev is the shop — the worker with its panel — and
bun run dev:storefront is the shop front, which calls it at
http://localhost:5173. Start the worker first; the storefront takes the next
free port.
At a strict root every Void command runs in the artifact: vc dev is
void dev in .vc/app, generated first if it is not there. vc generate
also runs void prepare and void db generate, so the app typechecks and
carries its migrations. Migrations are the one artifact that must be
committed: regenerated on a fresh clone they would get new names, and a deploy
would try to apply them twice — so they live in migrations/ at the root and
the app reaches them through a symlink.
Strict pins void to the version its vendored patch applies to. A strict
project has been generated, typechecked and production-built against real
packages; it has not yet been served or deployed end to end.
Push, and it is live
With everything on Cloudflare, a shop can reach production without a single credential in CI:
main ──push──▶ GitHub Actions: vc dist ──▶ void-dist branch ──▶ Cloudflare Workers Builds ──▶ livemain is the manifest and the data. The workflow regenerates the app and
force-pushes a self-contained Void app to void-dist; Cloudflare builds that
branch and deploys it, so it never needs to know voidcommerce exists. The
generated DEPLOY.md lists the one-time setup, including the one genuinely
non-obvious part: the API token Cloudflare generates for a build has no D1
permission, so a build that applies migrations needs a token you make.
Email is the same story. Cloudflare Email Service is reached through a Worker binding rather than an API key, and onboarding the sending domain writes its own SPF, DKIM and DMARC records — so mail costs no secret and no DNS work. Sending to real customers needs a Workers Paid plan.
Deploying
vc preflight # is the shop ready to advertise on?
vc deploy # preflight, then void deploy — the Void platform
vc deploy --cloudflare --provision # first time: your own Cloudflare account, with wrangler
vc deploy --cloudflare # every time aftervc deploy extends void deploy: vc's preflight runs first, then void's
deploy exactly as you typed it, with void's login. The Void platform is in
private beta; the pass-through is there for when it is not.
--cloudflare is the other road, and needs no Void account at all. It is
Void's documented manual path, automated with wrangler:
- wrangler is installed and logged in (
wrangler login, orCLOUDFLARE_API_TOKEN). When wrangler sees exactly one account, vc pins it inwrangler.jsoncfor you; with several, it asks you to choose. - Preflight. Every required key is either a worker secret
(
wrangler secret put) or a committed plaintext value; the worker's hostnames are the shop's. Not ready means no deploy, unless--force. On a first deploy the worker does not exist yet, so its secrets cannot be listed: preflight prints thewrangler secret putlines, and running them creates the draft worker. --provisioncreates the D1 database and the queue, and records them inwrangler.jsoncandvoidcommerce.json— so a regenerated strict artifact carries the real ids.- Build, then scrub. Void's build bakes every
.env*value into the worker'svarsas plaintext — a dev cron secret,unsetfor each credential — and a var shadows the secret of the same name. Every secret-class key and everyunsetis removed from the emitted config before it is uploaded; the worker reads those from its secrets. - Migrate the remote D1 from the committed migrations, then
wrangler deployexactly the config that was scrubbed.
At a monorepo root vc deploy deploys api/; the frontend deploys itself
from GitHub Actions on push. At a strict root it deploys the artifact.
The Cloudflare road has been exercised against a fake wrangler that answers
like the real one, and its wrangler deploy invocation dry-run against a real
build; it has not yet put a shop on a real account.
What vc init asks
void init asks three questions, which is right for Void — a Void app can be
anything. A shop cannot be anything: it has a payment rail or it does not
trade, it has email or nobody can sign in, it has a tax rate or it sells
unlawfully. So vc init asks about all of it, in the order a person thinks
about it:
| | you choose | |---|---| | Who signs in | magic link, email code, passkeys, Google/Apple/GitHub, password, username, phone, guest | | Account features | every official Better Auth plugin — 2FA, organizations, API keys, JWT, multi-session, … | | Catalogue | custom fields, personalization, inventory, bundles, price lists, translations, search | | Selling | channels, discounts, gift cards, loyalty, subscriptions, draft orders, returns, wishlists, reviews, customer groups, agentic commerce | | Operations | webhooks, workflows, files, marketplace | | Payment rail | Adyen or Stripe — one, never both | | Tax | declared rates, TaxJar, Avalara | | Carriers | Shippo, EasyPost, UPS, FedEx, DHL, USPS, Royal Mail, Aramex, DoorDash, Stuart | | ERP | Business Central | | Email | Cloudflare Email Service (a Worker binding, no API key) or Resend | | DNS, content | Cloudflare or Simply; blog and FAQ | | Interfaces | the generated storefront and panel (better-admin-ui, standard theming, one line to turn off), or the custom storefront kit (better-auth-ui + better-commerce-ui, hosted in your own app) |
Every choice carries a one-line hint written for the person choosing — "teams that share a shop account — a club ordering trophies as one buyer" — not a plugin name. Required choices are shown so you know what you are getting; recommended ones are ticked, because a shop that starts with reviews off has to remember to turn them on and never does.
The manifest
Answers are written to voidcommerce.json, and everything else is
generated from it. Run vc init again to change your mind: the answers are
pre-filled, regenerated files are rewritten, and files you own are kept.
That is the difference between a scaffolder and a generator. A wizard that
writes auth.ts once and forgets what it asked is useless on day thirty when
you want subscriptions and cannot remember which of forty checkboxes you
ticked.
What gets generated
voidcommerce.json the answers
auth.ts the plugin ring — every choice, with the hint as a comment
env.ts every key REQUIRED, none optional, none defaulted
.env.example with `unset` for each secret
.env.production plaintext values only
lib/deploy/requirements.ts what breaks without each key — read by `vc preflight`
wrangler.jsonc the worker's custom domain, api.<domain>And, written once then yours: .env, lib/domain.ts, lib/payment.ts,
lib/notifications.ts, lib/erp.ts. They are what a shop edits when it
grows past the wizard, so they are never overwritten.
A monorepo puts all of that under api/, and adds:
package.json workspaces: api, frontend; dev:api, dev:frontend, deploy …
.github/workflows/frontend-pages.yml build frontend/, publish to the static branch, CNAME
frontend/lib/api.ts the one client: better-auth/react + commerceClient (yours)
frontend/.env VITE_API_ORIGIN=http://localhost:5173 (yours)
frontend/.env.production VITE_API_ORIGIN=https://api.<domain>
frontend/void.json target: node, output: static — merged into void'sWhy the files are plain
Void's gen has no extensibility and its directory set is closed — it will
not scan a plugins/ folder. So vc writes ordinary Void files a reader can
see, the way rails generate writes into files you own, rather than hiding
registrations in a virtual module. A vc project is a valid Void project with
nothing magic in it, which is what would let Void absorb these generators one
day as a copy rather than a negotiation.
The rules the wizard enforces
- Nothing is optional. If a shop supports an integration, the deployment
sets it up.
env.tshas no.optional()and no defaults — a default is compiled into the worker's vars and shadows the real secret. - One integration per job. Adyen or Stripe, Cloudflare or Simply. Carrying both means every reader works out which half is live.
- DNS at Simply is refused when the API is on
api.<domain>. A Worker custom domain needs the zone on Cloudflare; the wizard says so rather than generating a configuration that resolves and then errors for every visitor. - The country's VAT is declared so a fresh deploy is never tax-free. For a
country the wizard does not know, it writes
TODO VERIFYrather than a guess. - The worker carries no catalogue.
catalogImportis registered with no source;vc importpushes fromdata/catalog.json. - Passwords are explicitly off unless chosen, because Better Auth's default is off and a version bump could flip it.
The generated shop typechecks clean against the real packages.
