├── .github
└── workflows
│ └── test-suite-ci.yml
├── .gitignore
├── README.md
├── configs
├── app_config.js
├── default.config.js
└── svite.config.js
├── index.d.ts
├── package.json
├── postBuild.js
├── spassr.png
├── src
├── cli.js
├── config.js
└── spassr.js
├── test
├── dynamic-import
│ ├── dist
│ │ ├── file.js
│ │ ├── index.html
│ │ └── main.js
│ └── test.spec.js
└── package.json
└── tsconfig.json
/.github/workflows/test-suite-ci.yml:
--------------------------------------------------------------------------------
1 | name: Test Suite CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 | name: Test on NodeJS v${{ matrix.node-version }} on ${{ matrix.os }}
8 | runs-on: ${{ matrix.os }}
9 |
10 | strategy:
11 | matrix:
12 | node-version: [12.x, 14.x]
13 | os: [ubuntu-latest, windows-latest, macOS-latest]
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | - run: npm i
22 | - run: npm test
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
3 | .DS_Store
4 | **/.history
5 | **/__roxi-ssr-bundle.js
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | ### Small Express server with SSR
6 |
7 | #### Usage example
8 |
9 | $ npx spassr --assets-dir dist --entrypoint dist/index.html --script dist/build/bundle.js --ssr
10 |
11 | #### Configuration
12 |
13 | Spassr can be configured through `CLI`, `package.json`, `spassr.config.js` and `.env`.
14 |
15 | Environment variables are converted from snake_case to camelCase, so `SPASSR_assets_dir = dist` becomes `{... assetsDir: 'dist'}`
16 |
17 | For configuration options, refer to the API below.
18 |
19 | * * *
20 |
21 | ### API
22 |
23 |
24 |
25 | ##### Table of Contents
26 |
27 | - [spassr](#spassr)
28 | - [Parameters](#parameters)
29 | - [Config](#config)
30 | - [Properties](#properties)
31 | - [Eval](#eval)
32 | - [Parameters](#parameters-1)
33 | - [config](#config-1)
34 |
35 | #### spassr
36 |
37 | ##### Parameters
38 |
39 | - `options` **Partial<config.Config>** \*
40 |
41 | #### Config
42 |
43 | Type: [object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)
44 |
45 | ##### Properties
46 |
47 | - `assetsDir` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>)** folders with static content to be served.
48 | - `entrypoint` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** HTML template, eg. assets/index.html.
49 | - `script` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** path to app, eg. build/bundle.js.
50 | - `port` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number))** port to serve on.
51 | - `ssr` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** enable SSR for routes not resolved in assetsDir.
52 | - `silent` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** quiet console.log.
53 | - `middleware` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function)** function to customize SPA server (_Not available in CLI_).
54 | - `ssrOptions` **Partial<tossr.Config>** options to pass to ssr.
55 |
56 |
57 | #### Eval
58 |
59 | Called before/after the app script is evaluated
60 |
61 | Type: [Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)
62 |
63 | ##### Parameters
64 |
65 | - `dom` **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** The DOM object
66 |
67 | #### config
68 |
69 | Type: [Config](#config)
70 |
71 |
72 | ---
73 |
74 | Business vector created by teravector - www.freepik.com
75 |
--------------------------------------------------------------------------------
/configs/app_config.js:
--------------------------------------------------------------------------------
1 | const map = {
2 | template: 'entrypoint'
3 | }
4 |
5 | module.exports = {
6 | name: 'appConfig',
7 | condition: ({ pkgjson }) => pkgjson.appConfig,
8 | supersedes: ['default', 'svite'],
9 | config: ({ pkgjson }) => {
10 | const cfg = Object.entries(pkgjson.appConfig)
11 | .reduce((acc, [key, val]) => ({
12 | ...acc,
13 | [map[key] || key]: val
14 | }), {})
15 |
16 | cfg.assetsDir = [cfg.distDir, cfg.assetsDir].filter(Boolean)
17 |
18 | return cfg
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/configs/default.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | name: 'Default',
3 | condition: () => true,
4 | config: ({ pkgjson }) => {
5 |
6 | // set some healthy defaults
7 | const defaults = {
8 | "assets": "assets",
9 | "dist": "dist",
10 | "script": "dist/build/main.js",
11 | "template": "assets/__app.html"
12 | }
13 |
14 | // merge with the app field from package.json, if it exists
15 | const config = { ...defaults, ...pkgjson.options }
16 |
17 | return {
18 | // prioritize 'dist' over 'assets', in case asset has been transformed
19 | "assetsDir": [config.dist, config.assets],
20 | "script": config.script,
21 | "entrypoint": config.template,
22 | "ssrOptions": {
23 | "inlineDynamicImports": true
24 | }
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/configs/svite.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | name: 'Svite',
3 | condition: ({ pkgjson }) => pkgjson.dependencies['@svitejs/vite-plugin-svelte'],
4 | supersedes: ['default'],
5 | config: () => {
6 | const config = {
7 | assetsDir: 'dist',
8 | entrypoint: 'dist/index.html',
9 | ssrOptions: { inlineDynamicImports: true }
10 | }
11 |
12 | const script = getScript(config.entrypoint)
13 | if (script)
14 | config.script = `dist${script}`
15 |
16 | return config
17 | }
18 | }
19 |
20 | function getScript(entrypoint) {
21 | const { readFileSync, existsSync } = require('fs')
22 | if (existsSync(entrypoint))
23 | return readFileSync(entrypoint, 'utf8')
24 | .match(/
3 | ///
4 | declare module "config" {
5 | export = config;
6 | /**
7 | * @typedef {object} Config
8 | * @prop {string|string[]} assetsDir - folders with static content to be served.
9 | * @prop {string} entrypoint - HTML template, eg. assets/index.html.
10 | * @prop {string} script - path to app, eg. build/bundle.js.
11 | * @prop {string|number} port - port to serve on.
12 | * @prop {boolean} ssr - enable SSR for routes not resolved in assetsDir.
13 | * @prop {boolean} silent - quiet console.log.
14 | * @prop {Partial} ssrOptions - options to pass to ssr
15 | */
16 | /**
17 | * Called before/after the app script is evaluated
18 | * @callback Eval
19 | * @param {object} dom The DOM object
20 | * */
21 | /** @type {Config} */
22 | const config: Config;
23 | namespace config {
24 | export { Config, Eval };
25 | }
26 | type Config = {
27 | /**
28 | * - folders with static content to be served.
29 | */
30 | assetsDir: string | string[];
31 | /**
32 | * - HTML template, eg. assets/index.html.
33 | */
34 | entrypoint: string;
35 | /**
36 | * - path to app, eg. build/bundle.js.
37 | */
38 | script: string;
39 | /**
40 | * - port to serve on.
41 | */
42 | port: string | number;
43 | /**
44 | * - enable SSR for routes not resolved in assetsDir.
45 | */
46 | ssr: boolean;
47 | /**
48 | * - quiet console.log.
49 | */
50 | silent: boolean;
51 | /**
52 | * - options to pass to ssr
53 | */
54 | ssrOptions: Partial;
55 | };
56 | /**
57 | * Called before/after the app script is evaluated
58 | */
59 | type Eval = (dom: object) => any;
60 | }
61 | declare module "spassr" {
62 | export function spassr(options: Partial): Promise;
63 | }
64 | declare module "cli" {
65 | export {};
66 | }
67 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spassr",
3 | "version": "2.6.0",
4 | "main": "src/spassr.js",
5 | "bin": {
6 | "spassr": "src/cli.js"
7 | },
8 | "scripts": {
9 | "build": "tsc && documentation readme src/spassr.js -s API && node ./postBuild",
10 | "test": "ava"
11 | },
12 | "keywords": [],
13 | "author": "jakobrosenberg@gmail.com",
14 | "license": "MIT",
15 | "dependencies": {
16 | "commander": "^6",
17 | "configent": "^2.2.0",
18 | "express": "^4.17.1",
19 | "tossr": "^1.4.1"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/roxiness/spassr.git"
24 | },
25 | "bugs": {
26 | "url": "https://github.com/roxiness/spassr/issues"
27 | },
28 | "homepage": "https://github.com/roxiness/spassr#readme",
29 | "description": "",
30 | "devDependencies": {
31 | "ava": "^3.12.1",
32 | "fs-extra": "^9.0.1",
33 | "node-fetch": "^2.6.1"
34 | },
35 | "ava": {
36 | "files": [
37 | "test/specs/**",
38 | "test/**/*.spec.*"
39 | ],
40 | "ignoredByWatcher": [
41 | "**/__roxi-ssr-bundle.js"
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/postBuild.js:
--------------------------------------------------------------------------------
1 | const {appendFileSync} = require('fs')
2 | const footer = '\n\n---\n\nBusiness vector created by teravector - www.freepik.com'
3 |
4 | appendFileSync('./README.md', footer)
--------------------------------------------------------------------------------
/spassr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roxiness/spassr/555f336cf9101f1de0a3ee26ea7430975aefeb59/spassr.png
--------------------------------------------------------------------------------
/src/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const program = require('commander')
4 | const { spassr } = require('./spassr')
5 | const { configent } = require('configent')
6 | const defaults = configent(require('./config'), {}, { useDetectDefaults: true, module })
7 |
8 | const toArray = x => x.split(/[, ]/)
9 |
10 | program
11 | .option('-d, --debug', 'extra debugging')
12 | .option('-a, --assets-dir ', 'path to distributables', toArray, defaults.assetsDir)
13 | .option('-s, --script ', 'path to app script', defaults.script)
14 | .option('-e, --entrypoint ', 'path from publish dir to entrypoint', defaults.entrypoint)
15 | .option('-p, --port ', 'port serving spa app', defaults.port.toString())
16 | .option('-q, --silent', 'silent console logs', defaults.silent)
17 | .option('-r, --ssr', 'serve SSR', defaults.ssr)
18 | .action(_options => {
19 | spassr(_options.opts())
20 | })
21 |
22 | program.parse(process.argv)
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const tossr = require('tossr')
2 |
3 | /**
4 | * @typedef {object} Config
5 | * @prop {string|string[]} assetsDir - folders with static content to be served.
6 | * @prop {string} entrypoint - HTML template, eg. assets/index.html.
7 | * @prop {string} script - path to app, eg. build/bundle.js.
8 | * @prop {string|number} port - port to serve on.
9 | * @prop {boolean} ssr - enable SSR for routes not resolved in assetsDir.
10 | * @prop {boolean} silent - quiet console.log.
11 | * @prop {Partial} ssrOptions - options to pass to SSR.
12 | * @prop {Function} middleware - function to customize SPA server.
13 | */
14 |
15 | /**
16 | * Called before/after the app script is evaluated
17 | * @callback Eval
18 | * @param {object} dom The DOM object
19 | * */
20 |
21 | /** @type {Config} */
22 | const config = {
23 | assetsDir: 'dist',
24 | entrypoint: 'dist/__app.html',
25 | script: 'dist/build/bundle.js',
26 | port: "5000",
27 | ssr: false,
28 | silent: false,
29 | ssrOptions: {},
30 | middleware: (server) => void 0
31 | }
32 |
33 | module.exports = config
34 |
--------------------------------------------------------------------------------
/src/spassr.js:
--------------------------------------------------------------------------------
1 | const { tossr } = require('tossr')
2 | const { resolve } = require('path')
3 | const { configent } = require('configent')
4 | const express = require('express')
5 | const config = require('./config')
6 |
7 | /**
8 | * @param {Partial} options
9 | **/
10 | module.exports.spassr = async function (options) {
11 | options = configent(require('./config'), options, { useDetectDefaults: true, module })
12 |
13 | let {
14 | assetsDir,
15 | port,
16 | silent,
17 | ssr,
18 | entrypoint,
19 | script,
20 | ssrOptions = {}
21 | } = options
22 |
23 | if (!await isPortFree(port)) {
24 | console.log(`[spassr] port already taken ${port}`)
25 | return
26 | }
27 |
28 | const server = express();
29 |
30 | const assetsDirs = Array.isArray(assetsDir) ? assetsDir : assetsDir.split(',')
31 | assetsDirs.forEach(dir => server.use(express.static(dir)))
32 |
33 | if (options.middleware)
34 | options.middleware(server)
35 |
36 | if (!ssr) {
37 | server.get('*', (req, res) =>
38 | res.sendFile(resolve(entrypoint)))
39 | }
40 | else
41 | server.get('*', async (req, res) =>
42 | res.send(await tossr(entrypoint, script, req.url, ssrOptions)))
43 |
44 | if (!silent) console.log(`[spassr] Serving ${ssr ? 'ssr' : 'spa'} on localhost:${port}`)
45 | return server.listen(port)
46 | }
47 |
48 |
49 | function isPortFree(port) {
50 | return new Promise((resolve, reject) => {
51 | const tester = require('net').createServer()
52 | .once('error', err => (err['code'] == 'EADDRINUSE' ? resolve(false) : reject(err)))
53 | .once('listening', () => tester.once('close', () => resolve(true)).close())
54 | .listen(port)
55 | })
56 | }
57 |
--------------------------------------------------------------------------------
/test/dynamic-import/dist/file.js:
--------------------------------------------------------------------------------
1 | export default { status: 'imported' }
--------------------------------------------------------------------------------
/test/dynamic-import/dist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/test/dynamic-import/dist/main.js:
--------------------------------------------------------------------------------
1 | import('./file.js').then(res => {
2 | document.getElementById('app').innerHTML = `${res.default.status}
`
3 |
4 | if (window.location.pathname !== '/timeout')
5 | dispatchEvent(new CustomEvent('app-loaded'))
6 | })
--------------------------------------------------------------------------------
/test/dynamic-import/test.spec.js:
--------------------------------------------------------------------------------
1 | const test = require('ava').default
2 | const { removeSync } = require('fs-extra')
3 | const { spassr } = require('../../src/spassr')
4 | const { resolve } = require('path')
5 | const fetch = require('node-fetch').default
6 |
7 | test.before(async t => {
8 | removeSync(resolve(__dirname, 'dist/__roxi-ssr-bundle.js'))
9 | await spassr({
10 | assetsDir: [],
11 | entrypoint: resolve(__dirname, 'dist/index.html'),
12 | script: resolve(__dirname, 'dist/main.js'),
13 | port: 5000,
14 | ssr: true,
15 | ssrOptions: {
16 | inlineDynamicImports: true,
17 | timeout: 1000,
18 | }
19 | })
20 | })
21 |
22 | test('dynamic imports', async t => {
23 | const res = await fetch('http://127.0.0.1:5000').then(res => res.text())
24 | const expected =`\n \n \n\n`
25 | t.log('RES', res)
26 | t.is(res, expected)
27 |
28 | })
29 |
30 | test('timeouts', async t => {
31 | const res2 = await fetch('http://127.0.0.1:5000/timeout').then(res => res.text())
32 | t.log('RES2', res2)
33 | const expected2 =`\n \n \n\n`
34 | t.is(res2, expected2)
35 | })
36 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "allowJs": true,
5 | "checkJs": true,
6 | "emitDeclarationOnly": true,
7 | "outFile": "index.d.ts"
8 | },
9 | "include": ["src"]
10 | }
11 |
--------------------------------------------------------------------------------