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 dusk
import { 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();
  },
];
localhost:3000/users/2200
Users / Ada Lovelace
AL
Ada Lovelace

ada@example.com

Role
Admin
Team
Engines
Last seen
Today
RequestGET /users/2
ServerBrowser

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
How it works

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 introduction
ServerBrowser

src/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
Data and forms

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 functions
src/server/users.tsServer
import { 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;
});
what the browser receivesBrowser
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 forms

Submission 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>
  );
}
idle

Invite a teammate

Submit the form to follow each step.
Streaming and real time

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 streaming

Streaming timeline model. The delay is chosen here to show the sequence, not measured response time.

Wait for everything

Waiting for the response

Waiting…

Stream the page

Waiting for the response

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.

Deploy

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 deploying
vite.config.ts
import { 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

$ npx dusk build
$ node dist/node/server.mjs

Writes

dist/
├─ client/
└─ node/
   └─ server.mjs

Supports

  • WebSockets
  • Server-sent events
  • Streaming
  • Static-file middleware
  • Async context
  • Early Hints
Featherweight

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 kBof JavaScript to load the starter's home page, gzipped
  • /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