diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..8ff4b74 --- /dev/null +++ b/index.ts @@ -0,0 +1,172 @@ +import * as http from "http"; +import * as https from "https"; +import * as fs from "fs"; +import * as path from "path"; + +import express from "express"; +import * as escapeHTML from "escape-html"; +import { ServerOptions } from "https"; + +const app = express(); + +let httpPort = 80; + +const htmlTop = ` + + + + + MDN Code Samples + + +
+
+

+ MDN Code Samples +

+
+
+

+ This site hosts code samples + for MDN Web Docs that require server assistance to operate, such as examples for WebSocket, + WebRTC, and other APIs. +

+
+
+ `; + + const htmlBottom = `
+ +
+ +`; + +app.get("/", (request, response) => { + let menuHTML = buildMenu("s"); + let html = htmlTop + "\r" + menuHTML + "\r" + htmlBottom; + response.send(html); +}); + +app.use("/s", express.static(path.join(__dirname, "s"))); +app.use("/css", express.static(path.join(__dirname, "css"))); + +// Try to load the key and certificate for HTTPS + +let httpsOptions: ServerOptions = {}; + +try { + httpsOptions.key = fs.readFileSync("/etc/pki/tls/private/mdn-samples.mozilla.org.key"); + httpsOptions.cert = fs.readFileSync("/etc/pki/tls/certs/mdn-samples.mozilla.org.crt"); +} catch(err) { + console.error("Unable to load HTTPS cert and/or key; available on HTTP only: " + err); + httpsOptions = null; +} + +let httpServer = http.createServer(app); +httpServer.listen(httpPort); +httpServer.on("error", (err: Error & {code: string}) => { + if (err.code === "EADDRINUSE") { + httpPort = 8888; + httpServer = http.createServer(app); + httpServer.listen(httpPort); + console.log("Listening on port " + httpPort); + } else { + console.error("HTTP startup error: " + err); + } +}); + +if (httpsOptions) { + let httpServer = https.createServer(httpsOptions, app); + httpServer.listen(443); + console.log("HTTPS listening on port 443"); +} + +const readJSONFile = (pathname: string): T|null => { + const options = { + encoding: "utf8" + }; + + try { + let data = fs.readFileSync(pathname, options); + return JSON.parse(data); + } catch(err) { + console.error(`Error loading JSON data for file ${pathname}: ${err}`); + } + + return null; +}; + +const buildMenuEntry = (manifest): string => { + let {name, docsUrl, description, pathname} = manifest; + let docsLink = `[Documentation]`; + let dt = `
${name}
`; + let dd = `
${escapeHTML(description)} ${docsLink}
`; + return dt+"\n"+dd+"\n"; +}; + +const buildMenuHTML = manifestList => { + let output = ""; + + manifestList.forEach(entry => { + output += buildMenuEntry(entry); + }); + return output; +}; + +function getManifestFromDirectory(pathname: string): {pathname: string, name: string} { + let manifestPath = `${pathname}${path.sep}manifest.json`; + return readJSONFile(manifestPath); +} + +const compareManifests = (a, b) => { + if (a.name < b.name) { + return -1; + } + else if (a.name > b.name) { + return 1; + } + return 0; +}; + +const loadAllManifests = (files, pathname) => { + let manifestList = []; + + files.forEach(entry => { + if (entry.isDirectory()) { + const entryPath = `${pathname}${path.sep}${entry.name}`; + let manifest = getManifestFromDirectory(entryPath); + if (manifest) { + manifest.pathname = entryPath; + manifestList.push(manifest); + } + } + }); + return manifestList.sort(compareManifests); +}; + +function buildMenu(pathname) { + let output = ""; + const readdirOptions: { encoding: BufferEncoding | null; withFileTypes?: false } = { + encoding: "utf8", + withFileTypes: false //true # MUST BE FALSE + }; + + try { + let files = fs.readdirSync(pathname, readdirOptions); + let manifestList = loadAllManifests(files, pathname); + + output = buildMenuHTML(manifestList); + } catch(err) { + console.error("Error reading directory: " + err); + return null; + } + + output = `
\n${output}
\n`; + return output; +} diff --git a/package.json b/package.json index 9b7f935..b67a3a5 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,10 @@ "dependencies": { "escape-html": "^1.0.3", "express": "^4.16.4" + }, + "devDependencies": { + "@types/escape-html": "0.0.20", + "@types/node": "^13.11.1", + "@types/websocket": "^1.0.0" } } diff --git a/s/webrtc-capturestill/capture.ts b/s/webrtc-capturestill/capture.ts new file mode 100644 index 0000000..703027a --- /dev/null +++ b/s/webrtc-capturestill/capture.ts @@ -0,0 +1,97 @@ +(() => { + // The width and height of the captured photo. We will set the + // width to the value defined here, but the height will be + // calculated based on the aspect ratio of the input stream. + + const width = 320; // We will scale the photo width to this + let height = 0; // This will be computed based on the input stream + + // |streaming| indicates whether or not we're currently streaming + // video from the camera. Obviously, we start at false. + + let streaming = false; + + // The various HTML elements we need to configure or control. These + // will be set by the startup() function. + + let video: HTMLVideoElement; + let canvas: HTMLCanvasElement; + let photo: HTMLImageElement; + let startbutton: HTMLButtonElement; + + // Capture a photo by fetching the current contents of the video + // and drawing it into a canvas, then converting that to a PNG + // format data URL. By drawing it on an offscreen canvas and then + // drawing that to the screen, we can change its size and/or apply + // other changes before drawing it. + const takepicture = () => { + const context = canvas.getContext('2d')!; + if (width && height) { + canvas.width = width; + canvas.height = height; + context.drawImage(video, 0, 0, width, height); + + const data = canvas.toDataURL('image/png'); + photo.setAttribute('src', data); + } else { + clearphoto(); + } + }; + + const startup = () => { + video = document.getElementById('video') as HTMLVideoElement; + canvas = document.getElementById('canvas') as HTMLCanvasElement; + photo = document.getElementById('photo') as HTMLImageElement; + startbutton = document.getElementById('startbutton') as HTMLButtonElement; + + navigator.mediaDevices.getUserMedia({video: true, audio: false}) + .then((stream) => { + video.srcObject = stream; + video.play(); + }) + .catch(err => + console.error("An error occurred: ", err) + ); + + video.addEventListener('canplay', ev => { + if (!streaming) { + height = video.videoHeight / (video.videoWidth/width); + + // Firefox currently has a bug where the height can't be read from + // the video, so we will make assumptions if this happens. + + if (isNaN(height)) { + height = width / (4/3); + } + + video.setAttribute('width', width.toString()); + video.setAttribute('height', height.toString()); + canvas.setAttribute('width', width.toString()); + canvas.setAttribute('height', height.toString()); + streaming = true; + } + }, false); + + startbutton.addEventListener('click', ev => { + takepicture(); + ev.preventDefault(); + }, false); + + clearphoto(); + }; + + // Fill the photo with an indication that none has been + // captured. + const clearphoto = () => { + const context = canvas.getContext('2d')!; + context.fillStyle = "#AAA"; + context.fillRect(0, 0, canvas.width, canvas.height); + + const data = canvas.toDataURL('image/png'); + photo.setAttribute('src', data); + }; + + // Set up our event listener to run the startup process + // once loading is complete. + window.addEventListener('load', startup, false); +})(); diff --git a/s/webrtc-capturestill/tsconfig.json b/s/webrtc-capturestill/tsconfig.json new file mode 100644 index 0000000..6a24e9a --- /dev/null +++ b/s/webrtc-capturestill/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/s/webrtc-from-chat/chatclient.ts b/s/webrtc-from-chat/chatclient.ts new file mode 100644 index 0000000..15e978d --- /dev/null +++ b/s/webrtc-from-chat/chatclient.ts @@ -0,0 +1,667 @@ +// WebSocket and WebRTC based multi-user chat sample with two-way video +// calling, including use of TURN if applicable or necessary. +// +// This file contains the JavaScript code that implements the client-side +// features for connecting and managing chat and video calls. +// +// To read about how this sample works: http://bit.ly/webrtc-from-chat +// +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +"use strict"; + +// Get our hostname + +import { IServerMessage } from './chatserver.definitions'; + +let myHostname = window.location.hostname; +if (!myHostname) { + myHostname = "localhost"; +} +log("Hostname: " + myHostname); + +// WebSocket chat/signaling channel variables. + +let connection: WebSocket; +let clientID = 0; + +// The media constraints object describes what sort of stream we want +// to request from the local A/V hardware (typically a webcam and +// microphone). Here, we specify only that we want both audio and +// video; however, you can be more specific. It's possible to state +// that you would prefer (or require) specific resolutions of video, +// whether to prefer the user-facing or rear-facing camera (if available), +// and so on. +// +// See also: +// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints +// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia +// + +const mediaConstraints = { + audio: true, // We want an audio track + video: { + aspectRatio: { + ideal: 1.333333 // 3:2 aspect is preferred + } + } +}; + +let myUsername: string; +let targetUsername: string | null; // To store username of other peer +let myPeerConnection: RTCPeerConnection; // RTCPeerConnection +let transceiver: RTCRtpTransceiver; // RTCRtpTransceiver +let webcamStream: MediaStream; // MediaStream from webcam + +// Output logging information to console. + +function log(text: string) { + const time = new Date(); + + console.log("[" + time.toLocaleTimeString() + "] " + text); +} + +// Output an error message to console. + +const log_error = (text: string|Error) => { + const time = new Date(); + + console.trace("[" + time.toLocaleTimeString() + "] " + text); +}; + +// Send a JavaScript object by converting it to JSON and sending +// it as a message on the WebSocket connection. + +const sendToServer = (msg: IServerMessage) => { + const msgJSON = JSON.stringify(msg); + + log("Sending '" + msg.type + "' message: " + msgJSON); + connection.send(msgJSON); +}; + +// Called when the "id" message is received; this message is sent by the +// server to assign this login session a unique ID number; in response, +// this function sends a "username" message to set our username for this +// session. +const setUsername = () => { + myUsername = (document.getElementById("name") as HTMLInputElement).value; + + sendToServer({ + name: myUsername, + date: Date.now(), + id: clientID, + type: "username" + }); +}; + +// Open and configure the connection to the WebSocket server. + +const connect = () => { + let serverUrl; + let scheme = 'ws'; + + // If this is an HTTPS connection, we have to use a secure WebSocket + // connection too, so add another "s" to the scheme. + + if (document.location.protocol === "https:") { + scheme += "s"; + } + serverUrl = scheme + "://" + myHostname + ":6503"; + + log(`Connecting to server: ${serverUrl}`); + connection = new WebSocket(serverUrl, "json"); + + connection.onopen = evt => { + (document.getElementById("text") as HTMLInputElement).disabled = false; + (document.getElementById("send") as HTMLButtonElement).disabled = false; + }; + + connection.onerror = evt => console.dir(evt); + + connection.onmessage = evt => { + const chatBox = document.querySelector('.chatbox'); + let text = ''; + const msg = JSON.parse(evt.data); + log("Message received: "); + console.dir(msg); + const time = new Date(msg.date); + const timeStr = time.toLocaleTimeString(); + + switch(msg.type) { + case "id": + clientID = msg.id; + setUsername(); + break; + + case "username": + text = "User " + msg.name + " signed in at " + timeStr + "
"; + break; + + case "message": + text = "(" + timeStr + ") " + msg.name + ": " + msg.text + "
"; + break; + + case "rejectusername": + myUsername = msg.name; + text = "Your username has been set to " + myUsername + + " because the name you chose is in use.
"; + break; + + case "userlist": // Received an updated user list + handleUserlistMsg(msg); + break; + + // Signaling messages: these messages are used to trade WebRTC + // signaling information during negotiations leading up to a video + // call. + + case "video-offer": // Invitation and offer to chat + handleVideoOfferMsg(msg); + break; + + case "video-answer": // Callee has answered our offer + handleVideoAnswerMsg(msg); + break; + + case "new-ice-candidate": // A new ICE candidate has been received + handleNewICECandidateMsg(msg); + break; + + case "hang-up": // The other peer has hung up the call + handleHangUpMsg(msg); + break; + + // Unknown message; output to console for debugging. + + default: + log_error("Unknown message received:"); + log_error(msg); + } + + // If there's text to insert into the chat buffer, do so now, then + // scroll the chat panel so that the new text is visible. + + if (text.length) { + chatBox!.innerHTML += text; + chatBox!.scrollTop = chatBox!.scrollHeight - chatBox!.clientHeight; + } + }; +}; + +// Handles a click on the Send button (or pressing return/enter) by +// building a "message" object and sending it to the server. +const handleSendButton = () => { + const textElement = document.getElementById('text') as HTMLInputElement; + const msg = { + text: textElement.value, + type: 'message', + id: clientID, + date: Date.now() + }; + sendToServer(msg); + textElement.value = ""; +}; + +// Handler for keyboard events. This is used to intercept the return and +// enter keys so that we can call send() to transmit the entered text +// to the server. +const handleKey = (evt: KeyboardEvent) => { + if (evt.keyCode === 13 || evt.keyCode === 14 && !(document.getElementById("send") as HTMLButtonElement).disabled) { + handleSendButton(); + } +}; + +// Create the RTCPeerConnection which knows how to talk to our +// selected STUN/TURN server and then uses getUserMedia() to find +// our camera and microphone and add that stream to the connection for +// use in our video call. Then we configure event handlers to get +// needed notifications on the call. + +async function createPeerConnection() { + log("Setting up a connection..."); + + // Create an RTCPeerConnection which knows to use our chosen + // STUN server. + + myPeerConnection = new RTCPeerConnection({ + iceServers: [ // Information about ICE servers - Use your own! + { + urls: "turn:" + myHostname, // A TURN server + username: "webrtc", + credential: "turnserver" + } + ] + }); + + // Set up event handlers for the ICE negotiation process. + + myPeerConnection.onicecandidate = handleICECandidateEvent; + myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent; + myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent; + myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent; + myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent; + myPeerConnection.ontrack = handleTrackEvent; +} + +// Called by the WebRTC layer to let us know when it's time to +// begin, resume, or restart ICE negotiation. + +async function handleNegotiationNeededEvent() { + log("*** Negotiation needed"); + + try { + log("---> Creating offer"); + const offer = await myPeerConnection.createOffer(); + + // If the connection hasn't yet achieved the "stable" state, + // return to the caller. Another negotiationneeded event + // will be fired when the state stabilizes. + + if (myPeerConnection.signalingState != "stable") { + log(" -- The connection isn't stable yet; postponing...") + return; + } + + // Establish the offer as the local peer's current + // description. + + log("---> Setting local description to the offer"); + await myPeerConnection.setLocalDescription(offer); + + // Send the offer to the remote peer. + + log("---> Sending the offer to the remote peer"); + sendToServer({ + name: myUsername, + target: targetUsername, + type: "video-offer", + sdp: myPeerConnection.localDescription + }); + } catch(err) { + log("*** The following error occurred while handling the negotiationneeded event:"); + reportError(err); + } +} + +// Called by the WebRTC layer when events occur on the media tracks +// on our WebRTC call. This includes when streams are added to and +// removed from the call. +// +// track events include the following fields: +// +// RTCRtpReceiver receiver +// MediaStreamTrack track +// MediaStream[] streams +// RTCRtpTransceiver transceiver +// +// In our case, we're just taking the first stream found and attaching +// it to the