Skip to content

Commit

Permalink
Add experimental SSR support for the dev server (#1086)
Browse files Browse the repository at this point in the history
* add an experimental API for SSR

* add --ssr support to build

* add docs
  • Loading branch information
FredKSchott authored Sep 26, 2020
1 parent 884e4ab commit 05b2a9e
Show file tree
Hide file tree
Showing 9 changed files with 133 additions and 11 deletions.
12 changes: 12 additions & 0 deletions docs/docs/04-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,18 @@ Snowpack supports [native CSS "@import" behavior](https://developer.mozilla.org/
**Note for webpack users:** If you're migrating an existing app to snowpack, note that `@import '~package/...'` (URL starting with a tilde) is a syntax specific to webpack. With Snowpack you remove the `~` from your `@import`s.
### Server Side Rendering (SSR)
SSR for Snowpack is supported but fairly new and experimental. This section of our documentation will be updated as we finalize support over the next few versions.
These frameworks have known experiments / examples of using SSR + Snowpack:
- React (Example): https://github.com/matthoffner/snowpack-react-ssr
- Svelte/Sapper (Experiment): https://github.com/Rich-Harris/snowpack-svelte-ssr
- [Join our Discord](https://discord.gg/rS8SnRk) if you're interested in getting involved!
### Optimized Builds
By default, Snowpack doesn't optimize your code for production. But, there are several plugins available to optimize your final build, including minification (reducing file sizes) and even bundling (combining files together to reduce the number of requests needed).
Expand Down
30 changes: 30 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,36 @@ In that case, the `resolve` property only takes a single `input` file type (`['.

Notice that `.svelte` is missing from `resolve.output` and isn't returned by `load()`. Only the files returned by the `load()` method are included in the final build. If you wanted your plugin to keep the original source file in your final build, you could add `{ '.svelte': contents }` to the return object.

### Server-Side Rendering (SSR)

Plugins can produce server-optimized code for SSR via the `load()` plugin hook. The `isSSR` flag tells the plugin that Snowpack is requesting your file for the server, and that it will expect a response that will run on the server.

Some frameworks/languages (like React) run the same code on both the browser and the server. Others (like Svelte) will create different output for the server than the browser. In the example below, we use the `isSSR` flag to tell the Svelte compiler to generate server-optimized code when requested by Snowpack.

```js
const svelte = require('svelte/compiler');
const fs = require('fs');

module.exports = function (snowpackConfig, pluginOptions) {
return {
name: 'basic-svelte-plugin',
resolve: {
input: ['.svelte'],
output: ['.js', '.css'],
},
async load({filePath, isSSR}) {
const svelteOptions = { /* ... */ };
const codeToCompile = fs.readFileSync(filePath, 'utf-8');
const result = svelte.compile(codeToCompile, { ...svelteOptions, ssr: isSSR });
// ...
},
};
};
```

If you're not sure if your plugin needs special SSR support, you are probably fine to skip this and ignore the `isSSR` flag in your plugin. Many languages won't need this, and SSR is always an intentional opt-in by the user.


### Optimizing & Bundling

Snowpack supports pluggable bundlers and other build optimizations via the `optimize()` hook. This method runs after the build and gives plugins a chance to optimize the final build directory. Webpack, Rollup, and other build-only optimizations should use this hook.
Expand Down
10 changes: 9 additions & 1 deletion plugins/plugin-svelte/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ module.exports = function plugin(snowpackConfig, pluginOptions = {}) {
output: ['.js', '.css'],
},
knownEntrypoints: ['svelte/internal'],
async load({filePath}) {
async load({filePath, isSSR}) {
let codeToCompile = fs.readFileSync(filePath, 'utf-8');
// PRE-PROCESS
if (preprocessOptions) {
Expand All @@ -44,8 +44,16 @@ module.exports = function plugin(snowpackConfig, pluginOptions = {}) {
).code;
}
// COMPILE
const ssrOptions = {};
if (isSSR) {
ssrOptions.generate = 'ssr';
ssrOptions.hydratable = true;
ssrOptions.css = true;
}

const {js, css} = svelte.compile(codeToCompile, {
...svelteOptions,
...ssrOptions,
outputFilename: filePath,
filename: filePath,
});
Expand Down
4 changes: 3 additions & 1 deletion snowpack/src/build/build-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {getExt, readFile, replaceExt} from '../util';

export interface BuildFileOptions {
isDev: boolean;
isSSR: boolean;
isHmrEnabled: boolean;
plugins: SnowpackPlugin[];
sourceMaps: boolean;
Expand Down Expand Up @@ -37,7 +38,7 @@ export function getInputsFromOutput(fileLoc: string, plugins: SnowpackPlugin[])
*/
async function runPipelineLoadStep(
srcPath: string,
{isDev, isHmrEnabled, plugins, sourceMaps}: BuildFileOptions,
{isDev, isSSR, isHmrEnabled, plugins, sourceMaps}: BuildFileOptions,
): Promise<SnowpackBuildMap> {
const srcExt = getExt(srcPath).baseExt;
for (const step of plugins) {
Expand All @@ -55,6 +56,7 @@ async function runPipelineLoadStep(
fileExt: srcExt,
filePath: srcPath,
isDev,
isSSR,
isHmrEnabled,
});
logger.debug(`✔ load() success [${debugPath}]`, {name: step.name});
Expand Down
2 changes: 2 additions & 0 deletions snowpack/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class FileBuilder {
const builtFileOutput = await buildFile(this.filepath, {
plugins: this.config.plugins,
isDev: false,
isSSR: this.config.buildOptions.ssr,
isHmrEnabled: false,
sourceMaps: this.config.buildOptions.sourceMaps,
});
Expand Down Expand Up @@ -429,6 +430,7 @@ export async function command(commandOptions: CommandOptions) {
await runPipelineOptimizeStep(buildDirectoryLoc, {
plugins: config.plugins,
isDev: false,
isSSR: config.buildOptions.ssr,
isHmrEnabled: false,
sourceMaps: config.buildOptions.sourceMaps,
});
Expand Down
78 changes: 69 additions & 9 deletions snowpack/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import merge from 'deepmerge';
import etag from 'etag';
import {EventEmitter} from 'events';
import {createReadStream, existsSync, promises as fs, statSync} from 'fs';
import got from 'got';
import http from 'http';
import HttpProxy from 'http-proxy';
import http2 from 'http2';
Expand Down Expand Up @@ -92,6 +93,41 @@ const DEFAULT_PROXY_ERROR_HANDLER = (
sendError(req, res, 502);
};

/**
* An in-memory build cache for Snowpack. Responsible for coordinating
* different builds (ex: SSR, non-SSR) to get/set individually but clear
* both at once.
*/
class InMemoryBuildCache {
ssrCache = new Map<string, SnowpackBuildMap>();
webCache = new Map<string, SnowpackBuildMap>();

private getCache(isSSR: boolean): Map<string, SnowpackBuildMap> {
if (isSSR) {
return this.ssrCache;
} else {
return this.webCache;
}
}

get(fileLoc: string, isSSR: boolean) {
return this.getCache(isSSR).get(fileLoc);
}
set(fileLoc: string, val: SnowpackBuildMap, isSSR: boolean) {
return this.getCache(isSSR).set(fileLoc, val);
}
has(fileLoc: string, isSSR: boolean) {
return this.getCache(isSSR).has(fileLoc);
}
delete(fileLoc: string) {
return this.getCache(true).delete(fileLoc) && this.getCache(false).delete(fileLoc);
}
clear() {
this.getCache(true).clear();
this.getCache(false).clear();
}
}

function shouldProxy(pathPrefix: string, req: http.IncomingMessage) {
const reqPath = decodeURI(url.parse(req.url!).pathname!);
return reqPath.startsWith(pathPrefix);
Expand Down Expand Up @@ -202,7 +238,7 @@ function getUrlFromFile(
return null;
}

export async function command(commandOptions: CommandOptions) {
export async function startServer(commandOptions: CommandOptions) {
const {cwd, config} = commandOptions;
const {port: defaultPort, hostname, open} = config.devOptions;
const isHmr = typeof config.devOptions.hmr !== 'undefined' ? config.devOptions.hmr : true;
Expand Down Expand Up @@ -234,7 +270,7 @@ export async function command(commandOptions: CommandOptions) {
config.plugins.map((p) => p.name),
);

const inMemoryBuildCache = new Map<string, SnowpackBuildMap>();
const inMemoryBuildCache = new InMemoryBuildCache();
const filesBeingDeleted = new Set<string>();
const filesBeingBuilt = new Map<string, Promise<SnowpackBuildMap>>();
const mountedDirectories: [string, string][] = Object.entries(config.mount).map(
Expand Down Expand Up @@ -349,6 +385,7 @@ export async function command(commandOptions: CommandOptions) {
const reqUrlHmrParam = reqUrl.includes('?mtime=') && reqUrl.split('?')[1];
let reqPath = decodeURI(url.parse(reqUrl).pathname!);
const originalReqPath = reqPath;
const isSSR = reqUrl.includes('?ssr');
let isProxyModule = false;
let isSourceMap = false;
if (reqPath.endsWith('.proxy.js')) {
Expand Down Expand Up @@ -480,10 +517,11 @@ export async function command(commandOptions: CommandOptions) {
const builtFileOutput = await _buildFile(fileLoc, {
plugins: config.plugins,
isDev: true,
isSSR,
isHmrEnabled: isHmr,
sourceMaps: config.buildOptions.sourceMaps,
});
inMemoryBuildCache.set(fileLoc, builtFileOutput);
inMemoryBuildCache.set(fileLoc, builtFileOutput, isSSR);
return builtFileOutput;
})();
filesBeingBuilt.set(fileLoc, fileBuilderPromise);
Expand Down Expand Up @@ -693,7 +731,7 @@ If Snowpack is having trouble detecting the import, add ${colors.bold(
}

// 1. Check the hot build cache. If it's already found, then just serve it.
let hotCachedResponse: SnowpackBuildMap | undefined = inMemoryBuildCache.get(fileLoc);
let hotCachedResponse: SnowpackBuildMap | undefined = inMemoryBuildCache.get(fileLoc, isSSR);
if (hotCachedResponse) {
const responseContent = await finalizeResponse(fileLoc, requestedFileExt, hotCachedResponse);
if (!responseContent) {
Expand All @@ -719,6 +757,7 @@ If Snowpack is having trouble detecting the import, add ${colors.bold(
// matches then assume the entire cache is suspect. In that case, clear the
// persistent cache and then force a live-reload of the page.
const cachedBuildData =
!isSSR &&
!filesBeingDeleted.has(fileLoc) &&
(await cacache.get(BUILD_CACHE, fileLoc).catch(() => null));
if (cachedBuildData) {
Expand All @@ -737,7 +776,7 @@ If Snowpack is having trouble detecting the import, add ${colors.bold(
map?: string;
}
>;
inMemoryBuildCache.set(fileLoc, coldCachedResponse);
inMemoryBuildCache.set(fileLoc, coldCachedResponse, false);
// Trust...
const wrappedResponse = await finalizeResponse(
fileLoc,
Expand Down Expand Up @@ -811,9 +850,16 @@ ${err}`);

sendFile(req, res, responseContent, fileLoc, responseFileExt);
const originalFileHash = etag(fileContents);
cacache.put(BUILD_CACHE, fileLoc, Buffer.from(JSON.stringify(responseOutput)), {
metadata: {originalFileHash},
});

// Only save the file to our cold cache if it's not SSR.
// NOTE(fks): We could do better and cache both, but at the time of writing SSR
// is still a new concept. Lets confirm that this is how we want to do SSR, and
// then can revisit the caching story once confident.
if (!isSSR) {
cacache.put(BUILD_CACHE, fileLoc, Buffer.from(JSON.stringify(responseOutput)), {
metadata: {originalFileHash},
});
}
}

type Http2RequestListener = (
Expand Down Expand Up @@ -921,7 +967,7 @@ ${err}`);
// Otherwise, reload the page if the file exists in our hot cache (which
// means that the file likely exists on the current page, but is not
// supported by HMR (HTML, image, etc)).
if (inMemoryBuildCache.has(fileLoc)) {
if (inMemoryBuildCache.has(fileLoc, false)) {
hmrEngine.broadcastMessage({type: 'reload'});
return;
}
Expand Down Expand Up @@ -995,5 +1041,19 @@ ${err}`);
depWatcher.on('change', onDepWatchEvent);
depWatcher.on('unlink', onDepWatchEvent);

return {
requestHandler,
/** @experimental - only available via unstable__startServer */
async loadByUrl(url: string, {isSSR}: {isSSR?: boolean}): Promise<string> {
if (!url.startsWith('/')) {
throw new Error(`url must start with "/", but got ${url}`);
}
return (await got.get(`http://localhost:${port}${url}${isSSR ? '?ssr=1' : ''}`)).body;
},
};
}

export async function command(commandOptions: CommandOptions) {
await startServer(commandOptions);
return new Promise(() => {});
}
2 changes: 2 additions & 0 deletions snowpack/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const DEFAULT_CONFIG: Partial<SnowpackConfig> = {
minify: false,
sourceMaps: false,
watch: false,
ssr: false,
},
};

Expand Down Expand Up @@ -135,6 +136,7 @@ const configSchema = {
minify: {type: 'boolean'},
sourceMaps: {type: 'boolean'},
watch: {type: 'boolean'},
ssr: {type: 'boolean'},
},
},
proxy: {
Expand Down
4 changes: 4 additions & 0 deletions snowpack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {CLIFlags} from './types/snowpack';
import {clearCache, readLockfile} from './util.js';
export * from './types/snowpack';

// Unstable: These APIs are in progress and subject to change
export {startServer as unstable__startServer} from './commands/dev';
export {loadAndValidateConfig as unstable__loadAndValidateConfig};

const cwd = process.cwd();

function printHelp() {
Expand Down
2 changes: 2 additions & 0 deletions snowpack/src/types/snowpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface PluginLoadOptions {
filePath: string;
fileExt: string;
isDev: boolean;
isSSR: boolean;
isHmrEnabled: boolean;
}

Expand Down Expand Up @@ -139,6 +140,7 @@ export interface SnowpackConfig {
minify: boolean;
sourceMaps: boolean;
watch: boolean;
ssr: false;
};
_extensionMap: Record<string, string>;
}
Expand Down

1 comment on commit 05b2a9e

@vercel
Copy link

@vercel vercel bot commented on 05b2a9e Sep 26, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.