Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions .changeset/plain-donkeys-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@amritk/mini-lynx-rsbuild-plugin': minor
---

Add `@amritk/mini-lynx-rsbuild-plugin` — the rspeedy build for a
`@amritk/mini-lynx` app, so `rspeedy dev` → QR code → Lynx Explorer works on day
one.

A Lynx template is a container with two code slots — a main-thread chunk the
engine executes to build the first screen and a background chunk it loads as
`/app-service.js` — plus CSS the encoder compiles. rspeedy builds the container;
which code goes in which slot is the framework's to say, and every framework
says it in a plugin of its own. This is that plugin for a runtime that renders
on the main thread and drives the Element PAPI itself: `pluginMiniLynx()` splits
one entry into two chunks, adds the template plugin and encoder, sets the
`lynx:main-thread` asset flag the encoder splits on, wraps the background chunk
for the engine's module loader, and points the JSX transform at
`@amritk/mini-lynx`.

In `dev` it wires the dev-server client and a devtool reload. Nothing hot-updates
and nothing should — a component runs once here — so an update no module accepts
falls through to a page reload, which is safe because `renderPage` claims
`removeComponents`.

`apps/starter-mini-lynx` is a four-file app built through it, and
`docs/mini-lynx-explorer.md` records the loop along with what has been verified
(one real build per commit, whose main-thread chunk is executed against the fake
Element PAPI and asserted on) and what a device still has to answer.
70 changes: 67 additions & 3 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ mini/
│ ├── lynx-location/ # @amritk/lynx-location — the second, built from its shape
│ ├── lynx-dialogs/ # @amritk/lynx-dialogs — the third: date picker, sheet, alert
│ ├── lynx-deep-linking/ # @amritk/lynx-deep-linking — the fourth, links in and out
│ └── lynx-secure-storage/ # @amritk/lynx-secure-storage — the fifth, a credential on disk
├── apps/ # Private kitchen-sink playgrounds, deployed to Cloudflare
│ ├── lynx-secure-storage/ # @amritk/lynx-secure-storage — the fifth, a credential on disk
│ └── mini-lynx-rsbuild-plugin/ # @amritk/mini-lynx-rsbuild-plugin — the rspeedy build
├── apps/ # Private apps: two playgrounds, one device starter
│ ├── playground-mini/ # every @amritk/mini entry point, running
│ └── playground-mini-lynx/# every @amritk/mini-lynx entry point, through a DOM Element PAPI
│ ├── playground-mini-lynx/# every @amritk/mini-lynx entry point, through a DOM Element PAPI
│ └── starter-mini-lynx/ # a real .lynx.bundle, built by rspeedy, run in Lynx Explorer
├── .claude/ # Developer guidelines
├── .changeset/ # Changesets config (release automation)
├── .github/ # CI, release, bench, issue & PR templates
Expand Down Expand Up @@ -418,6 +420,53 @@ behaviour are all platform behaviours, and all three are what the package is
*for*. The caveat is carried in its `README.md`, `AI.md` and `AGENTS.md` exactly
as its siblings carry theirs.

### `@amritk/mini-lynx-rsbuild-plugin` (`packages/mini-lynx-rsbuild-plugin`)

The only package here that runs on a laptop rather than on a phone: one rsbuild
plugin that teaches [rspeedy](https://lynxjs.org/rspeedy) how to build a
`@amritk/mini-lynx` app into a `.lynx.bundle`.

It exists because of a shape worth stating once. A Lynx template is not a bundle
with an entry point; it is a container with two code slots — a main-thread chunk
the engine executes to build the first screen, and a background chunk it loads
as `/app-service.js` — plus CSS the encoder compiles. rspeedy builds the
container, but **which code goes in which slot is the framework's to say**, and
every framework says it in a plugin of its own. `@lynx-js/react-rsbuild-plugin`
is ReactLynx's, and there is no framework-agnostic one beneath it:
`@lynx-js/rsbuild-plugin`, which sounds like one, is the dev server and the
per-thread minifier split. So the distance between "this runtime works" and "you
can run it on your phone" was ~150 lines of rspack wiring that every consumer
would otherwise write once each, wrongly.

What it does: splits one `source.entry` into a main-thread entry and a
background entry, adds `LynxTemplatePlugin` and `LynxEncodePlugin`, flags the
main-thread chunk with the `lynx:main-thread` asset info the encoder splits on,
wraps the background chunk for the engine's module loader, and points the JSX
transform at `@amritk/mini-lynx`. In `dev` it also puts the dev-server client
and `@rspack/core/hot/dev-server` on the front of the background chunk, so an
edit reloads the page through the devtool — there is no hot update here, and
there should not be: a component runs once, so there is nothing for a module
diff to be applied to, and `renderPage`'s `removeComponents` is what makes the
reload correct.

Two things about it are unlike the rest of the repo:

- **Its module load must stay side-effect free.** `@lynx-js/template-webpack-plugin`
opens a worker pool on import, and `scripts/dist-smoke.test.ts` imports every
published module under Node while `scripts/consumer-e2e.test.ts` imports the
entry from an install that has no `@lynx-js/*` in it at all. Both heavy
imports are dynamic, inside `modifyBundlerChain`.
- **Its test runs the artifact.** `src/build.test.ts` performs one real rspeedy
build and then executes the built main-thread chunk in a `node:vm` context
whose globals are `createFakeEngine`'s Element PAPI, asserting on the tree it
renders and on `firstScreen`. Everything this plugin can get wrong — a JSX
transform pointed at React, a wrapper on the wrong chunk, a missing
main-thread flag — is a template that encodes cleanly and a device that shows
nothing, so asserting on the config would assert on the wrong thing.

[`docs/mini-lynx-explorer.md`](../docs/mini-lynx-explorer.md) records the loop
and, more usefully, which links in it have been verified and which have not.

## The playgrounds (`apps/`)

Two private, unpublished apps — `@amritk/playground-mini` and
Expand Down Expand Up @@ -467,6 +516,21 @@ Two conventions keep them honest, and both are worth preserving:
without throwing, which is the cheapest guard against a screen that only fails
for whoever next opens that tab.

### `apps/starter-mini-lynx` — the third app, and not a playground

Four files that build to a real `.lynx.bundle` and run on a device through Lynx
Explorer. It is the build plugin's consumer, in the sense the playgrounds are
the runtimes' consumers, and it is the only app here that does not run in a
browser: `bun run dev` prints a QR code rather than opening localhost.

It is deliberately small. A playground's job is coverage; a starter's is to be
copied, so it carries a counter, `globalProps`, and the native bridge's
round-trip — the last because a missing background chunk is otherwise invisible,
and putting the answer on screen turns it into something a person holding a
phone can read. Its `src/app.test.ts` drives the entry the way the engine does,
which doubles as the shortest demonstration that an app on this runtime needs no
device to be tested.

`bun run check:reactivity` scans `apps/` alongside `packages/` for the same
reason — the called-signal footgun is a consumer's mistake to make, so the
consumer-shaped code is where it is most likely to appear.
Expand Down
28 changes: 26 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,29 @@ both re-export it from the subpath it already lived on.
Each is independently published and carries its own `AGENTS.md` with the
invariants that package cannot break.

One package is not a runtime at all:
[`packages/mini-lynx-rsbuild-plugin`](./packages/mini-lynx-rsbuild-plugin) —
`@amritk/mini-lynx-rsbuild-plugin`, the rspeedy build for a mini-lynx app. It
runs on a laptop rather than on a phone, and it exists because a Lynx template
is a container with two code slots rather than a bundle with an entry point:
which code goes in which slot is the framework's to say, ReactLynx says it in
`@lynx-js/react-rsbuild-plugin`, and there is no framework-agnostic plugin
underneath to reuse. Its own `AGENTS.md` carries the invariants; the loop it
serves, and what about that loop has and has not been verified, is
[`docs/mini-lynx-explorer.md`](./docs/mini-lynx-explorer.md).

Alongside them sit two private kitchen-sink playgrounds — `apps/playground-mini`
and `apps/playground-mini-lynx` — that exercise every public entry point and
deploy to Cloudflare Workers as static SPAs. They are the only code here written
the way a consumer writes it, which makes them the fastest way to see a change
and the place composition-level defects surface first.

A third app, `apps/starter-mini-lynx`, is a **starter** rather than a
playground: four files, built by rspeedy into a real `.lynx.bundle` and opened
on a device through Lynx Explorer. It is the build plugin's playground in the
sense the rule below means — the only place that package is used the way a
consumer uses it — and the only app here that does not run in a browser.

`apps/playground-mini-lynx` covers the bridge and all five native modules too,
which a browser has no more of than it has an engine. Its `src/lib/fake-device.ts`
is the answer: it installs both halves of the bridge over the fake contexts the
Expand All @@ -103,6 +120,13 @@ and every composition-level defect this repo has found was found there rather
than in a suite. A package with no screen is a package nobody has actually
tried.

The rule is about *being used like a consumer uses it*, not about the screen.
`@amritk/mini-lynx-rsbuild-plugin` has no runtime surface to render, so its
equivalent is `apps/starter-mini-lynx`: a real app that builds through it, plus
a test that runs one real rspeedy build and drives the artifact. A build-time
package is finished when a change to it can break something a consumer would
notice, in this repo, without a device.

## Workflow

```bash
Expand All @@ -112,8 +136,8 @@ bun run check # biome lint + format check
bun run check:reactivity # guard the compilerless-JSX called-signal footgun (packages + apps)
bun run check:ai-docs # every package's AI.md against what that package actually exports
bun run check:android # compile the notifications Kotlin (needs ANDROID_HOME; skips without)
bun run types:check # type-check both packages and both playgrounds
bun run build # build both packages and both playgrounds
bun run types:check # type-check every package and every app
bun run build # build every package and every app (the starter builds a .lynx.bundle)
bun run test:dist # load and drive the built dist/ artifacts (needs a prior build)
bun run bench -- --baseline <dir> # bundle-size delta against another checkout
```
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,25 @@ You'll need [Bun](https://bun.sh) ≥ 1.1.
| `bun run format` | Auto-format with biome |
| `bun run check:reactivity` | Catch signals frozen by being called in JSX |
| `bun run check:ai-docs` | Check every package's `AI.md` against what it publishes |
| `bun run types:check` | Type-check both packages and both playgrounds |
| `bun run build` | Build both packages and both playgrounds |
| `bun run types:check` | Type-check every package and every app |
| `bun run build` | Build every package and every app |
| `bun run test:dist` | Load, drive and npm-install the built artifacts (needs a prior build) |

Per package: `bun run --filter='@amritk/mini' test` (and `build`, `types:check`).

`bun run test` covers `packages/*` only — the kitchen-sink playgrounds under
`apps/` carry no tests of their own, and `bun run build` / `bun run types:check`
are what keep them honest. Run one with
`bun run test` covers `packages/*` plus the two apps that have suites of their
own; `apps/playground-mini` has none, and `bun run build` / `bun run types:check`
are what keep it honest. Run one with
`bun run --filter='@amritk/playground-mini' dev`, and see
[`apps/playground-mini`](./apps/playground-mini/README.md) and
[`apps/playground-mini-lynx`](./apps/playground-mini-lynx/README.md) for what
each demonstrates.

`apps/starter-mini-lynx` is the odd one out: not a playground but a four-file
starter that rspeedy builds into a real `.lynx.bundle` for
[Lynx Explorer](./docs/mini-lynx-explorer.md). `bun run --filter='@amritk/starter-mini-lynx' dev`
prints a QR code rather than opening a browser.

## Workflow

1. Create a branch off `main`.
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ shapes:
| **[`@amritk/mini`](./packages/mini)** | The DOM. Reactive bindings, keyed lists, static-template cloning, and a compilerless JSX runtime. |
| **[`@amritk/mini-lynx`](./packages/mini-lynx)** | **Lynx**, through its Element PAPI. The engine's own elements, attributes and events — no vocabulary in between. |

A third package, **[`@amritk/mini-helpers`](./packages/mini-helpers)**, holds the
Getting a Lynx app onto a phone takes one more package —
**[`@amritk/mini-lynx-rsbuild-plugin`](./packages/mini-lynx-rsbuild-plugin)**,
the [rspeedy](https://lynxjs.org/rspeedy) build for a mini-lynx app: two chunks,
one `.lynx.bundle`, and a QR code Lynx Explorer scans. See
[`docs/mini-lynx-explorer.md`](./docs/mini-lynx-explorer.md).

A third runtime package, **[`@amritk/mini-helpers`](./packages/mini-helpers)**, holds the
handful of helpers that turned out identical in both — route matching, query
parsing, JSON Schema compilation. Both packages depend on it and re-export it, so
you never import it directly; it is separate only because its charter is worth
Expand Down Expand Up @@ -125,6 +131,8 @@ package READMEs:
subpaths.
- [`@amritk/mini-helpers`](./packages/mini-helpers/README.md) — the pure helpers
both of the above share, and the bar for adding to them.
- [`@amritk/mini-lynx-rsbuild-plugin`](./packages/mini-lynx-rsbuild-plugin/README.md)
— the rspeedy build: `rspeedy dev`, a QR code, and the app on your phone.

## Playgrounds

Expand All @@ -145,6 +153,15 @@ bun run --filter '@amritk/playground-mini' dev
bun run --filter '@amritk/playground-mini-lynx' dev
```

A third app is not a playground but a **starter**:
[`starter-mini-lynx`](./apps/starter-mini-lynx/README.md) — four files, built by
rspeedy into a real `.lynx.bundle` and opened on a phone through Lynx Explorer.
It is the only app here that runs on a device rather than in a browser.

```sh
bun run --filter '@amritk/starter-mini-lynx' dev # prints a QR code
```

## For AI agents & LLMs

Each package ships an **`AI.md`** next to its `README.md`: a mental model, a
Expand Down
66 changes: 66 additions & 0 deletions apps/starter-mini-lynx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# @amritk/starter-mini-lynx

The smallest **real** `@amritk/mini-lynx` app: it builds to a `.lynx.bundle`,
`rspeedy dev` serves it, and Lynx Explorer runs it from a QR code.

Everything else in this repo runs in a browser or a test process. This one is
for a phone.

```bash
bun install
bun run dev # prints two QR codes; scan either with Lynx Explorer
bun run build # dist/main.lynx.bundle
bun run test # the app, driven through the fake Element PAPI — no device
```

The loop it belongs to, including where to get Explorer and what to check when
the phone shows nothing, is [`docs/mini-lynx-explorer.md`](../../docs/mini-lynx-explorer.md).

## The four files

```
lynx.config.ts rspeedy: the entry, the QR plugin, pluginMiniLynx
src/main-thread.tsx the main-thread chunk — the whole app, and the entry
src/background.ts the background chunk — installs the native bridge
src/app.css real CSS, compiled into the template by the encoder
```

There is no `App.tsx` importing an `index.tsx`, and no framework entry to
extend. `renderPage(App)` at the bottom of `main-thread.tsx` **is** the entry:
it installs the global the engine calls once at startup.

## Why two chunks

A Lynx bundle carries two code slots, and they are two different JavaScript
contexts on the device:

- The **main thread** runs the Element PAPI, which is what `@amritk/mini-lynx`
drives. Your components, your tree, your event handlers — a handler here runs
in the same frame as the gesture that triggered it.
- The **background thread** is the only place `NativeModules` and
`GlobalEventEmitter` exist.

`src/background.ts` is one line: `installNativeBridge()`, the background half of
[`@amritk/mini-lynx-native`](../../packages/mini-lynx-native). The screen
labelled *background chunk* is that wire being exercised — the main-thread side
asks whether a module is reachable, the answer crosses the thread boundary, and
a signal is written when it lands. A `no answer` there means the background
chunk is not in the bundle; on a device it should say `bridge answered`.

## What each screen is showing

| Card | What it exercises |
| --- | --- |
| the counter | a signal driving a `<text>` binding, and one element mutated rather than a subtree rebuilt |
| `globalProps` | the platform's own values as a signal — theme, locale, whatever your host pushes |
| `background chunk` | the two-chunk build, end to end, over the native bridge |

## Copy it

This directory is a starting point, not a demo of a framework's features. To
take it: copy the four files, replace `workspace:*` with the published versions
of `@amritk/mini-lynx`, `@amritk/mini-lynx-native` and
`@amritk/mini-lynx-rsbuild-plugin`, and delete this README.

Private and never published; it exists to be read and to be the thing this
repo's build plugin is tried against.
15 changes: 15 additions & 0 deletions apps/starter-mini-lynx/lynx.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { pluginMiniLynx } from '@amritk/mini-lynx-rsbuild-plugin'
import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
import { defineConfig } from '@lynx-js/rspeedy'

export default defineConfig({
source: { entry: { main: './src/main-thread.tsx' } },
plugins: [
// What `rspeedy dev` prints for a phone to scan. `fullscreen` adds a second
// QR carrying Explorer's own `?fullscreen=true` flag, which hides its
// chrome so the screen is the app rather than the shell around it; press
// `a` in the terminal to switch between the two.
pluginQRCode({ fullscreen: true }),
pluginMiniLynx({ background: './src/background.ts' }),
],
})
27 changes: 27 additions & 0 deletions apps/starter-mini-lynx/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@amritk/starter-mini-lynx",
"version": "0.0.0",
"private": true,
"description": "The smallest real @amritk/mini-lynx app: rspeedy dev, a QR code, and Lynx Explorer.",
"type": "module",
"scripts": {
"dev": "rspeedy dev",
"build": "rspeedy build",
"preview": "rspeedy preview",
"types:check": "tsgo -p . --noEmit",
"test": "vitest run"
},
"dependencies": {
"@amritk/mini-lynx": "workspace:*",
"@amritk/mini-lynx-native": "workspace:*",
"@amritk/mini-lynx-rsbuild-plugin": "workspace:*"
},
"devDependencies": {
"@lynx-js/qrcode-rsbuild-plugin": "^0.6.0",
"@lynx-js/rspeedy": "^0.16.4",
"@lynx-js/types": "^4.0.0",
"@rsbuild/core": "^2.1.10",
"typescript": "^5",
"vitest": "^4.1.5"
}
}
Loading
Loading