Workspace monorepo: the spindlework framework (packages/) plus an example Cloudflare Worker app (examples/tenet) using Hono with server-side JSX (hono/jsx). MVC architecture.
Run in the example app (examples/tenet), either from the workspace root with --workspace tenet or from inside the example directory:
| Task | Command |
|---|---|
| Dev server | npm run dev --workspace tenet |
| Deploy | npm run build --workspace tenet && wrangler deploy |
| Build (for preview) | npm run build --workspace tenet |
| Preview build | npm run preview --workspace tenet |
| Generate Cloudflare binding types | npm run cf-typegen --workspace tenet |
| Build the framework package | npm run build --workspace spindlework |
No linter or formatter yet.
The CSS build is automatic with full HMR support:
-
Development (
npm run dev):- CSS builds once at startup
- Vite watches
src/views/tokens/**/*.css,src/views/elements/**/*.css,src/views/components/**/*.module.css, andsrc/views/routes/**/*.module.css - On save: CSS rebuilds + Layout HMR updates instantly
-
Production build (
npm run build): CSS builds, then Vite bundles -
Deploy (
npm run deploy): CSS builds → Vite builds → Wrangler deploys
- Entry point:
src/index.tsx— creates theHonoapp, mounts controllers, and defines the root route directly. - Controllers (
src/views/routes/*/controller.tsx): Each controller extendsControllerBasefromspindlework/thread, sets an overridebasestring (the route prefix), declares routes via decorators, and callsconfigureRendering({ layout, handleError })in the constructor to wire up the shared layout and error handler. - API Controllers (
src/views/routes/*/controller.api.tsx): Same pattern but return JSON. Share business logic via services. - Views (
src/views/routes/*/views/): Top-level page components usingFCfromhono/jsxwith typed ViewModel. - Components (
src/views/components/): Reusable UI pieces. UseuseHandler()fromspindlework/fabricfor client-side handler wiring. - Services (
src/domains/*/service.ts): Business logic layer shared between HTML and API controllers. ExtendServiceBasefromspindlework/thread. - Repositories (
src/domains/*/repo.ts): Data access layer. ExtendRepositoryBase<T, QueryMap>fromspindlework/thread. D1Database is injected via constructor; repos are created per-request using factory functions (e.g.,tenetsRepo(db)). Inherit generic CRUD (findById,findAll,create,update,delete,count) and dynamic finders (findOneBy,findAllBy,existsBy,deleteBy). Stored queries are declared inprocs.ts(thespindlework/fiberSQL DSL) and compiled byfiberPlugininto a typedProcMap— repos invoke them via typedqueryOne/queryAll/executewith@namebinding. See Repository Security below for validation details. - Models (
src/domains/*/model.ts): Row types matching D1 table columns. - Schema (
src/domains/schema.ts): The D1 schema source of truth, declared with the TS DSL (defineSchema/table/col/indexfromspindlework/fiber).fiberPlugingeneratessrc/domains/db-types.d.ts(model interfaces), a derivedsrc/migrations/schema.sql(gitignored), and the runtime modulesrc/.generated/schema.ts. On first requestapplySchema()(spindlework/fiber) reconciles the live D1 DB against the desired state — adding columns in place, rebuilding tables for non-additive changes, syncing indexes..sqlquery files are unaffected. - Dev seed (
src/domains/seed.ts): Declarative dev-database data via thespindlework/fiberseed DSL (defineSeed/generate/rows+fake/pick/ref/seq). Columns not overridden are inferred from the schema (PKs sequence, uniques stay unique, FKs sample the referenced table, CHECK columns pick from their enums, DEFAULT columns are left to the DB).fiberPlugincompiles the spec — the faker library (a devDependency) runs only in the plugin — into a pure-data modulesrc/.generated/seed.ts;applySeed()(spindlework/fiber) sows it on DEV boot, re-sowing only when the spec's hash changes or a seeded table is empty. Runtime never imports faker. - Stored queries (
src/domains/*/procs.ts): TypeScript-authored actions & lookups via thespindlework/fiberSQL DSL (def/lookup/action/param/sql).fiberPlugincompiles eachprocs.tsonce into a static SQL module (procs.generated.ts) with a typedProcMap, deriving result/parameter types from the schema singleton — projections liket.*expand to the model interface, aliased columns inherit their type (enums, nullability), and params infer from the columns they bind. Repos execute them via the existingqueryOne/queryAll/executehelpers. - Requests (
src/views/routes/*/requests/): IValidatable form objects withvalidate()method. - Framework (
packages/): Reusable framework code shipped as thespindleworknpm package, imported viaspindlework/*subpath exports (spindlework/thread,spindlework/fiber,spindlework/fabric,spindlework/plugins). ContainsControllerBase,RepositoryBase,ServiceBase,BaseHandler, validation decorators, error classes, and utilities. - Client entry (
src/.generated/client-entry.ts): Auto-generated byfabricPlugin(see below). It imports the generated handler registry and re-exportshydrate/hydrateEvent. Loaded by the Layout and referenced by inline hydration scripts. - Error handler (
src/error-handler.tsx): Project-specific error handling that mapsAppErrorsubclasses to HTML responses. Imported by controllers viaconfigureRendering().
Routes are declared with decorators from ControllerBase:
import { Get, Post, Delete, ControllerBase } from "spindlework/thread";
class MyController extends ControllerBase {
override base = "api";
@Get("/") // GET /api
@Get("/users") // GET /api/users
@Post("/users") // POST /api/users
@Get("/users/:id") // GET /api/users/:id
@Delete("/users") // DELETE /api/users
}- Any valid Hono path works (
:id,*wildcards, nested segments, etc.). - The full route is
base+ decoratorpath.
Use validation/guard decorators to handle cross-cutting concerns before route handlers:
import { Exists, Validate } from "spindlework/thread";
import { parseRequestBody } from "spindlework/thread";
import { ProposeTenetRequest } from "../../domains/requests/ProposeTenetRequest";
class TenetsController extends ControllerBase {
@Get("/:slug")
@Exists("tenet", (c) => tenetsRepo(c.env.DB).findOneBy({ slug: c.req.param("slug")! }))
async show(c: Context) {
const tenet = c.get("tenet"); // loaded by @Exists, throws 404 if missing
// ...
}
@Post("/")
@Validate(ProposeTenetRequest)
async create(c: Context) {
const input = parseRequestBody(c) as ProposeTenetRequest; // already validated
// ...
}
}Body parsing:
@Validatereads the request body parsed by theparseBody()middleware (spindlework/thread). Mount it globally (app.use("*", parseBody())) or per controller. It parses JSON as-is and unflattens form bodies into nested objects/arrays, storing the result on the context underBODY_KEY. Handlers read it viaparseRequestBody(c)orc.get(BODY_KEY)— never callc.req.json()/c.req.parseBody()directly.
RepositoryBase includes built-in validation to prevent SQL injection:
All methods that build SQL from object keys (create, update, findOneBy, findAllBy, existsBy, deleteBy) validate column names using validateColumnName():
// Safe — valid column names
await repo.findOneBy({ slug: "my-tenet" });
await repo.create({ title: "New Tenet", status: "draft" });
// Unsafe — throws Error: Unsafe column name: "123abc"
await repo.findOneBy({ "123abc": "value" } as any);Column names must match /^[a-zA-Z_]\w*$/ (start with letter/underscore, then alphanumeric/underscore).
findAll({ orderBy }) validates the ORDER BY clause using validateOrderBy():
// Safe — valid ORDER BY
await repo.findAll({ orderBy: "created_at DESC" });
await repo.findAll({ orderBy: "status, title ASC" });
// Unsafe — throws Error: Unsafe ORDER BY clause
await repo.findAll({ orderBy: "id; DROP TABLE users" });Only allows column names, commas, spaces, and ASC/DESC keywords. SQL keywords like UNION, SELECT, DROP are rejected.
Dynamic finders handle null values correctly using IS NULL (since col = NULL is always false in SQL):
// Finds users where avatar_url IS NULL
await usersRepo(db).findOneBy({ avatar_url: null });
// Generates: WHERE avatar_url IS NULL AND login = ?
await usersRepo(db).findOneBy({ avatar_url: null, login: "alice" });Dynamic finders throw on empty criteria to prevent accidental full-table operations:
// Throws: Empty criteria is not allowed. Use findAll() for unfiltered queries.
await repo.findOneBy({});
await repo.deleteBy({});Never write data-controller, data-action, or hydration scripts by hand. Always use the useHandler() component factory from spindlework/fabric. It renders the element, generates a unique id, and emits an inline <script type="module"> that hydrates the handler onto that element — the handler ships with its HTML, and no handler name appears in the markup.
import { useHandler } from "spindlework/fabric";
import { AddOptionHandler } from "views/handlers/AddOptionHandler";
const AddOption = useHandler(AddOptionHandler);
// Wrapper + Trigger — handler scoped to a container
<AddOption start="2">
<div data-option-container>{content}</div>
<AddOption.Trigger event="click" method="add">
<button>Add</button>
</AddOption.Trigger>
</AddOption>
// Trigger-only — handler lives on the element itself
const Confirm = useHandler(ConfirmHandler);
<Confirm.Trigger event="click" method="ask" message="Are you sure?">
<button class="primary">Approve</button>
</Confirm.Trigger>Extra props passed to Wrapper or Trigger are automatically converted to data-{key} attributes on the element (read in the handler via this.data("key")). Known HTML attributes (class, style, data-*, aria-*, ...) pass through unchanged. The handler name is derived from the class name and used only internally to key the client registry — you never write it.
Handlers extend BaseHandler. The name is derived from the class name (DismissHandler → "dismiss") at runtime — no decorator or static declaration is needed:
import { BaseHandler } from "spindlework/fabric";
export class DismissHandler extends BaseHandler {
override connect(): void {
// Called when the handler is hydrated onto its element
}
hide(): void {
// Called when data-action="click->hide" fires
}
}New client handlers are auto-registered by fabricPlugin at build
time. The plugin glob-discovers any file under src/views/handlers/ whose
name ends in *Handler.ts, generates a registration module
(src/.generated/handlers.ts) and a client entry module
(src/.generated/client-entry.ts, which imports the handlers and re-exports
the framework's hydrate/hydrateEvent). There is no manual register(...)
step and no hand-written client entry file — just drop a new *Handler.ts
file in the handlers directory and it will be picked up automatically.
Controllers wrap every route response in a layout automatically via configureRendering():
import { ControllerBase, Get } from "spindlework/thread";
import { Layout } from "views/routes/Shared/Layout";
import { handleError } from "error-handler";
class MyController extends ControllerBase {
override base = "my";
constructor() {
super();
this.configureRendering({ layout: Layout, handleError });
}
}- Use
c.render(<View />)to render JSX wrapped in Layout - Controllers that need auth call
this._app.use("*", requireAuth())in their constructor - The Layout receives all values set via
c.set()(e.g.,user) pluscurrentPath
"type": "module"in package.json — always use ESM imports.noImplicitOverride: truein tsconfig —overridekeyword required on derived class members.wrangler.jsoncis gitignored (contains real D1/KV IDs). Usewrangler.jsonc.exampleas template.worker-configuration.d.tsis auto-generated bynpm run cf-typegen; do not hand-edit.- Decorators follow the Stage 3 TC39 proposal (not
experimentalDecorators).reflect-metadatais not used — metadata is stored viacontext.metadataand read back viaConstructor[Symbol.metadata]. ASymbol.metadatapolyfill is included inControllerBase.tsxfor environments that lack it. - Views use semantic HTML and rely on Pico CSS defaults for styling. Inline styles are prohibited.
Custom layouts use CSS Modules in the same directory (e.g.,
index.module.css,new.module.css). - Framework code lives in
packages/and is consumed via thespindleworkpackage subpath exports (spindlework/thread,spindlework/fiber,spindlework/fabric,spindlework/plugins). Application code lives insrc/(underexamples/tenet/) and imports framework code, never the reverse. - Build scripts have been replaced by Vite plugins (
fabricPlugin,threadPlugin,fiberPlugin), composed through the unifiedspindlePluginentry fromspindlework/plugins.