Skip to content
Open
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
30 changes: 30 additions & 0 deletions packages/fetch-router/demos/deno/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# fetch-router Deno Example

This example is a [Deno](https://deno.com/) server that handles routing using `@remix-run/fetch-router`.

It is the same blog application as the [Node](https://github.com/remix-run/remix/tree/main/packages/fetch-router/demos/node) and [Bun](https://github.com/remix-run/remix/tree/main/packages/fetch-router/demos/bun) examples, which shows that the router, middleware, and route helpers are all runtime-agnostic. Here there is no server entry point at all: `deno serve` runs `app/router.ts` directly.

## Running

```sh
pnpm install
deno task dev
```

The application will be available at `http://localhost:44100`.

Use `deno task start` to run without file watching, and `deno task typecheck` to type check every module in the demo.

## What This Demonstrates

- **`deno serve` with no server code**: a router is already a `{ fetch }` object, which is exactly the default export shape `deno serve` expects, so `app/router.ts` is the whole server. Deno owns the listener, port, and graceful shutdown on `SIGINT`/`SIGTERM`.
- **npm packages from a pnpm workspace**: `deno.json` sets `"nodeModulesDir": "manual"` so Deno resolves `@remix-run/*` from the `node_modules` directory pnpm installs, instead of managing its own dependencies.
- **Least-privilege permissions**: the tasks grant read access to `./public` only, and env access to the four variables `logger()` inspects for color detection (`CI`, `FORCE_COLOR`, `NO_COLOR`, `TERM`). Static imports need no read permission, and `deno serve` provides the network access itself, so nothing else is granted.
- **Node built-ins in Deno**: `app/router.ts` uses `node:url` to resolve the `public` directory, and `staticFiles()` reads it through `node:fs` under the hood.

## Key APIs

- `createRouter()` from `@remix-run/fetch-router` with `logger()`, `staticFiles()`, `formData()`, and `session()` middleware
- `route()`, `form()`, and `resources()` route helpers from `@remix-run/fetch-router/routes` for typed `href()` generation
- `router.map()` with an `actions` object, including per-action `middleware` for the authenticated "new post" route
- `html` from `@remix-run/html-template` for escaped HTML, returned with `createHtmlResponse()` from `@remix-run/response/html`
44 changes: 44 additions & 0 deletions packages/fetch-router/demos/deno/app/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export interface Post {
id: string
title: string
content: string
author: string
createdAt: Date
}

const posts: Post[] = [
{
id: '1',
title: 'Welcome to the Blog',
content: 'This is a simple blog demo built with fetch-router on Deno.',
author: 'Admin',
createdAt: new Date('2025-01-01'),
},
{
id: '2',
title: 'Getting Started with fetch-router',
content: 'fetch-router is a minimal, composable router built on the web Fetch API.',
author: 'Admin',
createdAt: new Date('2025-01-02'),
},
]

export function getPosts() {
return posts.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}

export function getPost(id: string) {
return posts.find((p) => p.id === id)
}

export function createPost(title: string, content: string, author: string) {
let post: Post = {
id: String(posts.length + 1),
title,
content,
author,
createdAt: new Date(),
}
posts.push(post)
return post
}
221 changes: 221 additions & 0 deletions packages/fetch-router/demos/deno/app/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { fileURLToPath } from 'node:url'
import { createRouter } from '@remix-run/fetch-router'
import { createCookie } from '@remix-run/cookie'
import * as s from '@remix-run/data-schema'
import * as f from '@remix-run/data-schema/form-data'
import { Session } from '@remix-run/session'
import { createCookieSessionStorage } from '@remix-run/session/cookie-storage'
import { formData } from '@remix-run/form-data-middleware'
import { logger } from '@remix-run/logger-middleware'
import { session } from '@remix-run/session-middleware'
import { staticFiles } from '@remix-run/static-middleware'
import { html } from '@remix-run/html-template'
import { createHtmlResponse } from '@remix-run/response/html'
import { createRedirectResponse as redirect } from '@remix-run/response/redirect'
import type { Middleware } from '@remix-run/fetch-router'

import { routes } from './routes.ts'
import * as data from './data.ts'

const publicDir = fileURLToPath(new URL('../public', import.meta.url))

const textField = f.field(s.defaulted(s.string(), ''))
const loginSchema = f.object({
username: textField,
})
const postSchema = f.object({
title: textField,
content: textField,
})

const sessionCookie = createCookie('__sess', {
secrets: ['s3cr3t'],
})

const sessionStorage = createCookieSessionStorage()

function requireAuth(): Middleware {
return (context, next) => {
let session = context.get(Session)
if (session == null) {
throw new Error('Expected session() middleware before requireAuth()')
}

let username = session.get('username')
if (!username) {
return redirect(routes.login.index.href())
}

return next()
}
}

export const router = createRouter({
middleware: [
logger(),
staticFiles(publicDir),
formData(),
session(sessionCookie, sessionStorage),
],
})

router.map(routes.home, ({ session }) => {
let posts = data.getPosts()
let username = session.get('username') as string | undefined

return createHtmlResponse(html`
<html>
<head>
<title>Simple Blog - fetch-router Demo</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav>
<h1>Simple Blog</h1>
<div>
${username
? html`
<span>Hello, ${username}!</span>
<form method="POST" action="${routes.logout.href()}">
<button type="submit">Logout</button>
</form>
<a href="${routes.posts.new.href()}">New Post</a>
`
: html`<a href="${routes.login.index.href()}">Login</a>`}
</div>
</nav>
<main>
${posts.length === 0 ? html`<p>No posts yet.</p>` : null}
${posts.map(
(post) => html`
<article>
<h2><a href="${routes.posts.show.href({ id: post.id })}">${post.title}</a></h2>
<p>${post.content.substring(0, 150)}${post.content.length > 150 ? '...' : ''}</p>
<div>By ${post.author} on ${post.createdAt.toLocaleDateString()}</div>
</article>
`,
)}
</main>
</body>
</html>
`)
})

router.map(routes.login, {
actions: {
index({ session }) {
let username = session.get('username') as string | undefined
if (username) {
return redirect(routes.home.href())
}

return createHtmlResponse(html`
<html>
<head>
<title>Login - Simple Blog</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<h1>Login</h1>
<p>Enter any username to login (no password required for demo)</p>
<form method="POST" action="${routes.login.action.href()}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required />
<button type="submit">Login</button>
</form>
<p><a href="${routes.home.href()}">← Back to Home</a></p>
</body>
</html>
`)
},
async action({ formData, session }) {
let { username } = s.parse(loginSchema, formData)
if (!username) {
return redirect(routes.login.index.href())
}

session.set('username', username)
return redirect(routes.home.href())
},
},
})

router.post(routes.logout, ({ session }) => {
session.destroy()
return redirect(routes.home.href())
})

router.map(routes.posts, {
actions: {
new: {
middleware: [requireAuth()],
handler() {
return createHtmlResponse(html`
<html>
<head>
<title>New Post - Simple Blog</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<h1>New Post</h1>
<form method="POST" action="${routes.posts.create.href()}">
<div>
<label for="title">Title:</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea>
</div>
<button type="submit">Create Post</button>
</form>
<p><a href="${routes.home.href()}">← Back to Home</a></p>
</body>
</html>
`)
},
},
async create({ formData, session }) {
let username = session.get('username') as string | undefined
if (!username) {
return redirect(routes.login.index.href())
}

let { content, title } = s.parse(postSchema, formData)
if (!title || !content) {
return redirect(routes.posts.new.href())
}

let post = data.createPost(title, content, username)
return redirect(routes.posts.show.href({ id: post.id }))
},
show({ params }) {
let post = data.getPost(params.id)
if (!post) {
return new Response('Post not found', { status: 404 })
}

return createHtmlResponse(html`
<html>
<head>
<title>${post.title} - Simple Blog</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<h1>${post.title}</h1>
<div>By ${post.author} on ${post.createdAt.toLocaleDateString()}</div>
${post.content.split('\n').map((line) => html`<p>${line}</p>`)}
<p><a href="${routes.home.href()}">← Back to Home</a></p>
</body>
</html>
`)
},
},
})

// A router is already a `{ fetch }` object, which is the shape `deno serve` expects.
export default router
8 changes: 8 additions & 0 deletions packages/fetch-router/demos/deno/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { route, form, resources } from '@remix-run/fetch-router/routes'

export const routes = route({
home: '/',
login: form('/login'),
logout: { method: 'POST', pattern: '/logout' },
posts: resources('posts', { only: ['new', 'create', 'show'] }),
})
8 changes: 8 additions & 0 deletions packages/fetch-router/demos/deno/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"tasks": {
"dev": "deno serve --watch --allow-read=./public --allow-env=CI,FORCE_COLOR,NO_COLOR,TERM --port 44100 app/router.ts",
"start": "deno serve --allow-read=./public --allow-env=CI,FORCE_COLOR,NO_COLOR,TERM --port 44100 app/router.ts",
"typecheck": "deno check"
},
"nodeModulesDir": "manual"
}
24 changes: 24 additions & 0 deletions packages/fetch-router/demos/deno/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "fetch-router-deno-demo",
"private": true,
"type": "module",
"dependencies": {
"@remix-run/cookie": "workspace:*",
"@remix-run/data-schema": "workspace:*",
"@remix-run/fetch-router": "workspace:*",
"@remix-run/form-data-middleware": "workspace:*",
"@remix-run/html-template": "workspace:*",
"@remix-run/logger-middleware": "workspace:*",
"@remix-run/response": "workspace:*",
"@remix-run/session": "workspace:*",
"@remix-run/session-middleware": "workspace:*",
"@remix-run/static-middleware": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:"
},
"scripts": {
"dev": "deno task dev",
"start": "deno task start"
}
}
Loading