Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/streaming-ssr/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ── Server ─────────────────────────────────────────────────────────────────
PORT=3001
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=info
TRUST_PROXY=true
155 changes: 155 additions & 0 deletions examples/streaming-ssr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# @scratchyjs/example-streaming-ssr — Streaming SSR Application

A focused example demonstrating **HTTP streaming SSR** with the Scratchy
framework. HTML is split into ordered chunks and sent to the browser via chunked
transfer encoding, allowing the browser to begin rendering critical content
before the full body has arrived.

## What It Shows

| Layer | Package | Role |
| --------------- | ------------------------- | ------------------------------------------------- |
| HTTP server | `@scratchyjs/core` | Fastify with CORS, helmet, rate-limiting |
| Streaming SSR | `@scratchyjs/renderer` | `createStreamingSSRHandler` + Piscina worker pool |
| Client bundling | `@scratchyjs/vite-plugin` | Vite + Qwik + Tailwind CSS |
| Utilities | `@scratchyjs/utils` | Request helpers |

Unlike the [starter example](../starter), this example intentionally **omits the
database and authentication layers** to stay focused on the streaming rendering
pipeline.

## Pages

| Route | Description |
| ----------- | --------------------------------------------------------------- |
| `/` | Home — hero section, framework stats, and feature highlights |
| `/about` | About — mission, story, values, and team |
| `/features` | Features — detailed feature cards for every Scratchy capability |
| `/blog` | Blog — list of articles with tags, author, and reading time |
| `/contact` | Contact — contact channels and a feedback form |

Every page route uses `createStreamingSSRHandler()`. The `/blog` and other
routes also pass **server-side props** to the worker, showing how to embed
structured data in the streamed response.

## How Streaming SSR Works

```
Browser Fastify Piscina Worker
│ │ │
│ GET /blog │ │
│──────────────►│ │
│ │ runTask({ │
│ │ type:"ssr-stream"│
│ │ route:"/blog" │
│ │ props:{posts:…} │
│ │ }) │
│ │───────────────────►│
│ │ │ renderStreamingSSR()
│ │ │ → chunks[0]: <head>
│◄──────────────│◄───────────────────│
│ chunk[0] │ │ → chunks[1]: <body>
│◄──────────────│◄───────────────────│
│ chunk[1] │ │ → chunks[2]: </body>
│◄──────────────│◄───────────────────│
│ chunk[2] │ │
```

Fastify's `reply.send(Readable.from(chunks))` automatically applies
`Transfer-Encoding: chunked`, enabling the browser to parse and render HTML
progressively as each chunk arrives.

## Getting Started

### Prerequisites

- Node.js >= 24
- pnpm >= 10

> **No database or Docker required** — this example uses only in-memory / static
> data.

### 1. Configure environment

```bash
cp .env.example .env
```

The default `.env.example` already works without modification.

### 2. Install dependencies

```bash
# From the monorepo root
pnpm install
```

### 3. Start the server

```bash
# From examples/streaming-ssr/
pnpm dev
```

The server starts on `http://localhost:3001`.

## API Endpoints

| Method | URL | Description |
| ------ | ----------- | ----------------------------------------- |
| GET | `/health` | Health check — returns `{ status: "ok" }` |
| GET | `/` | Streaming SSR — home page |
| GET | `/about` | Streaming SSR — about page |
| GET | `/features` | Streaming SSR — features page |
| GET | `/blog` | Streaming SSR — blog listing |
| GET | `/contact` | Streaming SSR — contact page |
| GET | `/*` | Streaming SSR — catch-all |

## Project Structure

```
src/
├── index.ts # Server entry point
├── server.ts # Server factory (wires all packages + page routes)
├── config.ts # App config (extends @scratchyjs/core)
├── types/
│ └── fastify.d.ts # Fastify type augmentations
├── renderer/
│ └── worker.ts # Piscina worker (delegates to @scratchyjs/renderer)
├── client/
│ ├── routes/
│ │ ├── layout.tsx # Qwik root layout (shared nav with 5 links)
│ │ ├── index.tsx # Home page
│ │ ├── about/
│ │ │ └── index.tsx # About page
│ │ ├── features/
│ │ │ └── index.tsx # Features page
│ │ ├── blog/
│ │ │ └── index.tsx # Blog listing
│ │ └── contact/
│ │ └── index.tsx # Contact page
│ └── styles/
│ └── global.css # Tailwind CSS entry point
└── server.test.ts # Integration tests
```

## Running Tests

```bash
# From the monorepo root
pnpm test
```

## Adding a New Page

1. Create `src/client/routes/my-page/index.tsx` with a Qwik component.
2. Add a route handler in `src/server.ts`:
```ts
server.get(
"/my-page",
createStreamingSSRHandler({
getProps: () => ({ page: "my-page", data: "…" }),
}),
);
```
3. Add a nav link in `src/client/routes/layout.tsx`.
26 changes: 26 additions & 0 deletions examples/streaming-ssr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@scratchyjs/example-streaming-ssr",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx --env-file=.env src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@scratchyjs/core": "workspace:*",
"@scratchyjs/renderer": "workspace:*",
"@scratchyjs/utils": "workspace:*",
"@scratchyjs/vite-plugin": "workspace:*"
},
"devDependencies": {
"@types/node": "22.16.0",
"fastify": "5.8.2",
"tsx": "4.21.0",
"typescript": "5.8.3",
"vite": "^5.4.0",
"@builder.io/qwik": "^1.5.0",
"@builder.io/qwik-city": "^1.5.0",
"@tailwindcss/vite": "^4.0.0"
}
}
2 changes: 2 additions & 0 deletions examples/streaming-ssr/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
130 changes: 130 additions & 0 deletions examples/streaming-ssr/src/client/routes/about/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";

/**
* Server-side data loader for the about page.
*/
export const useAboutData = routeLoader$(() => ({
mission:
"Make server-rendered web applications as fast and ergonomic as possible — without sacrificing developer experience.",
story: `Scratchy started as a personal project to explore what a modern,
server-first Node.js framework could look like if it were designed from scratch
today. The goal: combine the raw performance of Fastify with Qwik's
zero-hydration model and a proper Worker Thread rendering pipeline.`,
values: [
{
title: "Server-first",
description:
"Built for long-running hosted servers, not serverless cold starts. Persistent connections, warm worker pools, in-memory caches.",
},
{
title: "Type-safe",
description:
"TypeScript strict mode throughout — from the database schema to the last client component. No `any`, no surprises.",
},
{
title: "Convention over configuration",
description:
"Opinionated defaults so you spend time on your product, not on boilerplate setup.",
},
{
title: "Open source",
description:
"MIT licensed. Contributions, bug reports, and feedback are always welcome.",
},
],
team: [
{
name: "A-J Roos",
role: "Creator & Maintainer",
bio: "Full-stack developer passionate about performance and developer experience.",
},
{
name: "Community",
role: "Contributors",
bio: "Scratchy is shaped by the people who use it. Join us on GitHub.",
},
],
}));

/**
* About page — mission, story, values, and team.
*/
export default component$(() => {
const data = useAboutData();
const { mission, story, values, team } = data.value;

return (
<div>
{/* Page header */}
<section class="mb-12">
<h1 class="text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white">
About Scratchy
</h1>
<p class="mt-4 max-w-2xl text-lg text-gray-600 dark:text-gray-400">
{mission}
</p>
</section>

{/* Story */}
<section class="mb-12">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
Our Story
</h2>
<p class="max-w-3xl whitespace-pre-line text-gray-700 dark:text-gray-300">
{story}
</p>
</section>

{/* Values */}
<section class="mb-12">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
Our Values
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{values.map(({ title, description }) => (
<div
key={title}
class="rounded-xl border border-gray-200 p-5 dark:border-gray-800"
>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
))}
</div>
</section>

{/* Team */}
<section>
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
Team
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{team.map(({ name, role, bio }) => (
<div
key={name}
class="flex items-start gap-4 rounded-xl border border-gray-200 p-5 dark:border-gray-800"
>
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{name[0]}
</div>
<div>
<p class="font-semibold text-gray-900 dark:text-white">
{name}
</p>
<p class="text-sm text-blue-600 dark:text-blue-400">{role}</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{bio}
</p>
</div>
</div>
))}
</div>
</section>
</div>
);
});
Loading
Loading