Skip to content

Commit a913edc

Browse files
authored
Merge pull request #41 from relekang/sse
Add realtime events with SSE
2 parents c6b1c44 + 5e2ab85 commit a913edc

File tree

15 files changed

+818
-92
lines changed

15 files changed

+818
-92
lines changed

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,70 @@ If you just want to get the views for an id and don't want to increment the view
5050

5151
If you want to get all views for all ids, set the `all` query parameter to `true` on a root request. (i.e. `/?all=true`) If you pass the `all` parameter to an id, all ids starting with that pathname will be included. E.g. `/x?all=true` will match views for `/x`, `/xyz` but not `/y`.
5252

53+
### Options
54+
55+
```
56+
$ micro-analytics --help
57+
Usage: micro-analytics [options] [command]
58+
59+
Commands:
60+
61+
help Display help
62+
63+
Options:
64+
65+
-a, --adapter [value] Database adapter used (defaults to "flat-file-db")
66+
-h, --help Output usage information
67+
-H, --host [value] Host to listen on (defaults to "0.0.0.0")
68+
-p, --port <n> Port to listen on (defaults to 3000)
69+
-v, --version Output the version number
70+
```
71+
5372
### Database adapters
5473

5574
By default, `micro-analytics` uses `flat-file-db`, a fast in-process flat file database, which makes for easy setup and backups.
5675

57-
This works fine for side-project usage, but for a production application with bajillions of visitors you might want to use a real database with a _database adapter_. Install the necessary npm package (e.g. `micro-analytics-adapter-xyz`) and then specify the `DB_ADAPTER` environment variable: `$ DB_ADAPTER=xyz micro-analytics`
76+
This works fine for side-project usage, but for a production application with bajillions of visitors you might want to use a real database with a _database adapter_. Install the necessary npm package (e.g. `micro-analytics-adapter-xyz`) and then specify the `DB_ADAPTER` environment variable: `$ DB_ADAPTER=xyz micro-analytics` or use the `--adapter` cli option.
5877

5978
These are the available database adapters, made by the community:
6079

6180
- [`micro-analytics-adapter-redis`](https://github.com/relekang/micro-analytics-adapter-redis)
6281

6382
Don't see your favorite database here? Writing your own adapter is super easy! See [`writing-adapters.md`](writing-adapters.md) for a simple step-by-step guide.
6483

84+
### Live updates
85+
86+
micro-analytics also let's you listen into updates live with [server-sent events][].
87+
That means you can e.g. build a realtime dashboard for your analytics!
88+
89+
Note: Make sure your database adapter supports this feature. If not, bug them to implement it!
90+
micro-analytics will tell you when it starts up if it is supported, so the easiest way to find
91+
out is just to start it up.
92+
93+
The example below shows how you can listen for events in the browser, just swap
94+
micro-analytics.now.sh with your own domain and give it a try.
95+
96+
```es6
97+
const sse = new EventSource('https://micro-analytics.now.sh/realtime')
98+
sse.onopen = function () { console.log('[sse] open') }
99+
sse.onerror = function (error) { console.error('[sse error]', error) }
100+
sse.addEventListener('micro-analytics-ping', function (e) { console.log('[sse]', e) })
101+
```
102+
103+
#### Browser support
104+
105+
Server-sent events is not supported in all browsers. This can easily be fixed by using a polyfill.
106+
Take a look at [the caniuse table][] for server-sent events if you need one. Polyfills that are
107+
supported(disclaimer this list is from the documentation of the sse library we use [rexxars/sse-channel][]):
108+
109+
* [amvtek/EventSource](https://github.com/amvtek/EventSource)
110+
* [Yaffle/EventSource)](https://github.com/Yaffle/EventSource)
111+
* [remy/polyfills/EventSource.js](https://github.com/remy/polyfills/blob/master/EventSource.js)
112+
113+
[server-sent events]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
114+
[the caniuse table]: http://caniuse.com/#feat=eventsource
115+
[rexxars/sse-channel]: https://github.com/rexxars/sse-channel
116+
65117
## License
66118

67119
Copyright ©️ 2017 Maximilian Stoiber, licensed under the MIT License. See [`license.md`](./license.md) for more information.

package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,25 @@
1515
"node": ">=6.0.0"
1616
},
1717
"scripts": {
18-
"start": "micro dist/index.js",
18+
"start": "node dist/index.js",
1919
"build": "./node_modules/.bin/async-to-gen src --out-dir dist",
2020
"prepublish": "npm run build",
21-
"dev": "NODE_ENV=development nodemon --config package.json src/index.js",
21+
"dev": "NODE_ENV=development nodemon --config package.json --exec async-node src/index.js",
2222
"test": "jest"
2323
},
2424
"author": "Max Stoiber <[email protected]> (http://mxstbr.com/)",
2525
"license": "MIT",
2626
"repository": "https://github.com/mxstbr/micro-analytics",
2727
"dependencies": {
28+
"args": "^2.3.0",
2829
"flat-file-db": "^1.0.0",
2930
"micro": "6.1.0",
30-
"micro-analytics-adapter-flat-file-db": "^1.0.3",
31+
"micro-analytics-adapter-flat-file-db": "^1.2.1",
3132
"promise": "^7.1.1",
3233
"shelljs": "^0.7.6",
33-
"update-notifier": "^1.0.3"
34+
"sse-channel": "^2.0.6",
35+
"update-notifier": "^1.0.3",
36+
"zen-observable": "^0.4.0"
3437
},
3538
"devDependencies": {
3639
"async-to-gen": "^1.3.0",
@@ -39,6 +42,7 @@
3942
"babel-polyfill": "^6.20.0",
4043
"babel-preset-node6": "^11.0.0",
4144
"jest": "^19.0.2",
45+
"nodemon": "^1.11.0",
4246
"request-promise": "^4.1.1"
4347
},
4448
"execMap": {

src/db.js

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,33 @@
11
const promise = require('promise')
22

3-
let adapter
4-
const adapterName = `micro-analytics-adapter-${process.env.DB_ADAPTER || 'flat-file-db'}`
3+
function initDbAdapter(adapterName) {
4+
let adapter
5+
const repeatCharacter = (char, n) => `${Array(n + 1).join(char)}`
56

6-
const repeatCharacter = (char, n) => `${Array(n + 1).join(char)}`
7-
8-
try {
9-
adapter = require(adapterName)
10-
} catch (err) {
11-
if (err.code === 'MODULE_NOT_FOUND') {
12-
// Console.error a warning message, but normally exit the process to avoid printing ugly npm ERR lines and stack trace.
13-
console.error(`\n${repeatCharacter(' ', 22)}⚠️ ERROR ⚠️\n${repeatCharacter('-', 55)}\nYou specified "${process.env.DB_ADAPTER}" as the DB_ADAPTER, but no package\ncalled "${adapterName}" was found.\n\nPlease make sure you spelled the name correctly and\nhave "npm install"ed the necessary adapter package!\n${repeatCharacter('-', 55)}\n`)
14-
process.exit(0)
15-
} else {
16-
throw err
7+
try {
8+
adapter = require(`micro-analytics-adapter-${adapterName}`)
9+
} catch (err) {
10+
if (err.code === 'MODULE_NOT_FOUND') {
11+
// Console.error a warning message, but normally exit the process to avoid printing ugly npm ERR lines and stack trace.
12+
console.error(`\n${repeatCharacter(' ', 22)}⚠️ ERROR ⚠️\n${repeatCharacter('-', 55)}\nYou specified "${adapterName}" as the DB_ADAPTER, but no package\ncalled "micro-analytics-adapter-${adapterName}" was found.\n\nPlease make sure you spelled the name correctly and\nhave "npm install"ed the necessary adapter package!\n${repeatCharacter('-', 55)}\n`)
13+
process.exit(0)
14+
} else {
15+
throw err
16+
}
1717
}
18+
19+
20+
module.exports.get = adapter.get;
21+
module.exports.getAll = adapter.getAll;
22+
module.exports.put = adapter.put;
23+
module.exports.has = adapter.has;
24+
module.exports.keys = adapter.keys;
25+
module.exports.subscribe = adapter.subscribe;
26+
module.exports.hasFeature = (feature) => typeof adapter[feature] === "function";
27+
1828
}
1929

2030
module.exports = {
21-
get: adapter.get,
22-
getAll: adapter.getAll,
23-
put: adapter.put,
24-
has: adapter.has,
25-
keys: adapter.keys,
31+
initDbAdapter: initDbAdapter,
32+
hasFeature: (feature) => false,
2633
}

src/handler.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const url = require('url')
2+
const { send, createError, sendError } = require('micro')
3+
4+
const db = require('./db')
5+
const { pushView } = require('./utils')
6+
7+
let sse;
8+
9+
if (db.hasFeature("subscribe")) {
10+
const SseChannel = require('sse-channel')
11+
const sseHandler = require('./sse')
12+
sse = new SseChannel({ cors: { origins: ['*'] } })
13+
sseHandler(sse)
14+
}
15+
16+
module.exports = async function (req, res) {
17+
const { pathname, query } = url.parse(req.url, /* parseQueryString */ true)
18+
19+
if (pathname === '/realtime') {
20+
if (sse) {
21+
sse.addClient(req, res);
22+
} else {
23+
send(res, 400, {error: 'The current database adapter does not support live updates.'})
24+
}
25+
}
26+
27+
res.setHeader('Access-Control-Allow-Origin', '*')
28+
// Send all views down if "?all" is true
29+
if (String(query.all) === 'true') {
30+
try {
31+
const data = {
32+
data: await db.getAll({
33+
pathname: pathname,
34+
before: parseInt(query.before, 10),
35+
after: parseInt(query.after, 10),
36+
}),
37+
time: Date.now()
38+
}
39+
send(res, 200, data)
40+
return
41+
} catch (err) {
42+
console.log(err)
43+
throw createError(500, 'Internal server error.')
44+
}
45+
}
46+
// Check that a page is provided
47+
if (pathname.length <= 1) {
48+
throw createError(400, 'Please include a path to a page.')
49+
}
50+
if (req.method !== 'GET' && req.method !== 'POST') {
51+
throw createError(400, 'Please make a GET or a POST request.')
52+
}
53+
const shouldIncrement = String(query.inc) !== 'false'
54+
try {
55+
const currentViews = await db.has(pathname) ? (await db.get(pathname)).views.length : 0
56+
// Add a view and send the total views back to the client
57+
if (shouldIncrement) {
58+
await pushView(pathname, { time: Date.now() })
59+
}
60+
if (req.method === 'GET') {
61+
send(res, 200, { views: shouldIncrement ? currentViews + 1 : currentViews })
62+
} else {
63+
send(res, 200)
64+
}
65+
} catch (err) {
66+
console.log(err)
67+
throw createError(500, 'Internal server error.')
68+
}
69+
}

src/index.js

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,24 @@
1-
const url = require('url')
2-
const { send, createError, sendError } = require('micro')
1+
const micro = require('micro')
32

4-
const db = require('./db')
5-
const { pushView } = require('./utils')
3+
const parseArgs = require('./parseArgs')
4+
const db = require('./db');
65

7-
module.exports = async function (req, res) {
8-
const { pathname, query } = url.parse(req.url, /* parseQueryString */ true)
9-
res.setHeader('Access-Control-Allow-Origin', '*')
10-
// Send all views down if "?all" is true
11-
if (String(query.all) === 'true') {
12-
try {
13-
const data = {
14-
data: await db.getAll({
15-
pathname: pathname,
16-
before: parseInt(query.before, 10),
17-
after: parseInt(query.after, 10),
18-
}),
19-
time: Date.now()
20-
}
21-
send(res, 200, data)
22-
return
23-
} catch (err) {
24-
console.log(err)
25-
throw createError(500, 'Internal server error.')
26-
}
27-
}
28-
// Check that a page is provided
29-
if (pathname.length <= 1) {
30-
throw createError(400, 'Please include a path to a page.')
31-
}
32-
if (req.method !== 'GET' && req.method !== 'POST') {
33-
throw createError(400, 'Please make a GET or a POST request.')
34-
}
35-
const shouldIncrement = String(query.inc) !== 'false'
36-
try {
37-
const currentViews = await db.has(pathname) ? (await db.get(pathname)).views.length : 0
38-
// Add a view and send the total views back to the client
39-
if (shouldIncrement) {
40-
await pushView(pathname, { time: Date.now() })
41-
}
42-
if (req.method === 'GET') {
43-
send(res, 200, { views: shouldIncrement ? currentViews + 1 : currentViews })
44-
} else {
45-
send(res, 200)
46-
}
47-
} catch (err) {
48-
console.log(err)
49-
throw createError(500, 'Internal server error.')
6+
const flags = parseArgs(process.argv)
7+
8+
db.initDbAdapter(flags.adapter)
9+
10+
const handler = require('./handler')
11+
const server = micro(handler)
12+
13+
server.listen(flags.port, flags.host, (error) => {
14+
if (error) {
15+
console.error(error)
16+
process.exit(1)
5017
}
51-
}
18+
19+
console.log(
20+
'micro-analytics listening on ' + flags.host + ':' + flags.port + '\n' +
21+
' with adapter ' + flags.adapter +
22+
(db.hasFeature("subscribe") ? '\n with server side events' : '')
23+
)
24+
})

src/parseArgs.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
const args = require('args');
3+
4+
module.exports = function parseArgs(argv) {
5+
return args
6+
.option(['p', 'port'], 'Port to listen on', process.env.PORT || 3000, Number)
7+
.option(['H', 'host'], 'Host to listen on', process.env.HOST ||'0.0.0.0')
8+
.option(['a', 'adapter'], 'Database adapter used', process.env.DB_ADAPTER || 'flat-file-db')
9+
.parse(argv, { name: 'micro-analytics' })
10+
}

src/sse.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const db = require('./db')
2+
3+
module.exports = function handleSseConnection(connection) {
4+
const subscription = db.subscribe((event) => {
5+
connection.send({ event: 'micro-analytics-ping', data: JSON.stringify(event) })
6+
})
7+
8+
connection.on('close', function () {
9+
subscription.unsubscribe()
10+
})
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`parseArgs should have correct defaults 1`] = `
4+
Object {
5+
"H": "0.0.0.0",
6+
"a": "flat-file-db",
7+
"adapter": "flat-file-db",
8+
"host": "0.0.0.0",
9+
"p": 3000,
10+
"port": 3000,
11+
}
12+
`;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`sse should publish events with correct structure 1`] = `
4+
Array [
5+
Array [
6+
Object {
7+
"data": "{\\"key\\":\\"superview\\",\\"value\\":{\\"views\\":[{\\"time\\":1490432584312}]}}",
8+
"event": "micro-analytics-ping",
9+
},
10+
],
11+
]
12+
`;

tests/atomicity.test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ const request = require('request-promise')
22
const { listen, mockDb } = require('./utils')
33

44
jest.mock('flat-file-db', () => mockDb)
5-
const service = require('../src')
5+
const db = require('../src/db')
6+
const service = require('../src/handler')
67
let url
78

9+
db.initDbAdapter('flat-file-db');
10+
811
beforeEach(async () => {
912
url = await listen(service)
1013
mockDb._setDelay(10)

0 commit comments

Comments
 (0)