roster-server 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -113
- package/index.js +99 -111
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Welcome to **RosterServer**, the ultimate domain host router with automatic HTTP
|
|
|
11
11
|
- **Virtual Hosting**: Serve multiple domains from a single server.
|
|
12
12
|
- **Automatic Redirects**: Redirect `www` subdomains to the root domain.
|
|
13
13
|
- **Zero Configuration**: Well, almost zero. Just a tiny bit of setup.
|
|
14
|
+
- **Bun compatible**: Works with both Node.js and [Bun](https://bun.sh).
|
|
14
15
|
|
|
15
16
|
## 📦 Installation
|
|
16
17
|
|
|
@@ -18,6 +19,12 @@ Welcome to **RosterServer**, the ultimate domain host router with automatic HTTP
|
|
|
18
19
|
npm install roster-server
|
|
19
20
|
```
|
|
20
21
|
|
|
22
|
+
Or with [Bun](https://bun.sh):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add roster-server
|
|
26
|
+
```
|
|
27
|
+
|
|
21
28
|
## 🤖 AI Skill
|
|
22
29
|
|
|
23
30
|
You can also add RosterServer as a skill for AI agentic development:
|
|
@@ -145,10 +152,16 @@ roster.register('example.com:8080', (httpsServer) => {
|
|
|
145
152
|
### Running the Server
|
|
146
153
|
|
|
147
154
|
```bash
|
|
148
|
-
#
|
|
155
|
+
# With Node.js
|
|
149
156
|
node server.js
|
|
150
157
|
```
|
|
151
158
|
|
|
159
|
+
Or with Bun:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
bun server.js
|
|
163
|
+
```
|
|
164
|
+
|
|
152
165
|
And that's it! Your server is now hosting multiple HTTPS-enabled sites. 🎉
|
|
153
166
|
|
|
154
167
|
## 🤯 But Wait, There's More!
|
|
@@ -176,118 +189,6 @@ When creating a new `RosterServer` instance, you can pass the following options:
|
|
|
176
189
|
- `local` (boolean): Set to `true` to run in local development mode.
|
|
177
190
|
- `minLocalPort` (number): Minimum port for local mode (default: 4000).
|
|
178
191
|
- `maxLocalPort` (number): Maximum port for local mode (default: 9999).
|
|
179
|
-
- `tlsMode` (string): TLS backend to use — `'auto'` (default), `'greenlock'`, or `'static'`. See [TLS Configuration](#-tls-configuration) below.
|
|
180
|
-
- `tlsDomain` (string): Domain whose cert files are pre-loaded as the server default in static mode (optional).
|
|
181
|
-
- `tls` (object): Additional TLS options passed to `https.createServer` (e.g. `minVersion`, `maxVersion`, `ciphers`).
|
|
182
|
-
|
|
183
|
-
## 🔒 TLS Configuration
|
|
184
|
-
|
|
185
|
-
RosterServer supports three TLS backends, selectable with the `tlsMode` option.
|
|
186
|
-
|
|
187
|
-
### Behavior matrix
|
|
188
|
-
|
|
189
|
-
| `tlsMode` | Runtime | HTTPS server |
|
|
190
|
-
|-----------|---------|-------------|
|
|
191
|
-
| `'auto'` (default) | Node.js | Greenlock SNI — certs managed and auto-renewed automatically |
|
|
192
|
-
| `'auto'` (default) | Bun | Static file certs from `greenlockStorePath/live/<domain>/` |
|
|
193
|
-
| `'greenlock'` | any | Always Greenlock SNI |
|
|
194
|
-
| `'static'` | any | Always static file certs |
|
|
195
|
-
|
|
196
|
-
Bun's TLS stack does not support Greenlock's async SNICallback, causing a `tlsv1 alert protocol version` error. `'auto'` mode detects the runtime and picks the correct backend transparently.
|
|
197
|
-
|
|
198
|
-
### Static mode cert layout
|
|
199
|
-
|
|
200
|
-
In `'auto'` (Bun) or `'static'` mode, certs are read per-domain from:
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
greenlockStorePath/
|
|
204
|
-
live/
|
|
205
|
-
example.com/
|
|
206
|
-
privkey.pem
|
|
207
|
-
cert.pem
|
|
208
|
-
chain.pem
|
|
209
|
-
api.example.com/
|
|
210
|
-
privkey.pem
|
|
211
|
-
cert.pem
|
|
212
|
-
chain.pem
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Greenlock populates this layout automatically when it renews certificates, so no extra tooling is required.
|
|
216
|
-
|
|
217
|
-
### Default TLS options
|
|
218
|
-
|
|
219
|
-
The static-cert path enforces secure defaults that can be overridden with the `tls` option:
|
|
220
|
-
|
|
221
|
-
```javascript
|
|
222
|
-
{ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' }
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### Examples
|
|
226
|
-
|
|
227
|
-
**Default — works on both Node and Bun without changes:**
|
|
228
|
-
|
|
229
|
-
```javascript
|
|
230
|
-
const roster = new Roster({
|
|
231
|
-
email: 'admin@example.com',
|
|
232
|
-
wwwPath: '/srv/www'
|
|
233
|
-
// tlsMode defaults to 'auto'
|
|
234
|
-
});
|
|
235
|
-
roster.start();
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
**Force Greenlock on all runtimes:**
|
|
239
|
-
|
|
240
|
-
```javascript
|
|
241
|
-
const roster = new Roster({
|
|
242
|
-
email: 'admin@example.com',
|
|
243
|
-
wwwPath: '/srv/www',
|
|
244
|
-
tlsMode: 'greenlock'
|
|
245
|
-
});
|
|
246
|
-
roster.start();
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
**Force static certs with a pre-loaded default cert (avoids SNI-less connection failures):**
|
|
250
|
-
|
|
251
|
-
```javascript
|
|
252
|
-
const roster = new Roster({
|
|
253
|
-
email: 'admin@example.com',
|
|
254
|
-
wwwPath: '/srv/www',
|
|
255
|
-
tlsMode: 'static',
|
|
256
|
-
tlsDomain: 'example.com' // loaded as the server's fallback cert
|
|
257
|
-
});
|
|
258
|
-
roster.start();
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
**Custom TLS options (e.g. restrict ciphers):**
|
|
262
|
-
|
|
263
|
-
```javascript
|
|
264
|
-
const roster = new Roster({
|
|
265
|
-
email: 'admin@example.com',
|
|
266
|
-
wwwPath: '/srv/www',
|
|
267
|
-
tls: {
|
|
268
|
-
minVersion: 'TLSv1.2',
|
|
269
|
-
ciphers: 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
roster.start();
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
### Smoke test
|
|
276
|
-
|
|
277
|
-
After deploying, verify TLS is working:
|
|
278
|
-
|
|
279
|
-
```bash
|
|
280
|
-
curl -v https://example.com
|
|
281
|
-
# Should show TLSv1.2 or TLSv1.3 in the handshake — no "alert protocol version" errors
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
Server logs will display the active mode on startup:
|
|
285
|
-
|
|
286
|
-
```
|
|
287
|
-
Runtime: bun | TLS mode: static
|
|
288
|
-
HTTPS port 443: using static certs from /srv/greenlock.d/live [static]
|
|
289
|
-
HTTPS server listening on port 443
|
|
290
|
-
```
|
|
291
192
|
|
|
292
193
|
## 🏠 Local Development Mode
|
|
293
194
|
|
package/index.js
CHANGED
|
@@ -166,84 +166,14 @@ class Roster {
|
|
|
166
166
|
this.filename = options.filename || 'index';
|
|
167
167
|
this.minLocalPort = options.minLocalPort || 4000;
|
|
168
168
|
this.maxLocalPort = options.maxLocalPort || 9999;
|
|
169
|
+
this.tlsMinVersion = options.tlsMinVersion ?? 'TLSv1.2';
|
|
170
|
+
this.tlsMaxVersion = options.tlsMaxVersion ?? 'TLSv1.3';
|
|
169
171
|
|
|
170
172
|
const port = options.port === undefined ? 443 : options.port;
|
|
171
173
|
if (port === 80 && !this.local) {
|
|
172
174
|
throw new Error('⚠️ Port 80 is reserved for ACME challenge. Please use a different port.');
|
|
173
175
|
}
|
|
174
176
|
this.defaultPort = port;
|
|
175
|
-
|
|
176
|
-
const validTlsModes = ['auto', 'greenlock', 'static'];
|
|
177
|
-
this.tlsMode = options.tlsMode || 'auto';
|
|
178
|
-
if (!validTlsModes.includes(this.tlsMode)) {
|
|
179
|
-
throw new Error(`Invalid tlsMode "${this.tlsMode}". Must be one of: ${validTlsModes.join(', ')}`);
|
|
180
|
-
}
|
|
181
|
-
this.tlsDomain = options.tlsDomain || null;
|
|
182
|
-
this.tlsOptions = options.tls || {};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
detectRuntime() {
|
|
186
|
-
return typeof Bun !== 'undefined' ? 'bun' : 'node';
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
getEffectiveTlsMode() {
|
|
190
|
-
if (this.tlsMode !== 'auto') {
|
|
191
|
-
return this.tlsMode;
|
|
192
|
-
}
|
|
193
|
-
return this.detectRuntime() === 'bun' ? 'static' : 'greenlock';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
getDefaultTlsOptions() {
|
|
197
|
-
return Object.assign({ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' }, this.tlsOptions);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
createSNICallback() {
|
|
201
|
-
return (domain, callback) => {
|
|
202
|
-
try {
|
|
203
|
-
const certPath = path.join(this.greenlockStorePath, 'live', domain);
|
|
204
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
205
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
206
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
207
|
-
|
|
208
|
-
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
209
|
-
const key = fs.readFileSync(keyPath, 'utf8');
|
|
210
|
-
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
211
|
-
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
212
|
-
|
|
213
|
-
callback(null, tls.createSecureContext({ key, cert: cert + chain }));
|
|
214
|
-
} else {
|
|
215
|
-
callback(new Error(`No certificate files available for ${domain} at ${certPath}`));
|
|
216
|
-
}
|
|
217
|
-
} catch (error) {
|
|
218
|
-
callback(error);
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
createStaticHttpsServer(dispatcher) {
|
|
224
|
-
const tlsOpts = Object.assign(this.getDefaultTlsOptions(), {
|
|
225
|
-
SNICallback: this.createSNICallback()
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
if (this.tlsDomain) {
|
|
229
|
-
const certPath = path.join(this.greenlockStorePath, 'live', this.tlsDomain);
|
|
230
|
-
const keyPath = path.join(certPath, 'privkey.pem');
|
|
231
|
-
const certFilePath = path.join(certPath, 'cert.pem');
|
|
232
|
-
const chainPath = path.join(certPath, 'chain.pem');
|
|
233
|
-
|
|
234
|
-
const missing = [keyPath, certFilePath, chainPath].filter(p => !fs.existsSync(p));
|
|
235
|
-
if (missing.length > 0) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`Static TLS cert files missing for domain "${this.tlsDomain}":\n` +
|
|
238
|
-
missing.map(p => ` - ${p}`).join('\n')
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
tlsOpts.key = fs.readFileSync(keyPath, 'utf8');
|
|
243
|
-
tlsOpts.cert = fs.readFileSync(certFilePath, 'utf8') + fs.readFileSync(chainPath, 'utf8');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return https.createServer(tlsOpts, dispatcher);
|
|
247
177
|
}
|
|
248
178
|
|
|
249
179
|
async loadSites() {
|
|
@@ -446,13 +376,13 @@ class Roster {
|
|
|
446
376
|
getUrl(domain) {
|
|
447
377
|
// Remove www prefix if present
|
|
448
378
|
const cleanDomain = domain.startsWith('www.') ? domain.slice(4) : domain;
|
|
449
|
-
|
|
379
|
+
|
|
450
380
|
// Check if domain is registered
|
|
451
381
|
const isRegistered = this.sites[cleanDomain] || this.sites[`www.${cleanDomain}`];
|
|
452
382
|
if (!isRegistered) {
|
|
453
383
|
return null;
|
|
454
384
|
}
|
|
455
|
-
|
|
385
|
+
|
|
456
386
|
// Return URL based on environment
|
|
457
387
|
if (this.local) {
|
|
458
388
|
// Local mode: return localhost URL with assigned port
|
|
@@ -509,7 +439,7 @@ class Roster {
|
|
|
509
439
|
startLocalMode() {
|
|
510
440
|
// Store mapping of domain to port for later retrieval
|
|
511
441
|
this.domainPorts = {};
|
|
512
|
-
|
|
442
|
+
|
|
513
443
|
// Create a simple HTTP server for each domain with CRC32-based ports
|
|
514
444
|
for (const [hostKey, siteApp] of Object.entries(this.sites)) {
|
|
515
445
|
const domain = hostKey.split(':')[0]; // Remove port if present
|
|
@@ -521,7 +451,7 @@ class Roster {
|
|
|
521
451
|
|
|
522
452
|
// Calculate deterministic port based on domain CRC32, with collision detection
|
|
523
453
|
const port = this.assignPortToDomain(domain);
|
|
524
|
-
|
|
454
|
+
|
|
525
455
|
// Store domain → port mapping
|
|
526
456
|
this.domainPorts[domain] = port;
|
|
527
457
|
|
|
@@ -651,10 +581,6 @@ class Roster {
|
|
|
651
581
|
};
|
|
652
582
|
};
|
|
653
583
|
|
|
654
|
-
const runtime = this.detectRuntime();
|
|
655
|
-
const effectiveTlsMode = this.getEffectiveTlsMode();
|
|
656
|
-
log.info(`Runtime: ${runtime} | TLS mode: ${effectiveTlsMode}`);
|
|
657
|
-
|
|
658
584
|
httpServer.listen(80, this.hostname, () => {
|
|
659
585
|
log.info('HTTP server listening on port 80');
|
|
660
586
|
});
|
|
@@ -671,53 +597,115 @@ class Roster {
|
|
|
671
597
|
if (virtualServer) {
|
|
672
598
|
virtualServer.processUpgrade(req, socket, head);
|
|
673
599
|
} else {
|
|
600
|
+
// No virtual server found, destroy the socket
|
|
674
601
|
socket.destroy();
|
|
675
602
|
}
|
|
676
603
|
};
|
|
677
604
|
};
|
|
678
605
|
|
|
679
|
-
const attachHttpsListeners = (httpsServer, portNum, upgradeHandler) => {
|
|
680
|
-
httpsServer.on('upgrade', upgradeHandler);
|
|
681
|
-
httpsServer.on('error', (error) => {
|
|
682
|
-
log.error(`HTTPS server error on port ${portNum}:`, error.message);
|
|
683
|
-
});
|
|
684
|
-
httpsServer.on('tlsClientError', (error) => {
|
|
685
|
-
if (!error.message.includes('http request')) {
|
|
686
|
-
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
};
|
|
690
|
-
|
|
691
606
|
// Handle different port types
|
|
692
607
|
for (const [port, portData] of Object.entries(sitesByPort)) {
|
|
693
608
|
const portNum = parseInt(port);
|
|
694
609
|
const dispatcher = createDispatcher(portData);
|
|
695
610
|
const upgradeHandler = createUpgradeHandler(portData);
|
|
696
611
|
|
|
697
|
-
|
|
612
|
+
if (portNum === this.defaultPort) {
|
|
613
|
+
// Bun has known gaps around SNICallback compatibility.
|
|
614
|
+
// Fallback to static cert loading for the primary domain on default HTTPS port.
|
|
615
|
+
const isBunRuntime = typeof Bun !== 'undefined' || process.release?.name === 'bun';
|
|
616
|
+
const tlsOpts = { minVersion: this.tlsMinVersion, maxVersion: this.tlsMaxVersion };
|
|
617
|
+
let httpsServer;
|
|
618
|
+
|
|
619
|
+
if (isBunRuntime) {
|
|
620
|
+
const primaryDomain = Object.keys(portData.virtualServers)[0];
|
|
621
|
+
const certPath = path.join(this.greenlockStorePath, 'live', primaryDomain);
|
|
622
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
623
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
624
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
625
|
+
|
|
626
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
627
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
628
|
+
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
629
|
+
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
630
|
+
httpsServer = https.createServer({
|
|
631
|
+
...tlsOpts,
|
|
632
|
+
key,
|
|
633
|
+
cert: cert + chain
|
|
634
|
+
}, dispatcher);
|
|
635
|
+
log.warn(`⚠️ Bun runtime detected: using static TLS cert for ${primaryDomain} on port ${portNum}`);
|
|
636
|
+
} else {
|
|
637
|
+
log.warn(`⚠️ Bun runtime detected but cert files missing for ${primaryDomain}; falling back to Greenlock HTTPS server`);
|
|
638
|
+
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
httpsServer = glx.httpsServer(tlsOpts, dispatcher);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
this.portServers[portNum] = httpsServer;
|
|
698
645
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
646
|
+
// Handle WebSocket upgrade events
|
|
647
|
+
httpsServer.on('upgrade', upgradeHandler);
|
|
648
|
+
|
|
649
|
+
httpsServer.listen(portNum, this.hostname, () => {
|
|
650
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
651
|
+
});
|
|
702
652
|
} else {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
653
|
+
// Create HTTPS server for custom ports using Greenlock certificates
|
|
654
|
+
const httpsOptions = {
|
|
655
|
+
minVersion: this.tlsMinVersion,
|
|
656
|
+
maxVersion: this.tlsMaxVersion,
|
|
657
|
+
// SNI callback to get certificates dynamically
|
|
658
|
+
SNICallback: (domain, callback) => {
|
|
659
|
+
try {
|
|
660
|
+
const certPath = path.join(this.greenlockStorePath, 'live', domain);
|
|
661
|
+
const keyPath = path.join(certPath, 'privkey.pem');
|
|
662
|
+
const certFilePath = path.join(certPath, 'cert.pem');
|
|
663
|
+
const chainPath = path.join(certPath, 'chain.pem');
|
|
664
|
+
|
|
665
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certFilePath) && fs.existsSync(chainPath)) {
|
|
666
|
+
const key = fs.readFileSync(keyPath, 'utf8');
|
|
667
|
+
const cert = fs.readFileSync(certFilePath, 'utf8');
|
|
668
|
+
const chain = fs.readFileSync(chainPath, 'utf8');
|
|
669
|
+
|
|
670
|
+
callback(null, tls.createSecureContext({
|
|
671
|
+
key: key,
|
|
672
|
+
cert: cert + chain
|
|
673
|
+
}));
|
|
674
|
+
} else {
|
|
675
|
+
callback(new Error(`No certificate files available for ${domain}`));
|
|
676
|
+
}
|
|
677
|
+
} catch (error) {
|
|
678
|
+
callback(error);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
684
|
+
|
|
685
|
+
// Handle WebSocket upgrade events
|
|
686
|
+
httpsServer.on('upgrade', upgradeHandler);
|
|
687
|
+
|
|
688
|
+
httpsServer.on('error', (error) => {
|
|
689
|
+
log.error(`HTTPS server error on port ${portNum}:`, error.message);
|
|
707
690
|
});
|
|
708
|
-
httpsServer = https.createServer(httpsOptions, dispatcher);
|
|
709
|
-
}
|
|
710
691
|
|
|
711
|
-
|
|
712
|
-
|
|
692
|
+
httpsServer.on('tlsClientError', (error) => {
|
|
693
|
+
// Suppress HTTP request errors to avoid log spam
|
|
694
|
+
if (!error.message.includes('http request')) {
|
|
695
|
+
log.error(`TLS error on port ${portNum}:`, error.message);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
713
698
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
699
|
+
this.portServers[portNum] = httpsServer;
|
|
700
|
+
|
|
701
|
+
httpsServer.listen(portNum, this.hostname, (error) => {
|
|
702
|
+
if (error) {
|
|
703
|
+
log.error(`Failed to start HTTPS server on port ${portNum}:`, error.message);
|
|
704
|
+
} else {
|
|
705
|
+
log.info(`HTTPS server listening on port ${portNum}`);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
}
|
|
721
709
|
}
|
|
722
710
|
});
|
|
723
711
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roster-server",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"express",
|
|
27
27
|
"greenlock-express",
|
|
28
28
|
"shotx",
|
|
29
|
+
"bun",
|
|
29
30
|
"clasen"
|
|
30
31
|
],
|
|
31
32
|
"author": "Martin Clasen",
|