建構推播通知伺服器

在本程式碼研究室中,您將建構推播通知伺服器。伺服器會管理推播訂閱清單,並向訂閱者傳送通知。

用戶端程式碼已完成,您將在本程式碼研究室中著手處理伺服器端功能。

系統會自動封鎖內嵌 Glitch 應用程式的通知,因此您無法在本頁面上預覽該應用程式。請改用以下做法:

  1. 按一下「Remix to Edit」,即可編輯專案。
  2. 如要預覽網站,請按下「View App」。然後按下「Fullscreen」圖示 全螢幕

即時應用程式會在新分頁中開啟。在嵌入的 Glitch 中,按一下「View Source」即可再次顯示程式碼。

在本程式碼研究室中,請修改本頁嵌入的 Glitch 中的程式碼。為執行中的應用程式重新整理新分頁,就會看到變更。

首先,請查看應用程式的用戶端 UI。

在新的 Chrome 分頁中:

  1. 按下 `Control+Shift+J` 鍵 (或在 Mac 上按下 `Command+Option+J` 鍵) 開啟開發人員工具。再按一下「Console」(控制台) 分頁標籤即可。

  2. 請嘗試點選 UI 中的按鈕 (查看 Chrome 開發人員控制台的輸出內容)。

    • 註冊 Service Worker 會為 Glitch 專案網址的範圍註冊 Service Worker。取消註冊 Service Worker 會移除 Service Worker。如果已附加推播訂閱項目,推播訂閱項目也會停用。

    • 訂閱推送會建立推送訂閱項目。只有在服務工作者已註冊,且用戶端程式碼中出現 VAPID_PUBLIC_KEY 常數時,這項功能才會可用 (稍後會進一步說明),因此您目前無法點選該按鈕。

    • 當您訂閱有效的推播通知時,Notify current subscription 會要求伺服器傳送通知至其端點。

    • 通知所有訂閱項目會指示伺服器傳送通知給資料庫中的所有訂閱端點。

      請注意,其中某些端點可能處於停用狀態。當伺服器傳送通知時,訂閱項目可能會消失。

讓我們來看看伺服器端發生了什麼事。如要查看伺服器程式碼傳來的訊息,請在 Glitch 介面中查看 Node.js 記錄檔。

  • 在 Glitch 應用程式中,依序按一下「Tools」->「Logs」

    畫面上可能會顯示類似 Listening on port 3000 的訊息。

    如果您嘗試在即時應用程式 UI 中按一下「通知目前訂閱項目」或「通知所有訂閱項目」,也會看到以下訊息:

    TODO: Implement sendNotifications()
    Endpoints to send to:  []

接下來,我們來看看程式碼。

  • public/index.js 包含完成的用戶端程式碼。它會執行功能偵測、註冊及取消註冊服務工作者,並控制使用者訂閱推播通知的情況。並將新訂閱和已刪除訂閱的資訊傳送至伺服器。

    由於您只會處理伺服器功能,因此不會編輯這個檔案 (除了填入 VAPID_PUBLIC_KEY 常數)。

  • public/service-worker.js 是簡單的服務工作站,可擷取推播事件並顯示通知。

  • /views/index.html 包含應用程式 UI。

  • .env 包含 Glitch 在應用程式伺服器啟動時載入的環境變數。您將在 .env 中填入用於傳送通知的驗證詳細資料。

  • server.js 是您在本程式碼研究室中大部分工作所需的檔案。

    範例程式碼會建立簡易的 Express 網路伺服器。您有四個 TODO 項目,在程式碼註解中以 TODO: 標示。必要操作:

    在本程式碼研究室中,您將逐一處理這些 TODO 項目。

產生及載入 VAPID 詳細資料

第一個待辦事項是產生 VAPID 詳細資料,將其新增至 Node.js 環境變數,然後使用新值更新用戶端和伺服器程式碼。

背景

使用者訂閱通知時,必須信任應用程式及其伺服器的身分。使用者也必須確信收到的通知來自設定訂閱項目的應用程式。他們也必須信任,其他人無法讀取通知內容。

讓推播通知安全且私密的通訊協定稱為「Web Push 的應用程式伺服器自願識別資訊」(VAPID)。VAPID 會使用公開金鑰密碼編譯機制,驗證應用程式、伺服器和訂閱端點的身份,並加密通知內容。

在這個應用程式中,您將使用網頁推送 npm 套件產生 VAPID 金鑰,並加密並傳送通知。

實作

在這個步驟中,請為應用程式產生一組 VAPID 金鑰,並將這些金鑰新增至環境變數。在伺服器中載入環境變數,然後在用戶端程式碼中將公開金鑰新增為常數。

  1. 使用 web-push 程式庫的 generateVAPIDKeys 函式建立一組 VAPID 鍵。

    server.js 中,移除以下程式碼行周圍的註解:

    server.js

    // Generate VAPID keys (only do this once).
    /*
     * const vapidKeys = webpush.generateVAPIDKeys();
     * console.log(vapidKeys);
     */

    const vapidKeys = webpush.generateVAPIDKeys();
    console
    .log(vapidKeys);
  2. Glitch 重新啟動應用程式後,會將產生的鍵輸出至 Glitch 介面中的 Node.js 記錄 (不會輸出至 Chrome 主控台)。如要查看 VAPID 鍵,請在 Glitch 介面中依序選取「Tools」>「Logs」

    請務必從相同的金鑰組複製公開和私密金鑰!

    Glitch 會在您編輯程式碼時重新啟動應用程式,因此當您產生更多輸出內容時,第一組產生的鍵可能會從畫面上捲動。

  3. .env 中,複製並貼上 VAPID 金鑰。以雙引號括住金鑰 ("...")。

    VAPID_SUBJECT 中,您可以輸入 "mailto:test@test.test"

    .env

    # process.env.SECRET
    VAPID_PUBLIC_KEY
    =
    VAPID_PRIVATE_KEY
    =
    VAPID_SUBJECT
    =
    VAPID_PUBLIC_KEY
    ="BN3tWzHp3L3rBh03lGLlLlsq..."
    VAPID_PRIVATE_KEY
    ="I_lM7JMIXRhOk6HN..."
    VAPID_SUBJECT
    ="mailto:test@test.test"
  4. server.js 中,請再次將這兩行程式碼註解排除,因為您只需要產生一次 VAPID 金鑰。

    server.js

    // Generate VAPID keys (only do this once).
    /*
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    */

    const vapidKeys = webpush.generateVAPIDKeys();
    console
    .log(vapidKeys);
  5. server.js 中,從環境變數載入 VAPID 詳細資料。

    server.js

    const vapidDetails = {
     
    // TODO: Load VAPID details from environment variables.
      publicKey
    : process.env.VAPID_PUBLIC_KEY,
      privateKey
    : process.env.VAPID_PRIVATE_KEY,
      subject
    : process.env.VAPID_SUBJECT
    }
  6. 並將public 金鑰複製並貼到用戶端程式碼中。

    public/index.js 中,為複製到 .env 檔案的 VAPID_PUBLIC_KEY 輸入相同的值:

    public/index.js

    // Copy from .env
    const VAPID_PUBLIC_KEY = '';
    const VAPID_PUBLIC_KEY = 'BN3tWzHp3L3rBh03lGLlLlsq...';
    ````

實作傳送通知的功能

背景

在這個應用程式中,您將使用 web-push npm 套件傳送通知。

這個套件會在呼叫 webpush.sendNotification() 時自動加密通知,因此您不必擔心這點。

web-push 接受多個通知選項。例如,您可以附加標頭至郵件,並指定內容編碼。

在本程式碼研究室中,您只需要使用以下兩行程式碼來定義:

let options = {
  TTL
: 10000; // Time-to-live. Notifications expire after this.
  vapidDetails
: vapidDetails; // VAPID keys from .env
};

TTL (存留時間) 選項會為通知設定到期時間逾時值。這是伺服器避免在通知不再相關後,向使用者傳送通知的方法。

vapidDetails 選項包含您從環境變數載入的 VAPID 金鑰。

實作

server.js 中,按照以下方式修改 sendNotifications 函式:

server.js

function sendNotifications(database, endpoints) {
 
// TODO: Implement functionality to send notifications.
  console
.log('TODO: Implement sendNotifications()');
  console
.log('Endpoints to send to: ', endpoints);
  let notification
= JSON.stringify(createNotification());
  let options
= {
    TTL
: 10000, // Time-to-live. Notifications expire after this.
    vapidDetails
: vapidDetails // VAPID keys from .env
 
};
  endpoints
.map(endpoint => {
    let subscription
= database[endpoint];
    webpush
.sendNotification(subscription, notification, options);
 
});
}

由於 webpush.sendNotification() 會傳回承諾,您可以輕鬆新增錯誤處理機制。

server.js 中,再次修改 sendNotifications 函式:

server.js

function sendNotifications(database, endpoints) {
  let notification
= JSON.stringify(createNotification());
  let options
= {
    TTL
: 10000; // Time-to-live. Notifications expire after this.
    vapidDetails
: vapidDetails; // VAPID keys from .env
 
};
  endpoints
.map(endpoint => {
    let subscription
= database[endpoint];
    webpush
.sendNotification(subscription, notification, options);
    let id
= endpoint.substr((endpoint.length - 8), endpoint.length);
    webpush
.sendNotification(subscription, notification, options)
   
.then(result => {
      console
.log(`Endpoint ID: ${id}`);
      console
.log(`Result: ${result.statusCode} `);
   
})
   
.catch(error => {
      console
.log(`Endpoint ID: ${id}`);
      console
.log(`Error: ${error.body} `);
   
});
 
});
}

處理新訂閱

背景

使用者訂閱推播通知後會發生以下情況:

  1. 使用者點選「訂閱推播」

  2. 用戶端會使用 VAPID_PUBLIC_KEY 常數 (伺服器的公開 VAPID 鍵) 產生專屬的伺服器 subscription 物件。subscription 物件如下所示:

       {
         
    "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9...",
         
    "expirationTime": null,
         
    "keys":
         
    {
           
    "p256dh": "BNYDjQL9d5PSoeBurHy2e4d4GY0sGJXBN...",
           
    "auth": "0IyyvUGNJ9RxJc83poo3bA"
         
    }
       
    }
  3. 用戶端會將 POST 要求傳送至 /add-subscription 網址,並在主體中加入以字串化 JSON 格式表示的訂閱項目。

  4. 伺服器會從 POST 要求的主體中擷取字串化 subscription,將其解析為 JSON,然後加入訂閱資料庫。

    資料庫會使用訂閱項目的端點做為索引鍵來儲存訂閱項目:

    {
     
"https://fcm...1234": {
        endpoint
: "https://fcm...1234",
        expirationTime
: ...,
        keys
: { ... }
     
},
     
"https://fcm...abcd": {
        endpoint
: "https://fcm...abcd",
        expirationTime
: ...,
        keys
: { ... }
     
},
     
"https://fcm...zxcv": {
        endpoint
: "https://fcm...zxcv",
        expirationTime
: ...,
        keys
: { ... }
     
},
   
}

新的訂閱項目現在可供伺服器使用,用於傳送通知。

實作

新訂閱項目的要求會傳送至 /add-subscription 路徑,也就是 POST 網址。您會在 server.js 中看到虛設路徑處理程序:

server.js

app.post('/add-subscription', (request, response) => {
 
// TODO: implement handler for /add-subscription
  console
.log('TODO: Implement handler for /add-subscription');
  console
.log('Request body: ', request.body);
  response
.sendStatus(200);
});

在實作中,這個處理常式必須:

  • 從要求的主體擷取新訂閱項目。
  • 存取有效訂閱項目資料庫。
  • 將新訂閱項目加入有效訂閱項目清單。

如何處理新訂閱項目:

  • server.js 中,修改 /add-subscription 的路徑處理常式,如下所示:

    server.js

    app.post('/add-subscription', (request, response) => {
     
// TODO: implement handler for /add-subscription
      console
.log('TODO: Implement handler for /add-subscription');
      console
.log('Request body: ', request.body);
      let subscriptions
= Object.assign({}, request.session.subscriptions);
      subscriptions
[request.body.endpoint] = request.body;
      request
.session.subscriptions = subscriptions;
      response
.sendStatus(200);
   
});

處理訂閱項目取消作業

背景

伺服器不一定會知道何時訂閱項目失效,例如瀏覽器關閉服務工作者時,訂閱項目就可能會遭到清除。

不過,伺服器可以透過應用程式 UI 找出已取消的訂閱項目。在這個步驟中,您將實作從資料庫中移除訂閱項目的功能。

如此一來,伺服器就能避免向不存在的端點傳送大量通知。使用簡單的測試應用程式其實並不重要,但大規模而言都很重要。

實作

取消訂閱的要求會傳送至 /remove-subscription POST URL。

server.js 中的 Stub 路徑處理常式如下所示:

server.js

app.post('/remove-subscription', (request, response) => {
 
// TODO: implement handler for /remove-subscription
  console
.log('TODO: Implement handler for /remove-subscription');
  console
.log('Request body: ', request.body);
  response
.sendStatus(200);
});

在實作中,這個處理常式必須:

  • 從要求內文擷取已取消訂閱項目的端點。
  • 存取有效訂閱項目資料庫。
  • 從有效訂閱項目清單中移除已取消的訂閱項目。

用戶端的 POST 要求主體包含您要移除的端點:

{
 
"endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9..."
}

如何處理取消訂閱事宜:

  • server.js 中,修改 /remove-subscription 的路徑處理常式,如下所示:

    server.js

  app.post('/remove-subscription', (request, response) => {
   
// TODO: implement handler for /remove-subscription
    console
.log('TODO: Implement handler for /remove-subscription');
    console
.log('Request body: ', request.body);
    let subscriptions
= Object.assign({}, request.session.subscriptions);
   
delete subscriptions[request.body.endpoint];
    request
.session.subscriptions = subscriptions;
    response
.sendStatus(200);
 
});