diff --git a/README.md b/README.md index 864e267..45f439e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,23 @@ Try out the [**alpha release** of Fastify DX for Vue](https://github.com/fastify + + + + + +
+ +### [fastify-dx-svelte](https://github.com/fastify/fastify-dx/tree/main/packages/fastify-dx-svelte)

[![NPM version](https://img.shields.io/npm/v/fastify-dx-svelte.svg?style=flat)](https://www.npmjs.com/package/fastify-dx-svelte) + +

+ +Try out the [**alpha release** of Fastify DX for Svelte](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md). + +
+ + + ## Status Fastify DX is currently in **alpha**. diff --git a/docs/svelte/basic-setup.md b/docs/svelte/basic-setup.md new file mode 100644 index 0000000..9137bab --- /dev/null +++ b/docs/svelte/basic-setup.md @@ -0,0 +1,87 @@ +**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).** + +
+ +## Basic setup + +The [starter template](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte) follows [fastify-vite](https://github.com/fastify/fastify-vite)'s convention of having a `client` folder with an `index.js` file, which is automatically resolved as your `clientModule` setting. + +If you want flat directory setup, where server and client files are mixed together, you can manually set `clientModule` to something else. Note that in this case you'll also need to update `root` in your `vite.config.js` file. + +When deploying to production, bear in mind the `client/dist` directory, generated when you run `npm run build`, needs to be included. You'll also want to enable Fastify's [built-in logging](https://www.fastify.io/docs/latest/Reference/Logging/): + +```js +const server = Fastify({ logger: true }) +``` + +The starter template's `server.js` file: + +```js +import Fastify from 'fastify' +import FastifyVite from 'fastify-vite' +import FastifyDXSvelte from 'fastify-dx-svelte' + +const server = Fastify() + +await server.register(FastifyVite, { + root: import.meta.url, + renderer: FastifyDXSvelte, +}) + +await server.vite.ready() +await server.listen(3000) +``` + +The starter template's [`vite.config.js`](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/vite.config.js) file: + +```js +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +import { svelte as viteSvelte } from '@sveltejs/vite-plugin-svelte' +import viteSvelteFastifyDX from 'fastify-dx-svelte/plugin' +import unocss from 'unocss/vite' +import { extractorSvelte } from '@unocss/core' + +const path = fileURLToPath(import.meta.url) + +const root = join(dirname(path), 'client') +const plugins = [ + unocss({ extractors: [extractorSvelte] }), + viteSvelte({ + compilerOptions: { + hydratable: true, + } + }), + viteSvelteFastifyDX(), +] + +export default { root, plugins } +``` + +Note that you only need to use Fastify DX's Vite plugin, which includes all functionality from [fastify-vite](https://github.com/fastify/fastify-vite)'s Vite plugin. + + + + + +### Route exports + +Fastify DX picks up exports from route modules to determine route behavior and functionality, as per the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md). + +To add those exports, you must use ` + +

{data.message}

+``` diff --git a/docs/svelte/data-prefetching.md b/docs/svelte/data-prefetching.md new file mode 100644 index 0000000..d010712 --- /dev/null +++ b/docs/svelte/data-prefetching.md @@ -0,0 +1,30 @@ +**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).** + +
+ +## Isomorphic data prefetching + +Fastify DX for Svelte implements the `getData()` hook from the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md) to solve this problem. + +### `getData(ctx)` + +This hook is set up in a way that it runs server-side before any SSR takes place, so any data fetched is made available to the route component before it starts rendering. During first render, any data retrieved on the server is automatically sent to be hydrated on the client so no new requests are made. Then, during client-side navigation (post first-render), a JSON request is fired to an endpoint automatically registered for running the `getData()` function for that route on the server. + +The objet returned by `getData()` gets automatically assigned as `data` in the [universal route context](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md) object and is accessible from `getMeta()` and `onEnter()` hooks and also via the `useRouteContext()` hook. + +```html + + + + +

{data.message}

+``` diff --git a/docs/svelte/index.md b/docs/svelte/index.md new file mode 100644 index 0000000..b54efcf --- /dev/null +++ b/docs/svelte/index.md @@ -0,0 +1,94 @@ +# fastify-dx-svelte [![NPM version](https://img.shields.io/npm/v/fastify-dx-svelte.svg?style=flat)](https://www.npmjs.com/package/fastify-dx-svelte) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) + +- [**Introduction**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#introduction) +- [**Quick Start**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#quick-start) +- [**Package Scripts**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#package-scripts) +- [**Basic Setup**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/basic-setup.md) +- [**Project Structure**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/project-structure.md) +- [**Rendering Modes**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/rendering-modes.md) +- [**Routing Configuration**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/routing-config.md) +- [**Data Prefetching**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/data-prefetching.md) +- [**Route Layouts**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-layouts.md) +- [**Route Context**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md) +- [**Route Enter Event**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-enter.md) +- [**Virtual Modules**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/virtual-modules.md) + +## Introduction + +**Fastify DX for Svelte** is a renderer adapter for [**fastify-vite**](https://github.com/fastify/fastify-vite). + +It lets you run and SSR (server-side render) **Svelte applications built with Vite** on [Fastify](https://fastify.io/), with a minimal and transparent **server-first approach** — everything starts with `server.js`, your actual Fastify server). + +It also provides a set of built-in utilities for ease of development and managing a universal JavaScript context (SSR to CSR), very much like **Nuxt.js**, **Next.js** and **Remix**. All **Fastify DX** framework adapters implement the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md) and have almost the same API, with only minimal differences due to specific framework APIs or idioms. + +It is a **fast**, **lightweight** alternative to Nuxt.js packed with **Developer Experience** features. + +It has an extremely small core (~1k LOC total) and is built on top of [Fastify](https://github.com/fastify/fastify), [Vite](https://vitejs.dev/), [Valtio](https://github.com/pmndrs/valtio) and [Svelte Routing](https://github.com/EmilTholin/svelte-routing). + +[**See the release notes for the 0.0.1 alpha release**](https://github.com/fastify/fastify-dx/releases/tag/svelte-v0.0.1). + +> At this stage this project is mostly a [**one-man show**](https://github.com/sponsors/galvez), who's devoting all his free time to its completion. Contributions are extremely welcome, as well as bug reports for any issues you may find. + +In this first alpha release it's still missing a test suite. The same is true for [**fastify-vite**](https://github.com/fastify/fastify-vite). + +It'll move into **beta** status when test suites are added to both packages. + +## Quick Start + +Ensure you have **Node v16+**. + +Make a copy of [**starters/svelte**](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte). If you have [`degit`](https://github.com/Rich-Harris/degit), run the following from a new directory: + +```bash +degit fastify/fastify-dx/starters/svelte +``` + +> **If you're starting a project from scratch**, you'll need these packages installed. +> +> ```bash +> npm i fastify fastify-vite fastify-dx-svelte -P +> npm i @sveltejs/vite-plugin-svelte -D +> ``` + + +Run `npm install`. + +Run `npm run dev`. + +Visit `http://localhost:3000/`. + +## What's Included + +That will get you a **starter template** with: + +- A minimal [Fastify](https://github.com/fastify/fastify) server. +- Some dummy API routes. +- A `pages/` folder with some [demo routes](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte/client/pages). +- All configuration files. + +It also includes some _**opinionated**_ essentials: + +- [**PostCSS Preset Env**](https://www.npmjs.com/package/postcss-preset-env) by [**Jonathan Neal**](https://github.com/jonathantneal), which enables [several modern CSS features](https://preset-env.cssdb.org/), such as [**CSS Nesting**](https://www.w3.org/TR/css-nesting-1/). + +- [**UnoCSS**](https://github.com/unocss/unocss) by [**Anthony Fu**](https://antfu.me/), which supports all [Tailwind utilities](https://uno.antfu.me/) and many other goodies through its [default preset](https://github.com/unocss/unocss/tree/main/packages/preset-uno). + +- [**Valtio**](https://github.com/pmndrs/valtio) by [**Daishi Kato**](https://blog.axlight.com/), with a global and SSR-ready store which you can use anywhere.
Svelte support is provided via [Sveltio](https://github.com/wobsoriano/sveltio) by [Robert Soriano](https://robsoriano.com/). + + +## Package Scripts + +`npm run dev` boots the development server. + +`npm run build` creates the production bundle. + +`npm run serve` serves the production bundle. + +## Meta + +Created by [Jonas Galvez](https://github.com/sponsors/galvez), **Engineering Manager** and **Open Sourcerer** at [NearForm](https://nearform.com). + +## Sponsors + + + +Also [**Duc-Thien Bui**](https://github.com/aecea) and [**Tom Preston-Werner**](https://github.com/mojombo) [via GitHub Sponsors](https://github.com/sponsors/galvez). _Thank you!_ diff --git a/docs/svelte/meta-tags.md b/docs/svelte/meta-tags.md new file mode 100644 index 0000000..d406447 --- /dev/null +++ b/docs/svelte/meta-tags.md @@ -0,0 +1,30 @@ +**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).** + +
+ +## Meta Tags + +Following the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md), Fastify DX renders `` elements independently from the SSR phase. This allows you to fetch data for populating the first `` tags and stream them right away to the client, and only then perform SSR. + +> Additional `` preload tags can be produced from the SSR phase. This is **not currently implemented** in this **alpha release** but is a planned feature. If you can't wait for it, you can roll out your own (and perhaps contribute your solution) by providing your own [`createHtmlFunction()`](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/index.js#L57) to [fastify-vite](https://github.com/fastify/fastify-vite). + +### `getMeta()` + +To populate ``, `<meta>` and `<link>` elements, export a `getMeta()` function that returns an object matching the format expected by [unihead](https://github.com/galvez/unihead), the underlying library used by Fastify DX. + +It receives the [route context](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#route-context) as first parameter and runs after `getData()`, allowing you to access any `data` populated by these other functions to generate your tags. + +```html +<script context="module"> +export function getMeta (ctx) { + return { + title: 'Route Title', + meta: [ + { name: 'twitter:title', value: 'Route Title' }, + ] + } +} +</script> + +<p>Route with meta tags.</p> +``` diff --git a/docs/svelte/project-structure.md b/docs/svelte/project-structure.md new file mode 100644 index 0000000..dc3132c --- /dev/null +++ b/docs/svelte/project-structure.md @@ -0,0 +1,61 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Project Structure + +The [starter template](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte) looks like this: + +``` +├── server.js +├── client/ +│ ├── index.js +│ ├── context.js +│ ├── root.svelte +│ ├── index.html +│ ├── layouts/ +│ │ ├── default.svelte +│ │ └── auth.svelte +│ └── pages/ +│ ├── index.svelte +│ ├── client-only.svelte +│ ├── server-only.svelte +│ ├── using-data.svelte +│ └── using-store.svelte +├── vite.config.js +└── package.json +``` + +Several internal files are provided as virtual modules by Fastify DX. They are located inside the `fastify-dx-svelte` package in `node_modules`, and dynamically loaded so you don't have to worry about them unless you want them overriden. + +In this case, placing a file with the same name as the registered virtual module in your Vite project root will override it. Find the detailed rundown of all virtual modules [here][virtual-modules]. + +[virtual-modules]: https://github.com/fastify/fastify-dx/blob/main/docs/svelte/virtual-modules.md + +The `server.js` file is your application entry point. It's the file that runs everything. It boots a Fastify server configured with [**fastify-vite**](https://github.com/fastify/fastify-vite) and **Fastify DX for Svelte** as a renderer adapter to **fastify-vite**. + +The `client/index.js` file is your Vite server entry point, it's the file that provides your client bundle (which runs in the Vite-enriched environment) to the Node.js environment where Fastify runs. + +> Right now, it's mostly a **boilerplate file** because it must exist but it will also probably never need to be changed. + +It exports your application's factory function (must be named `create`), the application routes (must be named `routes`) and the universal route context [initialization module](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md#initialization-module) (must be named `context` and have a dynamic module import so Fastify DX can pick up `default` and named exports). + +The `client/index.html` file is the [root HTML template of the application](https://vitejs.dev/guide/#index-html-and-project-root), which Vite uses as the client bundling entry point. + +> You can expand this file with additional `<meta>` and `<link>` tags if you wish, provided you don't remove any of the placeholders. + +This files links to `/dx:mount.js`, which is a virtual module provided by Fastify DX. + +Virtual modules are covered [here][virtual-modules]. + +The `client/pages/` directory contains your route modules, whose paths are dynamically inferred from the directory structure itself. You can change this behavior easily. More on this [here][routing-config]. + +[routing-config]: https://github.com/fastify/fastify-dx/blob/main/docs/svelte/routing-config.md + +The `client/layouts/` directory contains your route layout modules, which can be associated to any route. By default, `layouts/default.svelte` is used, but if you don't need to do any modifications on that file, you can safely removed as it's provided by Fastify DX in that case. The starter template also comes with `layouts/auth.svelte`, to demonstrate a more advanced use of layouts. + +[routing-config]: https://github.com/fastify/fastify-dx/blob/main/docs/svelte/routing-config.md + +The `client/context.js` file is the universal [route context][route-context] initialization module. Any named exports from this file are attached to the `RouteContext` class prototype on the server, preventing them from being reassigned on every request. The `default` export from this file, however, runs for every request so you can attach any request-specific data to it. + +[route-context]: https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md diff --git a/docs/svelte/rendering-modes.md b/docs/svelte/rendering-modes.md new file mode 100644 index 0000000..8f2a237 --- /dev/null +++ b/docs/svelte/rendering-modes.md @@ -0,0 +1,39 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +# Rendering modes + +Following the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md), Fastify DX's route modules can be set for universal rendering (SSR + CSR hydration, the default behavior), SSR in streaming mode, SSR only (client gets no JavaScript) or CSR only (SSR fully disabled). Fastify DX for Svelte supports all of these modes minus streaming, which is currently not yet supported by Svelte itself. + +## `serverOnly` + +If a route module exports `serverOnly` set to `true`, only SSR will take place. The client gets the server-side rendered markup without any accompanying JavaScript or data hydration. + +You should use this setting to deliver lighter pages when there's no need to run any code on them, such as statically generated content sites. + +```html +<script context="module"> +export const serverOnly = true +</script> + +<p>This route is rendered on the server only!</p> +``` + +[This example](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/client/pages/server-only.svelte) is part of the [starter template](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte). + +## `clientOnly` + +If a route module exports `clientOnly` set to `true`, no SSR will take place, only data fetching and data hydration. The client gets the empty container element (the one that wraps `<!-- element -->` in `index.html`) and all rendering takes place on the client only. + +You can use this setting to save server resources on internal pages where SSR makes no significant diference for search engines or UX in general, such as a password-protected admin section. + +```html +<script context="module"> +export const clientOnly = true +</script> + +<p>This route is rendered on the client only!</p> +``` + +[This example](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/client/pages/client-only.svelte) is part of the [starter template](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte). diff --git a/docs/svelte/route-context.md b/docs/svelte/route-context.md new file mode 100644 index 0000000..cb152f1 --- /dev/null +++ b/docs/svelte/route-context.md @@ -0,0 +1,95 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Route Context + +### Initialization module + +The starter template includes a sample `context.js` file. This file is optional and can be safely removed. If it's present, Fastify DX automatically loads it and uses it to do any RouteContext extensions or data injections you might need. If you're familiar with [Nuxt.js](https://nuxtjs.org/), you can think of it as a [Nuxt.js plugin](https://nuxtjs.org/docs/directory-structure/plugins/). + +**Consuming the route context:** + +```js +import { + useRouteContext +} from '/dx:core.js' + +// ... +const { + state, + actions +} = useRouteContext() + +// ... +actions.addTodoItem(state, value) +``` + +See the [full example](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/client/pages/using-store.vue) in the starter template. + +This example demonstrates how to use it to set up an universally available (SSR and CSR) `$fetch` function (using [`ky-universal`](https://www.npmjs.com/package/ky-universal)) and also export some store actions. They're all made available by `useRouteContext()`, covered next. + +```js +import ky from 'ky-universal' + +export default (ctx) => { + if (ctx.server) { + // Populate state.todoList on the server + ctx.state.todoList = ctx.server.db.todoList + // It'll get automatically serialized to the client on first render! + } +} + +export const $fetch = ky.extend({ + prefixUrl: 'http://localhost:3000' +}) + +// Must be a function so each request can have its own state +export const state = () => ({ + todoList: null, +}) + +export const actions = { + async addTodoItem (state, item) { + await $fetch.put('api/todo/items', { + json: { item }, + }) + state.todoList.push(item) + }, +} +``` + +See the [full example](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/client/context.js) in the starter template. + +### The `useRouteContext()` hook + +This hook can be used in any Vue component to retrieve a reference to the current route context. It's modelled after the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md), with still some rough differences and missing properties in this **alpha release**. + +By default, It includes reference to `data` — which is automatically populated if you use the `getData()` function, and `state` which hold references to the global [`reactive()`](https://vuejs.org/api/reactivity-core.html#reactive) object. + +It automatically causes the component to be suspended if the `getData()`, `getMeta()` and `onEnter()` functions are asynchronous. + +```html +<script> +import { useRouteContext } from '/dx:core.js' +const { data } = useRouteContext() +</script> + +<p>{data.message}</p> +``` + +### Execution order + +This graph illustrates the execution order to expect from route context initialization. + +``` +context.js default function export +└─ getData() function export + └─ getMeta() function export + └─ onEnter() function export + └─ Route module +``` + +First the `default` function export from `context.js` (if present) is executed. This is where you can manually feed global server data into your application by populating the global state (the route context's `state` property, which is automatically hydrated on the client. + +Then `getData()` runs — which populates the route context's `data` property, and is also automatically hydrated on the client. Then `getMeta()`, which populates the route context's `head` property. Then `onEnter()`, and finally your route component. diff --git a/docs/svelte/route-enter.md b/docs/svelte/route-enter.md new file mode 100644 index 0000000..84abad8 --- /dev/null +++ b/docs/svelte/route-enter.md @@ -0,0 +1,27 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Universal Route Enter Event + +### `onEnter(ctx)` + +If a route module exports a `onEnter()` function, it's executed before the route renders, both in SSR and client-side navigation. That is, the first time a route render on the server, onEnter() runs on the server. Then, since it already ran on the server, it doesn't run again on the client for that first route. But if you navigate to another route on the client using `<Link>`, it runs normally as you'd expect. + +It receives the [universal route context][route-context] as first parameter, so you can make changes to `data`, `meta` and `state` if needed. + +[route-context]: https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md + +```html +<script context="module"> +export function onEnter (ctx) { + if (ctx.server?.underPressure) { + ctx.clientOnly = true + } +} +</script> + +<p>No pre-rendered HTML sent to the browser.</p> +``` + +The example demonstrates how to turn off SSR and downgrade to CSR-only, assuming you have a `pressureHandler` configured in [`underpressure`](https://github.com/fastify/under-pressure) to set a `underPressure` flag on your server instance. diff --git a/docs/svelte/route-layouts.md b/docs/svelte/route-layouts.md new file mode 100644 index 0000000..7f386d5 --- /dev/null +++ b/docs/svelte/route-layouts.md @@ -0,0 +1,39 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Route Layouts + +Fastify DX will automatically load layouts from the `layouts/` folder. By default, `/dx:layouts/default.svelte` is used — that is, if a project is missing a `layouts/defaults.svelte` file, the one provided by Fastify DX is used instead. + +See the section on [Virtual Modules](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/virtual-modules.md) to learn more about this. + +You assign a layout to a route by exporting `layout`. + +See [`pages/using-auth.svelte`](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/pages/using-auth.svelte) in the starter template: + +```js +export const layout = 'auth' +``` + +That'll will cause the route to be wrapped in the layout component exported by [`layouts/auth.svelte`](https://github.com/fastify/fastify-dx/blob/main/starters/svelte/layouts/auth.svelte): + +```html +<script> +import { useRouteContext } from '/dx:core.js' +const { snapshot, actions, state } = useRouteContext() +</script> + +<div class="contents"> + {#if !$snapshot.user.authenticated} + <p>This route needs authentication.</p> + <button on:click={() => actions.authenticate(state)}> + Click this button to authenticate. + </button> + {:else} + <slot /> + {/if} +</div> +``` + +Note that like routes, it has access to `useRouteContext()`. diff --git a/docs/svelte/routing-config.md b/docs/svelte/routing-config.md new file mode 100644 index 0000000..6a170ff --- /dev/null +++ b/docs/svelte/routing-config.md @@ -0,0 +1,47 @@ +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Routing Configuration + +By default, routes are loaded from the `<project-root>/pages` folder, where `<project-root>` refers to the `root` setting in `vite.config.js`. The route paths are **dynamically inferred from the directory structure**, very much like Next.js and Nuxt.js. + +### Dynamic parameters + +Dynamic route parameters follow the [Next.js convention](https://nextjs.org/docs/basic-features/pages#pages-with-dynamic-routes) (`[param]`), but that can be overriden by using the `paramPattern` plugin option. For example, this configuration switches the param pattern to the [Remix convention](https://remix.run/docs/en/v1/guides/routing#dynamic-segments) (`$param`). + +```js +// ... +const plugins = [ + // ... + viteSvelteFastifyDX({ paramPattern: /\$(\w+)/ }), +] +``` + +### Routes location + +You can also change the glob pattern used to determine where to route modules from. + +Since this setting is passed to [Vite's glob importers](https://vitejs.dev/guide/features.html#glob-import), the value needs to be a string: + +```js +// ... +const plugins = [ + // ... + viteSvelteFastifyDX({ globPattern: '/views/**/*.svelte' }), +] +``` + +### View modules + +You also can export a `path` constant from your route modules, in which case its value will be used to **override the dynamically inferred paths from the directory structure**. + +Additionally, [**you can provide your own routes**](https://github.com/fastify/fastify-dx/tree/dev/packages/fastify-dx-svelte#dxroutesjs). + +```html +<script context="module"> +export const path = '/my-page' +</script> + +<p>Route with path export</p> +``` diff --git a/docs/svelte/virtual-modules.md b/docs/svelte/virtual-modules.md new file mode 100644 index 0000000..1ba5dbb --- /dev/null +++ b/docs/svelte/virtual-modules.md @@ -0,0 +1,206 @@ + +<sub>**Go back to the [index](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md).**</sub> + +<br> + +## Virtual Modules + +**Fastify DX** relies on [virtual modules](https://github.com/rollup/plugins/tree/master/packages/virtual) to save your project from having too many boilerplate files. Virtual modules are a [Rollup](https://rollupjs.org/guide/en/) feature exposed and fully supported by [Vite](https://vitejs.dev/). When you see imports that start with `/dx:`, you know a Fastify DX virtual module is being used. + +Fastify DX virtual modules are **fully ejectable**. For instance, the starter template relies on the `/dx:root.svelte` virtual module to provide the Vue shell of your application. If you copy the `root.svelte` file [from the fastify-dx-svelte package](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/root.svelte) and place it your Vite project root, **that copy of the file is used instead**. In fact, the starter template already comes with a custom `root.svelte` of its own to include UnoCSS. + +Aside from `root.svelte`, the starter template comes with two other virtual modules already ejected and part of the local project — `context.js` and `layouts/default.svelte`. If you don't need to customize them, you can safely removed them from your project. + +### `/dx:root.svelte` + +This is the root Svelte component. It's provided as part of the starter template. You can use this file to add a common layout to all routes. The version provided as part of the starter template includes [UnoCSS](https://github.com/unocss/unocss)'s own virtual module import, necessary to enable its CSS engine. + +```html +<script> +import 'uno.css' +import { proxy } from 'sveltio' +import { Router, Route } from 'svelte-routing' +import DXRoute from '/dx:route.svelte' + +export let url = null +export let payload + +let state = proxy(payload.serverRoute.state) +</script> + +<Router url="{url}"> + {#each payload.routes as { path, component }} + <Route path="{path}" let:location> + <DXRoute + path={path} + location={location} + state={state} + payload={payload} + component={component} /> + </Route> + {/each} +</Router> +``` + +### `/dx:route.svelte` + +This is used by `root.svelte` to enhance your route modules with the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md). + +<b>You'll rarely need to customize this file.</b> + +```html +<script> +import { setContext } from 'svelte' +import Loadable from 'svelte-loadable' +import { routeContext, jsonDataFetch } from '/dx:core.js' +import layouts from '/dx:layouts.js' + +const isServer = import.meta.env.SSR + +setContext(routeContext, { + get routeContext () { + return ctx + } +}) + +export let path +export let component +export let payload +export let state +export let location + +let ctx = payload.routeMap[path] + +ctx.state = state +ctx.actions = payload.serverRoute.actions + +if (isServer) { + ctx.layout = payload.serverRoute.layout ?? 'default' + ctx.data = payload.serverRoute.data + ctx.state = state +} + +async function setup () { + if (payload.serverRoute.firstRender) { + ctx.data = payload.serverRoute.data + ctx.layout = payload.serverRoute.layout ?? 'default' + payload.serverRoute.firstRender = false + return + } + ctx.layout = ctx.layout ?? 'default' + const { getMeta, getData, onEnter } = await ctx.loader() + if (getData) { + try { + const fullPath = `${location.pathname}${location.search}` + const updatedData = await jsonDataFetch(fullPath) + if (!ctx.data) { + ctx.data = {} + } + if (updatedData) { + Object.assign(ctx.data, updatedData) + } + ctx.error = null + } catch (error) { + ctx.error = error + } + } + if (getMeta) { + const updatedMeta = await getMeta(ctx) + if (updatedMeta) { + payload.head.update(updatedMeta) + } + } + if (onEnter) { + const updatedData = await onEnter(ctx) + if (updatedData) { + Object.assign(ctx.data, updatedData) + } + } +} + +let setupClientRouteContext = !isServer && setup() +</script> + +{#if isServer} + <svelte:component this={layouts[ctx.layout].default}> + <svelte:component this={component} /> + </svelte:component> +{:else} +{#await setupClientRouteContext}{:then} + <svelte:component this={layouts[ctx.layout].default}> + <Loadable loader={component} /> + </svelte:component> +{/await} +{/if} +``` + +What you see above is its [full definition](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/route.svelte). + + +### `/dx:routes.js` + +Fastify DX has **code-splitting** out of the box. It does that by eagerly loading all route data on the server, and then hydrating any missing metadata on the client. That's why the routes module default export is conditioned to `import.meta.env.SSR`, and different helper functions are called for each rendering environment. + +```js +export default import.meta.env.SSR + ? createRoutes(import.meta.globEager('$globPattern')) + : hydrateRoutes(import.meta.glob('$globPattern')) +``` + +See [the full file](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/routes.js) for the `createRoutes()` and `hydrateRoutes()` definitions. + +If you want to use your own custom routes list, you must eject this file as-is and replace the glob imports with your own routes list: + +```js +const routes = [ + { + path: '/', + component: () => import('/custom/index.svelte'), + } +] + +export default import.meta.env.SSR + ? createRoutes(routes) + : hydrateRoutes(routes) +```` + +**Nested routes aren't supported yet.** + + +### `/dx:core.js` + +Implements `useRouteContext()`. + +See its full definition [here](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/core.js). + +### `/dx:layouts.js` + +This is responsible for loading **layout components**. It's part of `route.svelte` by default. If a project has no `layouts/default.svelte` file, the default one from Fastify DX is used. This virtual module works in conjunction with the `/dx:layouts/` virtual module which provides exports from the `/layouts` folder. + +<b>You'll rarely need to customize this file.</b> + +```js +import DefaultLayout from '/dx:layouts/default.svelte' + +const appLayouts = import.meta.globEager('/layouts/*.svelte') + +appLayouts['/layouts/default.svelte'] ??= DefaultLayout + +export default Object.fromEntries( + Object.keys(appLayouts).map((path) => { + const name = path.slice(9, -7) + return [name, appLayouts[path]] + }), +) + +``` + +What you see above is its [full definition](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/layouts.js). + +### `/dx:mount.js` + +This is the file `index.html` links to by default. It sets up the application with an `unihead` instance for head management, the initial route context, and provides the conditional mounting logic to defer to CSR-only if `clientOnly` is enabled. + +<b>You'll rarely need to customize this file.</b> + +[See the full file](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/virtual/mount.js) for the `mount()` function definition. diff --git a/packages/fastify-dx-svelte/.eslintrc b/packages/fastify-dx-svelte/.eslintrc new file mode 100644 index 0000000..e5461ce --- /dev/null +++ b/packages/fastify-dx-svelte/.eslintrc @@ -0,0 +1,21 @@ +{ + parser: '@babel/eslint-parser', + parserOptions: { + requireConfigFile: false, + ecmaVersion: 2021, + sourceType: 'module', + }, + plugins: [ + 'svelte3' + ], + overrides: [ + { + files: ['*.svelte'], + processor: 'svelte3/svelte3' + } + ], + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'import/no-absolute-path': 'off', + }, +} diff --git a/packages/fastify-dx-svelte/README.md b/packages/fastify-dx-svelte/README.md new file mode 100644 index 0000000..b54efcf --- /dev/null +++ b/packages/fastify-dx-svelte/README.md @@ -0,0 +1,94 @@ +# fastify-dx-svelte [![NPM version](https://img.shields.io/npm/v/fastify-dx-svelte.svg?style=flat)](https://www.npmjs.com/package/fastify-dx-svelte) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) + +- [**Introduction**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#introduction) +- [**Quick Start**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#quick-start) +- [**Package Scripts**](https://github.com/fastify/fastify-dx/blob/main/packages/fastify-dx-svelte/README.md#package-scripts) +- [**Basic Setup**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/basic-setup.md) +- [**Project Structure**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/project-structure.md) +- [**Rendering Modes**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/rendering-modes.md) +- [**Routing Configuration**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/routing-config.md) +- [**Data Prefetching**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/data-prefetching.md) +- [**Route Layouts**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-layouts.md) +- [**Route Context**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-context.md) +- [**Route Enter Event**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/route-enter.md) +- [**Virtual Modules**](https://github.com/fastify/fastify-dx/blob/main/docs/svelte/virtual-modules.md) + +## Introduction + +**Fastify DX for Svelte** is a renderer adapter for [**fastify-vite**](https://github.com/fastify/fastify-vite). + +It lets you run and SSR (server-side render) **Svelte applications built with Vite** on [Fastify](https://fastify.io/), with a minimal and transparent **server-first approach** — everything starts with `server.js`, your actual Fastify server). + +It also provides a set of built-in utilities for ease of development and managing a universal JavaScript context (SSR to CSR), very much like **Nuxt.js**, **Next.js** and **Remix**. All **Fastify DX** framework adapters implement the [URMA specification](https://github.com/fastify/fastify-dx/blob/main/URMA.md) and have almost the same API, with only minimal differences due to specific framework APIs or idioms. + +It is a **fast**, **lightweight** alternative to Nuxt.js packed with **Developer Experience** features. + +It has an extremely small core (~1k LOC total) and is built on top of [Fastify](https://github.com/fastify/fastify), [Vite](https://vitejs.dev/), [Valtio](https://github.com/pmndrs/valtio) and [Svelte Routing](https://github.com/EmilTholin/svelte-routing). + +[**See the release notes for the 0.0.1 alpha release**](https://github.com/fastify/fastify-dx/releases/tag/svelte-v0.0.1). + +> At this stage this project is mostly a [**one-man show**](https://github.com/sponsors/galvez), who's devoting all his free time to its completion. Contributions are extremely welcome, as well as bug reports for any issues you may find. + +In this first alpha release it's still missing a test suite. The same is true for [**fastify-vite**](https://github.com/fastify/fastify-vite). + +It'll move into **beta** status when test suites are added to both packages. + +## Quick Start + +Ensure you have **Node v16+**. + +Make a copy of [**starters/svelte**](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte). If you have [`degit`](https://github.com/Rich-Harris/degit), run the following from a new directory: + +```bash +degit fastify/fastify-dx/starters/svelte +``` + +> **If you're starting a project from scratch**, you'll need these packages installed. +> +> ```bash +> npm i fastify fastify-vite fastify-dx-svelte -P +> npm i @sveltejs/vite-plugin-svelte -D +> ``` + + +Run `npm install`. + +Run `npm run dev`. + +Visit `http://localhost:3000/`. + +## What's Included + +That will get you a **starter template** with: + +- A minimal [Fastify](https://github.com/fastify/fastify) server. +- Some dummy API routes. +- A `pages/` folder with some [demo routes](https://github.com/fastify/fastify-dx/tree/dev/starters/svelte/client/pages). +- All configuration files. + +It also includes some _**opinionated**_ essentials: + +- [**PostCSS Preset Env**](https://www.npmjs.com/package/postcss-preset-env) by [**Jonathan Neal**](https://github.com/jonathantneal), which enables [several modern CSS features](https://preset-env.cssdb.org/), such as [**CSS Nesting**](https://www.w3.org/TR/css-nesting-1/). + +- [**UnoCSS**](https://github.com/unocss/unocss) by [**Anthony Fu**](https://antfu.me/), which supports all [Tailwind utilities](https://uno.antfu.me/) and many other goodies through its [default preset](https://github.com/unocss/unocss/tree/main/packages/preset-uno). + +- [**Valtio**](https://github.com/pmndrs/valtio) by [**Daishi Kato**](https://blog.axlight.com/), with a global and SSR-ready store which you can use anywhere. <br>Svelte support is provided via [Sveltio](https://github.com/wobsoriano/sveltio) by [Robert Soriano](https://robsoriano.com/). + + +## Package Scripts + +`npm run dev` boots the development server. + +`npm run build` creates the production bundle. + +`npm run serve` serves the production bundle. + +## Meta + +Created by [Jonas Galvez](https://github.com/sponsors/galvez), **Engineering Manager** and **Open Sourcerer** at [NearForm](https://nearform.com). + +## Sponsors + +<a href="https://nearform.com"><img width="200px" src="https://user-images.githubusercontent.com/12291/172310344-594669fd-da4c-466b-a250-a898569dfea3.svg"></a> + +Also [**Duc-Thien Bui**](https://github.com/aecea) and [**Tom Preston-Werner**](https://github.com/mojombo) [via GitHub Sponsors](https://github.com/sponsors/galvez). _Thank you!_ diff --git a/packages/fastify-dx-svelte/index.js b/packages/fastify-dx-svelte/index.js index e69de29..3d8239e 100644 --- a/packages/fastify-dx-svelte/index.js +++ b/packages/fastify-dx-svelte/index.js @@ -0,0 +1,186 @@ +// Used to send a readable stream to reply.send() +import { Readable } from 'stream' + +// fastify-vite's minimal HTML templating function, +// which extracts interpolation variables from comments +// and returns a function with the generated code +import { createHtmlTemplateFunction } from 'fastify-vite' + +// Used to safely serialize JavaScript into +// <script> tags, preventing a few types of attack +import devalue from 'devalue' + +// Small SSR-ready library used to generate +// <title>, <meta> and <link> elements +import Head from 'unihead' + +// Helper to stream head, body and footer separately +import { generateHtmlStream } from './server/stream.js' + +// Holds the universal route context +import RouteContext from './server/context.js' + +export default { + prepareClient, + createHtmlFunction, + createRenderFunction, + createRouteHandler, + createRoute, +} + +export async function prepareClient ({ + Root, + routes: routesPromise, + context: contextPromise, + ...others +}) { + const context = await contextPromise + const resolvedRoutes = await routesPromise + return { context, routes: resolvedRoutes, Root } +} + +// The return value of this function gets registered as reply.html() +export function createHtmlFunction (source, scope, config) { + // Templating functions for universal rendering (SSR+CSR) + const [unHeadSource, unFooterSource] = source.split('<!-- element -->') + const unHeadTemplate = createHtmlTemplateFunction(unHeadSource) + const unFooterTemplate = createHtmlTemplateFunction(unFooterSource) + // Templating functions for server-only rendering (SSR only) + const [soHeadSource, soFooterSource] = source + // Unsafe if dealing with user-input, but safe here + // where we control the index.html source + .replace(/<script[^>]+type="module"[^>]+>.*?<\/script>/g, '') + .split('<!-- element -->') + const soHeadTemplate = createHtmlTemplateFunction(soHeadSource) + const soFooterTemplate = createHtmlTemplateFunction(soFooterSource) + // This function gets registered as reply.html() + return function ({ routes, context, app }) { + // Initialize hydration, which can stay empty if context.serverOnly is true + let hydration = '' + // Decide which templating functions to use, with and without hydration + const headTemplate = context.serverOnly ? soHeadTemplate : unHeadTemplate + const footerTemplate = context.serverOnly ? soFooterTemplate : unFooterTemplate + // Decide whether or not to include the hydration script + if (!context.serverOnly) { + hydration = ( + '<script>\n' + + `window.route = ${devalue(context.toJSON())}\n` + + `window.routes = ${devalue(routes.toJSON())}\n` + + '</script>' + ) + } + // Render page-level <head> elements + const head = new Head(context.head).render() + const style = ( + app.style?.code && ( + `<style>\n${app.style.code}\n</style>` + ) + ) || '' + + // Create readable stream with prepended and appended chunks + const readable = Readable.from(generateHtmlStream({ + body: app.html, + head: headTemplate({ + ...context, + style, + head, + hydration, + }), + footer: footerTemplate(context), + })) + // Send out header and readable stream with full response + this.type('text/html') + this.send(readable) + } +} + +export async function createRenderFunction ({ routes, Root }) { + // create is exported by client/index.js + return function (req) { + // Create convenience-access routeMap + const routeMap = Object.fromEntries(routes.toJSON().map((route) => { + return [route.path, route] + })) + // Creates main React component with all the SSR context it needs + const app = !req.route.clientOnly && Root.render({ + url: req.url, + payload: { + routes, + routeMap, + serverRoute: req.route, + }, + }) + // Perform SSR, i.e., turn app.instance into an HTML fragment + // The SSR context data is passed along so it can be inlined for hydration + return { routes, context: req.route, app } + } +} + +export function createRouteHandler (client, scope, config) { + return function (req, reply) { + reply.html(reply.render(req)) + return reply + } +} + +export function createRoute ({ client, handler, errorHandler, route }, scope, config) { + const onRequest = async function onRequest (req, reply) { + req.route = await RouteContext.create( + scope, + req, + reply, + route, + client.context, + ) + } + if (route.getData) { + // If getData is provided, register JSON endpoint for it + scope.get(`/-/data${route.path}`, { + onRequest, + async handler (req, reply) { + reply.send(await route.getData(req.route)) + }, + }) + } + + // See https://github.com/fastify/fastify-dx/blob/main/URMA.md + const hasURMAHooks = Boolean( + route.getData || route.getMeta || route.onEnter, + ) + + // Extend with route context initialization module + RouteContext.extend(client.context) + + scope.get(route.path, { + onRequest, + // If either getData or onEnter are provided, + // make sure they run before the SSR route handler + ...hasURMAHooks && { + async preHandler (req, reply) { + try { + if (route.getData) { + req.route.data = await route.getData(req.route) + } + if (route.getMeta) { + req.route.head = await route.getMeta(req.route) + } + if (route.onEnter) { + if (!req.route.data) { + req.route.data = {} + } + const result = await route.onEnter(req.route) + Object.assign(req.route.data, result) + } + } catch (err) { + if (config.dev) { + console.error(err) + } + req.route.error = err + } + }, + }, + handler, + errorHandler, + ...route, + }) +} diff --git a/packages/fastify-dx-svelte/package.json b/packages/fastify-dx-svelte/package.json index 4af29c5..d31f954 100644 --- a/packages/fastify-dx-svelte/package.json +++ b/packages/fastify-dx-svelte/package.json @@ -1,6 +1,44 @@ { + "scripts": { + "lint": "eslint . --ext .js,.svelte --fix" + }, + "type": "module", + "main": "index.js", "name": "fastify-dx-svelte", - "version": "0.0.0", - "files": ["index.js"], - "license": "MIT" -} \ No newline at end of file + "version": "0.0.1", + "files": [ + "virtual/root.svelte", + "virtual/route.svelte", + "virtual/layouts.js", + "virtual/layouts/default.svelte", + "virtual/context.js", + "virtual/mount.js", + "virtual/core.js", + "virtual/routes.js", + "index.js", + "plugin.cjs", + "server/context.js", + "server/stream.js" + ], + "license": "MIT", + "exports": { + ".": "./index.js", + "./plugin": "./plugin.cjs" + }, + "dependencies": { + "devalue": "^2.0.1", + "svelte-loadable": "^2.0.1", + "svelte-routing": "^1.6.0", + "sveltio": "^1.0.5", + "unihead": "^0.0.6" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.18.2", + "eslint": "^8.18.0", + "eslint-config-standard": "^16.0.2", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.3.1", + "eslint-plugin-svelte3": "^4.0.0" + } +} diff --git a/packages/fastify-dx-svelte/plugin.cjs b/packages/fastify-dx-svelte/plugin.cjs new file mode 100644 index 0000000..ecce70c --- /dev/null +++ b/packages/fastify-dx-svelte/plugin.cjs @@ -0,0 +1,106 @@ +const { readFileSync, existsSync } = require('fs') +const { dirname, join, resolve } = require('path') +const { fileURLToPath } = require('url') + +function viteSvelteFastifyDX (config = {}) { + const prefix = /^\/?dx:/ + const routing = Object.assign({ + globPattern: '/pages/**/*.svelte', + paramPattern: /\[(\w+)\]/, + }, config) + const virtualRoot = resolve(__dirname, 'virtual') + const virtualModules = [ + 'mount.js', + 'routes.js', + 'layouts.js', + 'root.svelte', + 'route.svelte', + 'layouts/', + 'context.js', + 'core.js' + ] + virtualModules.includes = function (virtual) { + if (!virtual) { + return false + } + for (const entry of this) { + if (virtual.startsWith(entry)) { + return true + } + } + return false + } + const virtualModuleInserts = { + 'routes.js': { + $globPattern: routing.globPattern, + $paramPattern: routing.paramPattern, + } + } + + let viteProjectRoot + + function loadVirtualModuleOverride (virtual) { + if (!virtualModules.includes(virtual)) { + return + } + const overridePath = resolve(viteProjectRoot, virtual) + if (existsSync(overridePath)) { + return overridePath + } + } + + function loadVirtualModule (virtual) { + if (!virtualModules.includes(virtual)) { + return + } + let code = readFileSync(resolve(virtualRoot, virtual), 'utf8') + if (virtualModuleInserts[virtual]) { + for (const [key, value] of Object.entries(virtualModuleInserts[virtual])) { + code = code.replace(new RegExp(escapeRegExp(key), 'g'), value) + } + } + return { + code, + map: null, + } + } + + // Thanks to https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js + function escapeRegExp (s) { + return s + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d') + } + + return { + name: 'vite-plugin-react-fastify-dx', + config (config, { command }) { + if (command === 'build' && config.build?.ssr) { + config.build.rollupOptions = { + output: { + format: 'es', + }, + } + } + }, + configResolved (config) { + viteProjectRoot = config.root + }, + async resolveId (id) { + const [, virtual] = id.split(prefix) + if (virtual) { + const override = await loadVirtualModuleOverride(virtual) + if (override) { + return override + } + return id + } + }, + load (id) { + const [, virtual] = id.split(prefix) + return loadVirtualModule(virtual) + }, + } +} + +module.exports = viteSvelteFastifyDX diff --git a/packages/fastify-dx-svelte/server/context.js b/packages/fastify-dx-svelte/server/context.js new file mode 100644 index 0000000..fab9a2e --- /dev/null +++ b/packages/fastify-dx-svelte/server/context.js @@ -0,0 +1,65 @@ + +const routeContextInspect = Symbol.for('nodejs.util.inspect.custom') + +export default class RouteContext { + static async create (server, req, reply, route, contextInit) { + const routeContext = new RouteContext(server, req, reply, route) + if (contextInit) { + if (contextInit.state) { + routeContext.state = contextInit.state() + } + if (contextInit.default) { + await contextInit.default(routeContext) + } + } + return routeContext + } + + constructor (server, req, reply, route) { + this.server = server + this.req = req + this.reply = reply + this.head = {} + this.state = null + this.data = route.data + this.firstRender = true + this.layout = route.layout + this.getMeta = !!route.getMeta + this.getData = !!route.getData + this.onEnter = !!route.onEnter + this.streaming = route.streaming + this.clientOnly = route.clientOnly + this.serverOnly = route.serverOnly + } + + [routeContextInspect] () { + return { + ...this, + server: { [routeContextInspect]: () => '[Server]' }, + req: { [routeContextInspect]: () => '[Request]' }, + reply: { [routeContextInspect]: () => '[Reply]' }, + } + } + + toJSON () { + return { + state: this.state, + data: this.data, + layout: this.layout, + getMeta: this.getMeta, + getData: this.getData, + onEnter: this.onEnter, + firstRender: this.firstRender, + clientOnly: this.clientOnly, + } + } +} + +RouteContext.extend = function (initial) { + const { default: _, ...extra } = initial + for (const [prop, value] of Object.entries(extra)) { + if (prop !== 'data' && prop !== 'state') { + Object.defineProperty(RouteContext.prototype, prop, value) + } + } +} diff --git a/packages/fastify-dx-svelte/server/stream.js b/packages/fastify-dx-svelte/server/stream.js new file mode 100644 index 0000000..8b97b8d --- /dev/null +++ b/packages/fastify-dx-svelte/server/stream.js @@ -0,0 +1,7 @@ + +// Helper function to prepend and append chunks the body stream +export async function * generateHtmlStream ({ head, body, footer }) { + yield head + yield body + yield footer +} diff --git a/packages/fastify-dx-svelte/virtual/context.js b/packages/fastify-dx-svelte/virtual/context.js new file mode 100644 index 0000000..1e605f5 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/context.js @@ -0,0 +1,4 @@ +// This file serves as a placeholder +// if no context.js file is provided + +export default () => {} diff --git a/packages/fastify-dx-svelte/virtual/core.js b/packages/fastify-dx-svelte/virtual/core.js new file mode 100644 index 0000000..9c267d7 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/core.js @@ -0,0 +1,28 @@ +import { getContext } from 'svelte' +import { useSnapshot } from 'sveltio' + +export const routeContext = Symbol('routeContext') + +export function useRouteContext () { + const ctx = getContext(routeContext).routeContext + ctx.snapshot = useSnapshot(ctx.state) + return ctx +} + +export async function jsonDataFetch (path) { + const response = await fetch(`/-/data${path}`) + let data + let error + try { + data = await response.json() + } catch (err) { + error = err + } + if (data?.statusCode === 500) { + throw new Error(data.message) + } + if (error) { + throw error + } + return data +} diff --git a/packages/fastify-dx-svelte/virtual/layouts.js b/packages/fastify-dx-svelte/virtual/layouts.js new file mode 100644 index 0000000..e188cc1 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/layouts.js @@ -0,0 +1,12 @@ +import DefaultLayout from '/dx:layouts/default.svelte' + +const appLayouts = import.meta.globEager('/layouts/*.svelte') + +appLayouts['/layouts/default.svelte'] ??= DefaultLayout + +export default Object.fromEntries( + Object.keys(appLayouts).map((path) => { + const name = path.slice(9, -7) + return [name, appLayouts[path]] + }), +) diff --git a/packages/fastify-dx-svelte/virtual/layouts/default.svelte b/packages/fastify-dx-svelte/virtual/layouts/default.svelte new file mode 100644 index 0000000..c63d36b --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/layouts/default.svelte @@ -0,0 +1,8 @@ +<script> +// This file serves as a placeholder if no +// layouts/default.svelte file is provided +</script> + +<div class="layout"> + <slot></slot> +</div> diff --git a/packages/fastify-dx-svelte/virtual/mount.js b/packages/fastify-dx-svelte/virtual/mount.js new file mode 100644 index 0000000..0d8f604 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/mount.js @@ -0,0 +1,45 @@ +import Head from 'unihead/client' +import Root from '/dx:root.svelte' +import routesPromise from '/dx:routes.js' + +mount('main') + +async function mount (target) { + if (typeof target === 'string') { + target = document.querySelector(target) + } + const context = await import('/dx:context.js') + const serverRoute = await extendContext(window.route, context) + const head = new Head(window.route.head, window.document) + const resolvedRoutes = await routesPromise + const routeMap = Object.fromEntries( + resolvedRoutes.map((route) => [route.path, route]), + ) + new Root({ + target: document.querySelector('main'), + props: { + payload: { + head, + serverRoute, + routes: window.routes, + routeMap, + }, + }, + hydrate: !serverRoute.clientOnly, + }) +} + +async function extendContext (ctx, { + // The route context initialization function + default: setter, + // We destructure state here just to discard it from extra + state, + // Other named exports from context.js + ...extra +}) { + Object.assign(ctx, extra) + if (setter) { + await setter(ctx) + } + return ctx +} diff --git a/packages/fastify-dx-svelte/virtual/root.svelte b/packages/fastify-dx-svelte/virtual/root.svelte new file mode 100644 index 0000000..126d02b --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/root.svelte @@ -0,0 +1,22 @@ +<script> +import { proxy } from 'sveltio' +import { Router, Route } from 'svelte-routing' +import DXRoute from '/dx:route.svelte' + +export let url = null +export let payload + +let state = proxy(payload.serverRoute.state) +</script> + +<Router url="{url}"> + {#each payload.routes as { path, component }} + <Route path="{path}" let:location> + <DXRoute + location={location} + state={state} + payload={payload} + component={component} /> + </Route> + {/each} +</Router> diff --git a/packages/fastify-dx-svelte/virtual/route.svelte b/packages/fastify-dx-svelte/virtual/route.svelte new file mode 100644 index 0000000..978bbd2 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/route.svelte @@ -0,0 +1,83 @@ +<script> +import { setContext } from 'svelte' +import Loadable from 'svelte-loadable' +import { routeContext, jsonDataFetch } from '/dx:core.js' +import layouts from '/dx:layouts.js' + +const isServer = import.meta.env.SSR + +setContext(routeContext, { + get routeContext () { + return ctx + }, +}) + +export let path +export let component +export let payload +export let state +export let location + +let ctx = payload.routeMap[path] + +ctx.state = state +ctx.actions = payload.serverRoute.actions + +if (isServer) { + ctx.layout = payload.serverRoute.layout ?? 'default' + ctx.data = payload.serverRoute.data + ctx.state = state +} + +async function setup () { + if (payload.serverRoute.firstRender) { + ctx.data = payload.serverRoute.data + ctx.layout = payload.serverRoute.layout ?? 'default' + payload.serverRoute.firstRender = false + return + } + ctx.layout = ctx.layout ?? 'default' + const { getMeta, getData, onEnter } = await ctx.loader() + if (getData) { + try { + const fullPath = `${location.pathname}${location.search}` + const updatedData = await jsonDataFetch(fullPath) + if (!ctx.data) { + ctx.data = {} + } + if (updatedData) { + Object.assign(ctx.data, updatedData) + } + ctx.error = null + } catch (error) { + ctx.error = error + } + } + if (getMeta) { + const updatedMeta = await getMeta(ctx) + if (updatedMeta) { + payload.head.update(updatedMeta) + } + } + if (onEnter) { + const updatedData = await onEnter(ctx) + if (updatedData) { + Object.assign(ctx.data, updatedData) + } + } +} + +let setupClientRouteContext = !isServer && setup() +</script> + +{#if isServer} + <svelte:component this={layouts[ctx.layout].default}> + <svelte:component this={component} /> + </svelte:component> +{:else} +{#await setupClientRouteContext}{:then} + <svelte:component this={layouts[ctx.layout].default}> + <Loadable loader={component} /> + </svelte:component> +{/await} +{/if} diff --git a/packages/fastify-dx-svelte/virtual/routes.js b/packages/fastify-dx-svelte/virtual/routes.js new file mode 100644 index 0000000..617eec2 --- /dev/null +++ b/packages/fastify-dx-svelte/virtual/routes.js @@ -0,0 +1,119 @@ +/* global $paramPattern */ + +export default import.meta.env.SSR + ? createRoutes(import.meta.globEager('$globPattern')) + : hydrateRoutes(import.meta.glob('$globPattern')) + +async function createRoutes (from, { param } = { param: $paramPattern }) { + // Otherwise we get a ReferenceError, but since + // this function is only ran once, there's no overhead + class Routes extends Array { + toJSON () { + return this.map((route) => { + return { + id: route.id, + path: route.path, + layout: route.layout, + getData: !!route.getData, + getMeta: !!route.getMeta, + onEnter: !!route.onEnter, + } + }) + } + } + const importPaths = Object.keys(from) + const promises = [] + if (Array.isArray(from)) { + for (const routeDef of from) { + promises.push( + getRouteModule(routeDef.path, routeDef.component) + .then((routeModule) => { + return { + id: routeDef.path, + path: routeDef.path ?? routeModule.path, + ...routeModule, + } + }), + ) + } + } else { + // Ensure that static routes have precedence over the dynamic ones + for (const path of importPaths.sort((a, b) => a > b ? -1 : 1)) { + promises.push( + getRouteModule(path, from[path]) + .then((routeModule) => { + return { + id: path, + layout: routeModule.layout, + path: routeModule.path ?? path + // Remove /pages and .jsx extension + .slice(6, -7) + // Replace [id] with :id + .replace(param, (_, m) => `:${m}`) + // Replace '/index' with '/' + .replace(/\/index$/, '/') + // Remove trailing slashs + .replace(/.+\/+$/, ''), + ...routeModule, + } + }), + ) + } + } + return new Routes(...await Promise.all(promises)) +} + +async function hydrateRoutes (from) { + if (Array.isArray(from)) { + from = Object.fromEntries( + from.map((route) => [route.path, route]), + ) + } + return window.routes.map((route) => { + route.loader = memoImport(from[route.id]) + route.component = () => route.loader() + return route + }) +} + +function getRouteModuleExports (routeModule) { + return { + // The Route component (default export) + component: routeModule.default, + // The Layout Route component + layout: routeModule.layout, + // Route-level hooks + getData: routeModule.getData, + getMeta: routeModule.getMeta, + onEnter: routeModule.onEnter, + // Other Route-level settings + streaming: routeModule.streaming, + clientOnly: routeModule.clientOnly, + serverOnly: routeModule.serverOnly, + } +} + +async function getRouteModule (path, routeModule) { + // const isServer = typeof process !== 'undefined' + if (typeof routeModule === 'function') { + routeModule = await routeModule() + return getRouteModuleExports(routeModule) + } else { + return getRouteModuleExports(routeModule) + } +} + +function memoImport (func) { + // Otherwise we get a ReferenceError, but since this function + // is only ran once for each route, there's no overhead + const kFuncExecuted = Symbol('kFuncExecuted') + const kFuncValue = Symbol('kFuncValue') + func[kFuncExecuted] = false + return async function () { + if (!func[kFuncExecuted]) { + func[kFuncValue] = await func() + func[kFuncExecuted] = true + } + return func[kFuncValue] + } +} diff --git a/starters/react/client/pages/client-only.jsx b/starters/react/client/pages/client-only.jsx index b55bf1d..2e72f69 100644 --- a/starters/react/client/pages/client-only.jsx +++ b/starters/react/client/pages/client-only.jsx @@ -3,21 +3,21 @@ import { Link } from 'react-router-dom' export const clientOnly = true export function getMeta () { - return { - title: 'Client Only Page' - } + return { + title: 'Client Only Page' + } } export default function ClientOnly () { - return ( - <> - <p>This route is rendered on the client only!</p> + return ( + <> + <p>This route is rendered on the client only!</p> <p> <Link to="/">Go back to the index</Link> </p> <p>⁂</p> <p>When this route is rendered on the server, no SSR takes place.</p> <p>See the output of <code>curl http://localhost:3000/client-only</code>.</p> - </> - ) + </> + ) } diff --git a/starters/svelte/.eslintignore b/starters/svelte/.eslintignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/starters/svelte/.eslintignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/starters/svelte/.eslintrc b/starters/svelte/.eslintrc new file mode 100644 index 0000000..e5461ce --- /dev/null +++ b/starters/svelte/.eslintrc @@ -0,0 +1,21 @@ +{ + parser: '@babel/eslint-parser', + parserOptions: { + requireConfigFile: false, + ecmaVersion: 2021, + sourceType: 'module', + }, + plugins: [ + 'svelte3' + ], + overrides: [ + { + files: ['*.svelte'], + processor: 'svelte3/svelte3' + } + ], + rules: { + 'comma-dangle': ['error', 'always-multiline'], + 'import/no-absolute-path': 'off', + }, +} diff --git a/starters/svelte/client/assets/logo.svg b/starters/svelte/client/assets/logo.svg new file mode 100644 index 0000000..9f2f2fd --- /dev/null +++ b/starters/svelte/client/assets/logo.svg @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + version="1.1" + id="Layer_1" + x="0px" + y="0px" + viewBox="0 0 135.23 65.65" + style="enable-background:new 0 0 135.23 65.65;" + xml:space="preserve" + sodipodi:docname="fastify-dx-white.svg" + inkscape:version="1.0.2 (e86c8708, 2021-01-15)"><metadata + id="metadata35"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs + id="defs33" /><sodipodi:namedview + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1" + objecttolerance="10" + gridtolerance="10" + guidetolerance="10" + inkscape:pageopacity="0" + inkscape:pageshadow="2" + inkscape:window-width="1289" + inkscape:window-height="811" + id="namedview31" + showgrid="false" + inkscape:zoom="4.5527763" + inkscape:cx="50.916377" + inkscape:cy="57.355966" + inkscape:window-x="0" + inkscape:window-y="25" + inkscape:window-maximized="0" + inkscape:current-layer="g26" /> +<style + type="text/css" + id="style10"> + .st0{fill-rule:evenodd;clip-rule:evenodd;} +</style> +<g + id="g28"> + <g + id="g14" + style="fill:#ffffff"> + <path + class="st0" + d="M114.51,21.97l-1.31-0.31c-1.42-0.33-2.1-0.49-2.73-0.91c-0.58-0.38-1-0.91-1.81-1.92 c-0.34-0.43-0.75-0.95-1.21-1.5c-0.21-0.25-0.17-0.63,0.08-0.84c0.14-0.12,0.32-0.16,0.48-0.13c1.08,0.17,2,0.28,2.73,0.36 c1.38,0.16,2.14,0.25,2.73,0.64c0.76,0.5,1,1.3,1.52,3.06l0.24,0.79c0.09,0.32-0.09,0.65-0.4,0.74 C114.73,22,114.62,22,114.51,21.97 M76.38,52.74c3.45-0.28,6.48-0.39,9.26,0.07c3.17,0.53,5.96,1.8,8.59,4.41l0.23,0.23 c1.88,1.89,8.42,8.41,11.69,8.2c1.12-0.07,1.81-1.2,1.63-4.12l-1.34-4.15c-3.31-8.62-4.71-14.32-2.26-23.47 c0.06-0.23,0.25-0.39,0.47-0.43l8.71-1.69c0.32-0.06,0.64,0.15,0.7,0.47c0.05,0.28-0.1,0.55-0.35,0.66 c-0.21,0.1-0.73,0.3-1.31,0.52c-0.82,0.32-1.77,0.68-2.03,0.83c-0.92,0.53-1.42,0.78-1.71,0.92c-0.13,0.07-0.2,0.1-0.24,0.13 l-0.05,0.06l10.64-0.74c2.02-0.15,4.55,0.32,6.95,0.78c2.84,0.54,5.5,1.04,6.63,0.4c0.86-0.49,0.77-1.91-1.03-5.09 c-0.16-0.29-0.06-0.65,0.23-0.81c0.24-0.13,0.52-0.09,0.71,0.09c1.35,1.31,2.23,1.91,2.71,1.99c0.01,0,0.01-0.06,0.01-0.16 c-0.01-0.46-0.23-1.18-0.64-2.06c-1.5-3.21-5.36-8.14-10.35-10.33c-3.5-1.54-4.74-3.03-6.03-4.58c-1.26-1.51-2.57-3.08-6.41-4.83 c-2.73-1.24-5.89-1.99-9.17-2.48c-3.32-0.49-6.75-0.7-10.02-0.86c-4.65-0.22-7.23-1.2-9.68-2.13l-1.08-0.4L80.7,5.82l-6.68,16.09 c-1.49,3.5-3.61,7.56-6.42,10.15c-0.02,0.02-0.05,0.05-0.08,0.07h4.74l0.02,0.19l0.09-0.19h11.47L75.39,47.4L76.38,52.74z" + id="path12" + style="fill:#ffffff" /> + </g> + <g + id="g18" + style="fill:#ffffff"> + <path + class="st0" + d="M123.18,36.46l-0.68,8.04c3.46-2.01,5.14-3.42,5.73-7.69L123.18,36.46z" + id="path16" + style="fill:#ffffff" /> + </g> + <g + id="g22" + style="fill:#ffffff"> + <path + class="st0" + d="M115.11,54.69c-7.73,0.66-5.2,0.04-7.73,1.85l2.19,4.84C112.32,59.47,113.56,57.55,115.11,54.69" + id="path20" + style="fill:#ffffff" /> + </g> + <g + id="g26"> + <path + d="M50.55,35.46c1.18-0.24,2.79-0.42,4.49-0.42c2.81,0,4.66,0.98,5.8,2.39c1.3,1.63,1.73,3.76,1.72,6.1 c0,5.96-2.64,10.99-5.04,12.96c-1.91,1.6-4.01,2.22-7.09,2.22c-1.37,0-3.13-0.15-4.2-0.44L50.55,35.46z M51.04,54.91 c0.29,0.04,0.71,0.11,1.15,0.11c1.43,0,3.04-1.15,4.05-3.24c1.09-2.24,1.86-5.16,1.86-8.68c0-2.25-0.69-4.55-2.91-4.55 c-0.44,0-0.76,0.02-0.98,0.09L51.04,54.91z M17.71,24.37c0.03-0.92,0.09-1.83,0.15-2.86h-0.12c-1.35,2.27-2.99,3.14-4.34,3.14 c-2.49,0-3.7-2.11-3.7-5.1c0-5.16,2.57-12.5,9.89-12.5c1.71,0,3.42,0.31,4.48,0.7l-1.9,9.5c-0.4,1.98-0.71,5.27-0.7,7.13H17.71z M0,24.37l2.66-13.95H0.4l0.57-3.09h2.29l0.13-0.68c0.4-2.02,1.15-3.77,2.58-5.1C7.02,0.59,8.56,0,10.28,0 c1.14,0,1.97,0.17,2.5,0.33l-1.01,3.35c-0.4-0.13-0.77-0.2-1.36-0.2c-1.55,0-2.47,1.63-2.72,3.14L7.55,7.33h3.28l-0.57,3.09H7.01 L4.34,24.37H0z M24.57,20.64c0.67,0.37,1.54,0.73,2.73,0.7c1.27-0.03,1.97-0.84,1.97-1.9c0-0.97-0.47-1.61-1.66-2.55 c-1.47-1.19-2.15-2.7-2.15-4.16c0-3.12,2.45-5.69,6.3-5.69c1.48,0,2.58,0.28,3.2,0.65l-0.93,3.16c-0.49-0.29-1.3-0.52-2.02-0.52 c-1.37,0-2.25,0.73-2.25,1.88c0,0.85,0.49,1.4,1.29,2.04c1.91,1.47,2.54,3.13,2.54,4.47c0,3.74-2.6,5.85-6.51,5.85 c-1.57,0-2.95-0.45-3.55-0.86L24.57,20.64z M19.27,10.4c-0.34-0.08-0.66-0.11-0.89-0.11c-2.86,0-4.4,5.7-4.42,8.21 c-0.01,1.49,0.2,2.56,1.29,2.56c1.17,0,2.3-1.93,2.91-5.03L19.27,10.4z M44.83,24.37l3.26-17.04h4.35l-3.26,17.04H44.83z M48.89,3 c0.02-1.25,0.91-2.68,2.43-2.68c1.44,0,2.09,1.07,2.06,2.27c-0.02,1.73-1.2,2.7-2.5,2.7C49.47,5.29,48.87,4.29,48.89,3z M68.36,7.33l0.11,8.19c0.02,1.61,0.06,2.78,0.03,4.04h0.07c0.35-1.39,0.69-2.53,1.27-4.35l2.59-7.87h4.37l-5.57,13.41 c-1.66,3.9-3.6,7.19-5.68,9.1c-1.01,0.94-2.18,1.68-2.93,1.99l-1.74-3.56c0.79-0.39,1.57-0.84,2.27-1.41 c1-0.77,1.93-1.77,2.33-2.71c0.09-0.23,0.15-0.42,0.11-0.74l-1.44-12.98h-2.52v0h-1.58l-2.67,13.95h-4.34l2.66-13.95h-2.26 l0.57-3.09h2.29l0.13-0.68c0.4-2.02,1.15-3.77,2.58-5.1C60.06,0.59,61.6,0,63.32,0c1.14,0,1.97,0.17,2.5,0.33l-1.01,3.35 c-0.4-0.13-0.77-0.2-1.36-0.2c-1.55,0-2.47,1.63-2.72,3.14L60.6,7.33h1.03h2.17h0.07h1.43H68.36z M43.79,2.76l-0.88,4.57h3.2 l-0.57,3.09h-3.22l-1.49,7.89c-0.1,0.59-0.15,1.11-0.15,1.44c0,1.05,0.54,1.43,1.33,1.43c0.32,0,0.75,0,1.18-0.07l-0.52,3.26 c-0.78,0.22-1.78,0.26-2.61,0.26c-2.61,0-3.89-1.45-3.89-3.73c0-0.79,0.12-1.71,0.29-2.57l1.5-7.92h-1.97l0.57-3.09h2.01 l0.68-3.49L43.79,2.76z M70.05,58.61l-0.73-5.15c-0.18-1.39-0.28-2.22-0.38-3.61h-0.08c-0.65,1.46-1.01,2.33-1.7,3.86l-2.29,4.9 h-4.4L67,46.52l-1.78-11.38h4.37l0.54,4.34c0.16,1.7,0.26,2.6,0.37,4.08h0.07c0.6-1.6,1.02-2.53,1.73-4.14l2-4.28h4.44l-6.5,11.75 l2.19,11.72H70.05z" + id="path24" + style="stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;fill:#ffffff" /> + </g> +</g> +</svg> diff --git a/starters/svelte/client/base.css b/starters/svelte/client/base.css new file mode 100644 index 0000000..fde0cfc --- /dev/null +++ b/starters/svelte/client/base.css @@ -0,0 +1,56 @@ +:root { + --color-base: #f1f1f1; + --color-highlight: #ff80ff; +} +html { + background: #222; +} +main { + width: 800px; + margin: 0 auto; + padding: 2em; + box-shadow: 5px 5px 30px rgba(0,0,0,0.4); + border-radius: 10px; + background-color: rgba(255, 255, 255, 0.1); + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--color-base); + margin-top: 60px; + & a { + color: var(--color-highlight); + text-decoration: none; + font-weight: bold; + border-bottom: 1px solid var(--color-highlight); + &:hover { + color: #ffde00; + } + &:active { + color: #eecf00 + } + } + & p { + font-size: 1.2em; + } + & ul { + & li { + &:not(:last-child) { + margin-bottom: 0.5em; + } + break-inside: avoid; + font-size: 1em; + } + } + & code { + color: #ffde00; + font-weight: bold; + font-family: 'Consolas', 'Andale Mono', monospace; + font-size: 0.9em; + } + & img { + width: 14em; + } + & button { + margin: 0 0.5em; + } +} diff --git a/starters/svelte/client/context.js b/starters/svelte/client/context.js new file mode 100644 index 0000000..0b62748 --- /dev/null +++ b/starters/svelte/client/context.js @@ -0,0 +1,36 @@ +import ky from 'ky-universal' + +export default (ctx) => { + if (ctx.server) { + ctx.state.todoList = ctx.server.db.todoList + } +} + +export const $fetch = ky.extend({ + prefixUrl: 'http://localhost:3000', +}) + +export const state = () => ({ + user: { + authenticated: false, + }, + todoList: null, +}) + +export const actions = { + authenticate (state) { + state.user.authenticated = true + }, + async addTodoItem (state, item) { + await $fetch.put('api/todo/items', { + json: { item }, + }) + state.todoList.push(item) + }, + async removeTodoItem (state, index) { + await $fetch.delete('api/todo/items', { + json: { index }, + }) + state.todoList.splice(index, 1) + }, +} diff --git a/starters/svelte/client/index.html b/starters/svelte/client/index.html new file mode 100644 index 0000000..dd27666 --- /dev/null +++ b/starters/svelte/client/index.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <link rel="stylesheet" href="./base.css"> + <!-- style --> + <!-- head --> + <!-- hydration --> + </head> + <body> + <main><!-- element --></main> + </body> + <script type="module" src="/dx:mount.js"></script> +</html> diff --git a/starters/svelte/client/index.js b/starters/svelte/client/index.js new file mode 100644 index 0000000..ddcae37 --- /dev/null +++ b/starters/svelte/client/index.js @@ -0,0 +1,9 @@ +import Root from '/dx:root.svelte' +import routes from '/dx:routes.js' +import * as context from '/dx:context.js' + +export default { + Root, + routes, + context, +} diff --git a/starters/svelte/client/layouts/auth.svelte b/starters/svelte/client/layouts/auth.svelte new file mode 100644 index 0000000..33a6a93 --- /dev/null +++ b/starters/svelte/client/layouts/auth.svelte @@ -0,0 +1,15 @@ +<script> +import { useRouteContext } from '/dx:core.js' +const { snapshot, actions, state } = useRouteContext() +</script> + +<div class="contents"> + {#if !$snapshot.user.authenticated} + <p>This route needs authentication.</p> + <button on:click={() => actions.authenticate(state)}> + Click this button to authenticate. + </button> + {:else} + <slot /> + {/if} +</div> diff --git a/starters/svelte/client/layouts/default.svelte b/starters/svelte/client/layouts/default.svelte new file mode 100644 index 0000000..c63d36b --- /dev/null +++ b/starters/svelte/client/layouts/default.svelte @@ -0,0 +1,8 @@ +<script> +// This file serves as a placeholder if no +// layouts/default.svelte file is provided +</script> + +<div class="layout"> + <slot></slot> +</div> diff --git a/starters/svelte/client/pages/client-only.svelte b/starters/svelte/client/pages/client-only.svelte new file mode 100644 index 0000000..0f01b14 --- /dev/null +++ b/starters/svelte/client/pages/client-only.svelte @@ -0,0 +1,21 @@ +<script context="module"> +export const clientOnly = true + +export function getMeta () { + return { + title: 'Client Only Page', + } +} +</script> + +<script> +import { Link } from 'svelte-routing' +</script> + +<p>This route is rendered on the client only!</p> +<p> + <Link to="/">Go back to the index</Link> +</p> +<p>⁂</p> +<p>When this route is rendered on the server, no SSR takes place.</p> +<p>See the output of <code>curl http://localhost:3000/client-only</code>.</p> diff --git a/starters/svelte/client/pages/index.svelte b/starters/svelte/client/pages/index.svelte new file mode 100644 index 0000000..a6625f0 --- /dev/null +++ b/starters/svelte/client/pages/index.svelte @@ -0,0 +1,31 @@ +<script context="module"> +export let getMeta = () => { + return { + title: 'Welcome to Fastify DX!', + } +} +</script> + +<script> +import logo from '/assets/logo.svg' +import { Link } from 'svelte-routing' +</script> + +<img src={logo} alt="Fastify DX" /> +<h1>Welcome to Fastify DX for Svelte!</h1> +<ul class="columns-2"> + <li><Link to="/using-data">/using-data</Link> demonstrates how to + leverage the <code>getData()</code> function + and <code>useRouteContext()</code> to retrieve server data for a route.</li> + <li><Link to="/using-store">/using-store</Link> demonstrates how to + leverage the + automated <a href="https://github.com/pmndrs/valtio">Valtio</a> store + to retrieve server data for a route and maintain it in a global + state even after navigating to another route.</li> + <li><Link to="/using-auth">/using-auth</Link> demonstrates how to + wrap a route in a custom layout component.</li> + <li><Link to="/client-only">/client-only</Link> demonstrates how to set + up a route for rendering on the client only (disables SSR).</li> + <li><Link to="/server-only">/server-only</Link> demonstrates how to set + up a route for rendering on the server only (sends no JavaScript).</li> +</ul> diff --git a/starters/svelte/client/pages/server-only.svelte b/starters/svelte/client/pages/server-only.svelte new file mode 100644 index 0000000..9224a8a --- /dev/null +++ b/starters/svelte/client/pages/server-only.svelte @@ -0,0 +1,21 @@ +<script context="module"> +export const serverOnly = true + +export function getMeta () { + return { + title: 'Server Only Page', + } +} +</script> + +<script> +import { Link } from 'svelte-routing' +</script> + +<p>This route is rendered on the server only!</p> +<p> + <Link to="/">Go back to the index</Link> +</p> +<p>⁂</p> +<p>When this route is rendered on the server, no JavaScript is sent to the client.</p> +<p>See the output of <code>curl http://localhost:3000/server-only</code>.</p> diff --git a/starters/svelte/client/pages/using-auth.svelte b/starters/svelte/client/pages/using-auth.svelte new file mode 100644 index 0000000..78b0dc3 --- /dev/null +++ b/starters/svelte/client/pages/using-auth.svelte @@ -0,0 +1,38 @@ +<script context="module"> +export let getMeta = () => { + return { title: 'Todo List — Using Store' } +} + +export let layout = 'auth' +</script> + +<script> +import { Link } from 'svelte-routing' +import { useRouteContext } from '/dx:core.js' + +let value = null + +const { snapshot, state, actions } = useRouteContext() + +const addItem = async () => { + await actions.addTodoItem(state, value) + value = '' +} +</script> + +<h2>Todo List — Using Store</h2> +<ul> + {#each $snapshot.todoList as item, i} + <li>{item}</li> + {/each} + </ul> +<div> + <input bind:value /> + <button on:click={addItem}>Add</button> +</div> +<p> + <Link to="/">Go back to the index</Link> +</p> +<p>⁂</p> +<p>When you navigate away from this route, any additions to the to-do +list are not lost, because they're bound to the global application state.</p> diff --git a/starters/svelte/client/pages/using-data.svelte b/starters/svelte/client/pages/using-data.svelte new file mode 100644 index 0000000..959ba73 --- /dev/null +++ b/starters/svelte/client/pages/using-data.svelte @@ -0,0 +1,46 @@ +<script context="module"> +export let getMeta = () => { + return { title: 'Todo List — Using Data' } +} + +export let getData = ({ server }) => { + return { + todoList: server.db.todoList, + } +} +</script> + +<script> +import { Link } from 'svelte-routing' +import { useRouteContext } from '/dx:core.js' + +const { data } = useRouteContext() + +let todoList = [...data.todoList] +let value = null + +function addItem () { + todoList = [...todoList, value] + value = '' +} +</script> + +<h2>Todo List — Using Data</h2> +<ul> + {#each todoList as item, i} + <li>{item}</li> + {/each} + </ul> +<div> + <input bind:value /> + <button on:click={addItem}>Add</button> +</div> +<p> + <Link to="/">Go back to the index</Link> +</p> +<p>⁂</p> +<p>When you navigate away from this route, any additions to the to-do +list will be lost, because they're bound to this route component only.</p> +<p>See the <Link to="/using-store">/using-store</Link> example to learn +how to use the application global state for it. +</p> diff --git a/starters/svelte/client/pages/using-store.svelte b/starters/svelte/client/pages/using-store.svelte new file mode 100644 index 0000000..10ad6c5 --- /dev/null +++ b/starters/svelte/client/pages/using-store.svelte @@ -0,0 +1,36 @@ +<script context="module"> +export let getMeta = () => { + return { title: 'Todo List — Using Store' } +} +</script> + +<script> +import { Link } from 'svelte-routing' +import { useRouteContext } from '/dx:core.js' + +let value = null + +const { snapshot, state, actions } = useRouteContext() + +const addItem = async () => { + await actions.addTodoItem(state, value) + value = '' +} +</script> + +<h2>Todo List — Using Store</h2> +<ul> + {#each $snapshot.todoList as item, i} + <li>{item}</li> + {/each} + </ul> +<div> + <input bind:value /> + <button on:click={addItem}>Add</button> +</div> +<p> + <Link to="/">Go back to the index</Link> +</p> +<p>⁂</p> +<p>When you navigate away from this route, any additions to the to-do +list are not lost, because they're bound to the global application state.</p> diff --git a/starters/svelte/client/root.svelte b/starters/svelte/client/root.svelte new file mode 100644 index 0000000..67d6ee9 --- /dev/null +++ b/starters/svelte/client/root.svelte @@ -0,0 +1,24 @@ +<script> +import 'uno.css' +import { proxy } from 'sveltio' +import { Router, Route } from 'svelte-routing' +import DXRoute from '/dx:route.svelte' + +export let url = null +export let payload + +let state = proxy(payload.serverRoute.state) +</script> + +<Router url="{url}"> + {#each payload.routes as { path, component }} + <Route path="{path}" let:location> + <DXRoute + path={path} + location={location} + state={state} + payload={payload} + component={component} /> + </Route> + {/each} +</Router> diff --git a/starters/svelte/package.json b/starters/svelte/package.json new file mode 100644 index 0000000..ed691a3 --- /dev/null +++ b/starters/svelte/package.json @@ -0,0 +1,40 @@ +{ + "type": "module", + "scripts": { + "dev": "node server.js --dev", + "build": "npm run build:client && npm run build:server", + "serve": "node server.js", + "devinstall": "zx ../../devinstall.mjs svelte -- node server.js --dev", + "build:client": "vite build --outDir dist/client --ssrManifest", + "build:server": "vite build --outDir dist/server --ssr /index.js", + "lint": "eslint . --ext .js,.svelte --fix" + }, + "dependencies": { + "fastify-dx-svelte": "^0.0.1-pre", + "fastify-vite": "^3.0.0-beta.23", + "ky": "^0.31.0", + "ky-universal": "^0.10.1" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.16.0", + "@sveltejs/vite-plugin-svelte": "^1.0.0-next.49", + "eslint": "^8.18.0", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-svelte3": "^4.0.0", + "postcss-preset-env": "^7.7.1", + "unocss": "^0.37.4" + }, + "devInstall": { + "local": { + "fastify-dx-svelte": "^0.0.1" + }, + "external": { + "fastify-vite": "^3.0.0-beta.23", + "ky-universal": "^0.10.1", + "ky": "^0.31.0" + } + } +} diff --git a/starters/svelte/postcss.config.cjs b/starters/svelte/postcss.config.cjs new file mode 100644 index 0000000..8b78078 --- /dev/null +++ b/starters/svelte/postcss.config.cjs @@ -0,0 +1,9 @@ +const postcssPresetEnv = require('postcss-preset-env') + +module.exports = { + plugins: [ + postcssPresetEnv({ + stage: 1, + }), + ] +} diff --git a/starters/svelte/server.js b/starters/svelte/server.js new file mode 100644 index 0000000..1f5e588 --- /dev/null +++ b/starters/svelte/server.js @@ -0,0 +1,32 @@ +import Fastify from 'fastify' +import FastifyVite from 'fastify-vite' +import FastifyDXSvelte from 'fastify-dx-svelte' + +const server = Fastify() + +server.decorate('db', { + todoList: [ + 'Do laundry', + 'Respond to emails', + 'Write report', + ], +}) + +server.put('/api/todo/items', (req, reply) => { + server.db.todoList.push(req.body.item) + reply.send({ ok: true }) +}) + +server.delete('/api/todo/items', (req, reply) => { + server.db.todoList.splice(req.body.index, 1) + reply.send({ ok: true }) +}) + +await server.register(FastifyVite, { + root: import.meta.url, + renderer: FastifyDXSvelte, +}) + +await server.vite.ready() + +await server.listen(3000) diff --git a/starters/svelte/vite.config.js b/starters/svelte/vite.config.js new file mode 100644 index 0000000..e463ff1 --- /dev/null +++ b/starters/svelte/vite.config.js @@ -0,0 +1,25 @@ +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +import { svelte as viteSvelte } from '@sveltejs/vite-plugin-svelte' +import viteSvelteFastifyDX from 'fastify-dx-svelte/plugin' +import unocss from 'unocss/vite' +import { extractorSvelte } from '@unocss/core' + +const path = fileURLToPath(import.meta.url) + +const root = join(dirname(path), 'client') +const plugins = [ + unocss({ extractors: [extractorSvelte] }), + viteSvelte({ + compilerOptions: { + hydratable: true, + }, + }), + viteSvelteFastifyDX(), +] + +export default { + root, + plugins, +} diff --git a/starters/vue/package.json b/starters/vue/package.json index 890352b..369391d 100644 --- a/starters/vue/package.json +++ b/starters/vue/package.json @@ -4,7 +4,7 @@ "dev": "node server.js --dev", "build": "npm run build:client && npm run build:server", "serve": "node server.js", - "devinstall": "zx ../../devinstall.mjs react -- node server.js --dev", + "devinstall": "zx ../../devinstall.mjs vue -- node server.js --dev", "build:client": "vite build --outDir dist/client --ssrManifest", "build:server": "vite build --outDir dist/server --ssr /index.js", "lint": "eslint . --ext .js,.jsx --fix"