diff --git a/docs-snippets/use-cases/automated-polling.ts b/docs-snippets/use-cases/automated-polling.ts index 731fdfe61..c188efb59 100644 --- a/docs-snippets/use-cases/automated-polling.ts +++ b/docs-snippets/use-cases/automated-polling.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -7,25 +7,31 @@ const pubnub = new PubNub({ }); // snippet.automatedPollingPublishTriggeredPoll -const pollsByReaction: Record = { - '\u{1F621}': { - title: 'Which team is playing dirtiest?', - options: [ - { id: 1, text: 'Home team' }, - { id: 2, text: 'Away team' }, - ], - }, - '\u{1F389}': { - title: 'Whose fans are celebrating hardest?', - options: [ - { id: 1, text: 'Home team' }, - { id: 2, text: 'Away team' }, - ], - }, -}; +const pollsByReaction = new Map([ + [ + '\u{1F621}', + { + title: 'Which team is playing dirtiest?', + options: [ + { id: 1, text: 'Home team' }, + { id: 2, text: 'Away team' }, + ], + }, + ], + [ + '\u{1F389}', + { + title: 'Whose fans are celebrating hardest?', + options: [ + { id: 1, text: 'Home team' }, + { id: 2, text: 'Away team' }, + ], + }, + ], +]); -async function openPollForReaction(reaction: string) { - const template = pollsByReaction[reaction]; +async function openPollForReaction(reaction = '') { + const template = pollsByReaction.get(reaction); if (!template) { console.log('no poll is defined for', reaction); @@ -46,10 +52,9 @@ async function openPollForReaction(reaction: string) { }); console.log('triggered poll published at timetoken:', response.timetoken); } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error( - `Publishing the triggered poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, + `Publishing the triggered poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`, ); } } @@ -73,17 +78,23 @@ function shouldOpenPoll() { // snippet.end // snippet.automatedPollingReceiveTrigger -type PollTrigger = { reaction: string }; - const triggerSubscription = pubnub.channel('game.poll-triggers').subscription({ receivePresenceEvents: false }); triggerSubscription.onMessage = (event) => { - const trigger = event.message as PollTrigger; + const trigger = event.message; + const rawReaction = + typeof trigger === 'object' && trigger !== null && !Array.isArray(trigger) && 'reaction' in trigger + ? trigger.reaction + : undefined; + + if (typeof rawReaction !== 'string') return; + + const reaction = rawReaction; - console.log('open a poll because fans keep tapping', trigger.reaction); + console.log('open a poll because fans keep tapping', reaction); if (shouldOpenPoll()) { - void openPollForReaction(trigger.reaction); + void openPollForReaction(reaction); } }; diff --git a/docs-snippets/use-cases/fan-behavior-management.ts b/docs-snippets/use-cases/fan-behavior-management.ts index 5e4b82e01..f9656885e 100644 --- a/docs-snippets/use-cases/fan-behavior-management.ts +++ b/docs-snippets/use-cases/fan-behavior-management.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; // Restricting what a fan may do is a server-side operation, so this client is // configured with the keyset's secret key and runs on your own infrastructure. @@ -16,75 +16,244 @@ const fanClient = new PubNub({ userId: 'fan-42', }); +// snippet.fanBehaviorStateStore +import fs from 'node:fs'; + +// A real token service tracks issued tokens and each fan's status in a database. +// This tutorial persists the same information in a JSON file next to the script, +// so `grant`, `mute`, `unmute`, and `ban` share state across separate `node server.js` +// invocations without needing a database just to run the tutorial. +const stateFilePath = new URL('./fan-state.json', import.meta.url); + +function loadState() { + try { + return JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + } catch { + return {}; + } +} + +function saveState(state = {}) { + fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2)); +} +// snippet.end + // snippet.fanBehaviorGrantChatAccess -try { - const token = await server.grantToken({ - ttl: 60, - authorizedUserId: 'fan-42', - resources: { - channels: { - 'game.chat': { read: true, write: true }, +async function grantChatAccess(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + if (entry.status === 'banned') { + console.log(`${userId} is banned, so no token was issued`); + return; + } + + const canWrite = entry.status !== 'muted'; + + try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true, write: canWrite }, + }, }, - }, - }); - console.log('token that allows reading and writing chat:', token); -} catch (error) { - console.error( - `Granting chat access failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + }); + + entry.status = entry.status ?? 'active'; + entry.tokens = [...entry.tokens, { token, write: canWrite }]; + state[userId] = entry; + saveState(state); + + console.log(`token that allows ${canWrite ? 'reading and writing' : 'reading'} chat:`, token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Granting chat access failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end // snippet.fanBehaviorMuteFan -try { - const token = await server.grantToken({ - ttl: 15, - authorizedUserId: 'fan-42', - resources: { - channels: { - 'game.chat': { read: true }, +async function muteFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + if (entry.status === 'banned') { + console.log(`${userId} is already banned, so there is nothing left to mute`); + return; + } + + const writableTokens = entry.tokens.filter((issued = { token: '', write: false }) => issued.write); + const stillValid = []; + + for (const issued of writableTokens) { + try { + await server.revokeToken(issued.token); + console.log('revoked an outstanding writable token'); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Revoking a writable token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + // Keep tracking a token you couldn't revoke, so the next mute or ban retries it. + stillValid.push(issued); + } + } + + // Record the mute before issuing anything new, so a later grant can't hand out write access. + entry.status = 'muted'; + entry.tokens = [...entry.tokens.filter((issued = { token: '', write: false }) => !issued.write), ...stillValid]; + state[userId] = entry; + saveState(state); + + if (stillValid.length > 0) { + console.error(`${stillValid.length} writable token(s) are still valid. Run mute again to retry revoking them.`); + } + + try { + const token = await server.grantToken({ + ttl: 15, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true }, + }, }, - }, - }); - console.log('token that allows reading chat but not writing to it:', token); -} catch (error) { - console.error( - `Muting the fan failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + }); + + entry.tokens = [...entry.tokens, { token, write: false }]; + state[userId] = entry; + saveState(state); + + console.log('token that allows reading chat but not writing to it:', token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Muting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end -// snippet.fanBehaviorRevokeToken -try { - const response = await server.revokeToken('replace-with-the-token-to-revoke'); - console.log('token revoked:', response); -} catch (error) { - console.error( - `Revoking the token failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); +// snippet.fanBehaviorUnmuteFan +async function unmuteFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + // This is the one command that clears a ban as well as a mute. Calling it is always + // a deliberate decision by whoever operates server.js, never a side effect of anything + // else in this tutorial. + try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true, write: true }, + }, + }, + }); + + entry.status = 'active'; + entry.tokens = [...entry.tokens, { token, write: true }]; + state[userId] = entry; + saveState(state); + + console.log('token that allows reading and writing chat again:', token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Unmuting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end -// snippet.fanBehaviorApplyToken -fanClient.setToken('replace-with-the-token-your-server-returned'); +// snippet.fanBehaviorBanFan +async function banFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + const stillValid = []; + + for (const issued of entry.tokens) { + try { + await server.revokeToken(issued.token); + console.log('revoked an outstanding token'); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Revoking a token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + // Keep tracking a token you couldn't revoke, so the next ban retries it. + stillValid.push(issued); + } + } + + entry.status = 'banned'; + entry.tokens = stillValid; + state[userId] = entry; + saveState(state); + + if (stillValid.length > 0) { + console.error( + `${userId} is banned, but ${stillValid.length} token(s) are still valid. Run ban again to retry revoking them.`, + ); + } else { + console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`); + } +} +// snippet.end + +// snippet.fanBehaviorServerDispatch +const [, , command, userIdArgument] = process.argv; +const targetUserId = userIdArgument ?? 'fan-42'; + +if (command === 'grant') { + await grantChatAccess(targetUserId); +} else if (command === 'mute') { + await muteFan(targetUserId); +} else if (command === 'unmute') { + await unmuteFan(targetUserId); +} else if (command === 'ban') { + await banFan(targetUserId); +} else { + console.log('usage: node server.js [userId]'); +} // snippet.end // snippet.fanBehaviorHandleAccessDenied fanClient.addListener({ status: (event) => { - if (event.category === 'PNAccessDeniedCategory') { + if (event.category === 'PNConnectedCategory') { + console.log('connected to game.chat'); + } else if (event.category === 'PNAccessDeniedCategory') { console.log('this fan may no longer write to', event.affectedChannels); } }, }); // snippet.end +// snippet.fanBehaviorApplyToken +const suppliedToken = process.argv[2]; + +if (!suppliedToken) { + console.error('usage: node fan.js '); + process.exit(1); +} + +fanClient.setToken(suppliedToken); +// snippet.end + +// snippet.fanBehaviorSubscribeAndPublish +const subscription = fanClient.channel('game.chat').subscription(); +subscription.subscribe(); + +try { + const response = await fanClient.publish({ + channel: 'game.chat', + message: { text: 'Come on!' }, + }); + console.log('chat message published at timetoken:', response.timetoken); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing to game.chat failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + // snippet.fanBehaviorInspectToken const parsed = fanClient.parseToken('replace-with-the-token-to-inspect'); diff --git a/docs-snippets/use-cases/fan-re-engagement.ts b/docs-snippets/use-cases/fan-re-engagement.ts index 04fc391c8..919f68bb5 100644 --- a/docs-snippets/use-cases/fan-re-engagement.ts +++ b/docs-snippets/use-cases/fan-re-engagement.ts @@ -1,5 +1,8 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; +// Presence monitoring and the decision to alert a fan run under their own service +// identity, separate from any fan's own client, so this script's watcher is never +// the same connection whose absence it is trying to detect. const pubnub = new PubNub({ publishKey: 'demo', subscribeKey: 'demo', @@ -14,29 +17,55 @@ try { }); console.log('fans currently watching the stream:', response.totalOccupancy); } catch (error) { - console.error( - `Counting the fans watching failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Counting the fans watching failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.reEngagementPublishMomentAlert +async function sendMomentAlert(userId = '') { + const alertChannel = `game.moment-alerts.${userId}`; + const moment = PubNub.notificationPayload('Injury time', 'Two minutes left, and it is still 2-2.'); + + moment.sound = 'default'; + moment.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; + + try { + const response = await pubnub.publish({ + channel: alertChannel, + message: { + ...moment.buildPayload(['apns2', 'fcm']), + moment: 'injury-time', + }, + customMessageType: 'moment-alert', + }); + console.log(`moment alert published to ${alertChannel} at timetoken:`, response.timetoken); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the moment alert failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end // snippet.reEngagementCheckOneFan -try { - const response = await pubnub.whereNow({ uuid: 'fan-42' }); +async function notifyIfAbsent(userId = '') { + try { + const response = await pubnub.whereNow({ uuid: userId }); - if (response.channels.includes('game.stream')) { - console.log('fan-42 is watching, so no alert is needed'); - } else { - console.log('fan-42 left the stream, so a push alert can bring them back'); + if (response.channels.includes('game.stream')) { + console.log(`${userId} is still subscribed to game.stream, so no alert is needed`); + return; + } + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error( + `Checking where ${userId} is subscribed failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); + return; } -} catch (error) { - console.error( - `Checking where the fan is failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + + console.log(`${userId} is not subscribed to game.stream, so sending a moment alert`); + await sendMomentAlert(userId); } // snippet.end @@ -46,50 +75,47 @@ const streamSubscription = pubnub.channel('game.stream').subscription({ receiveP streamSubscription.onPresence = (event) => { if (event.action === 'leave' || event.action === 'timeout') { console.log(`${event.uuid} stopped watching, and ${event.occupancy} fans remain`); + void notifyIfAbsent(event.uuid); } }; streamSubscription.subscribe(); // snippet.end -// snippet.reEngagementPublishMomentAlert -const moment = PubNub.notificationPayload('Injury time', 'Two minutes left, and it is still 2-2.'); +// snippet.reEngagementFanViewer +const viewerUserId = process.argv[2] ?? 'fan-a'; -moment.sound = 'default'; -moment.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; +const viewerClient = new PubNub({ + publishKey: 'demo', + subscribeKey: 'demo', + userId: viewerUserId, +}); -try { - const response = await pubnub.publish({ - channel: 'game.moment-alerts', - message: { - ...moment.buildPayload(['apns2', 'fcm']), - moment: 'injury-time', - }, - customMessageType: 'moment-alert', - }); - console.log('moment alert published at timetoken:', response.timetoken); -} catch (error) { - console.error( - `Publishing the moment alert failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); -} -// snippet.end +const watchSubscription = viewerClient.channel('game.stream').subscription({ receivePresenceEvents: false }); +watchSubscription.subscribe(); -// snippet.reEngagementRegisterForMomentAlerts try { - const response = await pubnub.push.addChannels({ - channels: ['game.moment-alerts'], + const response = await viewerClient.push.addChannels({ + channels: [`game.moment-alerts.${viewerUserId}`], device: 'replace-with-the-fcm-registration-token', pushGateway: 'fcm', }); - console.log('device registered for moment alerts:', response); + console.log(`${viewerUserId} registered its device for game.moment-alerts.${viewerUserId}:`, response); } catch (error) { - console.error( - `Registering for moment alerts failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } + +const alertSubscription = viewerClient.channel(`game.moment-alerts.${viewerUserId}`).subscription(); + +alertSubscription.onMessage = (event) => { + console.log(`${viewerUserId} received a moment alert:`, event.message); +}; + +alertSubscription.subscribe(); + +process.on('SIGINT', () => { + console.log(`${viewerUserId} left game.stream, but is still reachable for a moment alert`); + watchSubscription.unsubscribe(); +}); // snippet.end diff --git a/docs-snippets/use-cases/game-chat-moderation.ts b/docs-snippets/use-cases/game-chat-moderation.ts index fd1c314ec..f8c708623 100644 --- a/docs-snippets/use-cases/game-chat-moderation.ts +++ b/docs-snippets/use-cases/game-chat-moderation.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -6,8 +6,10 @@ const pubnub = new PubNub({ userId: 'moderator-7', }); -// Removing a message from Message Persistence is a server-side operation, so this -// client is configured with the keyset's secret key and runs on your own infrastructure. +// Removing a message from Message Persistence, and granting the token that lets +// moderator.js write hide decisions to the control channel, are both server-side +// operations, so this client is configured with the keyset's secret key and runs +// on your own infrastructure. const server = new PubNub({ publishKey: 'demo', subscribeKey: 'demo', @@ -15,32 +17,66 @@ const server = new PubNub({ userId: 'moderation-service', }); +// snippet.chatModerationGrantControlChannelAccess +try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: 'moderator-7', + resources: { + channels: { + 'game.chat': { read: true }, + 'game.chat.moderation': { read: true, write: true }, + }, + }, + }); + console.log('token that lets moderator-7 publish hide decisions:', token); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Granting moderator access failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.chatModerationApplyModeratorToken +pubnub.setToken('replace-with-the-token-server-js-printed'); +// snippet.end + // snippet.chatModerationFlagMessage try { - const response = await pubnub.addMessageAction({ - channel: 'game.chat', - messageTimetoken: 'replace-with-message-timetoken', - action: { - type: 'moderation', - value: 'hidden', + const response = await pubnub.publish({ + channel: 'game.chat.moderation', + message: { + action: 'hide', + messageTimetoken: 'replace-with-message-timetoken', }, + customMessageType: 'moderation-hide', + storeInHistory: true, }); - console.log('message flagged at timetoken:', response.data.actionTimetoken); + console.log('message flagged at timetoken:', response.timetoken); } catch (error) { - console.error( - `Flagging the message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Flagging the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.chatModerationReceiveModerationDecisions -const moderationSubscription = pubnub.channel('game.chat').subscription(); +const hiddenTimetokens = new Set(); + +const moderationSubscription = pubnub.channel('game.chat.moderation').subscription(); + +moderationSubscription.onMessage = (event) => { + const decision = event.message; + const action = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision + ? decision.action + : undefined; + const messageTimetoken = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision + ? decision.messageTimetoken + : undefined; -moderationSubscription.onMessageAction = (event) => { - if (event.data.type === 'moderation' && event.data.value === 'hidden') { - console.log('hide the message published at', event.data.messageTimetoken); + if (action === 'hide' && typeof messageTimetoken === 'string') { + hiddenTimetokens.add(messageTimetoken); + console.log('hide the message published at', messageTimetoken); } }; @@ -48,51 +84,56 @@ moderationSubscription.subscribe(); // snippet.end // snippet.chatModerationLoadHistoryWithFlags -// Requesting message actions alongside the messages adds an `actions` map to each -// entry, keyed by action type and then by action value. -type ModeratedEntry = { - timetoken: string | number; - message: unknown; - actions?: Record>; -}; - try { const response = await pubnub.fetchMessages({ - channels: ['game.chat'], + channels: ['game.chat', 'game.chat.moderation'], count: 25, - includeMessageActions: true, }); - const entries = (response.channels['game.chat'] ?? []) as ModeratedEntry[]; + const moderationEntries = response.channels['game.chat.moderation'] ?? []; + + moderationEntries.forEach((entry) => { + const decision = entry.message; + const action = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision + ? decision.action + : undefined; + const messageTimetoken = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision + ? decision.messageTimetoken + : undefined; + + if (action === 'hide' && typeof messageTimetoken === 'string') { + hiddenTimetokens.add(messageTimetoken); + } + }); + + const chatEntries = response.channels['game.chat'] ?? []; - entries.forEach((entry) => { - const hidden = entry.actions?.moderation?.hidden !== undefined; + chatEntries.forEach((entry) => { + const hidden = hiddenTimetokens.has(entry.timetoken.toString()); console.log(entry.timetoken, hidden ? '[hidden by a moderator]' : entry.message); }); } catch (error) { - console.error( - `Loading the moderated history failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading the moderated history failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.chatModerationDeleteMessage try { - const messageTimetoken = 17000000000000000; + const messageTimetoken = 'replace-with-message-timetoken'; + const start = (BigInt(messageTimetoken) - BigInt(1)).toString(); + const end = messageTimetoken; const response = await server.deleteMessages({ channel: 'game.chat', - start: (messageTimetoken - 1).toString(), - end: messageTimetoken.toString(), + start, + end, }); console.log('message deleted from Message Persistence:', response); } catch (error) { - console.error( - `Deleting the message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Deleting the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/live-commentary.ts b/docs-snippets/use-cases/live-commentary.ts index 22ad7f894..1fe2c7588 100644 --- a/docs-snippets/use-cases/live-commentary.ts +++ b/docs-snippets/use-cases/live-commentary.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -19,11 +19,8 @@ try { }); console.log('commentary published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the commentary failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the commentary failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -35,6 +32,14 @@ commentarySubscription.onMessage = (event) => { console.log(`[${event.timetoken}] ${JSON.stringify(event.message)}`); }; +pubnub.addListener({ + status: (event) => { + if (event.category === 'PNConnectedCategory') { + console.log('connected and ready to receive commentary'); + } + }, +}); + commentarySubscription.subscribe(); // snippet.end @@ -51,14 +56,15 @@ try { console.log(entry.timetoken, entry.message); }); } catch (error) { - console.error( - `Loading the commentary backlog failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading the commentary backlog failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.liveCommentaryUnsubscribe -commentarySubscription.unsubscribe(); +process.on('SIGINT', () => { + console.log('viewer shutting down, closing the commentary subscription'); + commentarySubscription.unsubscribe(); + process.exit(0); +}); // snippet.end diff --git a/docs-snippets/use-cases/live-event-rate-limiting.ts b/docs-snippets/use-cases/live-event-rate-limiting.ts index 1f2930ed9..87da6f1f8 100644 --- a/docs-snippets/use-cases/live-event-rate-limiting.ts +++ b/docs-snippets/use-cases/live-event-rate-limiting.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -17,16 +17,13 @@ try { console.log(`${channel} holds ${data.occupancy} fans`); }); } catch (error) { - console.error( - `Reading the shard occupancy failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Reading the shard occupancy failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.rateLimitingPickShardForFan -async function pickShardForFan(shardCount: number, maxFansPerShard: number) { +async function pickShardForFan(shardCount = 0, maxFansPerShard = 0) { const channels = Array.from({ length: shardCount }, (_, index) => `game.chat.shard-${index}`); const response = await pubnub.hereNow({ channels, includeUUIDs: false }); @@ -49,7 +46,7 @@ console.log('this fan joins', shard); const minimumMillisecondsBetweenMessages = 2000; let lastPublishedAt = 0; -async function sendChatMessage(text: string) { +async function sendChatMessage(text = '') { const now = Date.now(); if (now - lastPublishedAt < minimumMillisecondsBetweenMessages) { diff --git a/docs-snippets/use-cases/live-polls.ts b/docs-snippets/use-cases/live-polls.ts index ee1a90165..aaeb050ae 100644 --- a/docs-snippets/use-cases/live-polls.ts +++ b/docs-snippets/use-cases/live-polls.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -27,11 +27,8 @@ try { }); console.log('poll published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -59,11 +56,8 @@ try { console.log('poll that is already open:', entries[0].message); } } catch (error) { - console.error( - `Fetching the open poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Fetching the open poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -76,11 +70,8 @@ try { }); console.log('vote published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the vote failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the vote failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -98,11 +89,8 @@ try { }); console.log('results published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the results failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the results failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/match-stats.ts b/docs-snippets/use-cases/match-stats.ts index 32e0336d1..c3880d3f1 100644 --- a/docs-snippets/use-cases/match-stats.ts +++ b/docs-snippets/use-cases/match-stats.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -16,11 +16,8 @@ try { }); console.log('score published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the stat failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the stat failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -55,10 +52,7 @@ try { } }); } catch (error) { - console.error( - `Fetching the current stats failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Fetching the current stats failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/real-time-ads.ts b/docs-snippets/use-cases/real-time-ads.ts index 8559b9c7f..786a21e42 100644 --- a/docs-snippets/use-cases/real-time-ads.ts +++ b/docs-snippets/use-cases/real-time-ads.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -15,24 +15,27 @@ try { }); console.log('reaction published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the reaction failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the reaction failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.realTimeAdsReceiveAdDecision -type AdDecision = { adId: number; clickPoints: number }; - const adSubscription = pubnub.channel('game.ad-decisions').subscription({ receivePresenceEvents: false }); adSubscription.onMessage = (event) => { - const decision = event.message as AdDecision; + const decision = event.message; + const adId = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'adId' in decision + ? decision.adId + : undefined; + const clickPoints = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'clickPoints' in decision + ? decision.clickPoints + : undefined; - if (decision.adId) { - console.log(`show ad ${decision.adId}, worth ${decision.clickPoints} points`); + if (adId) { + console.log(`show ad ${adId}, worth ${clickPoints} points`); } else { console.log('no ad to show, so clear the ad slot'); } @@ -42,14 +45,20 @@ adSubscription.subscribe(); // snippet.end // snippet.realTimeAdsReceiveReactionUpgrade -type ReactionUpgrade = { reaction: string; replacement: string }; - const upgradeSubscription = pubnub.channel('game.reaction-upgrades').subscription({ receivePresenceEvents: false }); upgradeSubscription.onMessage = (event) => { - const upgrade = event.message as ReactionUpgrade; + const upgrade = event.message; + const reaction = + typeof upgrade === 'object' && upgrade !== null && !Array.isArray(upgrade) && 'reaction' in upgrade + ? upgrade.reaction + : undefined; + const replacement = + typeof upgrade === 'object' && upgrade !== null && !Array.isArray(upgrade) && 'replacement' in upgrade + ? upgrade.replacement + : undefined; - console.log(`render ${upgrade.reaction} as ${upgrade.replacement} from now on`); + console.log(`render ${reaction} as ${replacement} from now on`); }; upgradeSubscription.subscribe(); diff --git a/docs-snippets/use-cases/real-time-chat.ts b/docs-snippets/use-cases/real-time-chat.ts index 4345ce9a4..7af2ed4d2 100644 --- a/docs-snippets/use-cases/real-time-chat.ts +++ b/docs-snippets/use-cases/real-time-chat.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -17,11 +17,8 @@ try { }); console.log('channel metadata set:', response.data); } catch (error) { - console.error( - `Setting the channel metadata failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Setting the channel metadata failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -37,11 +34,8 @@ try { }); console.log('fan profile set:', response.data); } catch (error) { - console.error( - `Setting the fan profile failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Setting the fan profile failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -72,11 +66,8 @@ try { }); console.log('chat message published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the chat message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the chat message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -94,11 +85,8 @@ try { console.log(entry.timetoken, entry.message); }); } catch (error) { - console.error( - `Loading recent messages failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading recent messages failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -110,11 +98,8 @@ try { }); console.log('fans in the chat:', response.totalOccupancy); } catch (error) { - console.error( - `Counting the fans online failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Counting the fans online failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -130,11 +115,8 @@ try { }); console.log('reaction added at timetoken:', response.data.actionTimetoken); } catch (error) { - console.error( - `Adding the reaction failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Adding the reaction failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/score-alerts.ts b/docs-snippets/use-cases/score-alerts.ts index d97b1895a..98c0dd2eb 100644 --- a/docs-snippets/use-cases/score-alerts.ts +++ b/docs-snippets/use-cases/score-alerts.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -6,22 +6,32 @@ const pubnub = new PubNub({ userId: 'fan-42', }); +// snippet.scoreAlertsEnvironmentConstant +// Every APNs call below, device registration, the notification payload's target, +// listing, and removal, reads this same value. An iOS device token only works in the +// APNs environment that issued it: a development (sandbox) token comes from a +// debug or development-signed build and only works with environment: 'development'; +// a production token comes from a TestFlight or App Store build and only works with +// environment: 'production'. Registering with one value and publishing toward the +// other is why a registration can succeed while the notification it's supposed to +// produce never arrives. Change this one constant when you move from a development +// build to a TestFlight or App Store build, rather than editing every call below. +const APNS_ENVIRONMENT = 'development'; +// snippet.end + // snippet.scoreAlertsRegisterDeviceAPNs try { const response = await pubnub.push.addChannels({ channels: ['game.score-alerts'], device: 'replace-with-the-apns-device-token', pushGateway: 'apns2', - environment: 'production', + environment: APNS_ENVIRONMENT, topic: 'com.example.matchday', }); console.log('iOS device registered for score alerts:', response); } catch (error) { - console.error( - `Registering the iOS device failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the iOS device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -34,11 +44,8 @@ try { }); console.log('Android device registered for score alerts:', response); } catch (error) { - console.error( - `Registering the Android device failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the Android device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -46,7 +53,7 @@ try { const goal = PubNub.notificationPayload('Leeds score!', 'Southampton 0 - 2 Leeds'); goal.sound = 'default'; -goal.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; +goal.apns.configurations = [{ targets: [{ topic: 'com.example.matchday', environment: APNS_ENVIRONMENT }] }]; const payload = goal.buildPayload(['apns2', 'fcm']); @@ -66,15 +73,29 @@ try { }); console.log('score alert published at timetoken:', response.timetoken); } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the score alert failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.scoreAlertsListDeviceRegistrationsAPNs +try { + const response = await pubnub.push.listChannels({ + device: 'replace-with-the-apns-device-token', + pushGateway: 'apns2', + environment: APNS_ENVIRONMENT, + topic: 'com.example.matchday', + }); + console.log('this device receives alerts on:', response.channels); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error( - `Publishing the score alert failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, + `Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`, ); } // snippet.end -// snippet.scoreAlertsListDeviceRegistrations +// snippet.scoreAlertsListDeviceRegistrationsFCM try { const response = await pubnub.push.listChannels({ device: 'replace-with-the-fcm-registration-token', @@ -82,15 +103,32 @@ try { }); console.log('this device receives alerts on:', response.channels); } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error( + `Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); +} +// snippet.end + +// snippet.scoreAlertsRemoveDeviceRegistrationAPNs +try { + const response = await pubnub.push.removeChannels({ + channels: ['game.score-alerts'], + device: 'replace-with-the-apns-device-token', + pushGateway: 'apns2', + environment: APNS_ENVIRONMENT, + topic: 'com.example.matchday', + }); + console.log('device no longer receives score alerts:', response); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error( - `Listing the device registrations failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, + `Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`, ); } // snippet.end -// snippet.scoreAlertsRemoveDeviceRegistration +// snippet.scoreAlertsRemoveDeviceRegistrationFCM try { const response = await pubnub.push.removeChannels({ channels: ['game.score-alerts'], @@ -99,10 +137,9 @@ try { }); console.log('device no longer receives score alerts:', response); } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error( - `Removing the device registration failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, + `Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`, ); } // snippet.end