Skip to content

Let a stream script yield its output a piece at a time (1.0.0-rc.13) - #146

Merged
collin-thomas merged 4 commits into
mainfrom
pre-release-20261111
Nov 12, 2026
Merged

collin-thomas merged 4 commits into
mainfrom
pre-release-20261111

Conversation

@collin-thomas

Copy link
Copy Markdown
Collaborator

What this adds

A data-bascik-stream script written as a generator sends each value it yields to the browser as soon as it is yielded, where the script tag was:

<ul>
  <script data-bascik-stream type="text/typescript">
    import { html } from "@bascik/bascik/html";
    import { loadBooks } from "@/lib/books.ts";

    export default async function* () {
      for await (const book of loadBooks()) {
        yield html`<li>${book.title} by ${book.author}</li>`;
      }
    }
  </script>
</ul>

Before this, a generator was refused as [object AsyncGenerator] and left an empty slot, so streaming a list row by row took one script tag per row.

Behavior

  • Each yielded value follows the rules for a returned one (string, html value, array), gets the component's scoped class names, and may hold component tags, which are rendered as the value is sent.
  • The existing CSS recipes work unchanged: content below the list can paint first (source order plus grid areas), :has() hides a placeholder when the first row lands, and rows can cover skeleton rows one at a time.
  • A failure part-way keeps the rows already sent and follows scripts.onServerScriptError; the rest of the page still streams.
  • scripts.timeout counts only the time the script spends producing values, not the time the visitor's connection takes to accept them.
  • When the visitor leaves or the script times out, the generator is ended and its signal aborts, so finally blocks run.
  • A data-bascik-server script may be a generator too: its values are joined before the page is sent, so a failure is still a 500.
  • A value the generator returns is sent last, so an early return for an empty list is not lost.
  • Works on the dev server, bascik --server, the Cloudflare adapter and the Netlify adapter.

Two changes to existing behavior

  • A script that returns a generator object is now run instead of refused.
  • A returned typed array (Uint8Array) is refused with a decode hint. It used to print its byte values as comma-separated numbers. A Node Buffer still prints its text.

For adapter authors

@bascik/bascik/runtime gains ScriptOutputRunner (a runner that may resolve to an async iterable of chunks), chunks: true on invokeLoadedWithDeadline, and scriptChunkOutput, scriptChunkIterator and collectScriptJobOutput. A ScriptJobRunner that returns strings keeps working unchanged. The custom adapter tutorial's job runner is updated.

Docs and demos

  • New section "Yield rows as they arrive" on the Stream Scripts page, with recipes for skeleton rows, fastest-first and cleanup. The outputs shown were captured from a real bascik --server run.
  • A /stream-rows page on both adapter demo sites, in the nav, the overview and the shared browser contract. The docs link to it, so those links resolve once this merges and the demo deploys.
  • Versions: @bascik/bascik, create-bascik and @bascik/adapter-cloudflare 1.0.0-rc.13; @bascik/adapter-netlify 1.0.0-rc.3. Changelog section added.

Decisions worth a look

  • A stream script ahead of the write cursor is pulled once: it runs to its first yield and holds that one value until its turn, so adjacent scripts overlap the way returning scripts already do.
  • Each yield should hold whole elements. Scoping and component rendering run per yielded value for a stream script. The docs say so, and a server script is exempt because its values are joined first.
  • The limit of 64 nested component scripts counts the whole response, so a long list of cards that each run their own script needs a higher scripts.renderLimits.maxNestedScripts. Documented, not changed.
  • Not done: the per-host output policy (print, refuse, scope, report) is still written three times (Node, Cloudflare, Netlify), as it was before this change. Folding it into one runtime helper is a follow-up.

Tests

  • Unit: scheduler and deadline (request-execution-chunks.test.ts), Node host (server-scripts-chunks.test.ts), component rendering (request-render-chunks.test.ts), output classification, and both adapter runtimes.
  • Browser: nine new specs in server-scripts-stream.test.ts on the dev, HTTP/1.1 and HTTP/2 lanes (rows appended one at a time, chunk pacing on the wire, footer first with no layout shift, skeleton swap, failure part-way, components per chunk, HEAD, navigating away), plus a shared demo-contract spec that runs in workerd and Netlify Dev.
  • Local runs on the final commit: unit:all, integration:all, linear-time:all, all four pkg e2e lanes, the three exec lanes, docs e2e, both adapter suites and the adapter dev lane, shop build and e2e, webhint, docs Lighthouse, and the VS Code suite all pass, along with format, spelling and typecheck.
  • Not run: the pre-push skill and the coverage JSON regeneration, which the repo instructions leave to the maintainer.

🤖 Generated with Claude Code

A data-bascik-stream script written as a generator (export default async
function*) now sends each value it yields to the browser as soon as it is
yielded, where the script tag was. One script can stream a list row by row
from a cursor, a paged API or several calls, where it took one script tag
per row before. A generator used to be refused as "[object AsyncGenerator]"
and leave an empty slot.

- request-execution: a job runner may resolve to an async iterable of
  chunks (ScriptOutputRunner). The streamer writes each chunk of a stream
  job as it arrives and pulls the next only after that write resolved; a
  job ahead of the write cursor is pulled once and waits. Server jobs and
  buffered responses join the chunks. invokeLoadedWithDeadline takes
  chunks: true and returns a ScriptChunkSource whose deadline counts only
  the time a pull is pending, so a slow client does not time a script out.
- Node, Cloudflare and Netlify hosts print every yielded value by the
  rules for a returned one (string, html value, array; scoped class names,
  ANSI stripping) under the same onServerScriptError policy. A failure
  part-way keeps the rows already sent. When the client leaves or the
  script times out, the generator is ended so its finally blocks run. A
  value the generator returns is sent last.
- Request-time component rendering runs per yielded value for a stream
  job (one stylesheet per response, ids that never repeat across chunks)
  and on the joined output for a server job.
- A typed array is refused as output with a decode hint instead of
  printing its numbers; a Buffer still prints its text.
- Docs: "Yield rows as they arrive" on the Stream Scripts page with
  recipes for skeleton rows, fastest-first and cleanup; the custom adapter
  tutorial, the runtime reference, the server internals page and the
  bascik-scripts skill. A /stream-rows page on both adapter demo sites.
- Versions: @bascik/bascik, create-bascik and @bascik/adapter-cloudflare
  1.0.0-rc.13, @bascik/adapter-netlify 1.0.0-rc.3.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Shop preview: https://bascik-shop-pr-146.collin-inbox.workers.dev

Stripe sandbox payments are enabled. Paid orders create unconfirmed Printful drafts only. Production confirmation and customer email are blocked in test mode. No live domain changes.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Site Previews

Built from 96e152a on pre-release-20261111. Preview URLs stay the same for this pull request and show its newest successful build. Their hostname prefix is pr-<number> (or the branch name when there is no pull request), not the hosting provider. None of them is production.

Site Hosting resource Links
Bascik docs (Cloudflare branch preview) bascik / · /components · /deployment/cloudflare
Cloudflare adapter (branch preview) cloudflare-adapter / · /catalog · /catalog-stream
Netlify adapter (branch preview) Netlify: bascik-netlify-adapter / · /catalog · /catalog-stream

Deploy run

collin-thomas and others added 3 commits November 11, 2026 22:07
On the /stream-rows demo pages a row that had arrived went dim and solid in
turn. The skeleton rows pulse by animating opacity, which makes each one a
stacking layer of its own, and layers are painted above ordinary content
sharing the same grid cell, so the skeleton showed through on top of the
rows. The list of real rows now has its own layer above the skeletons
(position: relative; z-index: 1) on both adapter demo pages, in the docs
recipe, and in the pkg e2e fixture, whose skeleton rows now pulse too.

The demo contract and the pkg stream spec check that the topmost element
at the first row's center is the row. The contract's position check now
compares the two lists instead of the row, which slides in for a moment.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The adapter tests compile without DOM globals, so a bare `document` in an
evaluate callback failed the workspace typecheck.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@collin-thomas
collin-thomas merged commit 34a4c06 into main Nov 12, 2026
111 of 112 checks passed
@collin-thomas
collin-thomas deleted the pre-release-20261111 branch November 12, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant