Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
79a0f42
fix: expand event directives only in tag attributes
mirkobrombin Oct 16, 2026
153e01f
fix: scope the event attribute cursor to its scan
mirkobrombin Oct 16, 2026
8ce9ba2
fix: complete browser requests after transport exceptions
mirkobrombin Oct 16, 2026
3d1a23e
fix: pin scaffold dependencies to the CLI version
mirkobrombin Oct 16, 2026
904dc17
fix: keep scaffold options separate from the project name
mirkobrombin Oct 16, 2026
1c6432a
feat: add a runnable HTTP CRUD example and guide
mirkobrombin Oct 16, 2026
3cf41b8
fix: align embedded serving and deployment contracts
mirkobrombin Oct 16, 2026
9e6c1de
perf: cut event expansion allocations without changing markup
mirkobrombin Oct 17, 2026
698d7fd
feat: support cancellable browser HTTP requests
mirkobrombin Oct 17, 2026
32edb1b
fix: satisfy browser HTTP probe lint checks
mirkobrombin Oct 17, 2026
831de58
fix: remove capturing event listeners before releasing callbacks
mirkobrombin Oct 17, 2026
132418f
fix: preserve SSC fallback after browser setup errors
mirkobrombin Oct 19, 2026
1f034aa
fix: keep StreamBus writes outside browser callbacks
mirkobrombin Oct 19, 2026
da6473b
feat: authenticate browser StreamBus with first-frame credentials
mirkobrombin Oct 19, 2026
fe3f630
test: declare secure cookie attributes in StreamBus fixture
mirkobrombin Oct 19, 2026
7cbe621
fix: reject typed SSC calls cancelled before admission
mirkobrombin Oct 19, 2026
3fdeb15
fix: reconnect SSC before remote close acknowledgement
mirkobrombin Oct 19, 2026
ea87d2a
fix: stop dispatch after SSC session release
mirkobrombin Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/test-build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ jobs:
- name: Build Application
run: go build -o rfw -ldflags="-X 'github.com/rfwlab/rfw/v2/core.version=${{ github.sha }}'" ./cmd/rfw/main.go

- name: Build stock examples and check the Notes API
run: |
set -eu
cli="$PWD/rfw"
for example in counter dynamic-list dashboard notes; do
(cd "examples/$example" && GOFLAGS=-mod=readonly "$cli" build)
done
(cd examples/notes && go test ./server)

- name: Calculate and Save Checksums
run: sha256sum rfw > checksums.txt

Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@

## Unreleased

- Refuse typed SSC calls cancelled or expired before admission, including while
waiting for session reset. Do not queue or replay those commands.

- Add opt-in SSC StreamBus cookie authentication through an authenticated HTTP
bootstrap and single-use first-frame credential. Bound credential retention,
origin scope and handshake time; recheck authentication before consumption.

- Queue browser StreamBus writes outside DOM callbacks to prevent Go/WASM
deadlocks. Preserve frame order with per-connection frame and byte limits;
terminate failed or overflowing connections for SSC recovery.

- Preserve SSC WebSocket fallback after synchronous WebTransport setup failures.
Close partially created transports before returning the setup error.

- Add RequestContext and RequestBytesContext for cancellable browser fetch and
response-body reads. Preserve the existing unbounded Request APIs and options.
Cancellation reports status0 without retry or a server rollback guarantee.

- Cut event-expansion allocations by reusing tag attribute offsets and parsing
directive words directly. Keep HTML token boundaries and existing ASCII syntax.

- Apply the directory server's compressed-WASM negotiation, SPA entry cache policy
and selected StreamBus registration to embedded NewMuxFS deployments. Both
transport paths share the configured origin/identity guard.

- Add a runnable Notes HTTP CRUD example and tutorial with stable IDs, stale-write
refusal, draft retention and mount-scoped response handling. The loopback API
is an in-memory teaching server, not a production authentication/persistence layer.
- Reset the dashboard example's paused flag on remount so its live label and feed
state agree after leaving a paused page.

- Expand event directives only in whole, valueless tag attributes. Preserve
literal SSH URLs, quoted/unquoted values, comments and raw-text contents.
Existing explicit data-on-* attributes and event modifiers keep their syntax.

- Run configured mount/unmount callbacks on plain HTMLComponent values as well
as components registered with SetComponent. Explicit registration still invokes
each callback once per lifecycle event.
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,19 @@ rfw dev
```

`rfw init` takes a Go module path and creates the project in a directory named
after its last segment (`app` here). The scaffold is a working hello world: a
page, a component, and an RTML template. The component
after its last segment (`app` here). It pins the framework dependency to the
installed CLI version before running `go mod tidy`. A source-built CLI without
module version metadata requires an explicit version:

```bash
rfw init --rfw-version v2.3.0 github.com/user/app
```

Use a published v2 tag or Go pseudo-version. `--skip-tidy` skips dependency
resolution; an unversioned development CLI also leaves the dependency unpinned
in that mode, so select `--rfw-version` before building an independent project.
The scaffold is a working hello world: a page, a component, and an RTML
template. The component
(`components/app_component.go`):

```go
Expand Down
165 changes: 165 additions & 0 deletions bench/examples.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Run after building all examples and the native Notes server. Uses the existing
// benchmark Playwright dependency; writes only to the explicit results directory.
import {chromium, firefox, webkit} from 'playwright-core';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {spawn} from 'node:child_process';
import {once} from 'node:events';

const root = path.resolve(process.env.RFW_EXAMPLES_ROOT || fileURLToPath(new URL('../examples', import.meta.url)));
const output = process.env.RFW_EXAMPLE_RESULTS;
const api = process.env.RFW_NOTES_SERVER;
assert(output && api, 'RFW_EXAMPLE_RESULTS and RFW_NOTES_SERVER are required');
fs.mkdirSync(output, {recursive: true});
const results = [];
const servers = [];
let notes;

async function serve(name) {
const dir = path.join(root, name, 'build/client');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const name = ['/', '/about'].includes(url.pathname) ? 'index.html' : url.pathname.slice(1);
const file = path.resolve(dir, name);
if (!file.startsWith(dir + path.sep)) { res.writeHead(404).end(); return; }
try {
const data = fs.readFileSync(file);
const mime = {'.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm'};
res.writeHead(200, {'Content-Type': mime[path.extname(file)] || 'application/octet-stream'});
res.end(data);
} catch { res.writeHead(404).end(); }
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
servers.push(server);
return `http://127.0.0.1:${server.address().port}`;
}

try {
const bases = {};
for (const name of ['counter', 'dynamic-list', 'dashboard']) bases[name] = await serve(name);
const reservation = net.createServer();
reservation.listen(0, '127.0.0.1');
await once(reservation, 'listening');
const port = reservation.address().port;
await new Promise(resolve => reservation.close(resolve));
const fd = fs.openSync(path.join(output, 'notes-server.log'), 'w');
notes = spawn(api, ['--port', String(port), '--assets', path.join(root, 'notes/build/client')], {stdio: ['ignore', fd, fd]});
fs.closeSync(fd);
bases.notes = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100; n++) {
try { ready = (await fetch(bases.notes + '/api/notes')).ok; } catch {}
if (ready) break;
assert(notes.exitCode === null, 'Notes server exited');
await new Promise(resolve => setTimeout(resolve, 100));
}
assert(ready, 'Notes server did not start');
const deniedOrigin = await fetch(bases.notes + '/api/notes', {headers: {Origin: 'https://untrusted.example'}});
assert.equal(deniedOrigin.status, 403);
for (const [engine, type] of Object.entries({chromium, firefox, webkit})) {
const browser = await type.launch(engine === 'chromium' ? {executablePath: process.env.CHROME_PATH || '/usr/bin/google-chrome', args: ['--no-sandbox']} : {});
try {
for (const [size, viewport] of Object.entries({desktop: {width: 1280, height: 900}, mobile: {width: 390, height: 844}})) {
for (const name of ['counter', 'dynamic-list', 'dashboard', 'notes']) {
const context = await browser.newContext({viewport});
const page = await context.newPage();
page.setDefaultTimeout(15000);
const result = {engine, size, example: name, status: 'RUNNING', errors: []};
results.push(result);
page.on('pageerror', e => result.errors.push(e.message));
try {
await page.goto(bases[name]);
if (name === 'counter') {
await page.getByRole('button', {name: 'Increment', exact: true}).click();
await page.getByText('Count: 1', {exact: true}).waitFor();
} else if (name === 'dynamic-list') {
const text = `Literal <b>${engine} ${size}</b>`;
await page.locator('#item-name').fill(text);
await page.getByRole('button', {name: 'Add', exact: true}).click();
const row = page.locator('#items-rows tr').filter({hasText: text});
await row.waitFor();
assert.equal(await row.locator('b').count(), 0);
await row.getByRole('button', {name: 'Remove', exact: true}).click();
assert.equal(await row.count(), 0);
} else if (name === 'dashboard') {
await page.getByText('Status: live', {exact: true}).waitFor();
await page.getByRole('button', {name: 'Pause / resume', exact: true}).click();
await page.getByText('Status: paused', {exact: true}).waitFor();
await page.getByRole('link', {name: 'About this demo', exact: true}).click();
await page.getByRole('heading', {name: 'About', exact: true}).waitFor();
await page.getByRole('link', {name: 'Back to the dashboard', exact: true}).click();
await page.getByText('Status: live', {exact: true}).waitFor();
await page.waitForFunction(() => document.querySelector('ul li')?.textContent.includes('cpu sample'));
} else {
await page.getByText('Notes loaded.', {exact: true}).waitFor();
const text = `Literal <b>${engine} ${size}</b>`;
await page.getByLabel('Note text', {exact: true}).fill(text);
await page.getByRole('button', {name: 'Save note', exact: true}).click();
const row = page.locator('#notes-list li').filter({hasText: text});
await row.waitFor();
const id = await row.getAttribute('data-note');
assert.equal(await row.locator('b').count(), 0);
await page.reload();
await row.waitFor();
const other = await context.newPage();
other.setDefaultTimeout(15000);
await other.goto(bases.notes);
const selector = `#notes-list li[data-note="${id}"]`;
await other.locator(selector).getByRole('button', {name: 'Edit', exact: true}).click();
await page.locator(selector).getByRole('button', {name: 'Edit', exact: true}).click();
await page.getByLabel('Note text', {exact: true}).fill(`Updated ${engine} ${size}`);
await page.getByRole('button', {name: 'Save note', exact: true}).click();
await page.locator(selector).getByText(`Updated ${engine} ${size}`, {exact: true}).waitFor();
const draft = `Kept draft ${engine} ${size}`;
await other.getByLabel('Note text', {exact: true}).fill(draft);
await other.getByRole('button', {name: 'Save note', exact: true}).click();
await other.getByText('The note changed.', {exact: false}).waitFor();
assert.equal(await other.getByLabel('Note text').inputValue(), draft);
await other.close();
await page.locator(selector).getByRole('button', {name: 'Delete', exact: true}).click();
await page.locator(selector).waitFor({state: 'detached'});
await page.getByLabel('Note text').fill(draft);
await page.route('**/api/notes', route => route.request().method() === 'POST' ? route.abort() : route.continue(), {times: 1});
await page.getByRole('button', {name: 'Save note', exact: true}).click();
await page.getByText('Connection failed.', {exact: false}).waitFor();
assert.equal(await page.getByLabel('Note text').inputValue(), draft);
let release;
const delayed = new Promise(resolve => { release = resolve; });
let observed;
const started = new Promise(resolve => { observed = resolve; });
await page.route('**/api/notes', async route => { const response = await route.fetch(); observed(); await delayed; await route.fulfill({response}); }, {times: 1});
await page.getByRole('button', {name: 'Refresh list', exact: true}).click();
await started;
await page.getByRole('link', {name: 'About this example', exact: true}).click();
await page.getByRole('heading', {name: 'About Notes', exact: true}).waitFor();
release();
await page.waitForTimeout(100);
assert.equal(await page.locator('#notes-message').count(), 0);
await page.getByRole('link', {name: 'Back to notes', exact: true}).click();
await page.getByText('Notes loaded.', {exact: true}).waitFor();
assert.equal(await page.locator(selector).count(), 0);
result.steps = ['real UI create/reload/edit/delete against Go API', 'HTML-looking text stays literal', 'second-tab stale edit keeps draft', 'transport failure keeps draft without retry', 'late real GET cannot touch an unmounted page'];
}
await page.screenshot({path: path.join(output, `${name}-${engine}-${size}.png`), fullPage: true});
assert.deepEqual(result.errors, []);
result.status = 'PASS';
} catch (error) {
result.status = 'FAIL'; result.error = error.message;
await page.screenshot({path: path.join(output, `${name}-${engine}-${size}-failure.png`), fullPage: true}).catch(() => {});
throw error;
} finally { await context.close(); }
}
}
} finally { await browser.close(); }
}
} finally {
for (const server of servers) await new Promise(resolve => server.close(resolve));
if (notes?.exitCode === null) { notes.kill('SIGTERM'); await once(notes, 'exit'); }
fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify(results, null, 2) + '\n');
}
console.log(JSON.stringify(results));
Loading
Loading