commit 9916e95de0a4ef214e62600486c31624ecba7a6a Author: Bati Date: Thu Jun 26 21:42:05 2025 -0400 scaffold Vike app with Bati diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c7674b --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Cloudflare +.wrangler/ + +# Vercel +.vercel/ + +# Sentry Vite Plugin +.env.sentry-build-plugin + +# aws-cdk +.cdk.staging +cdk.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d85abc --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +Generated with [vike.dev/new](https://vike.dev/new) ([version 450](https://www.npmjs.com/package/create-vike/v/0.0.450)) using this command: + +```sh +pnpm create vike@latest --react --compiled-css --mantine --authjs --trpc --hono --cloudflare --biome +``` + +## Contents + +* [React](#react) + + * [`/pages/+config.ts`](#pagesconfigts) + * [Routing](#routing) + * [`/pages/_error/+Page.jsx`](#pages_errorpagejsx) + * [`/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts`](#pagesonpagetransitionstartts-and-pagesonpagetransitionendts) + * [SSR](#ssr) + * [HTML Streaming](#html-streaming) + +* [Mantine](#mantine) + +## React + +This app is ready to start. It's powered by [Vike](https://vike.dev) and [React](https://react.dev/learn). + +### `/pages/+config.ts` + +Such `+` files are [the interface](https://vike.dev/config) between Vike and your code. It defines: + +* A default [`` component](https://vike.dev/Layout) (that wraps your [`` components](https://vike.dev/Page)). +* A default [`title`](https://vike.dev/title). +* Global [`` tags](https://vike.dev/head-tags). + +### Routing + +[Vike's built-in router](https://vike.dev/routing) lets you choose between: + +* [Filesystem Routing](https://vike.dev/filesystem-routing) (the URL of a page is determined based on where its `+Page.jsx` file is located on the filesystem) +* [Route Strings](https://vike.dev/route-string) +* [Route Functions](https://vike.dev/route-function) + +### `/pages/_error/+Page.jsx` + +The [error page](https://vike.dev/error-page) which is rendered when errors occur. + +### `/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts` + +The [`onPageTransitionStart()` hook](https://vike.dev/onPageTransitionStart), together with [`onPageTransitionEnd()`](https://vike.dev/onPageTransitionEnd), enables you to implement page transition animations. + +### SSR + +SSR is enabled by default. You can [disable it](https://vike.dev/ssr) for all your pages or only for some pages. + +### HTML Streaming + +You can enable/disable [HTML streaming](https://vike.dev/stream) for all your pages, or only for some pages while still using it for others. + +## Mantine + +This is a boilerplate for Mantine based on the [Getting Started](https://mantine.dev/docs/getting-started/) guide. + +The following Packages are installed: + +* `@mantine/hooks` Hooks for state and UI management +* `@mantine/core` Core components library: inputs, buttons, overlays, etc. + +If you add more packages, make sure to update the `layouts/LayoutDefault.tsx` file to include the required CSSs. + +The theme is defined in `layouts/theme.ts`. + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..0fb65c0 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d03d259 --- /dev/null +++ b/biome.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "files": { + "ignore": ["dist/**", "*.js", "*.cjs", "*.mjs", "*.spec.ts"] + } +} diff --git a/components/Link.tsx b/components/Link.tsx new file mode 100644 index 0000000..e3b036c --- /dev/null +++ b/components/Link.tsx @@ -0,0 +1,9 @@ +import { usePageContext } from "vike-react/usePageContext"; +import { NavLink } from "@mantine/core"; + +export function Link({ href, label }: { href: string; label: string }) { + const pageContext = usePageContext(); + const { urlPathname } = pageContext; + const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href); + return ; +} diff --git a/database/todoItems.ts b/database/todoItems.ts new file mode 100644 index 0000000..5404680 --- /dev/null +++ b/database/todoItems.ts @@ -0,0 +1,17 @@ +interface TodoItem { + text: string; +} + +const todosDefault = [{ text: "Buy milk" }, { text: "Buy strawberries" }]; + +const database = + // We create an in-memory database. + // - We use globalThis so that the database isn't reset upon HMR. + // - The database is reset when restarting the server, use a proper database (SQLite/PostgreSQL/...) if you want persistent data. + // biome-ignore lint: + ((globalThis as unknown as { __database: { todos: TodoItem[] } }).__database ??= { todos: todosDefault }); + +const { todos } = database; + +export { todos }; +export type { TodoItem }; diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..b4fd74b --- /dev/null +++ b/global.d.ts @@ -0,0 +1,12 @@ +import type { Session } from "@auth/core/types"; + +declare global { + namespace Vike { + interface PageContext { + session?: Session | null; + } + } +} + +// biome-ignore lint/complexity/noUselessEmptyExport: ensure that the file is considered as a module +export {}; diff --git a/hono-entry.node.ts b/hono-entry.node.ts new file mode 100644 index 0000000..e5927a7 --- /dev/null +++ b/hono-entry.node.ts @@ -0,0 +1,31 @@ +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { type Context, Hono } from "hono"; +import { env } from "hono/adapter"; +import { compress } from "hono/compress"; +import app from "./hono-entry.js"; + +const envs = env<{ NODE_ENV?: string; PORT?: string }>({ env: {} } as unknown as Context<{ + Bindings: { NODE_ENV?: string; PORT?: string }; +}>); + +const nodeApp = new Hono(); + +nodeApp.use(compress()); + +nodeApp.use( + "/*", + serveStatic({ + root: `./dist/client/`, + }), +); + +nodeApp.route("/", app!); + +const port = envs.PORT ? parseInt(envs.PORT, 10) : 3000; + +console.log(`Server listening on http://localhost:${port}`); +serve({ + fetch: nodeApp.fetch, + port: port, +}); diff --git a/hono-entry.ts b/hono-entry.ts new file mode 100644 index 0000000..8274a07 --- /dev/null +++ b/hono-entry.ts @@ -0,0 +1,26 @@ +import { authjsHandler, authjsSessionMiddleware } from "./server/authjs-handler"; +import { vikeHandler } from "./server/vike-handler"; +import { Hono } from "hono"; +import { createHandler, createMiddleware } from "@universal-middleware/hono"; +import { trpcHandler } from "./server/trpc-handler"; + +const app = new Hono(); + +app.use(createMiddleware(authjsSessionMiddleware)()); + +/** + * Auth.js route + * @link {@see https://authjs.dev/getting-started/installation} + **/ +app.use("/api/auth/**", createHandler(authjsHandler)()); + +app.use("/api/trpc/*", createHandler(trpcHandler)("/api/trpc")); + +/** + * Vike route + * + * @link {@see https://vike.dev} + **/ +app.all("*", createHandler(vikeHandler)()); + +export default app; diff --git a/layouts/LayoutDefault.tsx b/layouts/LayoutDefault.tsx new file mode 100644 index 0000000..6cf9415 --- /dev/null +++ b/layouts/LayoutDefault.tsx @@ -0,0 +1,36 @@ +import "@mantine/core/styles.css"; +import { AppShell, Burger, Group, Image, MantineProvider } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import theme from "./theme.js"; + +import logoUrl from "../assets/logo.svg"; +import { Link } from "../components/Link"; + +export default function LayoutDefault({ children }: { children: React.ReactNode }) { + const [opened, { toggle }] = useDisclosure(); + return ( + + + + + + + {" "} + {" "} + + + + + + + + + {children} + + + ); +} diff --git a/layouts/theme.ts b/layouts/theme.ts new file mode 100644 index 0000000..21e4860 --- /dev/null +++ b/layouts/theme.ts @@ -0,0 +1,9 @@ +import { createTheme } from "@mantine/core"; +import type { MantineThemeOverride } from "@mantine/core"; + +const theme: MantineThemeOverride = createTheme({ + /** Put your mantine theme override here */ + primaryColor: "violet", +}); + +export default theme; diff --git a/package.json b/package.json new file mode 100644 index 0000000..fe39f74 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "scripts": { + "dev": "vike dev", + "build": "vike build", + "preview": "run-s build preview:wrangler", + "lint": "biome lint --write .", + "format": "biome format --write .", + "preview:wrangler": "wrangler pages dev", + "deploy:wrangler": "wrangler pages deploy", + "deploy": "run-s build deploy:wrangler" + }, + "dependencies": { + "vike": "^0.4.235", + "@auth/core": "^0.40.0", + "@universal-middleware/core": "^0.4.8", + "@compiled/react": "^0.18.6", + "@hono/node-server": "^1.14.4", + "@universal-middleware/hono": "^0.4.14", + "hono": "^4.8.2", + "@vitejs/plugin-react": "^4.6.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vike-react": "^0.6.4", + "@trpc/server": "^11.4.2", + "@trpc/client": "^11.4.2", + "vike-cloudflare": "^0.1.7", + "@mantine/core": "^8.1.1", + "@mantine/hooks": "^8.1.1" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^6.3.5", + "@biomejs/biome": "1.9.4", + "vite-plugin-compiled-react": "^1.3.1", + "@hono/vite-dev-server": "^0.19.1", + "@types/node": "^20.19.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@cloudflare/workers-types": "^4.20250620.0", + "wrangler": "^4.20.5", + "npm-run-all2": "^8.0.4", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1" + }, + "type": "module" +} \ No newline at end of file diff --git a/pages/+Head.tsx b/pages/+Head.tsx new file mode 100644 index 0000000..136234d --- /dev/null +++ b/pages/+Head.tsx @@ -0,0 +1,14 @@ +// https://vike.dev/Head + +import logoUrl from "../assets/logo.svg"; + +import { ColorSchemeScript } from "@mantine/core"; + +export default function HeadDefault() { + return ( + <> + + + + ); +} diff --git a/pages/+config.ts b/pages/+config.ts new file mode 100644 index 0000000..38a47a6 --- /dev/null +++ b/pages/+config.ts @@ -0,0 +1,18 @@ +import vikeReact from "vike-react/config"; +import type { Config } from "vike/types"; +import Layout from "../layouts/LayoutDefault.js"; + +// Default config (can be overridden by pages) +// https://vike.dev/config + +export default { + // https://vike.dev/Layout + Layout, + + // https://vike.dev/head-tags + title: "My Vike App", + description: "Demo showcasing Vike", + + passToClient: ["user"], + extends: vikeReact, +} satisfies Config; diff --git a/pages/+onPageTransitionEnd.ts b/pages/+onPageTransitionEnd.ts new file mode 100644 index 0000000..75af2e0 --- /dev/null +++ b/pages/+onPageTransitionEnd.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionEndAsync } from "vike/types"; + +export const onPageTransitionEnd: OnPageTransitionEndAsync = async () => { + console.log("Page transition end"); + document.querySelector("body")?.classList.remove("page-is-transitioning"); +}; diff --git a/pages/+onPageTransitionStart.ts b/pages/+onPageTransitionStart.ts new file mode 100644 index 0000000..12c344b --- /dev/null +++ b/pages/+onPageTransitionStart.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionStartAsync } from "vike/types"; + +export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { + console.log("Page transition start"); + document.querySelector("body")?.classList.add("page-is-transitioning"); +}; diff --git a/pages/_error/+Page.tsx b/pages/_error/+Page.tsx new file mode 100644 index 0000000..3ae853a --- /dev/null +++ b/pages/_error/+Page.tsx @@ -0,0 +1,19 @@ +import { usePageContext } from "vike-react/usePageContext"; + +export default function Page() { + const { is404 } = usePageContext(); + if (is404) { + return ( + <> +

404 Page Not Found

+

This page could not be found.

+ + ); + } + return ( + <> +

500 Internal Server Error

+

Something went wrong.

+ + ); +} diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx new file mode 100644 index 0000000..044f2f9 --- /dev/null +++ b/pages/index/+Page.tsx @@ -0,0 +1,16 @@ +import { Counter } from "./Counter.js"; + +export default function Page() { + return ( + <> +

My Vike app

+ This page is: +
    +
  • Rendered to HTML.
  • +
  • + Interactive. +
  • +
+ + ); +} diff --git a/pages/index/Counter.tsx b/pages/index/Counter.tsx new file mode 100644 index 0000000..a35a228 --- /dev/null +++ b/pages/index/Counter.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; + +export function Counter() { + const [count, setCount] = useState(0); + + return ( + + ); +} diff --git a/pages/star-wars/@id/+Page.tsx b/pages/star-wars/@id/+Page.tsx new file mode 100644 index 0000000..a2281be --- /dev/null +++ b/pages/star-wars/@id/+Page.tsx @@ -0,0 +1,16 @@ +import { useData } from "vike-react/useData"; +import type { Data } from "./+data.js"; + +export default function Page() { + const movie = useData(); + return ( + <> +

{movie.title}

+ Release Date: {movie.release_date} +
+ Director: {movie.director} +
+ Producer: {movie.producer} + + ); +} diff --git a/pages/star-wars/@id/+data.ts b/pages/star-wars/@id/+data.ts new file mode 100644 index 0000000..3b5a342 --- /dev/null +++ b/pages/star-wars/@id/+data.ts @@ -0,0 +1,32 @@ +// https://vike.dev/data + +import type { PageContextServer } from "vike/types"; +import type { MovieDetails } from "../types.js"; +import { useConfig } from "vike-react/useConfig"; + +export type Data = Awaited>; + +export const data = async (pageContext: PageContextServer) => { + // https://vike.dev/useConfig + const config = useConfig(); + + const response = await fetch(`https://brillout.github.io/star-wars/api/films/${pageContext.routeParams.id}.json`); + let movie = (await response.json()) as MovieDetails; + + config({ + // Set + title: movie.title, + }); + + // We remove data we don't need because the data is passed to + // the client; we should minimize what is sent over the network. + movie = minimize(movie); + + return movie; +}; + +function minimize(movie: MovieDetails): MovieDetails { + const { id, title, release_date, director, producer } = movie; + const minimizedMovie = { id, title, release_date, director, producer }; + return minimizedMovie; +} diff --git a/pages/star-wars/index/+Page.tsx b/pages/star-wars/index/+Page.tsx new file mode 100644 index 0000000..5bd4bd4 --- /dev/null +++ b/pages/star-wars/index/+Page.tsx @@ -0,0 +1,21 @@ +import { useData } from "vike-react/useData"; +import type { Data } from "./+data.js"; + +export default function Page() { + const movies = useData<Data>(); + return ( + <> + <h1>Star Wars Movies</h1> + <ol> + {movies.map(({ id, title, release_date }) => ( + <li key={id}> + <a href={`/star-wars/${id}`}>{title}</a> ({release_date}) + </li> + ))} + </ol> + <p> + Source: <a href="https://brillout.github.io/star-wars">brillout.github.io/star-wars</a>. + </p> + </> + ); +} diff --git a/pages/star-wars/index/+data.ts b/pages/star-wars/index/+data.ts new file mode 100644 index 0000000..73834ea --- /dev/null +++ b/pages/star-wars/index/+data.ts @@ -0,0 +1,32 @@ +// https://vike.dev/data + +import type { Movie, MovieDetails } from "../types.js"; +import { useConfig } from "vike-react/useConfig"; + +export type Data = Awaited<ReturnType<typeof data>>; + +export const data = async () => { + // https://vike.dev/useConfig + const config = useConfig(); + + const response = await fetch("https://brillout.github.io/star-wars/api/films.json"); + const moviesData = (await response.json()) as MovieDetails[]; + + config({ + // Set <title> + title: `${moviesData.length} Star Wars Movies`, + }); + + // We remove data we don't need because the data is passed to the client; we should + // minimize what is sent over the network. + const movies = minimize(moviesData); + + return movies; +}; + +function minimize(movies: MovieDetails[]): Movie[] { + return movies.map((movie) => { + const { title, release_date, id } = movie; + return { title, release_date, id }; + }); +} diff --git a/pages/star-wars/types.ts b/pages/star-wars/types.ts new file mode 100644 index 0000000..ffccdf5 --- /dev/null +++ b/pages/star-wars/types.ts @@ -0,0 +1,10 @@ +export type Movie = { + id: string; + title: string; + release_date: string; +}; + +export type MovieDetails = Movie & { + director: string; + producer: string; +}; diff --git a/pages/todo/+Page.tsx b/pages/todo/+Page.tsx new file mode 100644 index 0000000..efda410 --- /dev/null +++ b/pages/todo/+Page.tsx @@ -0,0 +1,13 @@ +import type { Data } from "./+data"; +import { useData } from "vike-react/useData"; +import { TodoList } from "./TodoList.js"; + +export default function Page() { + const data = useData<Data>(); + return ( + <> + <h1>To-do List</h1> + <TodoList initialTodoItems={data.todo} /> + </> + ); +} diff --git a/pages/todo/+config.ts b/pages/todo/+config.ts new file mode 100644 index 0000000..a668c0a --- /dev/null +++ b/pages/todo/+config.ts @@ -0,0 +1,3 @@ +export const config = { + prerender: false, +}; diff --git a/pages/todo/+data.ts b/pages/todo/+data.ts new file mode 100644 index 0000000..60954bf --- /dev/null +++ b/pages/todo/+data.ts @@ -0,0 +1,11 @@ +// https://vike.dev/data +import { todos } from "../../database/todoItems"; +import type { PageContextServer } from "vike/types"; + +export type Data = { + todo: { text: string }[]; +}; + +export default async function data(_pageContext: PageContextServer): Promise<Data> { + return { todo: todos }; +} diff --git a/pages/todo/TodoList.tsx b/pages/todo/TodoList.tsx new file mode 100644 index 0000000..e384e68 --- /dev/null +++ b/pages/todo/TodoList.tsx @@ -0,0 +1,38 @@ +import { trpc } from "../../trpc/client"; +import { useState } from "react"; + +export function TodoList({ initialTodoItems }: { initialTodoItems: { text: string }[] }) { + const [todoItems, setTodoItems] = useState(initialTodoItems); + const [newTodo, setNewTodo] = useState(""); + return ( + <> + <ul> + {todoItems.map((todoItem, index) => ( + // biome-ignore lint: + <li key={index}>{todoItem.text}</li> + ))} + </ul> + <div> + <form + onSubmit={async (ev) => { + ev.preventDefault(); + + // Optimistic UI update + setTodoItems((prev) => [...prev, { text: newTodo }]); + try { + await trpc.onNewTodo.mutate(newTodo); + setNewTodo(""); + } catch (e) { + console.error(e); + // rollback + setTodoItems((prev) => prev.slice(0, -1)); + } + }} + > + <input type="text" onChange={(ev) => setNewTodo(ev.target.value)} value={newTodo} /> + <button type="submit">Add to-do</button> + </form> + </div> + </> + ); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..91b69bc --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - "@biomejs/biome" diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..e817f56 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/server/authjs-handler.ts b/server/authjs-handler.ts new file mode 100644 index 0000000..be8e45a --- /dev/null +++ b/server/authjs-handler.ts @@ -0,0 +1,95 @@ +import { Auth, type AuthConfig, createActionURL, setEnvDefaults } from "@auth/core"; +import CredentialsProvider from "@auth/core/providers/credentials"; +import type { Session } from "@auth/core/types"; +// TODO: stop using universal-middleware and directly integrate server middlewares instead and/or use vike-server https://vike.dev/server. (Bati generates boilerplates that use universal-middleware https://github.com/magne4000/universal-middleware to make Bati's internal logic easier. This is temporary and will be removed soon.) +import type { Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; + +const env: Record<string, string | undefined> = + typeof process?.env !== "undefined" + ? process.env + : import.meta && "env" in import.meta + ? (import.meta as ImportMeta & { env: Record<string, string | undefined> }).env + : {}; + +if (!globalThis.crypto) { + /** + * Polyfill needed if Auth.js code runs on node18 + */ + Object.defineProperty(globalThis, "crypto", { + value: await import("node:crypto").then((crypto) => crypto.webcrypto as Crypto), + writable: false, + configurable: true, + }); +} + +const authjsConfig = { + basePath: "/api/auth", + trustHost: Boolean(env.AUTH_TRUST_HOST ?? env.VERCEL ?? env.NODE_ENV !== "production"), + // TODO: Replace secret {@see https://authjs.dev/reference/core#secret} + secret: "MY_SECRET", + providers: [ + // TODO: Choose and implement providers + CredentialsProvider({ + name: "Credentials", + credentials: { + username: { label: "Username", type: "text", placeholder: "jsmith" }, + password: { label: "Password", type: "password" }, + }, + async authorize() { + // Add logic here to look up the user from the credentials supplied + const user = { id: "1", name: "J Smith", email: "jsmith@example.com" }; + + // Any object returned will be saved in `user` property of the JWT + // If you return null then an error will be displayed advising the user to check their details. + // You can also Reject this callback with an Error thus the user will be sent to the error page with the error message as a query parameter + return user ?? null; + }, + }), + ], +} satisfies Omit<AuthConfig, "raw">; + +/** + * Retrieve Auth.js session from Request + */ +export async function getSession(req: Request, config: Omit<AuthConfig, "raw">): Promise<Session | null> { + setEnvDefaults(process.env, config); + const requestURL = new URL(req.url); + const url = createActionURL("session", requestURL.protocol, req.headers, process.env, config); + + const response = await Auth(new Request(url, { headers: { cookie: req.headers.get("cookie") ?? "" } }), config); + + const { status = 200 } = response; + + const data = await response.json(); + + if (!data || !Object.keys(data).length) return null; + if (status === 200) return data; + throw new Error(data.message); +} + +/** + * Add Auth.js session to context + * @link {@see https://authjs.dev/getting-started/session-management/get-session} + **/ +export const authjsSessionMiddleware: Get<[], UniversalMiddleware> = () => async (request, context) => { + try { + return { + ...context, + session: await getSession(request, authjsConfig), + }; + } catch (error) { + console.debug("authjsSessionMiddleware:", error); + return { + ...context, + session: null, + }; + } +}; + +/** + * Auth.js route + * @link {@see https://authjs.dev/getting-started/installation} + **/ +export const authjsHandler = (() => async (request) => { + return Auth(request, authjsConfig); +}) satisfies Get<[], UniversalHandler>; diff --git a/server/trpc-handler.ts b/server/trpc-handler.ts new file mode 100644 index 0000000..d4bc994 --- /dev/null +++ b/server/trpc-handler.ts @@ -0,0 +1,20 @@ +import { appRouter } from "../trpc/server"; +// TODO: stop using universal-middleware and directly integrate server middlewares instead and/or use vike-server https://vike.dev/server. (Bati generates boilerplates that use universal-middleware https://github.com/magne4000/universal-middleware to make Bati's internal logic easier. This is temporary and will be removed soon.) +import type { Get, UniversalHandler } from "@universal-middleware/core"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +export const trpcHandler = ((endpoint) => (request, context, runtime) => { + return fetchRequestHandler({ + endpoint, + req: request, + router: appRouter, + createContext({ req, resHeaders }) { + return { + ...context, + ...runtime, + req, + resHeaders, + }; + }, + }); +}) satisfies Get<[endpoint: string], UniversalHandler>; diff --git a/server/vike-handler.ts b/server/vike-handler.ts new file mode 100644 index 0000000..a0ff858 --- /dev/null +++ b/server/vike-handler.ts @@ -0,0 +1,18 @@ +/// <reference lib="webworker" /> +import { renderPage } from "vike/server"; +// TODO: stop using universal-middleware and directly integrate server middlewares instead and/or use vike-server https://vike.dev/server. (Bati generates boilerplates that use universal-middleware https://github.com/magne4000/universal-middleware to make Bati's internal logic easier. This is temporary and will be removed soon.) +import type { Get, UniversalHandler } from "@universal-middleware/core"; + +export const vikeHandler: Get<[], UniversalHandler> = () => async (request, context, runtime) => { + const pageContextInit = { ...context, ...runtime, urlOriginal: request.url, headersOriginal: request.headers }; + const pageContext = await renderPage(pageContextInit); + const response = pageContext.httpResponse; + + const { readable, writable } = new TransformStream(); + response.pipe(writable); + + return new Response(readable, { + status: response.statusCode, + headers: response.headers, + }); +}; diff --git a/trpc/client.ts b/trpc/client.ts new file mode 100644 index 0000000..dee0e18 --- /dev/null +++ b/trpc/client.ts @@ -0,0 +1,10 @@ +import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import type { AppRouter } from "./server.js"; + +export const trpc = createTRPCProxyClient<AppRouter>({ + links: [ + httpBatchLink({ + url: "/api/trpc", + }), + ], +}); diff --git a/trpc/server.ts b/trpc/server.ts new file mode 100644 index 0000000..68cef5d --- /dev/null +++ b/trpc/server.ts @@ -0,0 +1,32 @@ +import { initTRPC } from "@trpc/server"; + +/** + * Initialization of tRPC backend + * Should be done only once per backend! + */ +const t = initTRPC.context<object>().create(); + +/** + * Export reusable router and procedure helpers + * that can be used throughout the router + */ +export const router = t.router; +export const publicProcedure = t.procedure; + +export const appRouter = router({ + demo: publicProcedure.query(async () => { + return { demo: true }; + }), + onNewTodo: publicProcedure + .input((value): string => { + if (typeof value === "string") { + return value; + } + throw new Error("Input is not a string"); + }) + .mutation(async (opts) => { + console.log("Received new todo", { text: opts.input }); + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe7ca4a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "module": "ESNext", + "noEmit": true, + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "types": [ + "vite/client", + "vike-react", + "vike-cloudflare/types" + ], + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "exclude": [ + "dist" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1398c58 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,40 @@ +import { pages } from "vike-cloudflare"; +import react from "@vitejs/plugin-react"; +import devServer from "@hono/vite-dev-server"; +import { compiled } from "vite-plugin-compiled-react"; +import { defineConfig } from "vite"; +import vike from "vike/plugin"; + +export default defineConfig({ + plugins: [ + vike(), + compiled({ + extract: true, + }), + devServer({ + entry: "hono-entry.ts", + + exclude: [ + /^\/@.+$/, + /.*\.(ts|tsx|vue)($|\?)/, + /.*\.(s?css|less)($|\?)/, + /^\/favicon\.ico$/, + /.*\.(svg|png)($|\?)/, + /^\/(public|assets|static)\/.+/, + /^\/node_modules\/.*/, + ], + + injectClientScript: false, + }), + react(), + pages({ + server: { + kind: "hono", + entry: "hono-entry.ts", + }, + }), + ], + build: { + target: "es2022", + }, +}); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..4a29d32 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +name = "my-app" +compatibility_date = "2024-09-29" +pages_build_output_dir = "./dist/cloudflare" +compatibility_flags = [ "nodejs_compat" ]