Skip to content

Releases: C9up/aurora

v0.1.48

Choose a tag to compare

@github-actions github-actions released this 01 Oct 08:55

release: aurora 0.1.48

Refuse a slot in a script's URL, and name the file input that cannot take a value

A third audit, all four findings confirmed.

<script src> was guarded like any other URL: javascript:, vbscript: and data: were neutralised and everything else went through. But every URL in that position is code with the page's authority — https:// on another origin runs, and so does a blob: the page minted from someone's upload; Chromium ran one from the markup the server used to emit. No check on the value can tell the script the author meant from one the value picked, so a slot there is now refused on every path: src in HTML, href and xlink:href on an SVG script, and the .src property. A script chosen at runtime is a decision for code that creates the element itself. A non-empty .value on an threw the DOM's own InvalidStateError on the client render and on hydration, which named neither the binding nor the fix. aurora now throws E_AURORA_FILE_INPUT_VALUE first, pointing at "" to clear the field. The server's attribute is left alone: the browser ignores it, and the scanner does not read the value of type. HttpClient.extend() copied every default but the XSRF settings, so a derived client fell back to the default cookie and header names and stopped sending the CSRF header an app had configured. They are inherited now, and overridable like the rest. And liveClient's doc still required a reactive slot to be the only content of its element. SSR has wrapped every text slot in markers since, so `Count: ${count}!` hydrates and patches; the rule is gone and a test shows it. ### Close the scripts foreign content left open, and guard what loads code Two more audits, every execution finding confirmed in Chromium before it was fixed. The proofs live in tests/browser/execution-contexts.test.ts: each case renders the markup the old code emitted into a same-origin srcdoc iframe and watches a probe get set, then shows the new code refuse. createContextualFragment was the first attempt and it lied — it never runs an SVG script, so it made a live hole look closed. <script> is a script. The previous round suspended the raw-text rules under because the tokenizer does not switch state there, which is true of the tokenizer and irrelevant to the result: an SVG script element still runs its text content. The same holds for <style>. Both are now tracked as code inside foreign content and a slot in them is refused. MathML gets its remaining integration points (mi, mo, mn, ms, mtext), and foreign content now ends where the parser ends it — a ,

, and the rest of the breakout list, and the

and
end tags, pop back to HTML, so

<script> is an HTML script again, not SVG text. <script/> is not self-closing. In HTML a trailing slash is ignored on everything but a void element, so <script/>${v} put the value in a live script while the scanner believed the tag closed. The slash is honoured only in foreign content and on and themselves, and a space between the slash and the > cancels it —

Read more

v0.1.47

Choose a tag to compare

@github-actions github-actions released this 23 Sep 16:34

Give each render its own id sequence, not the process one

renderPage reset the counter and then awaited — resolving the page module,
gathering shared props, calling the component. Two requests overlapping across
any of those awaits shared one counter: the second reset it under the first,
and that response shipped trigger-2 while its own browser, hydrating from
zero, looked for trigger-1. Every id-based lookup then answered null, which
is silent — a tooltip that never opens on a page where everything else works.

Found by an external audit. The sequential tests could not see it, and now
there is one that does: two renders interleaved inside resolve(), both
expected to start at 1. It fails against the old counter.

The counter moved into the per-request AsyncLocalStorage renderPage already
runs — the same seam the cookie store and the route manifest use. id.ts
imports nothing from node:: it reads its cell through a reader the server
installs, and falls back to a module-level one in the browser, where a page is
rendered once and there is nothing to race.

release: aurora 0.1.47

Keep both lifecycles, and reset the id sequence per pass

Two bugs, one symptom: a component whose whole job happens on mount doing
nothing at all, silently, on a page where every binding works. A tooltip that
never opens while its trigger's aria-describedby toggles correctly.

FIRST — the lifecycle rides on the returned TemplateResult, under a symbol. A
component is free to return another one's result rather than a template of its
own:

const TextField = component((props) => Field({ ... }))
const Passthrough = component((props) => props.children)

and the outer wrap assigned straight over the inner one. The inner component's
setup ran, its bindings worked, its onMount was dropped. Merged now, outer
first — the order the renderer drains the queue in everywhere else.

SECOND — ids. Templates have no ref directive, so a component that must anchor
or measure a node finds it by id, and ARIA needs one regardless. The counter is
monotonic, which makes the sequence survive hydration ONLY if both passes start
from the same place. A server process is long-lived: with no reset its counter
climbs across requests, so the second page it serves ships trigger-14 while the
browser, starting fresh, looks up trigger-1. Every lookup then answers null —
and answers it silently.

uid / resetIds / byId now live here, and renderPage and hydrate each
reset before they build. nebula had its own counter, which saw none of those
resets; it re-exports these instead.


Changes since v0.1.46.

v0.1.46

Choose a tag to compare

@github-actions github-actions released this 22 Sep 21:07

release: aurora 0.1.46

Serve the assets ahead of the application, not as routes

A page ships unbundled, so one load is dozens of .js requests — ninety-six
on a real application. Mounted as routes on the host router, each one traversed
the whole middleware stack, and an application that resolves a user from a
session cookie therefore ran SELECT * FROM users ninety-six times for bytes
that are identical for every visitor.

Every application otherwise had to write the exemption itself, in its own
kernel, for a cost the framework created. Most never would, and nothing would
tell them.

So the provider resolves 'server' instead of 'router' and installs one
middleware covering every mount — the tier @adonisjs/static registers on, and
never as a route. Providers start before the preloads, which is what puts it
ahead of the application's own server.use([...]).

Only GET and HEAD are claimed; the longest prefix wins, so /__assets/comet/dist
beats a mount at /__assets/comet whatever order they were built in.


Changes since v0.1.45.

v0.1.45

Choose a tag to compare

@github-actions github-actions released this 22 Sep 18:51

Add a context primitive for compound components

A compound component — Select, Tooltip, DropdownMenu — owns state that every
one of its parts reads: the open flag, the active item, the shared ids.
Without a way to hand that down, each part takes it as a prop and every level
in between carries props it does not use, until the call site no longer looks
like the thing it builds.

createContext / provide / inject hang off the context stack component()
already maintains for onMount, so there is no second ownership mechanism to
keep in step. inject walks outward to the nearest provider; a context with no
default raises E_AURORA_MISSING_CONTEXT naming itself, because a part used
outside its parent is a mistake rather than a case to handle.

The one constraint is inherent to evaluating eagerly, and is documented rather
than worked around: a descendant sees the value only if the provider creates it
inside its own setup. Parent({ children: Item() }) runs Item() first and
gets nothing; Parent({ children: () => Item() }) works. A provider therefore
takes its children as a function and calls them itself.

Folded into the unpublished 0.1.46 rather than incremented.

Run onMount for a component that appears after hydration

Hydration collected mount hooks in one flat list and drained it once, at
the end of the first pass. A reactive slot that swapped its content later
kept appending to that same list, and nothing drained it again — so a
component built when the data arrived ran its setup and never its
onMount. The renderer had already met this and solved it; its
MountQueue comment describes the failure exactly. Hydration still
handed that queue flushed: false, hard-coded, so the flag it exists for
never told anyone the root was done.

flushed flips once the root has run what hydration collected. Anything
built afterwards runs its own hooks, after insertion, because a hook that
measures, positions or focuses has to see a live node.

Reactive bindings kept working throughout, which is what made it hard to
see: the symptom was a component whose whole job happens in onMount
doing nothing at all. A floating surface that only starts reacting once
it is in the document — tooltip, popover, dropdown, select — never
appeared, while the aria-describedby on its trigger toggled correctly.

A second defect came out of writing the test for the first: a subtree
hydrated inside a reactive slot put its hooks on the ROOT's queue, so
their teardowns belonged to the page rather than to the slot. Swapping
the slot removed the nodes and told the component nothing; it kept its
subscription until the whole page unmounted. That slot now carries its
own queue while hydrating too, and hands the hooks to the root's flush
through a wrapper that returns nothing — the real teardowns stay with the
slot.

Four unit tests and one in real Chromium, which is where the last one
belongs: the browser one asserts the node is laid out, not merely
attached, because a detached node measures zero and that is exactly what
a positioning hook would silently act on.

release: aurora 0.1.45

Require Node 24, and build the crates for production

Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.

Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.

CI moves to Node 24 with them, since that is what the packages now ask
for.

Say it in English

A French word had survived in a comment on the live-client patching
rules. The repository is English throughout; this one read as a note to
self.


Changes since v0.1.44.

v0.1.44

Choose a tag to compare

@github-actions github-actions released this 20 Sep 11:26

Make serving a package to the browser a mechanism

comet was one special case. chronos was a second, and I wrote it by
copying the first. atom would have been a third copy of the same
fourteen lines, which is the point at which a branch per package stops
being a shortcut and starts being the design.

config.browserPackages takes bare specifiers; DEFAULT_BROWSER_PACKAGES
keeps comet and chronos automatic, since aurora generates code that
imports them. Adding atom is now one line in config/aurora.ts instead
of ~200 across four files and a release.

What the mechanism knows, which the copies each had to be told: a
package needs its dist on a URL, its wasm sibling too WHEN it has one
-- probed, not declared, because a package gains or loses a wasm build
between releases and a list here would be one more thing to keep in
step with packages aurora does not own -- and an importmap entry
pointing INTO dist so a relative ../wasm/ lands on the sibling route
rather than climbing out of the mount. Never the package root, which
would answer both and also publish index.*.node.

Two packages resolving to the same mount now throw instead of serving
one of them unreachably, decided by registration order.

BEHAVIOUR CHANGE: comet's importmap entry moves from
/__assets/comet/index.js to /__assets/comet/dist/index.js, uniform with
every other served package. The importmap is regenerated on each render
and no consumer writes that URL, so nothing carries it forward.

Net 69 insertions against 106 deletions in src/.


Changes since v0.1.43.

v0.1.43

Choose a tag to compare

@github-actions github-actions released this 20 Sep 09:54

Say when the client has taken over

Nothing in aurora said it, so every caller that needed to know guessed
or polled -- in tests and in the application alike.

Read from the runtimes that solve the same problem before designing
one: @adonisjs/inertia dispatches CustomEvents (inertia:${name}),
Hotwire has turbo:load and turbo:frame-load, htmx has htmx:load per
swapped fragment. Three things they agree on, none of them obvious. It
is an event, not a promise, because a promise is consumed once while
the client becomes ready again after every navigation. Granularity
matters as much as the event -- a page-level signal alone is either too
early or too late for a page of islands. And the name states the phase,
never the intent: load, settle, navigate, never ready.

So: aurora:hydrate on each root as it is adopted, bubbling; aurora:load
once on document after the last one settles.

Two deliberate departures. The event fires even when a root throws,
with the reason in detail.error and hydrationErrors() -- a signal
withheld on failure turns a race into a silent hang, and the waiter
cannot tell the two apart. And there is a readable state beside the
event, because an event alone carries the very race it removes:
attach a listener after it fired and you wait for ever. whenHydrated()
resolves at once when the work is done, the arrangement the DOM uses
with document.readyState beside DOMContentLoaded.


Changes since v0.1.42.

v0.1.42

Choose a tag to compare

@github-actions github-actions released this 19 Sep 15:26

Serve chronos to the browser, wasm included

aurora already serves @c9up/comet automatically so the RPC client's
bare import resolves with no app wiring. chronos needs the same, and
cannot have it the same way.

comet is one directory. chronos's dist/native.js reaches out with
import("../wasm/chronos_engine_wasm.js"), a SIBLING of the dist, and
wasm-bindgen's glue then fetches the binary beside itself -- so serving
the dist alone answers 404 twice. Serving the package root instead
would work and would also publish the five .node binaries, some 13 MB
of server-only code, over HTTP. Two narrow roots keep the relative path
intact and expose nothing else: the importmap points INTO dist, so
../wasm lands on the second route rather than climbing out of the
prefix.

serveAssets also had no .wasm content type, and application/wasm is not
cosmetic: WebAssembly.instantiateStreaming refuses anything else, and
the bindgen glue calls it first. Served as octet-stream the module
compiles nowhere.

Both routes are skipped when chronos is not installed, as comet's is.


Changes since v0.1.41.

v0.1.41

Choose a tag to compare

@github-actions github-actions released this 19 Sep 14:52

Release 0.1.41: the page reload reaches what a page imports


Changes since v0.1.40.

v0.1.40

Choose a tag to compare

@github-actions github-actions released this 19 Sep 08:40

The reload does work under a TypeScript runner

The note added with the fix said the opposite, as measured. Re-measured
at tsx 4.7.0, 4.19.2 and 4.23.13, under tsx and tsx watch, with pages
written as .js and as .ts: importing Page.js?v=1 then Page.js?v=2 yields
two instances in every one of them, registered resolve hooks included,
and the second import sees an edited template.

The earlier reading is what a silently failed hook registration looks
like — the .js/.ts bug fixed in the same commit — and it is indeed
indistinguishable from a runner that ignores hooks. Which is why the
claim is now held up by a test that spawns a process and reads what
comes back, rather than by a comment: without the hooks the second
render still says ORIGINAL, so the assertion carries its weight.

A page reloaded; what it imported did not

Pages.resolve busts the ESM cache for a page with its own mtime, and its
comment says why: Node keys modules by URL, so a stable one freezes the
first-imported version for the process lifetime. That is right, and it
covers exactly one file. A page's layout, its organisms and the services it
pulls in resolve relative to that URL and come out without a query — onto
stable URLs already in the cache. Editing a page therefore reloaded and
editing a template did not, which from the outside reads as the server
caching files rather than as a module registry doing its job.

Two halves, neither of which works alone. The page's token becomes the
newest mtime anywhere under the pages root, so any edit in the tree gives
it a new key; and a resolution hook stamps each import under that root with
its own mtime, so the re-imported page gets fresh children — only the ones
that actually changed, since an untouched module keeps its key.

An already-versioned URL is left alone by the hook. Re-stamping the page
with its own mtime is precisely the bug, reintroduced one level up.

Measured while building it, and it bounds what this can do: the whole
approach rests on Node keying modules by full URL. Under plain Node it
does — two tokens, two instances, the second sees the edit. Under tsx it
does not: the query is normalised away, both imports return the same
instance, and a registered resolve hook never sees a relative specifier. So
under a TypeScript runner nothing here applies, including the page-level
busting that predates it, and the only reload left is restarting the
process. That belongs to a watcher, and a dev script should watch the page
sources as well as the server's own.

The registration reports a failure instead of swallowing it. The first
version caught everything and said nothing, so ERR_MODULE_NOT_FOUND on a
hooks file that ships as .js and lives as .ts produced no symptom
except templates going on not reloading — this bug, one level up again.
Both extensions are now tried.

Declare the Node floor this package already has

24 of the cohort's packages declared engines.node >=22.0.0 and 8 did
not. Seven of the eight depend on @c9up/ream, which declares it, so
their real floor was already 22 -- just invisible to npm. helix is
standalone but its CI builds and tests on Node 22 only.

Undeclared, a Node 20 install succeeds without a word and the failure
arrives later as a runtime error far from its cause.

Give every aurora error a code

aurora threw plain Errors everywhere, so the only way to branch on a
failure was to match its message -- prose, free to change. The cohort
shape is E__; inker, eclipse, blackhole and prism
already carry it, aurora and rosetta did not.

AuroraError extends Error with a typed code and standard ErrorOptions,
so cause passes through. All twelve throw sites converted, messages
unchanged, and the class is exported from both barrels -- a caller can
now catch every aurora failure by one name. The module has no Node
imports, so the browser barrel stays node-free.

Upstream shape, read from the tarballs rather than recalled: Edge's
loader discriminates on a filesystem fact (error.code === 'ENOENT')
and names the path; @adonisjs/core wraps with a message about the
operation and keeps { cause }, both in configure.js and codemods.js.

Report why a page import failed, not that the page is missing

Pages.resolve() reported every import failure as "page not found",
against the page's own path. A page's module graph fails for reasons
that say nothing about whether the page is there: a syntax error
anywhere in the graph, a throw at module top level, an export missing
from a transitively imported module. The message asserted a false
cause, pointed at the one file certain to be present, and named the
real culprit only at the end of the sentence. It also flattened the
original error into a string, losing the stack.

The page's presence is now answered from the filesystem rather than
from the error text. Node raises ERR_MODULE_NOT_FOUND for a missing
specifier anywhere in the graph and names the page in both cases --
as the missing module when it is the page, as the IMPORTER when a
transitive is missing -- so a substring test mis-sorts the second.
Under a loader that is not plain Node the text differs entirely.

A missing export in dev also carries the reason it can be on disk and
absent from the loaded module at once: only the page URL is
cache-busted, so an edited module it imports stays frozen in the ESM
cache. The docs already covered the hot-hook fix; they now name the
symptom that leads there.

Keep the dev-dependency alignment, drop the workspace: protocol

The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.


Changes since v0.1.39.

v0.1.39

Choose a tag to compare

@github-actions github-actions released this 09 Sep 16:22

Describe the validate() contract instead of naming the project behind it

Parity is shape, never product identity. Comment only.

docs: the preloads run AFTER the providers start, not before

Upstream's warm-up is providers.start() -> the 'starting' hooks -> the preloads.
These comments said the opposite and placed advice on it.

fix(assets): match If-None-Match as a list, not as a string

A strict === answered 200 for three shapes a real client sends: *, a
comma-separated list of tags, and the weak form W/"…". A browser holding the
exact bytes therefore downloaded them again — which is most of what a validator
exists to prevent, so the ETag was doing half its job.

RFC 9110 §13.1.2 semantics, written here rather than imported: aurora does not
depend on ream, where the same function already lives.

fix(assets): serve a validator, and stop caching page sources in dev

serveAssets emitted public, max-age=60 and no ETag or Last-Modified, and
pageAssetsHandler took that default. Page modules are SOURCE files in
development: an edited page was handed back stale for a minute, with no way for
the browser even to ask whether it had changed, and config/aurora.ts offered
no way to say otherwise.

Responses now carry an ETag and honour If-None-Match. Hashed from the bytes
being sent rather than from mtime and size: a checkout, a rebuild or a touched
file all move the metadata without changing the content, and each would
needlessly re-download.

AssetsRequest.header() is OPTIONAL, so a host that only implements param()
keeps working -- it simply gets no conditional requests, which is what happened
before.

Page assets are no-cache outside production. That does not mean "do not
cache": it means "always ask", and the ETag makes the question nearly free.

fix(render): run onMount for a component built by a later update

onMount hooks are collected into one queue while the tree is built and flushed
once, after the fragment is in the document. A reactive slot that swapped its
content AFTERWARDS kept appending to that same queue, and nothing flushed it
again: the component's setup ran, its onMount never did, and the hooks piled up
for the life of the page with nothing logged.

That is every row-scoped component in a table -- rows arrive from an RPC, so
each one is a later update. A menu built that way toggled its state and never
opened a panel, which is most of a floating layer.

The queue now carries whether it has been flushed, so a slot knows to run its
own hooks. It collects them apart either way, because the TEARDOWNS belong to
the slot: on the root's list a component mounted per row kept its subscription
until the whole page unmounted. During the initial pass it defers to the root's
flush, since the nodes are still in a detached fragment and an onMount that
measures or focuses must see a live one.


Changes since v0.1.38.