liquidstream
Streaming-first Liquid for Cloudflare Workers, edge runtimes, and Node.js.
liquidstream is now natively powered by Response objects. The primary engine is transform(input: Response): Response, which keeps HTML flowing through HTMLRewriter without switching back to a buffered string renderer. In Edge and Worker environments, that gives you a zero-buffering path by default: the engine rewrites the upstream response body as it streams, pauses only when it reaches a Liquid marker that needs resolution, and resumes as soon as the value is ready.
The other major shift is the async walker. Instead of requiring the entire context object up front, liquidstream can lazy-load root values on demand through .on(contextProp, handler). That makes it natural to fetch data only when a template actually references it, while still preserving Liquid-style filters, tags, and HTML-first rendering.
It is especially well suited to:
- documentation sites and marketing pages that are mostly HTML with targeted Liquid expressions
- edge-rendered pages where
eval()-heavy template engines are a poor fit - projects that want zero-buffering output and request-scoped data loading
- Markdown-to-HTML publishing flows where the final page still needs Liquid-aware layout composition
Getting started
Installation
npm install @sntran/liquidstream
Streaming Quick Start
Use transform() when you already have an HTML Response and you want the rewrite to stay inside the streaming pipeline.
import { Liquid } from "@sntran/liquidstream";
export default {
async fetch(request, env) {
const template = await env.ASSETS.fetch(
new Request(new URL("/templates/profile.html", request.url)),
);
const engine = new Liquid()
.on("user", {
async node() {
const id = new URL(request.url).searchParams.get("user") ?? "guest";
return await env.USERS.get(id, { type: "json" });
},
});
return engine.transform(template);
},
};
If /templates/profile.html contains:
<article>
<h1>{{ user.name }}</h1>
<p>{{ user.bio | capitalize }}</p>
</article>
the engine resolves user lazily the first time the template touches that root, then keeps streaming the rewritten response back to the client.
Liquid.js Compatibility Adapter
parseAndRender() remains fully supported as a Liquid.js compatibility adapter. It is the preferred choice for legacy workflows, unit tests, local scripts, and any environment where a final HTML string is still the most practical return value.
import { Liquid } from "@sntran/liquidstream";
const engine = new Liquid();
const html = await engine.parseAndRender(
`
<article>
<h1>{{ page.title }}</h1>
<p>{{ page.summary | capitalize }}</p>
</article>
`,
{
page: {
title: "Streaming Liquid",
summary: "fast on the edge",
},
},
);
The adapter uses the same engine semantics as transform(), but returns the fully rendered HTML string for compatibility-oriented workflows.
Product snapshot
- response-native rendering powered by
HTMLRewriter - zero-buffering transform path for Edge and Worker responses
- async walker with lazy root resolution through
.on() - safe-flush text handling that streams literal content immediately and only pauses at Liquid markers
- instance-local custom filters and custom tags
- partials loaded through
fetchforrenderandinclude - plugin-friendly design with optional Jekyll-style helpers
- this repository doubles as a no-build documentation site
Why it exists
Most Liquid engines optimize for broad compatibility and full-string rendering. liquidstream focuses on a different shape of problem:
- stream HTML as early as possible
- avoid
eval()andnew Function()in isolate runtimes - lazy-load template data only when a root is actually referenced
- stay small enough to inspect and maintain
- keep the API practical for content sites and edge rendering
If your templates are HTML-first and your runtime is closer to Cloudflare Workers than a full server process, that tradeoff is often the right one.
Runtime model
liquidstream is not trying to be a drop-in implementation of every Shopify or Jekyll behavior. The goal is a compact engine with predictable behavior in modern runtimes.
In practice, that means:
- HTML is treated as the primary document format
transform()works onResponseobjects directly instead of rendering through a separate string buffer- Liquid expressions are resolved while the markup flows through the rewriter
- root data can be loaded lazily through
.on(contextProp, handler) - custom filters and tags are registered per engine instance
- partials are loaded through
fetch, which maps naturally to Workers and web runtimes - cooperative yielding is available for large renders instead of blocking long loops
If you need the broadest possible Liquid compatibility across every legacy construct, another engine may be a better fit. If you want a pragmatic subset that behaves naturally in isolate runtimes and still offers a compatibility adapter for string-based flows, liquidstream is designed for that job.
Examples
Response-native transform
import { Liquid } from "@sntran/liquidstream";
const engine = new Liquid()
.on("page", {
async node() {
return {
title: "Streaming First",
summary: "response-native liquid",
};
},
});
const response = engine.transform(new Response(`
<section>
<h1>{{ page.title }}</h1>
<p>{{ page.summary | capitalize }}</p>
</section>
`));
Legacy string workflow
import { Liquid } from "@sntran/liquidstream";
const engine = new Liquid();
await engine.parseAndRender(
'<p>{{ " edge template " | strip | upcase }}</p>',
{},
);
Loop rendering
import { Liquid } from "@sntran/liquidstream";
const engine = new Liquid();
await engine.parseAndRender(
'{% for tag in post.tags limit:2 %}<li>{{ tag | capitalize }}</li>{% endfor %}',
{
post: {
tags: ["streaming", "workers", "liquid"],
},
},
);
Jekyll-style filters
import { Liquid } from "@sntran/liquidstream";
import jekyllFilters from "@sntran/liquidstream/jekyll";
const engine = new Liquid({
filters: jekyllFilters,
});
const html = await engine.parseAndRender(
`
{% assign docs = posts | where_exp: "post", "post.kind == 'guide'" %}
{% assign featured = docs | map: "title" | slice: 0, 3 %}
<section>
<h2>{{ docs | size }} guides ready</h2>
<p>{{ featured | join: ", " }}</p>
</section>
`,
{
posts: [
{ kind: "guide", title: "Install" },
{ kind: "guide", title: "Filters" },
{ kind: "reference", title: "Changelog" },
{ kind: "guide", title: "Deploy" },
],
},
);
Capture and control flow
import { Liquid } from "@sntran/liquidstream";
const engine = new Liquid();
const html = await engine.parseAndRender(
`
{% capture summary %}
{{ product.name | capitalize }} ships in {{ product.regions | join: ", " }}.
{% endcapture %}
{% if product.inventory > 0 and product.featured %}
<aside>{{ summary | strip }}</aside>
{% else %}
<aside>Check back soon.</aside>
{% endif %}
`,
{
product: {
name: "liquidstream",
featured: true,
inventory: 8,
regions: ["us", "eu"],
},
},
);
Partials through fetch
import { Liquid } from "@sntran/liquidstream";
const templates = new Map([
["/_includes/card.html", "<article><h3>{{ title }}</h3><p>{{ body }}</p></article>"],
]);
const engine = new Liquid({
fetch: async (input) => {
const pathname = new URL(String(input), "https://example.test").pathname;
const template = templates.get(pathname);
return new Response(template ?? "", { status: template ? 200 : 404 });
},
});
const html = await engine.parseAndRender(
'{% include "card.html", title: page.title, body: page.summary %}',
{
page: {
title: "Partial rendering",
summary: "Render and include can resolve templates through fetch.",
},
},
);
This documentation site
This repository is also the largest working example of liquidstream in this codebase:
- GitHub Pages applies the shared Liquid layout through Jekyll config
- Cloudflare Pages converts this README from Markdown to HTML with
marked - the layout itself exercises a wide range of supported Liquid filters and tags
- the final page stays small, inlined, and JavaScript-free
That makes the repository useful both as package documentation and as a practical end-to-end reference.
The Lazy Context Registry
The async walker resolves the first token of a Liquid path through .on(contextProp, handler).
import { Liquid, UNHANDLED } from "@sntran/liquidstream";
const engine = new Liquid()
.on("user", {
async node() {
return {
profile: { name: "Ada Lovelace" },
visits: 3,
};
},
async get(target, token, ctx) {
if (token === "visits" && ctx.root === "user") {
return 7;
}
return target?.[token] ?? UNHANDLED;
},
async filter(target, name, args, ctx) {
if (name === "badge") {
const prefix = args[0] ?? ctx.root;
return `**${prefix}:${String(target).toUpperCase()}**`;
}
return UNHANDLED;
},
});
TypeScript shape:
export type Awaitable<T> = T | Promise<T>;
export declare const UNHANDLED: unique symbol;
export type LiquidPathToken = string | number;
export type TrapResult<T> = T | typeof UNHANDLED;
export interface NodeTrapContext {
root: string;
input: Response;
expression: string;
signal: AbortSignal | null;
}
export interface TrapContext {
root: string;
input: Response;
expression: string;
path: readonly LiquidPathToken[];
index?: number;
signal: AbortSignal | null;
}
export interface LiquidContextHandler {
node?(ctx: NodeTrapContext): Awaitable<TrapResult<unknown>>;
get?(target: unknown, token: LiquidPathToken, ctx: TrapContext): Awaitable<TrapResult<unknown>>;
filter?(target: unknown, name: string, args: readonly unknown[], ctx: TrapContext): Awaitable<TrapResult<unknown>>;
}
Trap semantics:
node()resolves the root value for the registered property and is memoized once pertransform()callget(target, token, ctx)resolves one token hop at a time after the root has been loadedfilter(target, name, args, ctx)intercepts filter application for values originating from that rootctxcarries the metadata for the current resolution step, including the root name, expression, path, input response, and cancellation signal- returning
UNHANDLEDdelegates back to the engine UNHANDLEDis different fromundefined:undefinedis treated as a real resolved value, whileUNHANDLEDmeans “fall back to the default behavior”
Default fallback behavior:
- if no root handler exists, the engine falls back to the active render scope
- if
get()returnsUNHANDLED, normal path traversal continues - if
filter()returnsUNHANDLED, the global filter registry is checked next - if no filter exists at all, the current value is preserved
Operators
Expressions support the operators and literal forms you need for real templates:
- range expressions like
(1..5) - array and object literals
- boolean logic with
andandor - comparisons with
==,!=,>,<,>=,<=,contains - Liquid truthiness where only
false,null, andundefinedare falsey
This keeps conditional templates expressive without needing separate helper syntax for everyday checks.
Filters
The built-in filters are grouped below. The first category stays open by default, and the rest can be expanded as needed.
String and array filters
- `append` - `capitalize` - `downcase` - `first` - `includes` - `join` - `last` - `map` - `replace` - `size` - `slice` - `split` - `starts_with` - `strip` - `trim_end` - `trim_start` - `upcase`Math filters
- `abs` - `at_least` - `at_most` - `ceil` - `divided_by` - `floor` - `minus` - `round` - `sign` - `sqrt` - `trunc`HTML and output filters
- `default` - `newline_to_br` - `raw` - `strip_html`URL and encoding filters
- `base64_encode` - `relative_url` - `url_decode` - `url_encode`Time and sorting filters
- `date` - `sort`How to define a new filter
Custom filters receive a small engine-aware this binding:
this.context: current render scopethis.evaluate(expression, scope?): evaluate a Liquid conditionthis.resolveExpression(expression, scope?, options?): resolve a Liquid expression
That makes context-aware filters possible without reimplementing the parser.
const engine = new Liquid();
engine.registerFilter("only_matching", function (items, variableName, expression) {
if (!Array.isArray(items)) {
return [];
}
return items.filter((item) => this.evaluate(expression, { [variableName]: item }));
});
Custom filters can return plain values or HTML-safe output wrappers. In most cases, returning a normal string or array is exactly what you want.
Plugins
liquidstream keeps optional functionality in plugins instead of forcing everything into the core package.
Jekyll plugin
The optional Jekyll plugin lives at @sntran/liquidstream/jekyll.
It includes:
absolute_urlgroup_bygroup_by_expinclude_relativejsonifyrelative_urlslugifywherewhere_exp
import { Liquid } from "@sntran/liquidstream";
import jekyll from "@sntran/liquidstream/jekyll";
const engine = new Liquid().plugin(jekyll);
// The path is passed via context, keeping the engine API clean
const html = await engine.parseAndRender(input, {
page: { path: "docs/readme.md" },
});
More plugins coming, or contribute your own in the Contributing section.
API
new Liquid(options?)
Creates a renderer instance.
Supported options:
HTMLRewriterClass: customHTMLRewriterimplementationautoEscape: defaults totruefetch: fetch implementation used byrenderandincludefilters: instance-local filter overrides and extensionstags: instance-local custom tagsyieldAfter: iteration threshold for cooperative yieldingyieldControl: custom async yield hook
The constructor is intentionally small. Most customization happens by passing instance-local filters, tags, and fetch behavior instead of relying on globals.
on(contextProp, handler)
Registers a lazy root handler for the async walker.
Use this when you want to resolve a root like user, page, or posts on demand during transform(). A root handler can:
- load the root with
node() - override path traversal with
get() - intercept root-local filters with
filter()
Re-registering the same contextProp replaces the previous handler.
transform(input)
Transforms an HTML Response without buffering the full body in memory.
This is the primary API for Edge and Worker environments. Pass in an upstream HTML response, let liquidstream rewrite it through HTMLRewriter, and return the transformed response directly to the client.
Important guarantees:
- the streaming path is response-native
Content-Lengthis removed automatically because rewritten output length can change- plain text is safe-flushed immediately and the stream only pauses at Liquid markers that need evaluation
- async
.on()traps can await I/O without forcing a whole-document buffer
parseAndRender(html, context)
Renders a template string and returns the final HTML string.
This method is fully supported as a Liquid.js compatibility adapter. Prefer it for:
- legacy codebases already built around string templates
- unit tests that assert on final HTML strings
- local scripts, static generation steps, or non-streaming environments
- gradual migration to
transform()without changing every call site at once
Internally, it routes top-level context keys through the same async walker used by transform(), so the compatibility path stays behaviorally aligned with the streaming engine.
registerFilter(name, filter)
Adds or overrides an instance-local global filter.
Global filters remain the fallback path after a root-local filter() trap returns UNHANDLED.
registerTag(name, handler)
Adds or overrides an instance-local custom tag.
Custom tags are useful when filter chains stop being expressive enough and you want a named unit of rendering behavior.
plugin(pluginFn)
Runs a Liquid-style plugin against the current engine instance and returns the same instance for chaining.
Rendering behavior
There are a few practical rules worth knowing when you build with liquidstream:
- auto-escaping is enabled by default for variable output
- the
rawfilter and raw tag let you opt into literal HTML output when you mean it renderandincluderely onfetch, so relative template loading is part of the engine contract- the engine is happiest with HTML documents and HTML fragments rather than plain-text templates
- custom filters and tags are isolated to the engine instance that registered them
- lazy roots registered with
.on()are resolved per transformed response, not globally
Those constraints are deliberate. They keep the engine easier to reason about in environments where streaming and runtime safety matter.
Benchmarks
Current benchmark snapshot from this repository:
- gzipped worker entry:
1702 B - gzipped worker bundle:
16802 B - gzipped LiquidJS browser bundle for comparison:
35788 B - simple
transform()average, 26 B template:0.557 ms - first byte, static-prefix template, median:
2.601 ms - first byte, filter-dependent template, median:
0.351 ms - heavy 1 MB
transform():32.577 ms
These numbers come from scripts/benchmark-liquid.mjs and are better read as directional guidance than a universal speed claim.
The benchmark now measures the public transform() API directly. The important architectural behavior is still the safe-flush path in the streaming engine: literal text can be emitted immediately, and the rewriter only waits when it reaches a Liquid marker that actually needs a value. On large HTML documents with long static prefixes, that keeps first-byte latency dramatically lower than a buffered string render because the engine can ship the opening bytes before the full document has been resolved.
liquidstream is strongest when streaming behavior, request-scoped data loading, and edge compatibility matter more than raw string-at-once throughput.
Contributing
Issues, bug reports, test cases, and focused patches are all welcome. The project is still intentionally small, so code clarity and runtime behavior matter at least as much as feature count.
The current implementation is easiest to navigate if you keep the module ownership split in mind:
lib/mod.jsis the public facade and composition rootlib/rewriter.jsowns the streaming rewrite flow and render-state machinelib/evaluator.jsowns expression, condition, and partial resolutionlib/tag.jsowns tag runtime objects and shared tag-side helperslib/tags/contains the built-in tag semantics
Contributions are especially helpful in these areas:
- compatibility bugs in supported Liquid syntax
- small, well-tested filter or tag additions
- performance improvements that preserve the streaming model
- documentation improvements with concrete examples
As a rule of thumb when adding code:
- add or change built-in tag behavior in
lib/tags/ - add tag-side runtime helpers in
lib/tag.js - add expression or condition behavior in
lib/evaluator.js - add streaming or state-machine behavior in
lib/rewriter.js
If you propose a new feature, it helps to explain how it fits the project’s core goals: HTML-first rendering, edge-friendly runtime behavior, and a compact implementation that stays understandable.