UNPKG

14.3 kBJavaScriptView Raw
1// Copyright Jeff Wilcox
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15
16// Suggested fallback values if you're storing results in a document/db.
17// See also: http://msdn.microsoft.com/en-us/library/ff941100%28v=VS.92%29.aspx
18var HTTP_412_MINIMUM_DELAY_MINUTES = 61;
19var ERROR_MINIMUM_DELAY_MINUTES = 5;
20
21var url = require('url'),
22 http = require('http'),
23 https = require('https');
24
25var Toast = function(options) {
26 return new PushMessage('toast', '2', 'toast', options);
27};
28
29var LiveTile = function(options) {
30 return new PushMessage('tile', '1', 'token', options);
31};
32
33var FlipTile = function(options) {
34 if(!options){
35 options = {};
36 }
37 options.tileTemplate = 'FlipTile';
38 return new PushMessage('tile', '1', 'token', options);
39};
40
41var IconicTile = function(options) {
42 options = options || {};
43 options.tileTemplate = 'IconicTile';
44 return new PushMessage('tile', '1', 'token', options);
45};
46
47var RawNotification = function(payload, options) {
48 if (options === undefined) {
49 options = payload;
50 } else {
51 options.payload = payload;
52 }
53 return new PushMessage('raw', '3', undefined, options);
54};
55
56function PushMessage(pushType, quickNotificationClass, targetName, options) {
57 this.pushType = pushType;
58 this.notificationClass = quickNotificationClass;
59 this.targetName = targetName;
60
61 if (options) {
62 copyOfInterest(options, this, properties.ofInterest, this.pushType === 'tile');
63 copyOfInterest(options, this, properties.ssl);
64 copyOfInterest(options, this, properties.http);
65 }
66}
67
68PushMessage.prototype.send = function(pushUri, callback) {
69 var payload = this.getXmlPayload();
70 var me = this;
71
72 var options = url.parse(pushUri);
73 options.method = 'POST';
74 options.headers = {
75 'Content-Type': 'text/xml',
76 'Content-Length': Buffer.byteLength(payload).toString(),
77 'Accept': 'application/*',
78 'X-NotificationClass': this.notificationClass
79 };
80
81 if(this.targetName){
82 options.headers['X-WindowsPhone-Target'] = this.targetName;
83 }
84
85 if (this.proxy) {
86 var proxyOptions = url.parse(this.proxy);
87 options.headers['Host'] = options.host;
88 options.path = options.href;
89 options.host = proxyOptions.host;
90 options.hostname = proxyOptions.hostname;
91 options.port = proxyOptions.port;
92 }
93
94 var result = { };
95 var err;
96 var protocol = http;
97 if (options.protocol == 'https:') {
98 protocol = https;
99
100 copyOfInterest(me, options, properties.ssl);
101
102 //opt out of connection pooling - otherwise error handling gets a lot more complicated
103 options.agent = false;
104 }
105
106 var req = protocol.request(options);
107
108 req.on('response', function (message) {
109 result.statusCode = message.statusCode;
110 // Store the important responses from MPNS.
111 if (message.headers) {
112 renameFieldsOfInterest(message.headers, result, {
113 'x-deviceconnectionstatus': 'deviceConnectionStatus',
114 'x-notificationstatus': 'notificationStatus',
115 'x-subscriptionstatus': 'subscriptionStatus'
116 });
117 }
118
119 // Store the fields that were sent to make it easy to log.
120 copyOfInterest(me, result, properties.ofInterest, this.pushType === 'tile');
121
122 switch (message.statusCode) {
123 // The device is in an inactive state.
124 case 412:
125 result.minutesToDelay = HTTP_412_MINIMUM_DELAY_MINUTES; // Must be at least an hour.
126 err = result;
127 break;
128
129 // Invalid subscriptions.
130 case 400:
131 case 401:
132 case 404:
133 result.shouldDeleteChannel = true;
134 err = result;
135 break;
136
137 case 403:
138 err = result;
139 err.innerError = 'Authenticated push notifications certificate problem.';
140 break;
141
142 // Method Not Allowed (bug in this library)
143 case 405:
144 err = result;
145 break;
146
147 case 406:
148 err = result;
149 err.innerError = 'Per-day throttling limit reached.';
150 break;
151
152 case 503:
153 err = result;
154 result.minutesToDelay = ERROR_MINIMUM_DELAY_MINUTES;
155 err.innerError = 'The Push Notification Service is unable to process the request.';
156 break;
157 }
158
159 // The message object received in the 'response' event is a stream,
160 // and must be consumed since Node.js version 0.10.x before the
161 // connection is released back to the pool.
162 message.resume();
163
164 if (callback)
165 callback(err, err === undefined ? result : undefined);
166 });
167
168 req.on('error', function(e)
169 {
170 result.minutesToDelay = ERROR_MINIMUM_DELAY_MINUTES; // Just a recommendation.
171 err = result;
172 err.innerError = e;
173
174 if (callback)
175 callback(err);
176 });
177
178 // Send the push notification to Microsoft.
179 req.write(payload);
180 req.end();
181};
182
183function copyOfInterest(source, destination, fieldsOfInterest, allowNull) {
184 if (source && destination && fieldsOfInterest && fieldsOfInterest.length) {
185 for (var i = 0; i < fieldsOfInterest.length; i++) {
186 var key = fieldsOfInterest[i];
187 if (source[key] || (source[key] === null && allowNull === true)) {
188 destination[key] = source[key];
189 }
190 }
191 }
192}
193
194function renameFieldsOfInterest(source, destination, map) {
195 if (source && destination && map) {
196 for (var key in map) {
197 var newKey = map[key];
198 if (source[key]) {
199 destination[newKey] = source[key];
200 }
201 }
202 }
203}
204
205PushMessage.prototype.getXmlPayload = function() {
206 this.validate();
207 if (this.pushType == 'tile') {
208 return tileToXml(this);
209 } else if (this.pushType == 'toast') {
210 return toastToXml(this);
211 } else if (this.pushType == 'raw') {
212 return this.payload;
213 }
214};
215
216PushMessage.prototype.validate = function() {
217 if (this.pushType != 'toast' && this.pushType != 'tile' && this.pushType != 'raw') {
218 throw new Error("Only 'toast', 'tile' and 'raw' push types are currently supported.");
219 }
220};
221
222function escapeXml(value) {
223 if (value && value.replace) {
224 value = value.replace(/\&/g,'&amp;')
225 .replace(/</g, '&lt;')
226 .replace(/>/g, '&gt;')
227 .replace(/"/g, '&quot;');
228 }
229 return value;
230}
231
232function getPushHeader(type, attributes) {
233 return '<?xml version="1.0" encoding="utf-8"?><wp:Notification xmlns:wp="WPNotification">' +
234 startTag(type, attributes);
235}
236
237function getPushFooter(type) {
238 return endTag(type) + endTag('Notification');
239}
240
241function startTag(tag, attributes, endInstead) {
242 tag = '<' + (endInstead ? '/' : '') + 'wp:' + tag;
243 if(!endInstead && attributes && attributes.length){
244 attributes.forEach(function(pair){
245 tag += ' ' + pair[0] + '="' + escapeXml(pair[1]) + '"';
246 });
247 }
248 tag += '>';
249 return tag;
250}
251
252function endTag(tag) {
253 return startTag(tag, null, true);
254}
255
256function wrapValue(object, key, name) {
257 // We want to clear the value
258 if(object[key] === null){
259 return startTag(name, [['Action', 'Clear']]) + endTag(name);
260 } else {
261 return object[key] ? startTag(name) + escapeXml(object[key]) + endTag(name) : '';
262 }
263}
264
265function toastToXml(options) {
266 var type = 'Toast';
267 return getPushHeader(type) +
268 wrapValue(options, 'text1', 'Text1') +
269 wrapValue(options, 'text2', 'Text2') +
270 wrapValue(options, 'param', 'Param') +
271 getPushFooter(type);
272}
273
274function tileToXml(options) {
275 var type = 'Tile';
276 return getPushHeader(type, tileGetAttributes(options)) +
277 wrapValue(options, 'backgroundImage', 'BackgroundImage') +
278 wrapValue(options, 'count', 'Count') +
279 wrapValue(options, 'title', 'Title') +
280 wrapValue(options, 'backBackgroundImage', 'BackBackgroundImage') +
281 wrapValue(options, 'backTitle', 'BackTitle') +
282 wrapValue(options, 'backContent', 'BackContent') +
283 wrapValue(options, 'smallBackgroundImage', 'SmallBackgroundImage') +
284 wrapValue(options, 'wideBackgroundImage', 'WideBackgroundImage') +
285 wrapValue(options, 'wideBackContent', 'WideBackContent') +
286 wrapValue(options, 'wideBackBackgroundImage', 'WideBackBackgroundImage') +
287 wrapValue(options, 'backgroundColor', 'BackgroundColor') +
288 wrapValue(options, 'iconImage', 'IconImage') +
289 wrapValue(options, 'smallIconImage', 'SmallIconImage') +
290 wrapValue(options, 'wideContent1', 'WideContent1') +
291 wrapValue(options, 'wideContent2', 'WideContent2') +
292 wrapValue(options, 'wideContent3', 'WideContent3') +
293 getPushFooter(type);
294}
295
296function tileGetAttributes(options){
297 var attributes = [];
298 if(options.tileTemplate){
299 attributes.push(['Template', options.tileTemplate]);
300 }
301 if (options.id){
302 attributes.push(['Id', options.id]);
303 }
304 return attributes;
305}
306
307function create(type, typeProperties, objectType, args) {
308 var params = {};
309 if (typeof args[0] === 'object') {
310 var payload = Array.prototype.shift.apply(args);
311
312 // Adding back support for legacy 'smallbackgroundTile' property name,
313 // the removal of which was breaking backwards compat
314 if (payload.smallbackgroundImage) {
315 payload.smallBackgroundImage = payload.smallbackgroundImage;
316 }
317
318 copyOfInterest(payload, params, typeProperties, type === 'tile');
319 copyOfInterest(payload, params, properties.ssl, false);
320 copyOfInterest(payload, params, properties.http, false);
321 }
322 else {
323 // assume parameters are provided as atomic, string arguments of the function call
324 var i = 0;
325 while ((typeof args[0] === 'string' || typeof args[0] === 'number') && i < typeProperties.length) {
326 var item = Array.prototype.shift.apply(args);
327 var key = typeProperties[i++];
328 params[key] = item;
329 }
330 }
331
332 if (type == 'toast' && typeof params.text1 !== 'string') {
333 throw new Error('The text1 toast parameter must be set and a string.');
334 }
335
336 if (type == 'tile' && Object.keys(params).length === 0) {
337 throw new Error('At least 1 tile parameter must be set.');
338 }
339
340 return new objectType(params);
341}
342
343function send(type, typeProperties, objectType, args) {
344 var pushUri = Array.prototype.shift.apply(args);
345
346 if (typeof pushUri !== 'string')
347 throw new Error('The pushUri parameter must be the push notification channel URI string.');
348
349 var callback = args[args.length - 1];
350
351 if (callback && typeof callback !== 'function')
352 throw new Error('The callback parameter, if specified, must be the callback function.');
353
354 var instance = create(type, typeProperties, objectType, args);
355 instance.send(pushUri, callback);
356}
357
358var properties = {
359 http: [
360 'proxy'
361 ],
362 ssl: [
363 'pfx',
364 'key',
365 'passphrase',
366 'cert',
367 'ca',
368 'ciphers',
369 'rejectUnauthorized'
370 ],
371 toast: [
372 'text1',
373 'text2',
374 'param'
375 ],
376 tile: [
377 'backgroundImage',
378 'count',
379 'title',
380 'backBackgroundImage',
381 'backTitle',
382 'backContent',
383 'id'
384 ],
385 flipTile: [
386 'smallBackgroundImage',
387 'wideBackgroundImage',
388 'wideBackContent',
389 'wideBackBackgroundImage'
390 ],
391 iconicTile: [
392 'backgroundColor',
393 'count',
394 'title',
395 'iconImage',
396 'smallIconImage',
397 'wideContent1',
398 'wideContent2',
399 'wideContent3',
400 'id'
401 ],
402 ofInterest: [
403 'payload',
404 'pushType',
405 'tileTemplate'
406 ]
407};
408
409properties.flipTile = properties.flipTile.concat(properties.tile);
410properties.ofInterest = properties.ofInterest.concat(
411 properties.toast, properties.flipTile, properties.iconicTile);
412// Because of the fixed positions of the elements, addition of iconic tile
413// support introduces duplicate elements in ofInterest. Have to get rid of them.
414properties.ofInterest = properties.ofInterest.slice().sort().reduce(function (a, x) {
415 if (a.slice(-1) != x) a.push(x);
416 return a;
417}, []);
418
419module.exports = {
420 sendTile: function () {
421 send('tile', properties.tile, LiveTile, arguments);
422 },
423
424 sendFlipTile: function() {
425 send('tile', properties.flipTile, FlipTile, arguments);
426 },
427
428 sendIconicTile: function() {
429 send('tile', properties.iconicTile, IconicTile, arguments);
430 },
431
432 sendToast: function () {
433 send('toast', properties.toast, Toast, arguments);
434 },
435
436 sendRaw: function () {
437 send('raw', ['payload'], RawNotification, arguments);
438 },
439
440 createTile: function () {
441 return create('tile', properties.tile, LiveTile, arguments);
442 },
443
444 createFlipTile: function() {
445 return create('tile', properties.flipTile, FlipTile, arguments);
446 },
447
448 createIconicTile: function() {
449 return create('tile', properties.iconicTile, IconicTile, arguments);
450 },
451
452 createToast: function () {
453 return create('toast', properties.toast, Toast, arguments);
454 },
455
456 createRaw: function () {
457 return create('raw', ['payload'], RawNotification, arguments);
458 },
459
460 // Used for testing
461 Properties: properties
462};