Skip to content

Migrate to standalone @neabyte/dve, refresh rendering API, internal pipeline, and tests#1

Open
NeaByteLab wants to merge 1 commit into
mainfrom
release/v0.15.0
Open

Migrate to standalone @neabyte/dve, refresh rendering API, internal pipeline, and tests#1
NeaByteLab wants to merge 1 commit into
mainfrom
release/v0.15.0

Conversation

@NeaByteLab

Copy link
Copy Markdown
Owner

Warning

Draft pull request for extracting the bundled DVE rendering layer out of Deserve, depending on the standalone @neabyte/dve package, and modernizing the rendering API, internal rendering pipeline (compile, render, stream flow), and test suite. This is a breaking change. The public rendering API will change and is intentionally not backward compatible in this PR. Downstream usage and docs are reconciled in a follow-up once the new surface is finalized.

Summary

Deserve currently ships its own copy of the DVE template engine under src/rendering/engine/. That engine has since been pulled into a dedicated, runtime-agnostic package: @neabyte/dve.

This PR is a larger rendering-layer overhaul, not just a dependency swap. It:

  • removes the in-tree engine and depends on the published package,
  • redesigns the Deserve rendering API around the new engine (breaking),
  • reworks the internal rendering pipeline (compile, render, stream, include resolution flow),
  • refactors the rendering adapter, interfaces, and call sites for clarity.
  • expands and restructures the test suite,

The standalone engine is not a 1:1 copy. It dropped all filesystem coupling, switched to a synchronous streaming core, and gained a larger template feature set (layouts, blocks, slots, comments, whitespace control, else if, static validation). Deserve keeps owning the filesystem, caching, discovery, and hot reload while the parsing and rendering core moves to the package.

Motivation

  • Single source of truth. Engine logic lives in one package, versioned, tested, and released independently.
  • Lighter framework surface. Deserve no longer maintains a tokenizer, parser, expression evaluator, and HTML escaper inline.
  • Better API. The rendering surface is redesigned around the package rather than constrained by the old embedded shape.
  • Reusability. The same engine now serves edge functions, static builds, email templates, and the browser via CDN, not only Deserve.
  • More features for free. Layouts, blocks, slots, comments, and whitespace control land in Deserve without extra maintenance.

Scope

In scope:

  • Engine removal. Delete src/rendering/engine/ (Tokenizer, Parser, Expression, Eval, Utils) and its index.ts re-export.
  • Dependency. Add @neabyte/dve to deno.json imports and update the lockfile.
  • API redesign (breaking). Rework src/rendering/Engine.ts and the ctx.render / ctx.streamRender surface around the package.
  • Interface reconciliation. Replace src/interfaces/Rendering.ts local AST, token, and option types with the package types.
  • Internal pipeline. Rework the rendering flow (compile, cache, include resolution, render, stream).
  • Tests. Restructure and expand the rendering test suite.
  • Refactor. Clean up the rendering adapter, error mapping, and call sites in Context, Handler, and Router.

Out of scope:

  • Backward compatibility shims for the old rendering API.
  • Documentation site updates beyond the CHANGELOG (handled in a follow-up).

References

- Remove editor README and packaged VSIX
- Remove language configuration, package manifest, and snippets
- Remove tmLanguage grammar for dve files
@NeaByteLab

Copy link
Copy Markdown
Owner Author

Context API Redesign - Decision Record (v0.15.0)

This release is bigger than a DVE dependency swap. The Context surface was redesigned so every interaction is grouped by what it does, instead of being one flat list of methods.

The API is now organized into three namespaces, where ctx.get.* reads the request, ctx.set.* stages changes on the outgoing response, and ctx.send.* produces the final Response. On top of that, ctx.render(...) folds streaming into an option rather than a second method.

Why it changed

The old Context kept read, write, and respond concerns flat on the same object, such as ctx.header(), ctx.setHeader(), ctx.json(), and ctx.streamRender(). A few problems kept coming up:

  • The header() and setHeader() pair was easy to mix up, since one reads and one writes yet both sit at the same level with no visual difference, so each one had to be memorized.
  • Typing ctx. listed around 25 methods with no hint about which ones touch the request and which ones touch the response, so the shape taught nothing.
  • The render() and streamRender() methods were near duplicates that drifted apart over time, even though streaming is really just a mode of rendering.

The goal of this redesign is a shape that explains itself, where the request is read with get, response changes are staged with set, and the handler finishes with send.

What changed, read side: ctx.get.*

Everything that reads from the incoming request now lives under ctx.get, with the same data in one consistent place and an overload for "all values" against "one key".

// Old: read helpers were flat on ctx, mixed in with writers
const type = ctx.header('content-type')
const q = ctx.query('q')
const sid = ctx.cookie('sid')
const id = ctx.param('id')
const payload = await ctx.json()
const ip = ctx.ip

// New: every request read is grouped under ctx.get
const type = ctx.get.header('content-type')
const q = ctx.get.query('q')
const sid = ctx.get.cookie('sid')
const id = ctx.get.param('id')
const payload = await ctx.get.json()
const ip = ctx.get.ip()

The full read surface is get.ip(), get.method(), get.url(), get.pathname(), get.request(), get.header(), get.cookie(), get.query(), get.param(), get.body(), get.json(), get.text(), get.formData(), get.blob(), get.bytes(), and get.session().

What got better here:

  • The call style is uniform, because get.header() returns the whole record while get.header(key) returns one value, and the same overload works across header, cookie, query, and param, so there is no guessing what each one returns.
  • Discoverability improves, because typing ctx.get. lists only request reads, so the editor points straight to the right method.
  • get.ip() is a method rather than a property, and it takes { direct: true } for the direct socket IP against the resolved client IP, which replaces the old pair of ctx.ip and ctx.directIp with one method and one option.

What changed, write side: ctx.set.*

Anything that stages a change on the outgoing response, without producing it yet, now lives under ctx.set, and the calls chain.

// Old: separate setter methods, returning nothing useful to chain
ctx.setHeader('x-trace', id)
ctx.setHeaders({ 'x-a': '1', 'x-b': '2' })

// New: one chainable group where each call returns ctx.set
ctx.set
  .header('x-trace', id)
  .headers({ 'x-a': '1', 'x-b': '2' })
  .cookie('sid', token, { httpOnly: true })
  .session(data)

The surface is set.header(key, value), set.headers(record), set.cookie(name, value, options), and set.session(data).

What got better here:

  • One verb covers mutating the response, because set never returns a Response and only stages, so the old setHeader together with the cookie helper that lived elsewhere are now one group.
  • Chaining works because each set.* call returns the set helper, so staging headers, a cookie, and a session reads top to bottom in a single statement.
  • Cookies are a first class write now, because cookie writes used to leak through header handling, while set.cookie(...) is explicit and typed with CookieInit.

What changed, respond side: ctx.send.*

The send group is the only one that produces a Response. It existed before, but the boundary is now tighter, where get reads, set stages, and send ends the handler.

// Each helper returns a finished Response, ready to return from the handler
return ctx.send.json({ ok: true })
return ctx.send.text('hi')
return ctx.send.html('<h1>Hi</h1>')
return ctx.send.custom(body, { status: 201 })
return ctx.send.download(buf, 'report.pdf')
return ctx.send.empty(204)
return ctx.send.redirect('/login', 302)

The surface is send.json(), send.text(), send.html(), send.custom(), send.download(), send.empty(), and send.redirect(). Each one takes SendInit, which is a ResponseInit with a typed status, so any headers or status staged through set.* merge in automatically.

What got better here:

  • The terminal verb is clear, because seeing send in a handler signals that the line returns the response, which get and set never do.
  • The options are consistent, because every responder takes the same SendInit shape, so status and extra headers behave the same way across json, text, html, custom, and download.

What changed, rendering: ctx.render(..., { stream })

Streaming is now an option on a single render call rather than a separate method.

// Old: two separate methods doing the same job
return await ctx.render('index', data)
return await ctx.streamRender('index', data)

// New: one method, where streaming is a flag and status is optional
return await ctx.render('index', data)
return await ctx.render('index', data, { stream: true })
return await ctx.render('index', data, { status: 201, stream: true })

RenderInit is { status?, stream? }. Behind it, the renderer is injected into Context through the constructor rather than pulled out of request state, and the engine is now a thin adapter over @neabyte/dve. Deserve still owns the filesystem reads, the compile cache, path resolution, and the watch and invalidate flow.

What got better here:

  • There is one render entry point now, so the two near duplicate methods can no longer drift apart, and streaming is just a flag, which is what it always was.
  • The status can be set in the same call, for example a 404 page, without a separate staging step and a raw response.
  • Render returns a Response directly, because it builds the text/html response with the right content type and merges any staged headers, so it composes with set.*.

How it reads in a handler

Namespace What it does Behavior
ctx.get.* read the request never mutates, never responds
ctx.set.* stage response changes chainable, never responds
ctx.send.* produce the Response ends the handler
ctx.render() produce an HTML Response ends the handler, stream is an option

Three verbs, one job each. A handler reads like a sentence, where it gets what is needed, sets what should change, and sends or renders the result.

Migration cheat sheet

Old New
ctx.header(k) ctx.get.header(k)
ctx.query(k) ctx.get.query(k)
ctx.cookie(k) ctx.get.cookie(k)
ctx.param(k) ctx.get.param(k)
ctx.json() or body() ctx.get.json() or ctx.get.body()
ctx.ip or ctx.directIp ctx.get.ip() or ctx.get.ip({ direct: true })
ctx.setHeader(k, v) ctx.set.header(k, v)
ctx.setHeaders(rec) ctx.set.headers(rec)
ctx.streamRender(t, d) ctx.render(t, d, { stream: true })
ctx.send.html(html) ctx.send.html(html), unchanged

Removed features, such as the state helpers and the validator surface, are out of scope for this record. Most of them are planned to come back. The intent is to lock the shape first, then add capability on top of a surface that is settled.

@NeaByteLab NeaByteLab added the enhancement New feature or request label Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant