Git Product home page Git Product logo

saus's Introduction

saus

Work-in-progress SSR framework (powered by Vite)

Its goal is to be the common core for every SSR framework, which means it needs to be highly customizable. Vite plugins can only take you so far. Saus extends the Vite plugin interface with powerful new hooks, builds on Vite's dev server, adds a flexible client/server runtime, and provides the saus CLI tool.

Features

  • Has JS-defined page routes
  • Can serve any HTTP response (not just HTML)
  • Has layout modules for easy code reuse
  • Has state modules designed for HTTP caching
  • Uses its own SSR engine (instead of Vite's)
  • Can isolate stateful SSR modules between page requests
  • Has deployment plugins for your entire stack (not just SSR bundle)
  • Has CLI-driven management of encrypted secrets
  • Can pre-render at build-time
  • Has helpers for client-side routing (optional)
  • Has runtime SSR plugins
  • Has hidden debug URLs that let you safely inspect unminified production code with sourcemaps
  • Has everything Vite can do

Roadmap

  • Multi-threaded SSR handling
  • Nested routes
  • Generate one SSR bundle per route (instead of a monolith)
  • Server-only components
  • File-based routing plugin
  • Compile-time evaluation
  • Astro support
  • Marko support

Documentation

I haven't written any yet. This is pre-alpha!

saus's People

Contributors

aleclarson avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

cliffordfajardo

saus's Issues

Add `headProps` option to `route` declarations

This option can be an object or a function that returns an object.

route('/books/:slug', () => import('../BookPage'), {
  headProps: async (_url, { slug }) => ({ 
    title: await titleForBookSlug(slug) 
  })
})

The headProps object will be passed to the renderer's head callback.

render(() => {
  return <div />
}).head((props, request) => {
  return <head><title>{props.title}</title></head>
})

This is useful for defining data that's only used to render the <head> element, and isn't needed for <body> hydration, so it won't be included in the [page].html.js module.

Add packages for open-source headless CMS

https://github.com/netlify/netlify-cms

The @saus/netlify-cms package would export a useNetlifyCms function that hooks into Saus to add a renderer and a route (it uses /admin/ by default). It might also inject state for pages with CMS data. I haven't looked much into how an integration would work, but perhaps it would also export helpers for creating "state fragments" (a new feature coming in [email protected]) that load data from the CMS as well.

I would probably write the Saus docs with this. πŸ€”

Allow server module to be defined through `process.stdin`

This would allow the server to be generated by another tool, like Viteflare, without intermediate files.

The bundle's output path would have to be defined explicitly via the saus bundle ./path/to/bundle.js command, since we won't have a server input path to derive it from.

Export `clearCache` helper

This clears the cache that defineStateModule loaders use.

You can call clearCache in the then callback of a renderer to ensure defineStateModule loaders are called on each render. Or you can pass a state module to it (along with any arguments) to clear a specific entry in the cache whenever you need.

Add `transformHtml` function

Since SSR bundles don't run Vite plugins, we need a way for renderer packages to apply HTML transformations (like Vite plugins do with transformIndexHtml) outside the scope of a Vite plugin.

Calls to transformHtml would be included in SSR bundles.

Add hooks for Vite plugins to manipulate `saus bundle` command

Some ideas:

  • sausBundleLoadEntry(config: UserConfig): Promise<string | null> | string | null
    Generate an entry module for the SSR bundle.

  • sausBundlePublish(bundle: OutputChunk, assets: OutputAsset[]): void
    Do something with the bundle and/or its binary assets, like uploading it to a web host.
    This hook only runs when --publish is provided to the CLI.

Use bare bones HTML parser

html5parser is too full-featured for our needs. We would prefer a smaller bundle footprint in exchange for less robust parsing. We can safely assume that a renderer is responsible enough to produce valid HTML and isn't too clever.

This would also let us intertwine the visitor pattern with parsing, so if a node gets skipped, we can skip parsing of its children's attributes (literally just look for < and > tokens).

Cloudflare worker runtime

Similar to #2, but for Cloudflare workers specifically. This is most useful for SPA-style navigation with HTTP/2 server push of the resources described in #2 (comment).

# saus.yaml
worker: ../worker/saus.ts

Then in your worker…

// ../worker/index.ts
import saus from './saus'

addEventListener('fetch', saus.render)

πŸ’­ Still need to figure out what the data-loading flow looks like.

Bundle the SSR runtime before publishing

This avoids having to rebundle it every time the user's code is changed.

The SSR runtime depends on some (internal) dynamic modules, so those need to be externalized.

Partial hydration

Feature description

  • When a module with .client before its file extension is imported, the importer (and its importers, and so on) is marked as a "server-only" module.
  • Any modules marked "server-only" are never loaded on the client, and so those modules cannot produce HTML that relies on hydration.

Implementation details

  • The static HTML should include empty <div> elements with data-saus attribute that tells Saus which JS module needs to be loaded for hydration of that subtree.
  • Each renderer package (eg: @saus/react) is responsible for hydration of each subtree.
  • Each rendered page already has .html.js module. Ensure that module contains a map of data-saus attributes β†’ JS modules, so it can import them and re-render each of the previous subtrees when a user navigates to its page.

/state.json endpoint

This feature was mentioned in #2, but will be implemented before then.

Here's the original proposal:

Every route has a generated state.json module. For example, you can fetch /foo/state.json to get the client state for the /foo page. This client state contains the routeModuleId and routeParams by default, in addition to any state set by the renderer that generated the page's HTML.

Similar to #1, there will be fetchClientState and applyClientState helpers exported by saus/client, which can be used in your client-side router.

Amendments since then include:

  • Omit applyClientState helper for now
  • Rename fetchClientState to loadClientState
  • State returned by loadClientState is (indefinitely) cached locally
  • The initial state included in the page HTML is injected into the cache
  • The state.json module works in both SSG and SSR scenarios

Future

Improvements we could make in the future include:

  • Special ttl property in state object would define how long to cache it for

Auto-generated barrel for state modules

It's common to keep all state modules in the same folder. What if we could take advantage of that by generating a module that re-exports all state modules (aka: a barrel), but also unwraps them so client modules don't have to use .get anymore. 🀩

// Generated code
import { foo as _foo } from './foo'
export const foo = _foo.get()

We can even generate the barrel slightly different when loading the routes module, since it works with StateModule objects, not the raw values. The only issue here is that we may not be able to use different typings based on who imports the barrel. πŸ€”

Add @saus/lazyload

This package would inject a client-side script that scans the document for img elements with a data-src or data-srcset attribute. Then it would load them in batches, according to data-pri (or data-priority). Loaded images would only become visible after images of the same batch but higher in the document have become visible.

Prefetch links could also be injected into the HTML in SSR by scanning the body for data-src images.

Some kind of actual lazy-loading would be welcome too :)

Add `state` method to route config

import { route, fetch } from 'saus'

route('/posts/:id', () => import('./routes/SinglePost'), {
  state: id => fetch(`https://jsonplaceholder.typicode.com/posts/${id}`),
  paths: () => [1, 2, 3],
})

The state method receives the route parameters and returns an object (or object promise). The resolved state object is given to the renderer and ultimately embedded in the page as JSON. This state should be passed from your render callback into your application.

Skip bundling by default when `saus build` is used

Currently, we basically run saus bundle and use that bundle to pre-render, but it seems unnecessary. We should use Saus JIT compiler instead. We can provide a --bundle flag for those who want to bundle for whatever reason (testing mostly, I suppose).

Cache the worker pool globally for multiple build calls

Currently, each build call manages its own worker pool, but that means multiple builds will incur the same startup penalty when they could be reusing workers from previous builds.

The proposed feature would introduce a terminateWorkerPool exported function that you would only call when you're done calling build for the rest of your program.

Add compile-time evaluation

Useful for compile-time data generation used in declaring routes, paths, etc.

// src/node/routes.ts
import { preval } from 'saus'

// Run this module at compile-time. Unwrap its default export. Top-level await is allowed.
const result = preval(import('./path/to/module'))

In the future, we could support inline function that references outside its scope.

// src/node/routes.ts
import { preval } from 'saus'
import fs from 'fs'

const result = preval(() => {
  return fs.readdirSync('./data')
})

…or even a simple expression:

// src/node/routes.ts
import { preval } from 'saus'
import fs from 'fs'

const result = preval(fs.readdirSync('./data'))

The result would be passed through our dataToEsm helper.

Replace magic-string in SSR bundles with a bare bones replacement

The magic-string package is 19.5kb minified, and we don't actually need all those features. For example, it can produce sourcemaps, but SSR bundles work with pre-compiled JS modules, so there's no use case for sourcemap generation. In the SSR bundle, magic-string is only used in HTML manipulation, where sourcemaps aren't supported.

Add `beforeRender` hook

The code within a beforeRender callback is executed in both SSR and browser environments.

It can be used to share logic between renderers, or to inject logic into the default renderer.

Share logic between renderers

This example only makes sense after #2 is implemented.

beforeRender(async ({ state, headers }) => {
  // Define `state.user` for *every* renderer.
  state.user = await getUser(headers['Cookie'])
})

Inject logic into default renderer

beforeRender('/search', async ({ query, state }) => {
  // Derive global UI state from query string
  app.search = query.replace(/.*?\b(q=([^&]*))?.*/, '$2')
  // Inject backend state into the page
  state.results = await searchDatabase(app.search)
})

render((module, { path, state }) => {
  if (path == '/search') {
    app.search    // => "..."
    state.results // => [object Array]
  } else {
    app.search    // => undefined
    state.results // => undefined
  }
})

Add watch mode to `saus bundle`

Pass the --watch or -w flag to enable watch mode. When your server module or client modules are changed, the bundle will be regenerated and your server will be restarted.

Store static client modules outside the SSR bundle

Currently, all client modules are inlined in the SSR bundle. For client modules whose raw code we don't need when generating pages, we only have to inline their name and dependencies array, which leaves more room for user code in storage-scarce SSR environments (like the 1MB size limit for Cloudflare workers).

SSR bundle splitting

Being able to have one worker per route would be fantastic, and that means one SSR bundle per route.

Every possible beforeRender and render call (based on route matching) would be included in the route bundle.

Add client API for head tags

Each page has its <head> tags parsed and provided via the headTags export of saus/client.

import { fetchHeadTags } from 'saus/client'

fetchHeadTags('/') // => Promise<HeadTags>

The fetchHeadTags helper returns a promise that resolves to an array of head tags, like this:

[
  ["title", "Hello world"],
  ["link", { "rel": "stylesheet", "href": "..." }]
]

Then you can use the applyHeadTags helper to update the DOM with them.

import { applyHeadTags, fetchHeadTags } from 'saus/client'

fetchHeadTags('/').then(applyHeadTags)
  • Some tags are replaced (eg: <title>) when defined
  • Some tags are appended (eg: <script> or <link>) if missing

_head.json

The _head.json metadata file is what fetchHeadTags requests. It's generated for every pre-rendered path at build time, and it can also be generated for on-demand routes.

fetch("/users/1234/_head.json")

Add error routing

When an error is thrown while rendering a page, fall back to the user-defined error route if possible. This is useful in both development and for dynamic rendering in production.

Add `generateRoute` function

The generateRoute function defines a route whose module/state/paths/etc are defined dynamically, but set in stone when bundled for SSR.

import { generateRoute } from 'saus'

// Define the route module using route params.
generateRoute('/path/:param', { 
  entry: (param) => `./pages/${param}`,
  paths() { 
	// Paths must be provided statically, or bundling wouldn't be possible.
	// Of course, routes with no dynamic params don't need this function.
  } 
})

// Define the route module using data from an outer scope.
for (const entry in import.meta.glob('./pages/**/*.md')) {
  const route = entry.replace(/^\.\/pages(.+)\.md$/, '$1')
  generateRoute(route, { entry })
}

Support file-based routing

Inside your src/node/routes.ts module:

import { findPages } from 'saus'

// The "/**" suffix is optional.
findPages('./pages/**', ['*.mdx?', '*.njk'])

Paths are relative to the project root, so the findRoutes call above would work with the following file structure:

my-app/
β”œβ”€ pages/
β”‚  β”œβ”€ index.mdx
β”‚  β”œβ”€ users/
β”‚  β”‚  β”œβ”€ [id].njk
β”œβ”€ src/
β”‚  β”œβ”€ render.ts
β”‚  β”œβ”€ node/
β”‚  β”‚  β”œβ”€ routes.ts

Renderers would receive the file content in string form, passed by the module.default argument.

We would also expose a fileType property, which allows a renderer to handle multiple file types if so desired.

Renderers could still target specific routes (via route paths) and route parameters could be used to parse out parts of the filename. In the example above, a page named ./pages/foo/index.mdx would be served for the /foo and /foo/ URLs.

Dynamic routes

Naturally, square brackets in a page's filename would indicate route parameters. For example, a page named…

./pages/users/[id].njk

…would be served by the /users/:id route. Although, these pages would not be eligible for pre-rendering (until we come up with a suitable API, of course).

Front matter

Lastly, a helper for parsing front matter from a page would be exported by the saus module.

import { extractFrontMatter } from 'saus'

render((module) => {
  const metadata = extractFrontMatter(module)
  // return [...]
})

Calling it would update the content of module.default, removing the parsed front matter.

Isolate app modules for each page

By re-executing app modules for every page render, you aren't required to reset your app's global state manually and parallel rendering becomes possible.

I recently made it easy for this to implement. In build mode, app modules are bundled to allow for multiple instances to exist at once. In dev mode, this was already possible with Vite SSR (thanks to my Vite fork). Just need some spare time (or enough demand) to add a parallel rendering mode to Saus.

SSR runtime

Allow saus build to emit a SSR module that renders on-demand routes.

# saus.yaml
runtime: ../server/saus.ts

The configuration above tells Saus where to emit its TypeScript runtime, which can be used in your Node server code.

// ../server/index.ts
import { renderMiddleware } from './saus'
import express from 'express'

const app = express()
app.use(renderMiddleware)

Unfortunately, Vite SSR is not in a good position to provide an SSR runtime without extra baggage, like a websocket server and file watcher. But it shouldn't be terribly difficult to implement this when the time comes.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.