alloc / saus Goto Github PK
View Code? Open in Web Editor NEWVite SSR/SSG framework that aspires to be a layer for opinionated web frameworks to build upon
License: Other
Vite SSR/SSG framework that aspires to be a layer for opinionated web frameworks to build upon
License: Other
https://medium.com/hackernoon/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
For production builds, give each node_modules
package its own chunk.
.client
before its file extension is imported, the importer (and its importers, and so on) is marked as a "server-only" module.<div>
elements with data-saus
attribute that tells Saus which JS module needs to be loaded for hydration of that subtree.@saus/react
) is responsible for hydration of each subtree..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.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.
When a renderer returns a non-nullish value, it inherits the head
callback of the default renderer if it doesn't define its own.
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.
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.
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.
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.
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).
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.
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 :)
When calling defineStateModule
, accept an options object with { ttl?: number }
that defines when the cached state is cleared automatically.
Avoid re-rendering pages whose dependencies have not changed.
At build time, separate critical CSS from the rest.
Press r
to restart the dev server, but it doesn't appear to do anything
The on-change
package is 9.8kb and SSR bundles don't need all of its features. This package is only used by HTML visitors.
You might prefer to hit the database every time a page is rendered that depends on a given state module.
Once the page is hydrated, apply the buffered events in order.
Prior art:
Since request.state
is only meant to be mutated in SSR environment, assignments to it (as well as any statements used only in those assignments) can be removed before the client is generated.
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.
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'])
})
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
}
})
For some reason, saus build
produces a broken sourcemap (ie: you cannot set a breakpoint in your render.tsx
module when sourcemaps are generated with build.sourcemap
set to true).
Wrap repng
with Saus :)
It would be cool to see if this works OOTB or if Saus needs to be made compatible somehow.
https://github.com/antfu/vite-plugin-pwa
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.
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.
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 therouteModuleId
androuteParams
by default, in addition to any state set by the renderer that generated the page's HTML.Similar to #1, there will be
fetchClientState
andapplyClientState
helpers exported bysaus/client
, which can be used in your client-side router.
Amendments since then include:
applyClientState
helper for nowfetchClientState
to loadClientState
loadClientState
is (indefinitely) cached locallystate.json
module works in both SSG and SSR scenariosImprovements we could make in the future include:
ttl
property in state object would define how long to cache it forThis 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.
Which options would you like added to Saus CLI?
Describe them below 👇
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.
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.
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).
Then we don't need to install Terser, and esbuild is quite a bit faster IIRC
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.
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.
When HTML processors are used, the SSR-produced HTML can differ from the hydrated DOM. We should provide a way to process the inlined HTML from inside the state loader that's responsible for its existence.
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)
<title>
) when defined<script>
or <link
>) if missingThe _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")
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. 🤔
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. 🤔
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.
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.
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).
Repeating HTTP requests leads to slower builds, so workers should share them.
If cache: true
is defined, remote assets won't be fetched if already cached on disk, even if their contents changed.
Question: How would cache busting be achieved?
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).
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.
Could be based on this: https://github.com/jenstornell/tiny-html-minifier/blob/master/src/TinyHtmlMinifier.php
The current minifier being used is 259kb (not sure if it's the smallest one or not).
https://bundlephobia.com/package/[email protected]
HTML minification doesn't need to be overly configurable.
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.
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.
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 })
}
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.