From e5aafc69105f4bdb08257103648df6b872c7b9ea Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 9 Jul 2026 16:49:44 +0530 Subject: [PATCH 1/2] docs: add 12 SEO blog posts for shipped-but-unblogged features Cover framework features that shipped without a blog post yet, each a distinct high-intent topic with no overlap against the existing 29 posts: Next.js 16 file-routing parity, cancelling actions with AbortSignal, component-level Suspense streaming, SSR action-result seeding, native SQLite on Node and Bun, Bun.serve throughput, page and layout import-only elision, device-adaptive prefetch, per-action middleware, production error sanitization, file uploads and storage, and WebSockets. Topics were checked against the published posts to avoid direct or indirect duplication; two initial drafts (path aliases, HTTP-verb actions) were dropped as duplicates and replaced with file storage and WebSockets. Prose follows the house style and the em-dash / pause punctuation invariant. Closes #874 --- blog/bun-serve-vs-node-http.md | 60 ++++++++++++ blog/cancel-server-actions-abortsignal.md | 95 ++++++++++++++++++ blog/component-suspense-streaming.md | 79 +++++++++++++++ blog/device-adaptive-link-prefetch.md | 76 +++++++++++++++ blog/file-uploads-and-storage.md | 102 +++++++++++++++++++ blog/import-only-pages-zero-js.md | 57 +++++++++++ blog/native-sqlite-node-and-bun.md | 85 ++++++++++++++++ blog/nextjs-16-file-routing-parity.md | 113 ++++++++++++++++++++++ blog/per-action-middleware.md | 103 ++++++++++++++++++++ blog/sanitizing-server-action-errors.md | 82 ++++++++++++++++ blog/ssr-action-seeding-no-refetch.md | 75 ++++++++++++++ blog/websockets-and-realtime.md | 102 +++++++++++++++++++ 12 files changed, 1029 insertions(+) create mode 100644 blog/bun-serve-vs-node-http.md create mode 100644 blog/cancel-server-actions-abortsignal.md create mode 100644 blog/component-suspense-streaming.md create mode 100644 blog/device-adaptive-link-prefetch.md create mode 100644 blog/file-uploads-and-storage.md create mode 100644 blog/import-only-pages-zero-js.md create mode 100644 blog/native-sqlite-node-and-bun.md create mode 100644 blog/nextjs-16-file-routing-parity.md create mode 100644 blog/per-action-middleware.md create mode 100644 blog/sanitizing-server-action-errors.md create mode 100644 blog/ssr-action-seeding-no-refetch.md create mode 100644 blog/websockets-and-realtime.md diff --git a/blog/bun-serve-vs-node-http.md b/blog/bun-serve-vs-node-http.md new file mode 100644 index 00000000..6beea82b --- /dev/null +++ b/blog/bun-serve-vs-node-http.md @@ -0,0 +1,60 @@ +--- +title: "Why Bun.serve Beats the node:http Bridge (a ~1.9x Story)" +date: 2026-06-07T10:00:00+05:30 +slug: bun-serve-vs-node-http +description: "On Bun, WebJs serves through native Bun.serve instead of routing every request through Node's compatibility bridge. That is roughly 1.9x more requests per second on the listening path, at near-complete feature parity, with 103 Early Hints the one honest gap." +tags: bun, nodejs, performance, runtime, throughput +author: Vivek +--- + +You have a WebJs app in production. It is buildless, so the same `.ts` source runs on Node 24 and on Bun with nothing to recompile. One afternoon you try Bun under it, mostly because you have heard it is faster. You flip the runtime, run a load test, and the requests-per-second number on the listening path jumps by roughly 1.9x. You changed no code. That number is the whole subject of this post: where it comes from, what "the listening path" actually is, and the one feature you give up to get it. + +If you want the mechanics of how WebJs runs on two runtimes at all (the runtime-neutral seam, the two TypeScript strippers, the parity test matrix), that is a separate story in the companion post `node-and-bun-no-build`. This one is purely about throughput. + +# What "requests per second" and "the listening path" mean + +Let me define the pieces in plain terms, because the win lives in one specific place and it is easy to overstate. + +Every web server has a listening path, sometimes just called the listener. It is the code that accepts an incoming network connection, reads the raw HTTP bytes off the socket, hands a request object to your application, takes the response your app hands back, and writes it to the socket. It is the plumbing between the network and your code. Separate from it is your application work: the SSR, the routing, the queries, the actual WebJs logic. + +Requests per second (req/s) is simply how many of those accept-read-respond cycles the server turns through in one second under load. A leaner listening path means more req/s for the same application work, because less of each request's time is spent in the plumbing. + +So the 1.9x is a listening-path number. It is not 1.9x on your whole app end to end, because your SSR and queries cost the same on either runtime. It is 1.9x on the plumbing, which you get for free by choosing the runtime. + +# A compatibility bridge, and why it costs you + +Bun can run Node's built-in `node:http` module, which is a big reason so much of the Node ecosystem runs on Bun unmodified. But when Bun runs `node:http`, it runs a compatibility bridge (a compat bridge for short): a translation layer that emulates Node's HTTP request and response objects on top of Bun's own native machinery. Every single request pays that translation cost. You are asking Bun to pretend to be Node on the hottest path in the server. + +Bun also ships its own native HTTP server, `Bun.serve`, which speaks Bun's request and response objects directly with zero emulation. The catch is that `Bun.serve` is not shaped like `node:http`, so a framework that wants the native path cannot just flip a config value; it has to write a second listener that talks to `Bun.serve` on its own terms. + +WebJs writes that second listener. On Bun it serves through a native `Bun.serve` shell and skips the compat bridge entirely. On Node it serves through `node:http`. Your application code sits above that boundary and never knows which shell is underneath. + +```sh +# same app, same source. the runtime is a command choice: +npm run dev # Node, node:http listener +bun --bun run dev # Bun, native Bun.serve listener +``` + +Skipping the bridge is where the 1.9x comes from. Nothing in your app changed. You stopped paying a per-request translation tax that existed only so Bun could look like Node. + +# The one honest gap: 103 Early Hints + +I will not pretend the native path is a perfect superset of the bridged one, because the missing piece is worth naming plainly. + +The one Node-only feature the Bun listener cannot match is 103 Early Hints. That is an informational HTTP response (a preliminary status the server can send before the real one) that lets the server tell the browser to start preloading assets while the actual response is still being produced. It is a first-paint latency optimization. `Bun.serve` has no informational-response API at all, so there is nothing for WebJs to build on, and on Bun that optimization is simply off. + +That is the entire tradeoff. You give up one preload-timing feature and you get 1.9x on the listener in exchange. For most apps that is a clear win, and I would rather document the gap honestly than paper over it with a shim that fakes an API Bun does not have. + +# Trimming the per-request cost on the Bun path + +The listener choice is the headline, but a buildless server has to earn throughput in the small places too, because there is no build step ahead of time to absorb overhead. So the Bun request path got its own passes. + +Brotli compression on the Bun listener runs through `node:zlib` (#518), so a Bun-served response gets the same compression a Node-served one does, no native gap there. And the per-request overhead got trimmed directly (#773): one pass removed a full request-object clone that existed only to stamp the client's IP address onto every request, and another removed an extra stream hop that every compressed response was being bridged through. Neither was large alone, but they are per-request costs, so they multiply by your traffic. On the hot path, a clone you do not need and a stream hop you can collapse are exactly the kind of thing worth cutting by hand. + +# You pick the runtime + +The runtime is your choice, not the framework's. You write one WebJs app. Run it on Node and you get the mature `node:http` listener and 103 Early Hints. Run it on Bun and you get the native `Bun.serve` listener and roughly 1.9x the listening-path throughput, minus that one Early Hints feature. Everything else behaves the same. Pass `--runtime bun` to `webjs create` and the generated app is wired for Bun from the first commit, or run `bun create webjs ` and it detects the runtime for you. + +# The takeaway + +The 1.9x is not magic and it is not the whole app; it is the listening path, the plumbing that accepts a connection and produces a response. On Bun, WebJs serves through native `Bun.serve` instead of running `node:http` over a compatibility bridge, and skipping that per-request translation tax is where the throughput comes from. The honest cost is 103 Early Hints, which Bun has no API to support. Brotli compression rides `node:zlib` and the per-request overhead has been trimmed to keep the Bun path lean. You pick the runtime, and if you pick Bun you get a real throughput win on the hot path with almost no change in behavior. diff --git a/blog/cancel-server-actions-abortsignal.md b/blog/cancel-server-actions-abortsignal.md new file mode 100644 index 00000000..03c29564 --- /dev/null +++ b/blog/cancel-server-actions-abortsignal.md @@ -0,0 +1,95 @@ +--- +title: "Cancelling Server Actions With AbortSignal (No Wasted Work on Disconnect)" +date: 2026-06-15T10:00:00+05:30 +slug: cancel-server-actions-abortsignal +description: "WebJs wires the platform's own AbortSignal through the server-action RPC boundary in both directions, so a client that navigates away actually cancels the in-flight request and the server stops paying for work nobody is waiting for." +tags: server-actions, abortsignal, performance, cancellation, web-standards +author: Vivek +--- + +Picture a typeahead search. The user types "web", you fire an action, they type "webj", you fire another, they type "webjs", you fire a third. By the time they stop, the first two requests are answers nobody wants. In most setups those first two still run to completion on the server, still hit the database, still serialize a result, and only then get thrown away on the client. You paid full price for two answers that were obsolete the moment they were requested. + +I did not want WebJs to pay for work nobody is waiting for. The web platform already has the primitive for this. It is `AbortSignal`, the same object `fetch` accepts to be cancelled. So WebJs wires that signal through the server-action RPC (remote procedure call) boundary, in both directions, and a cancelled request is actually cancelled on the wire. + +# The server side reads the request's signal + +Inside an action, you can read the current request's `AbortSignal` and stop working the moment the client goes away. It is one import. + +```ts +// modules/search/queries/search.server.ts +'use server'; +import { actionSignal } from '@webjsdev/server'; + +export async function search(term: string) { + const signal = actionSignal(); + const res = await fetch(`https://api.example.com/q?term=${term}`, { signal }); + return res.json(); +} +``` + +`actionSignal()` returns the `AbortSignal` tied to the in-flight request. When the client disconnects or aborts, that signal fires, and anything you passed it to unwinds. You hand it to a `fetch`, or to a database query that accepts a signal, or you check `signal.aborted` yourself inside a loop. + +```ts +export async function crunch(rows: Row[]) { + const signal = actionSignal(); + const out = []; + for (const row of rows) { + if (signal.aborted) break; // client left, stop chewing + out.push(expensiveTransform(row)); + } + return out; +} +``` + +There is a deliberate safety detail here. When you call an action directly server-to-server (not across an HTTP request, just one server function calling another), `actionSignal()` returns a signal that never aborts. So the pattern is always safe to write. Your action does not need to know whether it is being reached over the network or called inline, and it will never spuriously abort just because there was no request context around it. + +# The client side aborts the superseded render for you + +The server half is only useful if the client actually signals the abort, and this is where the RPC boundary earns its keep. When a component's `async render()` is superseded by a newer render (a prop changed, the user typed again), WebJs does not merely ignore the old render's result. It aborts the in-flight action fetch that render started. + +The generated RPC stub binds every fetch it issues to a per-render `AbortController`. When that render is superseded, its controller is aborted, the underlying HTTP request is torn down, and the `AbortSignal` you read on the server with `actionSignal()` fires. The whole chain connects. Client supersedes a render, the fetch is aborted on the wire, the server's signal trips, your action stops. + +```ts +class Search extends WebComponent({ term: String }) { + async render() { + if (!this.term) return html``; + const hits = await search(this.term); // superseded by the next keystroke + return html``; + } +} +Search.register('search-box'); +``` + +Type fast into this and every keystroke supersedes the last render. Without cancellation, each stale request runs to completion server-side before its result is dropped. With it, the stale request is actually cancelled the moment the next keystroke lands, so the server stops the query it was running and moves on. Under a burst of typing that is the difference between one live query and five, and you wrote none of the plumbing. It falls out of `async render()` being cancellable and the stub binding each fetch to the render that issued it. + +# Streaming actions cancel their source too + +The same principle extends to streaming results. An action that returns a `ReadableStream`, an async iterable, or an async generator streams its chunks over the single RPC response instead of buffering them all first. + +```ts +// modules/tokens/actions/stream-tokens.server.ts +'use server'; +export async function* streamTokens(n: number) { + for (let i = 0; i < n; i++) { + yield { i, token: await nextToken() }; + } +} +``` + +```ts +for await (const chunk of await streamTokens(8)) { + this.tokens = [...this.tokens, chunk.token]; +} +``` + +If the client disconnects, or the render that started the stream is superseded, WebJs cancels the source generator. The `for await` on the server stops advancing, so a long or infinite stream (think a token feed from a model) does not keep producing into a void. The producer is torn down at the source, not just ignored at the consumer. Back-pressure is respected the same way, so a slow reader does not force the generator to run ahead of what anyone is consuming. + +# Why this is the right shape + +The thing I like about this is that it is not a WebJs invention layered on top of the platform. `AbortSignal` and `AbortController` are the web's own cancellation primitives, the same ones `fetch` has accepted for years. WebJs just threads them through the one place they were previously missing, which is the RPC boundary between a client component and a server action. The server reads the platform signal, the client drives the platform controller, and the framework connects the two ends across the network so an abort on one side becomes an abort on the other. + +You get to write the ordinary, boring, correct thing. Pass a signal to your `fetch`, check `signal.aborted` in a hot loop, let a superseded render clean up after itself. No cancellation library, no request-ID bookkeeping, no manual "is this response still the latest" guard on the client. + +# The takeaway + +Work that nobody is waiting for is work you should not be doing. WebJs makes server actions cancellable by wiring the platform's own `AbortSignal` through the RPC boundary in both directions. On the server, `actionSignal()` gives you the request's signal to pass to a fetch, a DB query, or an `aborted` check, and it returns a never-aborting signal outside a request so server-to-server calls stay safe. On the client, a superseded `async render()` aborts the previous render's in-flight fetch through a per-render `AbortController`, so a fast typeahead cancels stale requests on the wire instead of letting them finish and drop. Streaming actions cancel their source generator on disconnect or supersession too. It is the platform's cancellation model, threaded through the one boundary that was missing it, so you stop paying for answers no one wants. diff --git a/blog/component-suspense-streaming.md b/blog/component-suspense-streaming.md new file mode 100644 index 00000000..3038101b --- /dev/null +++ b/blog/component-suspense-streaming.md @@ -0,0 +1,79 @@ +--- +title: "Streaming Slow Web Components With " +date: 2026-05-30T13:00:00+05:30 +slug: component-suspense-streaming +description: "How WebJs streams slow web components with so the first byte flushes immediately, the fallback shows, and the slow data streams in over plain HTML with no RSC or Flight protocol, all while keeping progressive enhancement." +tags: streaming, suspense, web-components, ssr, performance +author: Vivek +--- + +Picture a dashboard page. Most of it is instant. There is a header, a summary card, a nav. But one panel calls out to a slow analytics query that takes 400ms on a good day. In WebJs the default behavior is that `async render()` blocks server-side rendering (SSR), which means the framework awaits your data before sending a single byte of HTML. For the fast parts that is exactly what you want, because the resolved data lands in the first paint, a crawler indexes real content, and a reader with JavaScript off still sees everything. But that one slow panel would hold the entire page's first byte hostage for 400ms. Everything else is ready and the user sees a blank tab. + +That is the wrong trade for a slow region, and the fix is ``. + +# Block by default, stream on purpose + +I want to be precise about the default first, because the streaming case only makes sense against it. When you write `await` inside a component's `render()`, WebJs blocks SSR for it and bakes the resolved data into the HTML. No spinner, no empty box, no hydration flicker. That is the right default. It is progressive-enhancement-safe (the page works before JavaScript runs) and it is good for search engine optimization (SEO) because the content is really in the document. + +The moment blocking the first byte on one query starts to hurt time-to-first-byte, you opt that region out of blocking and into streaming. You do it by wrapping the slow component in a `` element with a fallback. + +```ts +class SalesDashboard extends WebComponent { + render() { + return html` +
+ + `}> + + +
+ `; + } +} +SalesDashboard.register('sales-dashboard'); +``` + +Here is what happens on the wire. The framework renders everything that is fast, flushes the first byte with the summary card already filled in, and in the hole where `` goes it emits the `.fallback` markup (the skeleton). The browser paints that immediately. Meanwhile the slow component keeps rendering on the server, and when its data resolves WebJs streams the real content down the same response and swaps it into place. The user sees the shell instantly, a skeleton where the slow panel will be, and then the panel fills in. + +One detail that matters and is easy to miss. The `.fallback` hole is a property hole, so it must be unquoted. Write `.fallback=${html`...`}`, never `.fallback="${...}"`. The leading dot tells WebJs to pass the template as a DOM property rather than stringify it into an attribute. + +# Multiple boundaries fetch concurrently + +The part that makes this genuinely fast is that boundaries do not queue. If a page has three `` regions, each wrapping its own slow query, they all start on the server at once and stream in as they finish, in whatever order they resolve. There is no server-side waterfall where the second slow query waits for the first to flush. You get fast-content-first, and each slow region arrives independently the instant it is ready. + +```ts +html` + `}> + + + `}> + + +` +``` + +Revenue and customers fetch in parallel. If customers resolves first, it streams in first. Nothing about one boundary is coupled to another. + +# This is plain HTML streaming, not RSC + +If you come from React, the word "Suspense" carries baggage, so let me draw the line clearly. WebJs has no React Server Components, no Flight protocol, no serialized render tree traveling over a special wire format. `` is HTML streaming and nothing more. The server flushes ordinary HTML in chunks, the fallback is real markup, and the streamed-in content is real markup too, rendered with Declarative Shadow DOM or light DOM exactly like every other WebJs component. There is no client runtime reconstructing a virtual tree from a binary protocol. The browser's own streaming HTML parser does the work, which is why it degrades so gracefully. + +# It streams on soft navigation too + +The streaming is not only a first-load trick. When the client router does a soft navigation (an in-app link click that swaps content without a full page reload), the same boundaries stream progressively into the new view. The fallback flushes into the swapped region, and the slow content streams in after, so a client-side navigation to a page with a slow panel behaves exactly like the initial server load. You author it once and it works both ways. + +# A thrown component is isolated + +Slow data is also flaky data, so failure handling matters. If a component inside a boundary throws while it renders, WebJs isolates it. That one component renders its own error state, and its siblings keep streaming as if nothing happened. A single broken panel does not blank the page and it does not tear down the other boundaries mid-stream. This is the same per-component error isolation WebJs gives you for a blocking `async render()`, carried through the streaming path. + +# When to reach for it, and when not to + +Do not reach for `` reflexively. It is the exception, not the pattern. For request-time server data that resolves fast, block in `async render()` and bake it into the first paint. That is faster and simpler and it is the whole reason WebJs blocks by default. Reserve `` for a region that is genuinely slow enough that holding the first byte for it hurts more than showing a fallback for it would. + +It is worth knowing this is the ONLY way to show a fallback on the first paint in WebJs. A blocking `async render()` never shows a first-paint fallback, by design, because it has real data instead. So if you find yourself wanting a first-paint skeleton, that want is the signal that the region is slow and belongs in a boundary. There is also a page-level and region-level `Suspense({ fallback, children })` (a value you drop into a template hole rather than an element you wrap), and a `loading.js` route file that wraps a whole page in Suspense with an immediately flushed fallback, for when the slow thing is the entire route rather than one island. + +One implementation note for the curious. A streamed component that uses `static shadow = true` always ships its JavaScript module, even when it would otherwise be elided. Declarative Shadow DOM only attaches during initial HTML parsing, so a component that arrives mid-stream needs its module to re-run `attachShadow` in the browser. Light-DOM streamed components have no such requirement. + +# The takeaway + +WebJs blocks SSR by default so your data is in the first paint, which is the right call for fast queries and keeps the page working without JavaScript. For the one slow panel that would otherwise stall the whole first byte, wrap it in ``. The fallback flushes immediately, the slow content streams in when it resolves, multiple boundaries fetch concurrently with no server waterfall, a throwing component stays isolated, and it all works on soft navigation too. It is plain HTML streaming, no RSC and no Flight, so the browser's own parser carries it. Fast first byte for slow data, without giving up progressive enhancement. This shipped in #470 and #471. diff --git a/blog/device-adaptive-link-prefetch.md b/blog/device-adaptive-link-prefetch.md new file mode 100644 index 00000000..759bb77e --- /dev/null +++ b/blog/device-adaptive-link-prefetch.md @@ -0,0 +1,76 @@ +--- +title: "Device-Adaptive Link Prefetch That Does Not Bloat the Network Tab" +date: 2026-07-04T10:00:00+05:30 +slug: device-adaptive-link-prefetch +description: "How WebJs's client router prefetches links with a device-adaptive default: intent-based prefetch on desktop hover, dwell-gated viewport prefetch on mobile. Instant navigation without a bandwidth tax, tuned to the device, no import required." +tags: client-router, prefetch, performance, mobile, navigation +author: Vivek +--- + +Prefetching is the trick that makes a link feel instant. Before you click, the browser has already fetched the page behind the link, so the moment you tap it, the content is just there. No spinner, no wait. It feels like the app read your mind. + +The naive way to do it is to prefetch every link on the page. Every card in a feed, every item in a nav, every footer link, all fetched the instant they render. On a fast desktop connection you might get away with it. On a phone on cellular data you are torching someone's bandwidth to prefetch forty pages they will never visit. Open the network tab on a fetch-everything prefetcher and it lights up like a christmas tree. That is not a feature. That is a bandwidth tax you are quietly charging your users. + +The rule I held while building this was simple. Snappy is the goal, but never at the cost of over-fetching. When in doubt, under-fetch. A prefetch you did not need is waste, and waste on mobile data is worse than a 100ms wait. + +# The device-adaptive default + +WebJs's client router prefetches, but the default strategy adapts to the device it is running on. The signal it uses is whether the pointer can hover. + +On a hover-capable pointer (a desktop with a mouse), the default is `intent` prefetch. The page behind a link is fetched when you hover over it. A hover is a strong signal. On desktop, moving the cursor onto a link and pausing there is most of the way to a click already. So the fetch fires on hover, and by the time your finger comes down on the mouse button, the page is usually ready. You prefetch exactly the links the user is actively considering, not the whole document. + +On a touch device (a phone or tablet, no hover), there is no hover to lean on. Your finger does not float over a link before tapping it. So the default switches to `viewport` prefetch, with two guards that keep it honest. + +The first guard is dwell-gating. A link is not prefetched the instant it scrolls into view. It has to STAY in view for a short moment first. If you are flicking through a long feed, links stream past the viewport constantly, and prefetching each one as it flashed by would be exactly the network-tab flood we are trying to avoid. The dwell timer says "prefetch this only if the user actually paused on it," which is the touch-device equivalent of a hover. + +The second guard is cancel-on-scroll-out. If a link enters the viewport, starts its dwell timer, but scrolls back out before the timer elapses, the prefetch never fires. You looked away in time, so nothing was fetched. Only a link you dwelt on long enough to plausibly tap gets warmed. + +Together those two guards mean a mobile user scrolling a feed prefetches a handful of links they lingered on, not the hundreds they scrolled past. The network tab stays quiet. The navigation still feels instant on the links that matter. + +# Per-link override + +The default is device-adaptive, but you can override any single link with `data-prefetch`. + +```ts +import { html } from '@webjsdev/core'; + +export default function Nav() { + return html` + + `; +} +``` + +Set `data-prefetch="none"` on a link whose target is expensive to render or rarely visited, and it is never prefetched regardless of device. Force a strategy the other way when you know something the heuristic does not. The default is tuned for the common case, and the attribute is the escape hatch for the ones you understand better than the framework does. + +# You do not import anything to get this + +There is no prefetch library to install and no router to wire up. The client router auto-enables the moment `@webjsdev/core` loads in the browser, and core is the bundle every component pulls in. So any page that ships a single component gets client navigation, and with it device-adaptive prefetch, for free. Prefetch is on by default. You did not opt into it and you do not configure it to get sensible behaviour. + +```ts +// a page with one interactive component. prefetch is already on, nothing to wire. +import { html } from '@webjsdev/core'; +import '#components/like-button.ts'; + +export default function Feed() { + return html` + + `; +} +``` + +# Prefetch is one piece of a larger router + +Prefetch is the front edge of a navigation, but the client router does more once you click. It swaps pages in place with no full-page reload, so there is no white flash between routes and your nested layouts stay mounted. It sends an `X-Webjs-Have` header listing the layout fragments it already holds, so the server returns only the divergent part of the next page instead of re-serializing the shared header, sidebar, and footer every time. It restores scroll position on back and forward. Prefetch and those pieces reinforce each other. Prefetch warms the divergent fragment early, `X-Webjs-Have` keeps that fragment small, and the in-place swap paints it without a flash. I will not go deep on those here (the client-router post covers them), but it is worth knowing prefetch is not a bolt-on. It is the leading edge of one coherent navigation pipeline. + +# The takeaway + +Prefetching the page behind a link is what makes navigation feel instant, but fetching every visible link is a bandwidth tax that hits mobile users hardest. WebJs's client router prefetches with a device-adaptive default. On a hover-capable desktop it uses `intent` prefetch (fetch on hover, since a hover is a strong click signal), and on touch it uses `viewport` prefetch that is dwell-gated and cancel-on-scroll-out, so only the links a user actually pauses on get warmed. Override any link with `data-prefetch`, and get all of it with no import because the router auto-enables when core loads. The guiding rule underneath it is the one worth stealing. Be snappy, but under-fetch when in doubt, because a prefetch you did not need is pure waste. diff --git a/blog/file-uploads-and-storage.md b/blog/file-uploads-and-storage.md new file mode 100644 index 00000000..1c5fdf1d --- /dev/null +++ b/blog/file-uploads-and-storage.md @@ -0,0 +1,102 @@ +--- +title: "File Uploads and Storage in WebJs: FileStore, Signed URLs, S3-Ready" +date: 2026-06-10T10:00:00+05:30 +slug: file-uploads-and-storage +description: "How WebJs handles file uploads with a built-in FileStore and diskStore default: streaming multipart uploads, path-traversal-safe keys, signed URLs for private files, and an S3-ready swap that never touches your call sites." +tags: file-upload, storage, s3, server-actions, security +author: Vivek +--- + +Let me set the scene with the feature every app eventually grows: a user wants to upload an avatar. Or attach a PDF to a support ticket. Or drop an image into a post. It sounds like a five-minute job, and then you open your editor and remember why it never is. + +You have to parse the multipart request (the special encoding a browser uses to send a file plus form fields in one POST). You have to decide where the bytes actually land, because a file is not a row in your database. You have to serve it back later without letting one user read another user's private file. And you have to do all of that in a way you will not have to rewrite the day you outgrow the local disk and move to S3. Four separate problems wearing one innocent-looking trench coat. + +WebJs ships a built-in for this. It is small, it is streaming, and it is deliberately shaped so the local-disk version and the S3 version are the same code. + + +# The upload half: a File that just arrives + +The first pleasant surprise is that you do not hand-parse anything. A WebJs server action (a function in a `*.server.ts` file marked `'use server'`) can receive a native `File`, a `Blob`, or a whole `FormData` as an argument, because the wire serializer round-trips them for you. You call the action from a client component with a normal import, and on the server you get a real `File` object. + +```ts +// modules/uploads/actions/upload-avatar.server.ts +'use server'; +import { getFileStore, generateKey } from '@webjsdev/server'; + +export async function uploadAvatar(file: File) { + const store = getFileStore(); + const key = generateKey(file.name); // opaque, safe key + const { size, contentType } = await store.put(key, file); + return { success: true, data: { key, size, contentType } }; +} +``` + +The `store.put(key, file)` call streams the bytes to storage. Streaming matters more than it sounds. A naive upload reads the entire file into memory before writing it, so a handful of users sending 50 MB videos at once can knock your server over. WebJs pipes the file's stream straight to disk, so a large upload uses roughly constant memory no matter how big it is. + +You also want a guardrail on how big an upload is allowed to be in the first place. That lives in your `package.json` config, not in the action. + +```jsonc +// package.json +{ + "webjs": { + "maxMultipartBytes": 10485760 + } +} +``` + +That cap (default 10 MiB) bounds the request before the bytes ever reach the store, so an oversized upload is rejected at the door. The store stays simple and just keeps streaming whatever it is handed. + + +# Why you never use the filename as the key + +Notice I called `generateKey(file.name)` instead of storing the file under its original name. This is the part beginners get bitten by, so it is worth spelling out. + +A "key" is just the identifier the store files the bytes under. If you trust a user-supplied filename as the key, a malicious user names their file `../../etc/passwd` and now your write (or a later read) tries to escape your uploads folder and touch a system file. That escape trick is called path traversal, and it is one of the oldest holes in the book. + +WebJs closes it in two layers. Every key is resolved to an absolute path and rejected if it lands outside the store directory, so a key with `..`, a leading slash, a NUL byte, or a backslash throws before any filesystem operation runs. And `generateKey(filename)` hands you a random UUID-based key that keeps only a sanitized, whitelisted file extension from the original name. A hostile `'../../x.sh'` comes back as a bare opaque key with no path and no dangerous extension. Use it, and the traversal problem simply cannot exist in your app. + + +# The diskStore default, and serving files back + +Out of the box the file store is a `diskStore` rooted at `/.webjs/uploads`, served under `/uploads`. You add that directory to `.gitignore` (it holds user data, not source) and you are done for local development. Override the location at startup if you want: + +```ts +import { setFileStore, diskStore } from '@webjsdev/server'; +setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' })); +``` + +Serving a file back is a `route.ts` handler that reads a streaming handle from the store and hands its body to a `Response`, so the file streams to the browser without being loaded into memory. One caveat that is genuinely important. The content-type recorded on an upload is the one the browser claimed at upload time, so it is attacker-controlled. A route that reflects it inline lets someone upload HTML dressed up as an image and run script on your origin (stored XSS). The fix is to send `X-Content-Type-Options: nosniff` and a `Content-Disposition: attachment` for anything a user uploaded, and only ever serve inline from a strict allowlist of inert types you have validated. Serving uploads from a separate cookieless origin is the strongest version of that mitigation. + + +# Signed URLs: a private file without a session lookup + +Some files are public. Some are not, and you do not want the serving route doing a database session check on every image request. That is what a signed URL is for. It is a time-limited link that carries its own proof of permission. + +```ts +import { signedUrl, verifySignedUrl } from '@webjsdev/server'; + +// mint a link that is valid for one hour +const url = signedUrl(key, { secret: process.env.AUTH_SECRET, expiresIn: 3600 }); + +// in the serving route.ts +const check = verifySignedUrl(new URL(request.url).searchParams, process.env.AUTH_SECRET); +if (!check.valid) return new Response('Forbidden', { status: 403 }); +``` + +Under the hood WebJs signs the exact key plus its expiry with HMAC-SHA256 (a keyed hash, so nobody without the secret can forge or tamper with the link), and the comparison is constant-time. Neither the key nor the expiry can be edited after the fact without the signature failing. A nice safety detail: passing `expiresIn: 0` or a negative number fails closed, so a "no access" intent never silently becomes a one-hour grant. The one-hour default only applies when you omit `expiresIn` entirely. + + +# The day you move to S3 + +Here is the payoff that makes all of the above worth it. Every method on the store operates on web-standard objects only: a `File` goes in, a streaming handle comes out. So an S3 (or R2, or GCS, or MinIO) adapter is a drop-in that implements the same `put`, `get`, `delete`, and `url` against the cloud SDK. Because the shape is identical, moving your whole app off local disk is one line at startup. + +```ts +setFileStore(s3Store({ /* ... */ })); +``` + +Not one call site changes. Your `uploadAvatar` action, your serving route, your signed URLs all keep working. WebJs ships no S3 SDK itself (no dependency you did not ask for); the adapter is a thin wrapper you provide. That is the whole point of putting the interface first. The thing you build on a Friday afternoon with `diskStore` is the same thing you scale in production, minus the rewrite. + + +# The takeaway + +File uploads feel like a rite of passage because most stacks make you assemble four things by hand: multipart parsing, a place for the bytes, safe serving, and an eventual cloud migration. WebJs folds them into one built-in. A server action receives a native `File` because the serializer round-trips it, `store.put` streams it so a big upload does not eat your memory, `generateKey` gives you a path-traversal-safe key so a malicious filename cannot escape, and `signedUrl` gates a private file without a session lookup. The `diskStore` default gets you running with zero config, and the web-standard interface means swapping to S3 is one `setFileStore` call with no change to a single call site. Write it once on disk, ship it to the cloud unchanged. diff --git a/blog/import-only-pages-zero-js.md b/blog/import-only-pages-zero-js.md new file mode 100644 index 00000000..20945500 --- /dev/null +++ b/blog/import-only-pages-zero-js.md @@ -0,0 +1,57 @@ +--- +title: "Your Page Module Should Not Be in the Network Tab" +date: 2026-07-03T10:00:00+05:30 +slug: import-only-pages-zero-js +description: "In WebJs, pages and layouts never hydrate, so their JavaScript has no job in the browser. An import-only page or layout is dropped entirely and the boot ships just the interactive component leaves it imported. How it works, when a page still ships whole, and how to check." +tags: performance, elision, no-build, ssr, web-components +author: Vivek +--- + +Open a WebJs blog post, pop the network tab, and look for `page.ts`. It is not there. Look for `layout.ts` either. Neither downloaded. The page rendered, the theme toggle in the corner works, the two files that describe the whole page never crossed the wire. Do the same on a typical React or Next app and you watch the page component and its layout come down as client JavaScript that then hydrates. That contrast is the whole point of this post. + +The reason those two files stayed home is not something you configured. It falls out of one fact about the framework, and once you see it, the empty network tab becomes the thing you check for. + +# Pages run on the server. Components run in the browser. + +The mental model to unlearn comes from React, where a page component is JavaScript you ship and then hydrate. In WebJs there is no server or client component split, and pages do not work that way. + +Pages and layouts are isomorphic modules, meaning the same source can run on both sides. But a page function runs only on the server. It executes once to produce HTML and is never invoked again in the browser. There is no second render, no client-side re-run, no event wiring at the page level. A layout is the same: it runs on the server to wrap its children and it is done. + +All the interactivity lives one level down, in components. A component is an island: its module loads in the browser, the custom element upgrades in place, and its `@click` handlers, signals, and reactive properties come alive. That client-side run is the entire reason a component's module ships at all. It hydrates; the page around it does not. + +So a page's own module usually has nothing to do on the client. It rendered its HTML on the server, the components inside it are the parts that come alive, and the page function finished the moment SSR did. Shipping that module to the browser buys you nothing, because no code in it runs there. + +# What "import-only" means + +Here is the subtle case, and the one this post is really about. It landed in #605 and #609. + +A page rarely sits there doing nothing. It imports things, in particular the interactive components it wants to render: a ``, a ``, a ``. Those components DO hydrate, so their modules genuinely need to reach the browser. + +The naive way to get them there is to ship the page module and let the browser follow its imports. But that drags the whole page module across the wire just to act as a pointer to its children. WebJs does better. A page or layout that is non-inert only because it imports interactive components is called import-only. Since the page never hydrates, the framework does not need it in the browser to reach its imports. The boot emits the component modules directly and drops the page or layout wrapper, so the browser fetches the interactive leaves and nothing else. The page module was the middleman, and the middleman gets cut out. + +You never annotated anything. No `"use client"` on the components, no marker on the page. You wrote a page that imports some components, and the browser received exactly the components that run there, not the page that listed them. + +The other module elision touches is the display-only component itself, the interactive-looking element whose JavaScript changes nothing so the framework strips it too. I wrote that side up separately in `ship-zero-javascript-display-only-components`; here I am staying on the page and layout half. + +# When a page still ships whole + +Import-only is a specific condition, and it is worth knowing when it fails, because that is when `page.ts` shows up in your network tab. A page or layout ships whole when it has a client side effect of its own, not just interactive imports. Any of these in the module's closure pins it to the browser: + +- An explicit client-router import. +- A call at module scope, something that runs the instant the module loads. +- A self-registering bare import, a module imported purely for its side effect. +- Importing a client-effecting non-component utility. The classic offender is a `cn.ts` class-name helper that touches a browser global. Import that into your page and the page module now does client work, so it ships whole. + +That last one is sneaky, because a class-name helper looks innocent. But if it reads a browser global at module load, importing it makes your page module client-effecting, and a client-effecting page module ships. The fix is to keep client-only behavior inside a component and server-only work in a `.server.ts` file, and if a utility mixes a pure helper with client-global code, split the client part out so the pure helper does not pin every page that imports it. + +# The self-check, and the tool that names the reason + +The rule of thumb is short. `page.ts` and `layout.ts` should never appear in the network tab or in the boot `