[]previous and next page

A minimal app

A minimal app in the Dusk guide.

For agentsUpdated
text
vite.config.ts
src/
  app.tsx                 root component: wraps every page
  entry-server.ts         options of the request handler (optional)
  routes/
    _layout.tsx           layout of every page
    index.tsx             /
    posts/[id].tsx        /posts/:id   (loader calls a server function)
    api/posts.ts          /api/posts   (API route)
  server/
    posts.ts              server functions

Everything below is the content of those files. The configuration is one plugin:

vite.config.tsts
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk()] });

src/app.tsx is the root component. It receives the routed page as children, on the server and in the browser:

src/app.tsxtsx
import type { JSX } from "@shadow-js/web";

export default function App(props: { children?: JSX.Element }): JSX.Element {
  return <div class="shell">{props.children}</div>;
}

src/entry-server.ts holds the options of createHandler. The generated server entry reads its options export or its default export. allowedHosts is the one option you should always set in production; without it the first production request is served after one warning.

src/entry-server.tsts
import type { HandlerOptions } from "@shadow-js/dusk/server";

const options: Omit<HandlerOptions, "manifest"> = {
  security: { allowedHosts: ["localhost", "example.com"] },
};

export default options;

The data lives behind server functions. A server function is async code that runs only on the server; the browser build replaces it with a stub that sends the call over HTTP. src/schema.ts is a stand-in for a schema library: any Standard Schema (Zod, Valibot, ArkType) works in its place.

src/schema.tsts
import type { StandardSchemaV1 } from "@shadow-js/dusk/server";

/** Wraps a parse function that throws on bad input as a Standard Schema. */
export function schema<T>(parse: (value: unknown) => T): StandardSchemaV1<T, T> {
  return {
    "~standard": {
      version: 1,
      vendor: "app",
      validate(value) {
        try {
          return { value: parse(value) };
        } catch (error) {
          return { issues: [{ message: error instanceof Error ? error.message : "invalid" }] };
        }
      },
    },
  };
}
src/server/posts.tsts
import { HttpError, serverFn } from "@shadow-js/dusk/server";
import { schema } from "../schema";

export interface Post {
  id: string;
  title: string;
}

const posts = new Map<string, Post>([
  ["1", { id: "1", title: "First post" }],
  ["2", { id: "2", title: "Second post" }],
]);

const postId = schema((value) => {
  if (typeof value === "object" && value !== null && "id" in value && typeof value.id === "string") {
    return { id: value.id };
  }
  throw new Error("id must be a string");
});

export const getPost = serverFn({ method: "GET", input: postId }, async ({ id }): Promise<Post> => {
  const post = posts.get(id);
  if (post === undefined) throw new HttpError(404, "No such post");
  return post;
});

export function listPosts(): Post[] {
  return [...posts.values()];
}

Routes are files. The layout wraps every page and renders the matched child with Outlet; A is a link that navigates in the browser.

src/routes/_layout.tsxtsx
import { A, Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";

export default function Layout(): JSX.Element {
  return (
    <main>
      <nav>
        <A to="/">Home</A> <A to="/posts/:id" params={{ id: "1" }}>Post 1</A>
      </nav>
      <Outlet />
    </main>
  );
}
src/routes/index.tsxtsx
import type { HeadTag, JSX } from "@shadow-js/web";

export const head = (): HeadTag[] => [{ tag: "title", children: "Home" }];

export default function Home(): JSX.Element {
  return <p>Welcome</p>;
}

The post page has a loader. It runs on the server for the first request and in the browser for later navigations, so it may only call code that is safe in both places: a server function is.

src/routes/posts/[id].tsxtsx
import { type LoaderArgs, useLoaderData } from "@shadow-js/router";
import type { HeadTag, JSX } from "@shadow-js/web";
import { getPost, type Post } from "../../server/posts";

export const loader = ({ params }: LoaderArgs<"/posts/:id", unknown>): Promise<Post> =>
  getPost({ id: params.id });

export const head = ({ loaderData }: { loaderData: Post | undefined }): HeadTag[] => [
  { tag: "title", children: loaderData?.title ?? "Post" },
];

export default function PostPage(): JSX.Element {
  const post = useLoaderData<Post>();
  return (
    <article>
      <h1>{post()?.title}</h1>
    </article>
  );
}

A file with HTTP-method exports and no default export is an API route. The handler answers it before the page router sees the path.

src/routes/api/posts.tsts
import { json } from "@shadow-js/dusk/server";
import { listPosts } from "../../server/posts";

export const GET = () => json({ posts: listPosts() });