Fine-grained. Full-stack. Featherweight.
Dusk is the full-stack framework for ShadowJS. File routes, loaders, server functions, forms and streaming in one request handler.
npm create shadow-app@latest my-app -- --template duskimport { type Middleware, redirect } from "@shadow-js/dusk/server";
/** Every page and API route under `users/` runs this first. */
export const middleware: Middleware[] = [
async (event, next) => {
if (event.cookies.get("session") === undefined) {
throw redirect("/login");
}
return next();
},
];ada@example.com
- Role
- Admin
- Team
- Engines
- Last seen
- Today
A request crosses the seam. Watch one go through the middleware, the loader and the render, then click a link in the page.
Fine-grained
Click a link in the page. Only the nodes that changed update.
Full-stack
One request, both sides of the seam.
Featherweight
The browser gets only the files that run there.
- [id].tsx
- users.ts (stub)
- _middleware.ts stays on the server
- schema.ts stays on the server
- db.server.ts stays on the server
Files decide where code runs
Put a file in the right place and Dusk builds it for the right side. Server code stays out of the browser bundle: the build removes it and stops any import that would pull it in.
Read the introductionsrc/routes/users/[id].tsx
The page renders on both sides. Its loader runs on the server for the first request, then in the browser for every navigation after it.
- Runs here
- Replaced by a stub
- Build time
- Not included
Call a function. Dusk makes it a request.
Write an async function on the server and import it anywhere. In the browser the build swaps it for a few lines that call the handler. Types and errors cross with it.
Read about server functionsimport { notFound, serverFn } from "@shadow-js/dusk/server";
import { users } from "./db.server";
import { userId } from "./schema";
export const getUser = serverFn({ method: "GET", input: userId }, ({ id }) => {
const user = users.get(id);
if (user === undefined) throw notFound();
return user;
});import { serverFnStub as $$serverFnStub } from "@shadow-js/dusk/client";
export const getUser = $$serverFnStub(
"-qS5J_zAeUpbgSPmoQH5EE",
{
method: "GET",
form: false,
base: "",
batch: true,
singleFlight: false,
},
);In the first request
The loader calls getUser directly. The result is embedded in the page, so nothing is fetched twice.
On a link click
The same loader runs in the browser. The stub sends the call to /_dusk/fn/<id> and returns the same value.
Forms that work before the script loads
A form posts to a server function. With JavaScript it submits in place and shows its state. Without it, the browser posts and the server redirects back.
Read about formsSubmission model. Compare an in-place update with a full-page form submission. Invitations stay in this example.
import { Form, useSubmission } from "@shadow-js/dusk/client";
import type { JSX } from "@shadow-js/web";
import { invite } from "../../server/invite";
export default function Invite(): JSX.Element {
const last = useSubmission(invite);
return (
<Form action={invite} resetOnSuccess>
<label>
Email <input name="email" type="email" />
</label>
<button type="submit">{last.pending ? "Sending" : "Send invite"}</button>
</Form>
);
}Send the page before the data
Wrap slow data in Await and the shell goes out first. The rest streams in when it is ready. When the page should keep updating, add a server function stream, server-sent events or a WebSocket route.
Read about streamingStreaming timeline model. The delay is chosen here to show the sequence, not measured response time.
Wait for everything
Waiting…
Stream the page
Waiting…
import { serverFn } from "@shadow-js/dusk/server";
export const ticks = serverFn({ stream: true }, async function* () {
yield 1;
yield 2;
});Server function stream
Started by the page, ends when the function returns. Each yielded value is one item.
One handler, eight targets
An adapter is one line in vite.config.ts. The handler is a function from Request to Response, so the same app runs on each of them.
Read about deployingimport { node } from "@shadow-js/dusk/node";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [dusk({ adapter: node() })],
});Run
Writes
dist/ ├─ client/ └─ node/ └─ server.mjs
Supports
- WebSockets
- Server-sent events
- Streaming
- Static-file middleware
- Async context
- Early Hints
Only what runs in the browser ships
Server functions, middleware and database code stay on the server. This is what a real build of the starter sends to load each of its pages.
/27.4 kB/*rest28.6 kB/about26.1 kB/posts31.4 kB/posts/:id28.8 kB
Start with one route
Create an app, open src/routes/index.tsx and save. The page updates.
npm create shadow-app@latest my-app -- --template dusk