Spec: spec.md (snapshot of the shared doc). This file tracks what is built, what deviates, and what is left.
| Path | Contents |
|---|---|
src/ir.ts |
IR v1 types shared by compiler and runtime |
src/compiler/ |
compile(): HTML rewrite, includes, expression checking and codegen, helper manifest |
src/runtime/ |
store, scheduler, renderer, actions, request layer, config, helpers, security checks |
src/sse.ts, src/optimistic.ts, src/paginate.ts, src/persist.ts, src/cache.ts |
feature modules |
src/ws.ts |
WebSocket transport (imported by the app's config module, not a feature) |
src/vite.ts |
Vite adapter |
src/project.ts |
the app's helpers, config and types modules, found the same way by the plugin and the CLI; pages from the Vite config |
src/check/, src/cli.ts |
sinter check: shadow files, tsc, error mapping |
src/lsp/ |
sinter lsp: JSON-RPC, the workspace of projects and open buffers, the TypeScript bridge, quick fixes |
editors/vscode/ |
the VS Code extension: starts the project's sinter lsp |
src/compiler/diagnostic-docs.ts |
example, meaning and fix per code; docs/diagnostics.md is generated from it |
src/testing/ |
render, mockRequests, mockStream |
src/compiler/vocabulary.ts |
the attribute/element table; compiler, llms.txt and custom data derive from it |
test/ |
Vitest suites (happy-dom); test/compiler/golden/__out__ are file snapshots |
docs/recipes/ |
the nine llms.txt recipes, each with its executed test |
scripts/size.mjs |
min+gzip per module (npm run size) |
scripts/release.mjs |
bump versions, date the changelog, commit and tag (npm run release -- 0.3.0) |
- M0 scaffold: package, tsconfig, test runner
- M1 compiler: HTML parse/rewrite,
<sn-include>, attribute vocabulary, diagnostics - M2 expressions: parse, scope resolution, sandbox rules, dirty-path analysis, codegen
- M3 runtime core: store, scheduler, bindings, keyed lists (LIS moves), if/else, model, events, closed dialogs
- M4 request layer + actions: middleware, bodies, concurrency, then/catch, unhandled errors, 401
- M5 SSE sync module: change messages, session probe, backoff, missed-event refetch, bfcache
- M6 optimistic layers (actions and
app.optimistic()) - M7 pagination: keyset, offset, append
- M8 persist,
$url,$now,$locale, helpers and namespaces - M9 testing module
- M10 Vite plugin (transformIndexHtml, virtual page module, root-confined partials, full reload on change)
- M11 diagnostics fixture per code, golden outputs + IR schema check, property-based tests,
security corpus (URLs, CSS, injection, clobbering, pollution, origins), recipes with tests,
generated
llms.txtandhtml-custom-data.json - M12 size budget: measured, not met (see below)
- M13 real-browser suite (Playwright on Chromium, Firefox, WebKit; Trusted Types + strict CSP run)
(started:
e2e/transportsruns<sn-sync>over SSE, WebSocket and SignalR against real servers in headless Chrome)
- The compiler resolves every identifier statically and emits one function per
expression taking a scope object
$: loop variables are$.c, state is$.$s<n>.q, resources$.$r.contacts, magic names$.$urletc., store functions$.$f.patch. Scopes are prototype chains (page → state → row). - Dependency and write paths are strings like
$s0.draft.emails.{i}.value;{name}is filled from the row scope at runtime,*matches any segment. A binding re-runs when one of its paths and a dirty path are prefixes of each other. Row-local reads (loop items) re-run only when the item is replaced. - Resources hold base + optimistic layers; server events and
thenwrites go to the base; an optimistic action records ops into a layer, dropped on settle.
-
2026-11-04: spec imported, plan written.
-
2026-11-04: compiler, runtime, feature modules and testing module in; the spec's test example passes. 38 behaviour tests (rendering, models, lists, actions, errors, 401, SSE, optimistic overlaps, pagination).
-
Runtime rewritten as closures (private state minifies to short names); dev warnings, freezing and the outside-write detector moved to the
devfeature module, which the compiler adds to dev builds only. Lists and if blocks use one end-marker comment instead of recursive node bookkeeping. The size script now minifies fully (Vite's lib mode keeps whitespace in ES output, which had inflated earlier numbers). -
js-framework-benchmark entry added (
bench/js-framework-benchmark, results in benchmark.md); flush and reconcile optimized from ~1.2× to ~0.94× Svelte. Found while optimizing: index reads across lists (names[i]in a row of another list) missed structure changes; fixed with a test. -
RealWorld example (
examples/realworld), run against the official e2e suite: 130–133/140. Bugs it found in sinter, each fixed with a test: parenthesized expressions failed to parse; inlined partials shifted node paths (adjacent text merges in the browser); a load answered after a local write wiped that write (writes during a load are now replayed); mutations were cancelled by navigations (now sent withkeepalive). -
URL templates omit query parameters whose value is
null/undefined(spec addition, 2026-11-04). -
data-sn-pathon<html>(spec addition, decided 2026-11-04 for the RealWorld app): path parameters in$url, read-only, several patterns per page. Server rewrites are not generated yet; the compiler puts the patterns in the IR (ir.path) so the Vite plugin can later. -
Fixed: delegated listeners were only added for event types used by root elements, so handlers used only inside template rows never fired.
-
Vite plugin with a real build test; diagnostics fixtures for all 21 codes; golden outputs; property-based list and layer tests; security tests; recipes; generated docs. 129 tests pass, typecheck clean.
-
2026-11-05: pluggable stream transports.
configure({ transport })picks one per<sn-sync>URL (EventSource otherwise);@tiaforge-ui/sinter/wsis a WebSocket transport ({event, data}messages or adecodefunction; http(s) stream URLs are opened as ws(s), so the compiler's URL rules are unchanged); SignalR is anllms.txtrecipe with its adapter in the app. Transports now deliver parsed payloads (SignalR and WebSocket adapters parse themselves).render()falls back to the configured transport for stream URLs without a mock. -
2026-11-05: type checking, phase 2 of the adoption roadmap (
type=only; OpenAPI is phase 3).compile(…, { check: true })returns a check unit (expressions with positions and scope frames);src/check/writes one checked JS file per page to.sinter/check/and runs the project'stsc(TypeScript 7's nativetscworks; no in-process compiler API is needed). Newtypeattribute on<sn-resource>,SN208 type-error,SN504 untyped-resource,sinter checkCLI,sinter({ typecheck, types }). The untyped RealWorld example (377 expressions) checks with no errors; that is a test. -
2026-11-05: type checking, phase 3: OpenAPI.
sinter({ openapi })loads JSON/YAML specs (yamlis now a dependency, required only when a spec is configured);src/compiler/openapi.tsmatches resources to GET operations in every build (SN307,SN308,SN505);src/check/openapi-types.tsconverts schemas to TypeScript. RealWorld now uses the Conduit spec (examples/realworld/openapi.yml): 15 of 15 resources typed with no annotations, 0 errors (a test); a plantedarticle.titelreports "Property 'titel' does not exist on type 'Article'. Did you mean 'title'?". -
2026-11-05: type checking, last phase: actions match OpenAPI operations by method (
data-sn-bodyagainst the request schema, typed$result: 20 of RealWorld's 23thenhandlers),typeon<sn-event>(payload typed,item/setchecked against the resource,SN506 untyped-event),cursortyped as the resource's item,checkPage()for tests, and the "Typed resources, state and events" recipe. A value that is wrong as a whole is reported by TypeScript on the shadow'sreturn; that line now maps to the expression's start. -
2026-11-05: pre-launch fixes. The core's size budget is now 11 KB min+gzip (10.1 KB today); 5 KB was never met.
docs/diagnostics.mdis generated fromsrc/compiler/diagnostic-docs.ts(example, meaning and fix per code) and ships in the package; diagnostics link to its section on GitHub (github.com/tiaforge/sinter). -
2026-11-05: editor integration, phase 1:
sinter lsp(src/lsp/: JSON-RPC framing, aWorkspaceof projects, pages, partials and open buffers, the LSP glue). Compiler diagnostics as you type; partials are compiled through the pages that include them; Vite entries are compiled on load (about 0.4 s for RealWorld, mostly Vite's config resolution); file watching reloads the project when helpers, types, specs or the Vite config change.Diagnostic.spangives attribute- and element-level diagnostics their full range. Reading the Vite config moved fromcli.tstoviteProject()inproject.ts. Tests drive the server over in-memory streams (test/lsp/). -
2026-11-05: editor integration, phase 2:
src/lsp/typescript.tsstarts the project'stsc --lsp --stdio(TypeScript 7) per project, keeps one shadow per page open in it (written to.sinter/lsp/once so its tsconfig includes them, then changed in memory), pulls errors 300 ms after the last compile and maps them with TypeScript's range; hover maps the cursor into the shadow (shadowPosition()inshadow.ts) and the range back. Before TypeScript 7, thesinter checkpipeline runs on save (tested with a fake TypeScript 5 package).check()is split intowriteSupport,shadowOfandtypeDiagnosticso both share them. RealWorld over stdio: a plantedarticle.titelis reported 340 ms after the edit; a hover takes 6 ms. -
2026-11-05: editor integration, phase 3: completion (
completionItem/resolveforwarded to TypeScript), go to definition and the did-you-mean quick fix (src/lsp/quickfix.ts, which reads the suggestion from the message, so TypeScript's suggestions work too). In check mode the compiler keeps expressions with errors asbroken(written last in the shadow; their TypeScript errors dropped, also insinter check); state and loop frames and resources carry the location of their names, and the shadow maps its declarations there.src/lsp/spec-location.tsfinds a schema or property in the spec withyaml's source ranges. RealWorld over stdio: completion afterarticle.22 ms, definition oftitle→openapi.yml:56123 ms. -
2026-11-05: editor integration, phase 4: the VS Code extension (
editors/vscode, plain CommonJS,vscode-languageclient10.1.2, packaged with@vscode/vsce4.0.0 to a 694 KB.vsix). It starts thesinter lspof the nearest installation above each HTML file, one client per installation; middleware keeps a client away from files of a nested project. Itshtml-custom-data.jsonis kept current by the docs test.docs/editors.md: setup for VS Code, Neovim, Helix and Emacs. Tested with fakes forvscodeand the client (test/vscode/); not yet run inside VS Code itself. -
2026-11-05: data across page loads (roadmap item 3), phase 1. Sharing one stream across tabs was dropped (it changes what servers see per connection; the 6-connection limit is HTTP/1.1's). Instead, dev builds warn (code 18) when an EventSource stream's origin was reached over HTTP/1.1 (
protocolOf()indev.ts, from Resource Timing; the SSE module callsapp.streamOpenedfor default-transport streams only), and the spec, README andllms.txtsay to serve streams over HTTP/2. -
2026-11-05: item 3, phase 2: the page-load cache.
cacheon<sn-resource>(value: max age,SN401if malformed) compiles toResourceDef.cacheand adds thecachefeature (src/cache.ts, 757 B gzip). The loader reads the entry on start (after embedded data) and reports every load to the feature (fill/storesplit increateLoader); the feature writes after loads and onpagehidefor resources whose base changed (storebase(), subscriptions fire for confirmed writes only). The request layer clearssn:c:entries on any 401, atfetchand after the middleware chain;clearPersisted()clears them too.render({ session, storage })andmemoryStorage()in the testing module. Core: 10.30 → 10.53 KB gzip. -
2026-11-05: RealWorld uses
cacheon all 9 resources and callsclearPersisted()on sign-in and sign-out. The e2e suite (local backend,spa) found that cached data setloadedAt, so the editor's and settings' login-redirect guard ($meta.user.loadedAt && !user) fired on a cacheduser: null: 5 tests failed reproducibly.loadedAtnow stays null until the server answers (e582d83). After that: 134/140, the same 5 known failures plus 1page.gotoload timeout that passes alone (the baseline without cache had 5 such timeouts in one run). -
2026-11-06: 0.2 roadmap, phase 1: benchmarks.
npm run bench(bench/runtime/, its own Vitest config, not part ofnpm test) times the store (5,000 items ordered by name: ~10 ms per write, because every write re-sorts from arrival order) and one flush (0.2–0.3 µs per root binding; 1.6 / 11.3 ms for one upsert into 1,000 / 5,000 sorted rows).npm run sizemeasures a minimal page and every RealWorld page from a production build; budgets moved from the core to pages. Vitest 5 droppedbench(), so the cases are plain tests that print tables. -
2026-11-06: 0.2 phase 2, store fixes. The sorted list is patched per write instead of re-sorted (one upsert on 5,000 items: 9.9 → 0.007 ms); ties keep arrival order exactly, which the property test now checks against a full sort.
meta.totalis computed when the total or a layer changes, not when the list is read: a page that shows only$meta.x.totalupdates, and a load no longer needs a second flush pass.setTotalmarks onlymeta. Lists are frozen in dev builds only (frozen arrays are 3–50× slower to copy and filter in V8). Core +481 B gzip. -
2026-11-06: 0.2 phases 3–4 (builder calls, per-page core) stopped after measuring: positional builder calls save 250–345 B gzip per RealWorld page (~2 %), a per-page core about 0.3 KB on two pages (every page uses lists, conditionals and handlers). Both stay possible features.
-
2026-11-06: flush cost per root binding. A profile of one flush on a page with 4,000 root bindings showed two per-binding costs besides matching: every binding that matched ran
el.closest('dialog:not([open])'), and every binding tested did aWeakMaplookup for its parsed dependencies, plus aSetlookup in the deferred set. The compiler now marks nodes that are a<dialog>or inside one of their block (dlin the IR; it replaces the runtime'squerySelector('dialog')per row template), bindings keep their parsed dependencies, and the deferred set is consulted only when non-empty. Chrome 156, one change on 4,000 root bindings: 0.49 → 0.18–0.24 ms; happy-dom (npm run bench), 2,000 root bindings: 0.41 → 0.10 ms. Row creation, select and update of 1,000 rows and mount unchanged within noise. Core −30 B gzip. -
2026-11-06: identical expressions share one entry in the page's function table (
fn()in the compiler keys by code). RealWorld article: 112 → 60 functions, 16,507 → 16,293 B gzip; index, profile and editor 28–55 B smaller. A first call costs about 5 µs per function in Chrome, so mount gains at most about 0.26 ms on the article page, not measurable in a page load's script time.
CompileOptionsgains optionalmoduleSrc(URL of the injected<script type="module" src>; an inline module script would breakscript-src 'self') andconfig(app config module imported before mount).MountOptionsgainsenv,requestandtransportseams for the testing module.Transport.connecttakes a 4th argument, the event names to listen for (EventSource needs them), plus an optionallisten(name)for late subscribers.- Subscribers of
resource.subscribe()get{ op: 'refetch' }after each full load. - A plain
data-sn-on-<event>handler and an action on the same element both run, the handler first. helper(draft)in a handler invalidates the wholedraftroot (opaque call); built-in helpers, namespace members and store functions do not invalidate.render()options also acceptfiles(partials for<sn-include>). Mock responses are held untilsettle(), soflush()can observe optimistic state.- Payload scopes (
<sn-event>,cursor) resolve unknown identifiers to payload fields before the browser-API check, so fields likenamework. - Diagnostics in partials append the include chain to the message.
loginRedirectstores the return URL in sessionStorage (sn:return);takeReturnUrl(allow)reads it back after login../..in a URL path interpolation is refused at runtime (reported viaonError) instead of being encoded as%2E/%2E%2E: WHATWG URL parsing, and sofetch, turns%2E%2Eback into a..segment. Query values are still encoded as specified.SN503fires for any non-literalid/namevalue (row-local reads have no dependency paths, so "bound from data" means "not a literal").- Type checking turns off
strictNullChecksandnoImplicitAny: bindings that readnullrender nothing at runtime, one attribute cannot narrow another (data-sn-if="x"aroundx.y), and{ error: null }state stays unchecked unless it has a JSDoc cast. With them on, the untyped RealWorld example had 26 errors, none of them bugs. - OpenAPI types come from sinter's own schema converter, not
openapi-typescript: that package builds its output with the TypeScript 5 compiler API and declarestypescript@^5as a peer, which conflicts with TypeScript 7 projects (this one included). The converter also turns named schemas into named declarations, so messages saytype 'Article'instead of spelling out the object. typeon<sn-resource>is any TypeScript type over the types module's exports (Contact[],Page<Article>); the shadow file brings the exports into scope with a JSDoc@import, so state casts use plain names too.- The compiler's browser-API list does not include very generic names (
name,open,print,event); they areSN201instead ofSN202.
-
Size budgets per page since 2026-11-06 (0.2 roadmap, decision 2): the minimal page (one resource, one text binding) ≤ 15 KB gzip (11.5 KB until 2026-11-07: 0.3's features took it to 12.1 KB, and the budget was raised rather than features moved out of the core), replacing "core ≤ 11 KB", and the RealWorld article page ≤ 20 KB (16.5 KB until 2026-11-07, when it stood at 16.9 KB), with every other RealWorld page reported (baseline in benchmark.md). CI runs
npm run size -- --check. The table below is the core budget's last measurement. Until then: 11 KB for the core, met (it was 5 KB until 2026-11-05, which the core never reached).npm run size(rolldown bundle, oxc minify, gzip -9), 2026-11-05:Module min gzip core ( mount,$k)23.5 KB 10.1 KB (11.5 KB before the closure rewrite) sse 3.1 KB 1.4 KB paginate 2.9 KB 1.2 KB persist 0.7 KB 0.4 KB optimistic 0.2 KB 0.2 KB dev (dev builds only) 1.4 KB 0.8 KB contacts page (core + sse + optimistic + persist) 27.2 KB 11.4 KB The list performance work of 2026-11-05 (bench/swap, js-framework-benchmark) added 354 B gzip to the core (9,944 → 10,298 B), and compiled pages carry one element-lookup function per
data-sn-fortemplate (RealWorld's 7 pages: 18 functions, 275 B gzip in all).Marginal gzip cost inside the core: request layer + config ≈ 0.9 KB, store ≈ 0.9, app object/API ≈ 1.2, mount/bind ≈ 0.8, loaders ≈ 0.9, actions ≈ 0.7, lists ≈ 0.7, render ≈ 0.4, security ≈ 0.4, forms ≈ 0.3, models ≈ 0.3. Further trimming yields tens of bytes; 5 KB would need either moving parts (actions, forms, models, loaders) into compiler-selected modules or a different budget definition.
-
Playwright suite in real browsers (
<dialog>/commandfor, bfcache,EventSource, focus preservation), with Trusted Types and strict CSP. -
List-update benchmark in CI.
-
Release pipeline (CI-only trusted publishing with provenance).