From 940c5f43d31a134474afcf03a0f2271e55f8d145 Mon Sep 17 00:00:00 2001 From: Takuya Murakami Date: Fri, 31 Aug 2018 12:50:18 +0900 Subject: [PATCH] Initial import --- .gitignore | 8 + ChangeLog.txt | 40 ++++ LICENSE | 21 ++ LICENSE.eventsource | 22 ++ README.md | 116 +++++++++++ example/.gitignore | 1 + example/client.js | 40 ++++ example/config.js.template | 15 ++ lib/.gitignore | 3 + lib/.npmignore | 0 lib/eventsource.js | 401 +++++++++++++++++++++++++++++++++++++ lib/ssepush.ts | 313 +++++++++++++++++++++++++++++ package.json | 42 ++++ tests/ssepushTest.js | 83 ++++++++ tsconfig.json | 15 ++ tslint.json | 58 ++++++ 16 files changed, 1178 insertions(+) create mode 100644 .gitignore create mode 100644 ChangeLog.txt create mode 100644 LICENSE create mode 100644 LICENSE.eventsource create mode 100644 README.md create mode 100644 example/.gitignore create mode 100644 example/client.js create mode 100644 example/config.js.template create mode 100644 lib/.gitignore create mode 100644 lib/.npmignore create mode 100644 lib/eventsource.js create mode 100644 lib/ssepush.ts create mode 100644 package.json create mode 100644 tests/ssepushTest.js create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02bf6d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*~ +*.bak +node_modules/ +package-lock.json + +.idea/ +*.iml + diff --git a/ChangeLog.txt b/ChangeLog.txt new file mode 100644 index 0000000..23e9b1c --- /dev/null +++ b/ChangeLog.txt @@ -0,0 +1,40 @@ +2018/06/20 : v7.0.3 + + * 機能追加・改善 + - 送信者許可設定API追加 + - Push受信待機完了通知追加 + +2018/05/16 : v7.0.2 + + * 機能追加・改善 + - CONNECTメソッドの Host ヘッダ値修正 + - tunnel モジュールを tunnel-fork に変更 + - js-sdk v7.0.2の適用 + +2018/03/29 : v7.0.0 + + * js-sdk v7.0.1の適用 + +2018/03/09 : v7.0.0 rc1 + + * 機能追加・改善 + - [8591] パッケージ名を @nec-baas/ssepush-node に変更 + +2017/12/13 : v7.0.0-beta1 + + * 不具合修正 + - [7701] サーバ証明書検証が行われない問題を修正 (eventsource.js 側の不具合回避) + +2017/10/04 + + - Proxy 設定変更。HTTPS接続時は自動的に tunnel agent を使用する。 + +2017/09/21 : v1.0.1 + + - shutdown() 時に heart beat タイマを停止する処理を追加 + - start() 時に古い eventSource を shutdown する処理を追加 + - デバッグ用ログ追加 + +2017/07/14 : v1.0.0 + + - 初版 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1078a87 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +NEC Mobile Backend Platform: SSE Push SDK for Node.js + +Copyright (c) 2013-2018 NEC Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.eventsource b/LICENSE.eventsource new file mode 100644 index 0000000..505b0d9 --- /dev/null +++ b/LICENSE.eventsource @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) EventSource GitHub organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ffb02b --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +NECモバイルバックエンド基盤: Node.js 用 SSE Push SDK +==================================================== + +NECモバイルバックエンド基盤(BaaS)の Node.js 向け SSE Push +クライアントライブラリです。 + +本ライブラリは Node.js 専用です。ブラウザでは利用できません。 + +準備 +---- + +本ライブラリを使用するアプリケーションディレクトリ(package.jsonが存在する +ディレクトリ)に移動し、本ライブラリを npm install でインストールしてください。 + + $ npm install @nec-baas/ssepush-node --save + +使用方法 +-------- + +サンプルが example/client.js にありますので、詳細はこちらを確認ください。 + +### インスタンスの生成 + +最初に、本ライブラリを require してください。 +(BaaS ライブラリの require と初期化も必要です) + + const SsePush = require('@nec-baas/ssepush-node').SsePush; + +以下手順で SsePush インスタンスを生成します。 + + const ssePush = new SsePush(Nebula); + // デバイストークン設定 + ssePush.setDeviceToken(deviceToken); + +デバイストークンは、デバイスに固有の識別子です。UUIDなどを使用することを +推奨します(SsePush.uuid()で生成可能)。デバイストークンは同一デバイス +では原則変更しないように、適宜保存するなどしてください。 + +### 受信チャネル設定 + +受信チャネルを設定する場合は、setChannels で指定します。 + + ssePush.setChannels(['channel1', 'channel2']) + +### ハートビート間隔・監視設定 + +ハートビート間隔の設定は setHeartbeatInterval で行います。 +値はサーバ側の設定に合わせてください。 + + ssePush.setHeartbeatInterval(30); + +ハートビート間隔の2倍の時間が経過してもハートビート受信ができない場合は、 +onError でエラー通知されます。 + +なお、ハートビート間隔を設定しない場合は、ハートビート監視は行われません。 + +### 登録・受信開始 + +BaaSサーバへのインスタレーション登録と Push 待ち受けを開始します。 +ユーザがログイン状態になっている場合は、インスタレーションは自動的にユーザ +に紐づけされます。 + + // BaaSサーバへの登録と Push 待ち受けを開始 + ssePush.start({ + onMessage: (message) => { + // 受信処理 + }, + onError: (error) => { + // エラー処理 + } + }); + +SSE Push メッセージを受信するたびに onMessage が呼び出されます。 +引数でメッセージが渡されます。メッセージは以下のような JSON object +で、本文は "data" に格納されます。 + + { + "type": "message", + "data": "This is a test.", + "lastEventId": "", + "origin": "https://baas.example.com" + } + +エラー時は onError が呼び出されますので、適宜回復処理を行ってください。 +ステータスコードは error.status に格納されます。 + +オプション +--------- + +Proxy サーバを使用する場合は、SsePush コンストラクタの第二引数にオプション +を指定してください (注: 認証付きの Proxy は使用できません) + + const options = { proxy: 'http://proxy.example.com:8080' } + const ssePush = new SsePush(Nebula, options); + +HTTPS接続でかつサーバ側が自己署名証明書を使用している場合は、以下のオプションを指定します。 +(非推奨) + + const options = { rejectUnauthorized: false } + +その他のオプションについては、[EventSource](https://github.com/EventSource/eventsource) +の README を参照してください。 + +OSSライセンス +------------- + +本モジュールは EventSource v1.0.5 を改造したものを含んでいます。 +(lib/eventsource.js) +https://github.com/EventSource/eventsource + +改造内容は以下のとおりです。 + +* oncomment コールバックを追加 +* options に agent を追加 + +ライセンス条件は LICENSE.eventsource を参照してください。 diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..1bf4259 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1 @@ +config.js diff --git a/example/client.js b/example/client.js new file mode 100644 index 0000000..16bb63d --- /dev/null +++ b/example/client.js @@ -0,0 +1,40 @@ +const Nebula = require('@nec-baas/jssdk').Nebula; +const SsePush = require('../lib/ssepush.js').SsePush; +//const SsePush = require('baas-ssepush-nodejs').SsePush; + +const config = require('./config.js'); +const proxy = config.proxy; + +// BaaS 初期化 +if (proxy != null) { + Nebula.setHttpProxy(proxy); + Nebula.setHttpsProxy(proxy); +} +Nebula.initialize(config); + +// SSE Push インスタンス生成 +const options = {}; +if (proxy != null) { + options.proxy = "http://" + proxy.host + ":" + proxy.port; +} +const ssePush = new SsePush(Nebula, options); + +// デバイストークン設定 +ssePush.setDeviceToken(config.deviceToken); + +// ハートビート間隔設定 +ssePush.setHeartbeatInterval(30); + +// 登録・受信開始 +console.log("Start connect: deviceToken = " + config.deviceToken); +ssePush.start({ + onMessage: (message) => { + console.log("push message: " + JSON.stringify(message)); + }, + onError: (error) => { + console.log("ERROR: " + JSON.stringify(error)); + } +}); + + + diff --git a/example/config.js.template b/example/config.js.template new file mode 100644 index 0000000..835e6a7 --- /dev/null +++ b/example/config.js.template @@ -0,0 +1,15 @@ +module.exports = { + tenant: '', + appId: '', + appKey: '', + baseUri: '', + debugMode: true, + /* + proxy: { + host: 'proxy.example.com', + port: 8080 + } + */ + + deviceToken: '214cd7f5-700a-6739-2adcbba14833', +}; diff --git a/lib/.gitignore b/lib/.gitignore new file mode 100644 index 0000000..7d129eb --- /dev/null +++ b/lib/.gitignore @@ -0,0 +1,3 @@ +ssepush.js +*.js.map +*.d.ts diff --git a/lib/.npmignore b/lib/.npmignore new file mode 100644 index 0000000..e69de29 diff --git a/lib/eventsource.js b/lib/eventsource.js new file mode 100644 index 0000000..d05d090 --- /dev/null +++ b/lib/eventsource.js @@ -0,0 +1,401 @@ +var original = require('original') +var parse = require('url').parse +var events = require('events') +var https = require('https') +var http = require('http') +var util = require('util') + +var httpsOptions = [ + 'pfx', 'key', 'passphrase', 'cert', 'ca', 'ciphers', + 'rejectUnauthorized', 'secureProtocol', 'servername' +] + +/** + * Creates a new EventSource object + * + * @param {String} url the URL to which to connect + * @param {Object} [eventSourceInitDict] extra init params. See README for details. + * @api public + **/ +function EventSource (url, eventSourceInitDict) { + var readyState = EventSource.CONNECTING + Object.defineProperty(this, 'readyState', { + get: function () { + return readyState + } + }) + + Object.defineProperty(this, 'url', { + get: function () { + return url + } + }) + + var self = this + self.reconnectInterval = 1000 + + function onConnectionClosed () { + if (readyState === EventSource.CLOSED) return + readyState = EventSource.CONNECTING + _emit('error', new Event('error')) + + // The url may have been changed by a temporary + // redirect. If that's the case, revert it now. + if (reconnectUrl) { + url = reconnectUrl + reconnectUrl = null + } + setTimeout(function () { + if (readyState !== EventSource.CONNECTING) { + return + } + connect() + }, self.reconnectInterval) + } + + var req + var lastEventId = '' + if (eventSourceInitDict && eventSourceInitDict.headers && eventSourceInitDict.headers['Last-Event-ID']) { + lastEventId = eventSourceInitDict.headers['Last-Event-ID'] + delete eventSourceInitDict.headers['Last-Event-ID'] + } + + var discardTrailingNewline = false + var data = '' + var eventName = '' + + var reconnectUrl = null + + function connect () { + var options = parse(url) + var isSecure = options.protocol === 'https:' + options.headers = { 'Cache-Control': 'no-cache', 'Accept': 'text/event-stream' } + if (lastEventId) options.headers['Last-Event-ID'] = lastEventId + if (eventSourceInitDict && eventSourceInitDict.headers) { + for (var i in eventSourceInitDict.headers) { + var header = eventSourceInitDict.headers[i] + if (header) { + options.headers[i] = header + } + } + } + + // Legacy: this should be specified as `eventSourceInitDict.https.rejectUnauthorized`, + // but for now exists as a backwards-compatibility layer + options.rejectUnauthorized = !(eventSourceInitDict && !eventSourceInitDict.rejectUnauthorized) + + // If specify http proxy, make the request to sent to the proxy server, + // and include the original url in path and Host headers + var useProxy = eventSourceInitDict && eventSourceInitDict.proxy + if (useProxy) { + var proxy = parse(eventSourceInitDict.proxy) + isSecure = proxy.protocol === 'https:' + + options.protocol = isSecure ? 'https:' : 'http:' + options.path = url + options.headers.Host = options.host + options.hostname = proxy.hostname + options.host = proxy.host + options.port = proxy.port + } + + // If https options are specified, merge them into the request options + if (eventSourceInitDict && eventSourceInitDict.https) { + for (var optName in eventSourceInitDict.https) { + if (httpsOptions.indexOf(optName) === -1) { + continue + } + + var option = eventSourceInitDict.https[optName] + if (option !== undefined) { + options[optName] = option + } + } + } + + // tunnel agent for https with proxy + if (eventSourceInitDict && eventSourceInitDict.agent) { + options.agent = eventSourceInitDict.agent; + } + + // Pass this on to the XHR + if (eventSourceInitDict && eventSourceInitDict.withCredentials !== undefined) { + options.withCredentials = eventSourceInitDict.withCredentials + } + + req = (isSecure ? https : http).request(options, function (res) { + // Handle HTTP errors + if (res.statusCode === 500 || res.statusCode === 502 || res.statusCode === 503 || res.statusCode === 504) { + _emit('error', new Event('error', {status: res.statusCode})) + onConnectionClosed() + return + } + + // Handle HTTP redirects + if (res.statusCode === 301 || res.statusCode === 307) { + if (!res.headers.location) { + // Server sent redirect response without Location header. + _emit('error', new Event('error', {status: res.statusCode})) + return + } + if (res.statusCode === 307) reconnectUrl = url + url = res.headers.location + process.nextTick(connect) + return + } + + if (res.statusCode !== 200) { + _emit('error', new Event('error', {status: res.statusCode})) + return self.close() + } + + readyState = EventSource.OPEN + res.on('close', function () { + res.removeAllListeners('close') + res.removeAllListeners('end') + onConnectionClosed() + }) + + res.on('end', function () { + res.removeAllListeners('close') + res.removeAllListeners('end') + onConnectionClosed() + }) + _emit('open', new Event('open')) + + // text/event-stream parser adapted from webkit's + // Source/WebCore/page/EventSource.cpp + var buf = '' + res.on('data', function (chunk) { + buf += chunk + + var pos = 0 + var length = buf.length + + while (pos < length) { + if (discardTrailingNewline) { + if (buf[pos] === '\n') { + ++pos + } + discardTrailingNewline = false + } + + var lineLength = -1 + var fieldLength = -1 + var c + + for (var i = pos; lineLength < 0 && i < length; ++i) { + c = buf[i] + if (c === ':') { + if (fieldLength < 0) { + fieldLength = i - pos + } + } else if (c === '\r') { + discardTrailingNewline = true + lineLength = i - pos + } else if (c === '\n') { + lineLength = i - pos + } + } + + if (lineLength < 0) { + break + } + + parseEventStreamLine(buf, pos, fieldLength, lineLength) + + pos += lineLength + 1 + } + + if (pos === length) { + buf = '' + } else if (pos > 0) { + buf = buf.slice(pos) + } + }) + }) + + req.on('error', onConnectionClosed) + if (req.setNoDelay) req.setNoDelay(true) + req.end() + } + + connect() + + function _emit () { + if (self.listeners(arguments[0]).length > 0) { + self.emit.apply(self, arguments) + } + } + + this._close = function () { + if (readyState === EventSource.CLOSED) return + readyState = EventSource.CLOSED + if (req.abort) req.abort() + if (req.xhr && req.xhr.abort) req.xhr.abort() + } + + function parseEventStreamLine (buf, pos, fieldLength, lineLength) { + if (lineLength === 0) { + if (data.length > 0) { + var type = eventName || 'message' + _emit('message', new MessageEvent(type, { // ### always emit 'message', not type. + data: data.slice(0, -1), // remove trailing newline + lastEventId: lastEventId, + origin: original(url) + })) + data = '' + } + eventName = void 0 + } else if (fieldLength > 0) { + var noValue = fieldLength < 0 + var step = 0 + var field = buf.slice(pos, pos + (noValue ? lineLength : fieldLength)) + + if (noValue) { + step = lineLength + } else if (buf[pos + fieldLength + 1] !== ' ') { + step = fieldLength + 1 + } else { + step = fieldLength + 2 + } + pos += step + + var valueLength = lineLength - step + var value = buf.slice(pos, pos + valueLength) + + if (field === 'data') { + data += value + '\n' + } else if (field === 'event') { + eventName = value + } else if (field === 'id') { + lastEventId = value + } else if (field === 'retry') { + var retry = parseInt(value, 10) + if (!Number.isNaN(retry)) { + self.reconnectInterval = retry + } + } + } else if (fieldLength === 0) { // ### handling comment + _emit('comment', null); + } + } +} + +module.exports = EventSource + +util.inherits(EventSource, events.EventEmitter) +EventSource.prototype.constructor = EventSource; // make stacktraces readable + +['open', 'error', 'message', 'comment'].forEach(function (method) { // ### add oncomment + Object.defineProperty(EventSource.prototype, 'on' + method, { + /** + * Returns the current listener + * + * @return {Mixed} the set function or undefined + * @api private + */ + get: function get () { + var listener = this.listeners(method)[0] + return listener ? (listener._listener ? listener._listener : listener) : undefined + }, + + /** + * Start listening for events + * + * @param {Function} listener the listener + * @return {Mixed} the set function or undefined + * @api private + */ + set: function set (listener) { + this.removeAllListeners(method) + this.addEventListener(method, listener) + } + }) +}) + +/** + * Ready states + */ +Object.defineProperty(EventSource, 'CONNECTING', {enumerable: true, value: 0}) +Object.defineProperty(EventSource, 'OPEN', {enumerable: true, value: 1}) +Object.defineProperty(EventSource, 'CLOSED', {enumerable: true, value: 2}) + +EventSource.prototype.CONNECTING = 0 +EventSource.prototype.OPEN = 1 +EventSource.prototype.CLOSED = 2 + +/** + * Closes the connection, if one is made, and sets the readyState attribute to 2 (closed) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close + * @api public + */ +EventSource.prototype.close = function () { + this._close() +} + +/** + * Emulates the W3C Browser based WebSocket interface using addEventListener. + * + * @param {String} type A string representing the event type to listen out for + * @param {Function} listener callback + * @see https://developer.mozilla.org/en/DOM/element.addEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + * @api public + */ +EventSource.prototype.addEventListener = function addEventListener (type, listener) { + if (typeof listener === 'function') { + // store a reference so we can return the original function again + listener._listener = listener + this.on(type, listener) + } +} + +/** + * Emulates the W3C Browser based WebSocket interface using removeEventListener. + * + * @param {String} type A string representing the event type to remove + * @param {Function} listener callback + * @see https://developer.mozilla.org/en/DOM/element.removeEventListener + * @see http://dev.w3.org/html5/websockets/#the-websocket-interface + * @api public + */ +EventSource.prototype.removeEventListener = function removeEventListener (type, listener) { + if (typeof listener === 'function') { + listener._listener = undefined + this.removeListener(type, listener) + } +} + +/** + * W3C Event + * + * @see http://www.w3.org/TR/DOM-Level-3-Events/#interface-Event + * @api private + */ +function Event (type, optionalProperties) { + Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true }) + if (optionalProperties) { + for (var f in optionalProperties) { + if (optionalProperties.hasOwnProperty(f)) { + Object.defineProperty(this, f, { writable: false, value: optionalProperties[f], enumerable: true }) + } + } + } +} + +/** + * W3C MessageEvent + * + * @see http://www.w3.org/TR/webmessaging/#event-definitions + * @api private + */ +function MessageEvent (type, eventInitDict) { + Object.defineProperty(this, 'type', { writable: false, value: type, enumerable: true }) + for (var f in eventInitDict) { + if (eventInitDict.hasOwnProperty(f)) { + Object.defineProperty(this, f, { writable: false, value: eventInitDict[f], enumerable: true }) + } + } +} diff --git a/lib/ssepush.ts b/lib/ssepush.ts new file mode 100644 index 0000000..df4665f --- /dev/null +++ b/lib/ssepush.ts @@ -0,0 +1,313 @@ +/* + * NEC Mobile Backend Platform : SSE Push(Node.js) library version 7.0.0 + * + * Copyright (C) 2014-2018, NEC Corporation. + * All rights reserved. + */ +import Timer = NodeJS.Timer; + +const Nebula = require('@nec-baas/jssdk'); +const EventSource = require('./eventsource.js'); +const url = require('url'); + +/** + * SSE Push 受信クラス + */ +export class SsePush { + private Nebula: any; + private options: any; + private channels: string[]; + private allowedSenders: string[]; + private heartbeatInterval: number; + + private deviceToken: string; + private callback: any; + private eventSource: any; + private timer: Timer; + + /** + * コンストラクタ + * @param Nebula Nebulaインスタンス + * @param options オプション。詳細は EventSource のマニュアル参照。 + * @constructor + */ + constructor(Nebula: any, options: any) { + SsePush._assertNotNull(Nebula, "Nebula"); + + this.Nebula = Nebula; + this.options = options; + + this.channels = []; + this.allowedSenders = []; + this.heartbeatInterval = 0; + } + + static _assertNotNull(value: any, name: string) { + if (value == null) { + throw new Error(name + " must not be null"); + } + } + + /** + * デバイストークン(デバイス固有の識別子)をセットする。 + * UUID を使用することを推奨 + * @param deviceToken デバイストークン + * @returns {SsePush} this + */ + setDeviceToken(deviceToken: string) { + SsePush._assertNotNull(deviceToken, "deviceToken"); + + this.deviceToken = deviceToken; + return this; + } + + /** + * 購読する channel 名の配列を指定する + * @param channels channel名の配列 + * @returns {SsePush} this + */ + setChannels(channels: string[]) { + SsePush._assertNotNull(channels, "channels"); + + this.channels = channels; + return this; + } + + /** + * Push 送信を許可する allowedSender 名の配列を指定する + * @param allowedSenders allowedSender名の配列 + * @returns {SsePush} this + */ + setAllowedSenders(allowedSenders: string[]) { + SsePush._assertNotNull(allowedSenders, "allowedSenders"); + + this.allowedSenders = allowedSenders; + return this; + } + + /** + * ハートビート間隔(秒)を指定する。 + *

+ * ハートビート間隔の2倍時間経過してもハートビートを受信できない場合は、 + * onError でエラー通知する。未設定時はハートビート監視しない。 + * @param interval ハートビート間隔(秒) + * @returns {SsePush} this + */ + setHeartbeatInterval(interval: number) { + this.heartbeatInterval = interval; + return this; + } + + /** + * SSE Push インスタレーション登録と受信を開始する。 + * callback には以下の2つのメソッドを指定すること。 + *

+ *

+ * 注) タイミングによっては、401 Unauthorized エラーになる場合がある。 + * (Pushサーバ側に認証情報が登録される前に接続が開始された場合)。 + * この場合はアプリケーション側でリトライする必要がある。 + * @param callback コールバック + */ + start(callback: any) { + SsePush._assertNotNull(callback, "callback"); + + this.shutdown(); // shutdown old eventSource. + + console.log("SsePush: start"); + this.callback = callback; + this._startRegister(); + } + + /** + * 接続をシャットダウンする。 + * ハートビートタイマも停止する。 + */ + shutdown() { + this._stopHeartbeatTimer(); + if (this.eventSource != null) { + console.log("SsePush: shutdown: closing eventSource."); + this.eventSource.close(); + this.eventSource = null; + } else { + console.log("SsePush: shutdown: already closed."); + } + } + + /** + * BaaS サーバに対し Installation の登録を行う。 + * 登録が完了したら _startReceiver を呼び出して受信を開始する。 + * @private + */ + _startRegister() { + const req = new this.Nebula.HttpRequest(this.Nebula, "/push/installations"); + + const data = this._createRegistrationData(); + + req.setMethod("POST"); + req.setContentType("application/json"); + + req.setData(data); + + req.execute() + .then((r: any) => { + const response = JSON.parse(r); + const sse = response._sse; + const uri = sse.uri; + const username = sse.username; + const password = sse.password; + + console.log("SsePush: Installation registered: uri=" + uri + " username=" + username); + + // TODO: 登録後時間を開けずに接続すると 401 エラーになる場合があるため、 + // ウェイトを入れる。 + setTimeout(() => { + this._startReceiver(uri, username, password); + }, 3000); + }) + .catch((err: any) => { + console.error("SsePush: Installation register failed: " + JSON.stringify(err)); + this.callback.onError(err); + }); + } + + _createRegistrationData() { + SsePush._assertNotNull(this.deviceToken, "deviceToken"); + SsePush._assertNotNull(this.channels, "channels"); + SsePush._assertNotNull(this.allowedSenders, "allowedSenders"); + + const data: any = { + "_osType": "js", + "_osVersion": "Unknown", + "_deviceToken": this.deviceToken, + "_pushType": "sse", + "_channels": this.channels, + "_appVersionCode": -1, + "_appVersionString": "1.0.0", + "_allowedSenders": this.allowedSenders + }; + return data; + } + + /** + * SSE Push の受信を開始する。 + * @param uri SSE Push サーバ URI + * @param username ユーザ名 + * @param password パスワード + * @private + */ + _startReceiver(uri: string, username: string, password: string) { + console.log("SsePush: Start receiver"); + + const options: any = { + rejectUnauthorized: true // デフォルト値 (#7701 対応) + }; + if (this.options != null) { + Object.assign(options, this.options); + } + if (options.headers == null) { + options.headers = {} + } + // Basic認証用のヘッダ指定 + options.headers['Authorization'] = 'Basic ' + new Buffer(username + ':' + password).toString('base64'); + + // Proxy 関連設定 + this._convertProxyOptions(uri, options); + + const eventSource = new EventSource(uri, options); + this.eventSource = eventSource; + + eventSource.onopen = () => { + console.log("SsePush: Connection established."); + this.callback.onMessage("SsePush: Connection established."); + this._restartHeartbeatTimer(); + }; + eventSource.onmessage = this.callback.onMessage; + eventSource.onerror = this.callback.onError; + eventSource.oncomment = () => { + this._onHeartbeat(); + }; + } + + /** + * Proxy オプションの変換。 + * SSE Push サーバ接続が HTTPS の場合、agent を使用するように変更する。 + * @param uri SSE Push サーバ URI + * @param options オプション + * @private + */ + _convertProxyOptions(uri: string, options: any) { + if (options.proxy == null) return; // no proxy options + + const parsedUrl = url.parse(uri); + if (parsedUrl.protocol !== 'https:') return; // not https + + const proxyUrl = url.parse(options.proxy); + const agentOptions: any = { + proxy: { + host: proxyUrl.hostname, + port: parseInt(proxyUrl.port) + } + }; + if (options.rejectUnauthorized != null) { + agentOptions.rejectUnauthorized = options.rejectUnauthorized; + } + const tunnel = require('tunnel-fork'); + const agent = tunnel.httpsOverHttp(agentOptions); + agent.defaultPort = 443; + + options.agent = agent; + delete options.proxy; + } + + /** + * ハートビート受信処理 + * @private + */ + _onHeartbeat() { + //console.log("heartbeat."); + this._restartHeartbeatTimer() + } + + /** + * ハートビートタイマ設定 + * @private + */ + _restartHeartbeatTimer() { + this._stopHeartbeatTimer(); + if (this.heartbeatInterval <= 0) return; + + this.timer = setTimeout(() => { + console.log("SsePush: heartbeat timer timed out, shut down."); + this.shutdown(); + this.callback.onError({ + 'type': 'error', + 'status': 500, + 'message': 'heartbeat timer timed out.' + }); + }, this.heartbeatInterval * 2 * 1000); + } + + /** + * ハートビートタイマ停止 + * @private + */ + _stopHeartbeatTimer() { + if (this.timer != null) { + clearTimeout(this.timer); + } + } + + /** + * ユーティリティ。UUID生成。 + * @returns {string} UUID + */ + uuid() { + const S4 = () => { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + }; + return S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4(); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0386aac --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@nec-baas/ssepush-node", + "version": "7.0.3", + "description": "BaaS SSEPush Client for Node.js", + "author": "NEC Coporation", + "license": "MIT", + "keywords": [ + "nec", + "baas", + "push", + "sse", + "iot", + "mobile" + ], + "homepage": "https://github.com/nec-baas/baas-ssepush-nodejs", + "main": "lib/ssepush.js", + "files": [ + "lib/ssepush.js", + "lib/ssepush.d.ts", + "lib/eventsource.js", + "example/*.js", + "example/*.template" + ], + "types": [ + "lib/ssepush.d.ts" + ], + "scripts": { + "ts": "tsc", + "test": "mocha tests/*.js" + }, + "dependencies": { + "@nec-baas/jssdk": "^7.0.2", + "@types/node": "^10.3.4", + "original": "^1.0.1", + "tunnel-fork": "^0.0.6" + }, + "devDependencies": { + "chai": "^4.1.2", + "mocha": "^5.2.0", + "typescript": "^2.9.2" + } +} diff --git a/tests/ssepushTest.js b/tests/ssepushTest.js new file mode 100644 index 0000000..32ab7fc --- /dev/null +++ b/tests/ssepushTest.js @@ -0,0 +1,83 @@ +const SsePush = require('../lib/ssepush.js').SsePush; + +const chai = require('chai'); +const assert = chai.assert; +const expect = chai.expect; + +describe("SsePush init", () => { + const sse = new SsePush({}, {}); + + it("初期化", () => { + expect(sse.channels).have.lengthOf(0); + expect(sse.allowedSenders).have.lengthOf(0); + expect(sse.heartbeatInterval).equal(0); + }); + + it("デバイストークン設定", () => { + expect(sse.setDeviceToken("TOKEN")).equal(sse); + expect(sse.deviceToken).equal("TOKEN"); + }); + + it("チャネル設定", () => { + expect(sse.setChannels(["a","b"])).equal(sse); + expect(sse.channels).deep.equal(["a", "b"]); + }); + + it("送信者許可設定", () => { + expect(sse.setAllowedSenders(["g:group1","g:group2"])).equal(sse); + expect(sse.allowedSenders).deep.equal(["g:group1","g:group2"]); + }); + + it("ハートビート間隔設定", () => { + expect(sse.setHeartbeatInterval(60)).equal(sse); + expect(sse.heartbeatInterval).equal(60); + }); +}); + +describe("_createRegistrationData", () => { + const sse = new SsePush({}, {}); + + it("正常に registration data が生成されること", () => { + sse.setDeviceToken("TOKEN1"); + sse.setChannels(["c1", "c2"]); + sse.setAllowedSenders(["g:group1","g:group2"]); + + const data = sse._createRegistrationData(); + + expect(data._deviceToken).equal("TOKEN1"); + expect(data._channels).deep.equal(["c1", "c2"]); + expect(data._allowedSenders).deep.equal(["g:group1","g:group2"]); + expect(data._pushType).equal("sse"); + }); +}); + +describe("_convertProxyOptions", () => { + const sse = new SsePush({}, {}); + const proxy = "http://proxy.example.com:8080"; + + it("Proxy未指定時は何もしないこと", () => { + const options = {}; + sse._convertProxyOptions("xxx", options); + expect(options).deep.equal({}); + }); + + it("HTTPS接続でない場合は何もしないこと", () => { + const options = {proxy: proxy}; + sse._convertProxyOptions("http://example.com/sse", options); + expect(options.proxy).equal(proxy); + expect(options).not.have.property("agent"); + }); + + it("HTTPS接続時に tunnel agent が設定されること", () => { + const options = {proxy: proxy, rejectUnauthorized: false}; + sse._convertProxyOptions("https://example.com/sse", options); + + expect(options).not.have.property("proxy"); + const agent = options.agent; + expect(agent).not.be.null; + expect(agent.options.proxy.host).equal("proxy.example.com"); + expect(agent.options.proxy.port).equal(8080); + expect(agent.options.rejectUnauthorized).be.false; + expect(agent.defaultPort).equal(443); + }) +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..39e44c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "commonjs", + "declaration": true, + "noImplicitAny": true, + "sourceMap": true + //"lib": ["dom", "es5", "scripthost"] + //"rootDir": ".", + //"outDir": "build/" + }, + "files": [ + "lib/ssepush.ts" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..5212902 --- /dev/null +++ b/tslint.json @@ -0,0 +1,58 @@ +// based on https://github.com/google/ts-style +{ + "rules": { + "array-type": [true, "array-simple"], + "arrow-return-shorthand": true, + "ban": [true, + {"name": "parseInt", "message": "tsstyle#type-coercion"}, + {"name": "parseFloat", "message": "tsstyle#type-coercion"}, + {"name": "Array", "message": "tsstyle#array-constructor"} + ], + "ban-types": [true, + ["Object", "Use {} instead."], + ["String", "Use 'string' instead."], + ["Number", "Use 'number' instead."], + ["Boolean", "Use 'boolean' instead."] + ], + "class-name": false, // TODO: + "curly": [true, "ignore-same-line"], + "forin": true, + "interface-name": [true, "never-prefix"], + "jsdoc-format": true, + "label-position": true, + "member-access": [true, "no-public"], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-any": true, // TODO: + "no-arg": true, + "no-conditional-assignment": true, + "no-construct": true, + "no-debugger": true, + "no-default-export": true, + "no-duplicate-variable": true, + "no-inferrable-types": true, + "no-namespace": [true, "allow-declarations"], + "no-reference": true, + "no-string-throw": true, + "no-unused-expression": false, // TODO + "no-var-keyword": true, + "object-literal-shorthand": true, + "only-arrow-functions": [true, "allow-declarations", "allow-named-functions"], + "prefer-const": true, + "radix": true, + "semicolon": [true, "always", "ignore-bound-class-methods"], + "switch-default": true, + "triple-equals": [true, "allow-null-check"], + "use-isnan": true, + "variable-name": [ + false, // TODO: + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore" + ] + }, + "jsRules": { + "class-name": true + } +} \ No newline at end of file