commit 7a6efbcf90244e7d9c4f84b96bafcdc2800d4281 Author: Bruno Windels Date: Fri Nov 27 17:05:20 2020 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..fc78de8 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..5b09efe --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "finalhandler": "^1.1.2", + "serve-static": "^1.14.1" + } +} diff --git a/scripts/serve-local.js b/scripts/serve-local.js new file mode 100644 index 0000000..ae07d18 --- /dev/null +++ b/scripts/serve-local.js @@ -0,0 +1,43 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const finalhandler = require('finalhandler') +const http = require('http') +const serveStatic = require('serve-static') +const path = require('path'); + +// Serve up parent directory with cache disabled +const serve = serveStatic( + path.resolve(__dirname, "../"), + { + etag: false, + setHeaders: res => { + res.setHeader("Pragma", "no-cache"); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); + }, + index: ['index.html', 'index.htm'] + } +); + +// Create server +const server = http.createServer(function onRequest (req, res) { + console.log(req.method, req.url); + serve(req, res, finalhandler(req, res)) +}); + +// Listen +server.listen(3000); diff --git a/src/Link.js b/src/Link.js new file mode 100644 index 0000000..a9fd0b7 --- /dev/null +++ b/src/Link.js @@ -0,0 +1,151 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +import {createEnum} from "./utils/enum.js"; + +const ROOMALIAS_PATTERN = /^#([^:]*):(.+)$/; +const ROOMID_PATTERN = /^!([^:]*):(.+)$/; +const EVENT_WITH_ROOMID_PATTERN = /^[!]([^:]*):(.+)\/\$([^:]+):(.+)$/; +const EVENT_WITH_ROOMALIAS_PATTERN = /^[#]([^:]*):(.+)\/\$([^:]+):(.+)$/; +const USERID_PATTERN = /^@([^:]+):(.+)$/; +const GROUPID_PATTERN = /^\+([^:]+):(.+)$/; + +export const IdentifierKind = createEnum( + "RoomId", + "RoomAlias", + "UserId", + "GroupId", +); + +function asPrefix(identifierKind) { + switch (identifierKind) { + case IdentifierKind.RoomId: return "!"; + case IdentifierKind.RoomAlias: return "#"; + case IdentifierKind.GroupId: return "+"; + case IdentifierKind.UserId: return "@"; + default: throw new Error("invalid id kind " + identifierKind); + } +} + +export const LinkKind = createEnum( + "Room", + "User", + "Group", + "Event" +) + +function orderedUnique(array) { + const copy = []; + for (let i = 0; i < array.length; ++i) { + if (i === 0 || array.lastIndexOf(array[i], i - 1) === -1) { + copy.push(array[i]); + } + } + return copy; +} + +export class Link { + static parseFragment(fragment) { + let [identifier, queryParams] = fragment.split("?"); + + let viaServers = []; + if (queryParams) { + viaServers = queryParams.split("&") + .map(pair => pair.split("=")) + .filter(([key, value]) => key === "via") + .map(([,value]) => value); + } + + if (identifier.startsWith("#/")) { + identifier = identifier.substr(2); + } + + let kind; + let matches; + // longest first, so they dont get caught by ROOMALIAS_PATTERN and ROOMID_PATTERN + matches = EVENT_WITH_ROOMID_PATTERN.exec(identifier); + if (matches) { + const roomServer = matches[2]; + const messageServer = matches[4]; + const roomLocalPart = matches[1]; + const messageLocalPart = matches[3]; + return new Link(viaServers, IdentifierKind.RoomId, roomLocalPart, roomServer, messageLocalPart, messageServer); + } + matches = EVENT_WITH_ROOMALIAS_PATTERN.exec(identifier); + if (matches) { + const roomServer = matches[2]; + const messageServer = matches[4]; + const roomLocalPart = matches[1]; + const messageLocalPart = matches[3]; + return new Link(viaServers, IdentifierKind.RoomAlias, roomLocalPart, roomServer, messageLocalPart, messageServer); + } + matches = USERID_PATTERN.exec(identifier); + if (matches) { + const server = matches[2]; + const localPart = matches[1]; + return new Link(viaServers, IdentifierKind.UserId, localPart, server); + } + matches = ROOMALIAS_PATTERN.exec(identifier); + if (matches) { + const server = matches[2]; + const localPart = matches[1]; + return new Link(viaServers, IdentifierKind.RoomAlias, localPart, server); + } + matches = ROOMID_PATTERN.exec(identifier); + if (matches) { + const server = matches[2]; + const localPart = matches[1]; + return new Link(viaServers, IdentifierKind.RoomId, localPart, server); + } + matches = GROUPID_PATTERN.exec(identifier); + if (matches) { + const server = matches[2]; + const localPart = matches[1]; + return new Link(viaServers, IdentifierKind.GroupId, localPart, server); + } + return; + } + + constructor(viaServers, identifierKind, localPart, server, messageLocalPart = null, messageServer = null) { + const servers = [server]; + if (messageServer) { + servers.push(messageServer); + } + servers.push(...viaServers); + this.servers = orderedUnique(servers); + this.identifierKind = identifierKind; + this.identifier = `${asPrefix(identifierKind)}${localPart}:${server}`; + this.eventId = messageLocalPart ? `$${messageLocalPart}:${messageServer}` : null; + } + + get kind() { + if (this.eventId) { + return LinkKind.Event; + } + switch (this.identifierKind) { + case IdentifierKind.RoomId: + case IdentifierKind.RoomAlias: + return LinkKind.Room; + case IdentifierKind.UserId: + return LinkKind.User; + case IdentifierKind.GroupId: + return LinkKind.Group; + default: + return null; + } + } +} diff --git a/src/TemplateView.js b/src/TemplateView.js new file mode 100644 index 0000000..07dea01 --- /dev/null +++ b/src/TemplateView.js @@ -0,0 +1,345 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; + +/** + Bindable template. Renders once, and allows bindings for given nodes. If you need + to change the structure on a condition, use a subtemplate (if) + + supports + - event handlers (attribute fn value with name that starts with on) + - one way binding of attributes (other attribute fn value) + - one way binding of text values (child fn value) + - refs to get dom nodes + - className binding returning object with className => enabled map + - add subviews inside the template +*/ +// TODO: should we rename this to BoundView or something? As opposed to StaticView ... +export class TemplateView { + constructor(value, render = undefined) { + this._value = value; + this._render = render; + this._eventListeners = null; + this._bindings = null; + this._subViews = null; + this._root = null; + this._boundUpdateFromValue = null; + } + + get value() { + return this._value; + } + + _subscribe() { + if (typeof this._value?.on === "function") { + this._boundUpdateFromValue = this._updateFromValue.bind(this); + this._value.on("change", this._boundUpdateFromValue); + } + } + + _unsubscribe() { + if (this._boundUpdateFromValue) { + if (typeof this._value.off === "function") { + this._value.off("change", this._boundUpdateFromValue); + } + this._boundUpdateFromValue = null; + } + } + + _attach() { + if (this._eventListeners) { + for (let {node, name, fn, useCapture} of this._eventListeners) { + node.addEventListener(name, fn, useCapture); + } + } + } + + _detach() { + if (this._eventListeners) { + for (let {node, name, fn, useCapture} of this._eventListeners) { + node.removeEventListener(name, fn, useCapture); + } + } + } + + mount(options) { + const builder = new TemplateBuilder(this); + if (this._render) { + this._root = this._render(builder, this._value); + } else if (this.render) { // overriden in subclass + this._root = this.render(builder, this._value); + } else { + throw new Error("no render function passed in, or overriden in subclass"); + } + const parentProvidesUpdates = options && options.parentProvidesUpdates; + if (!parentProvidesUpdates) { + this._subscribe(); + } + this._attach(); + return this._root; + } + + unmount() { + this._detach(); + this._unsubscribe(); + if (this._subViews) { + for (const v of this._subViews) { + v.unmount(); + } + } + } + + root() { + return this._root; + } + + update(value) { + this._value = value; + if (this._bindings) { + for (const binding of this._bindings) { + binding(); + } + } + } + + _updateFromValue(changedProps) { + this.update(this._value, changedProps); + } + + _addEventListener(node, name, fn, useCapture = false) { + if (!this._eventListeners) { + this._eventListeners = []; + } + this._eventListeners.push({node, name, fn, useCapture}); + } + + _addBinding(bindingFn) { + if (!this._bindings) { + this._bindings = []; + } + this._bindings.push(bindingFn); + } + + _addSubView(view) { + if (!this._subViews) { + this._subViews = []; + } + this._subViews.push(view); + } +} + +// what is passed to render +class TemplateBuilder { + constructor(templateView) { + this._templateView = templateView; + } + + get _value() { + return this._templateView._value; + } + + addEventListener(node, name, fn, useCapture = false) { + this._templateView._addEventListener(node, name, fn, useCapture); + } + + _addAttributeBinding(node, name, fn) { + let prevValue = undefined; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + setAttribute(node, name, newValue); + } + }; + this._templateView._addBinding(binding); + binding(); + } + + _addClassNamesBinding(node, obj) { + this._addAttributeBinding(node, "className", value => classNames(obj, value)); + } + + _addTextBinding(fn) { + const initialValue = fn(this._value); + const node = text(initialValue); + let prevValue = initialValue; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + node.textContent = newValue+""; + } + }; + + this._templateView._addBinding(binding); + return node; + } + + _setNodeAttributes(node, attributes) { + for(let [key, value] of Object.entries(attributes)) { + const isFn = typeof value === "function"; + // binding for className as object of className => enabled + if (key === "className" && typeof value === "object" && value !== null) { + if (objHasFns(value)) { + this._addClassNamesBinding(node, value); + } else { + setAttribute(node, key, classNames(value)); + } + } else if (key.startsWith("on") && key.length > 2 && isFn) { + const eventName = key.substr(2, 1).toLowerCase() + key.substr(3); + const handler = value; + this._templateView._addEventListener(node, eventName, handler); + } else if (isFn) { + this._addAttributeBinding(node, key, value); + } else { + setAttribute(node, key, value); + } + } + } + + _setNodeChildren(node, children) { + if (!Array.isArray(children)) { + children = [children]; + } + for (let child of children) { + if (typeof child === "function") { + child = this._addTextBinding(child); + } else if (!child.nodeType) { + // not a DOM node, turn into text + child = text(child); + } + node.appendChild(child); + } + } + + _addReplaceNodeBinding(fn, renderNode) { + let prevValue = fn(this._value); + let node = renderNode(null); + + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + const newNode = renderNode(node); + if (node.parentNode) { + node.parentNode.replaceChild(newNode, node); + } + node = newNode; + } + }; + this._templateView._addBinding(binding); + return node; + } + + el(name, attributes, children) { + return this.elNS(HTML_NS, name, attributes, children); + } + + elNS(ns, name, attributes, children) { + if (attributes && isChildren(attributes)) { + children = attributes; + attributes = null; + } + + const node = document.createElementNS(ns, name); + + if (attributes) { + this._setNodeAttributes(node, attributes); + } + if (children) { + this._setNodeChildren(node, children); + } + + return node; + } + + // this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template + // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). + view(view) { + let root; + try { + root = view.mount(); + } catch (err) { + return errorToDOM(err); + } + this._templateView._addSubView(view); + return root; + } + + // sugar + createTemplate(render) { + return vm => new TemplateView(vm, render); + } + + // map a value to a view, every time the value changes + mapView(mapFn, viewCreator) { + return this._addReplaceNodeBinding(mapFn, (prevNode) => { + if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) { + const subViews = this._templateView._subViews; + const viewIdx = subViews.findIndex(v => v.root() === prevNode); + if (viewIdx !== -1) { + const [view] = subViews.splice(viewIdx, 1); + view.unmount(); + } + } + const view = viewCreator(mapFn(this._value)); + if (view) { + return this.view(view); + } else { + return document.createComment("node binding placeholder"); + } + }); + } + + // creates a conditional subtemplate + if(fn, viewCreator) { + return this.mapView( + value => !!fn(value), + enabled => enabled ? viewCreator(this._value) : null + ); + } +} + + +function errorToDOM(error) { + const stack = new Error().stack; + const callee = stack.split("\n")[1]; + return tag.div([ + tag.h2("Something went wrong…"), + tag.h3(error.message), + tag.p(`This occurred while running ${callee}.`), + tag.pre(error.stack), + ]); +} + +function objHasFns(obj) { + for(const value of Object.values(obj)) { + if (typeof value === "function") { + return true; + } + } + return false; +} + +for (const [ns, tags] of Object.entries(TAG_NAMES)) { + for (const tag of tags) { + TemplateBuilder.prototype[tag] = function(attributes, children) { + return this.elNS(ns, tag, attributes, children); + }; + } +} \ No newline at end of file diff --git a/src/ViewModel.js b/src/ViewModel.js new file mode 100644 index 0000000..3d00dec --- /dev/null +++ b/src/ViewModel.js @@ -0,0 +1,59 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class EventEmitter { + constructor() { + this._handlersByName = {}; + } + + emit(name, ...values) { + const handlers = this._handlersByName[name]; + if (handlers) { + for(const h of handlers) { + h(...values); + } + } + } + + on(name, callback) { + let handlers = this._handlersByName[name]; + if (!handlers) { + this.onFirstSubscriptionAdded(name); + this._handlersByName[name] = handlers = new Set(); + } + handlers.add(callback); + return () => { + this.off(name, callback); + } + } + + off(name, callback) { + const handlers = this._handlersByName[name]; + if (handlers) { + handlers.delete(callback); + if (handlers.length === 0) { + delete this._handlersByName[name]; + this.onLastSubscriptionRemoved(name); + } + } + } +} + +export class ViewModel extends EventEmitter { + emitChange() { + this.emit("change"); + } +} \ No newline at end of file diff --git a/src/create/CreateLinkViewModel.js b/src/create/CreateLinkViewModel.js new file mode 100644 index 0000000..4318a45 --- /dev/null +++ b/src/create/CreateLinkViewModel.js @@ -0,0 +1,26 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class CreateLinkViewModel extends ViewModel { + createLink(identifier) { + this._link = Link.fromIdentifier(identifier); + this.emitChange(); + } + + get link() { + this._link.toURL(); + } +} \ No newline at end of file diff --git a/src/html.js b/src/html.js new file mode 100644 index 0000000..c391a00 --- /dev/null +++ b/src/html.js @@ -0,0 +1,111 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// DOM helper functions + +export function isChildren(children) { + // children should be an not-object (that's the attributes), or a domnode, or an array + return typeof children !== "object" || !!children.nodeType || Array.isArray(children); +} + +export function classNames(obj, value) { + return Object.entries(obj).reduce((cn, [name, enabled]) => { + if (typeof enabled === "function") { + enabled = enabled(value); + } + if (enabled) { + return cn + (cn.length ? " " : "") + name; + } else { + return cn; + } + }, ""); +} + +export function setAttribute(el, name, value) { + if (name === "className") { + name = "class"; + } + if (value === false) { + el.removeAttribute(name); + } else { + if (value === true) { + value = name; + } + el.setAttribute(name, value); + } +} + +export function el(elementName, attributes, children) { + return elNS(HTML_NS, elementName, attributes, children); +} + +export function elNS(ns, elementName, attributes, children) { + if (attributes && isChildren(attributes)) { + children = attributes; + attributes = null; + } + + const e = document.createElementNS(ns, elementName); + + if (attributes) { + for (let [name, value] of Object.entries(attributes)) { + if (name === "className" && typeof value === "object" && value !== null) { + value = classNames(value); + } + setAttribute(e, name, value); + } + } + + if (children) { + if (!Array.isArray(children)) { + children = [children]; + } + for (let c of children) { + if (!c.nodeType) { + c = text(c); + } + e.appendChild(c); + } + } + return e; +} + +export function text(str) { + return document.createTextNode(str); +} + +export const HTML_NS = "http://www.w3.org/1999/xhtml"; +export const SVG_NS = "http://www.w3.org/2000/svg"; + +export const TAG_NAMES = { + [HTML_NS]: [ + "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", + "p", "strong", "em", "span", "img", "section", "main", "article", "aside", + "pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"], + [SVG_NS]: ["svg", "circle"] +}; + +export const tag = {}; + + +for (const [ns, tags] of Object.entries(TAG_NAMES)) { + for (const tagName of tags) { + tag[tagName] = function(attributes, children) { + return elNS(ns, tagName, attributes, children); + } + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b1433ab --- /dev/null +++ b/src/main.js @@ -0,0 +1,16 @@ +import {xhrRequest} from "./utils/xhr.js"; +import {validateHomeServer} from "./matrix/HomeServer.js"; +import {Link, LinkKind} from "./Link.js"; + +export async function main() { + const link = Link.parseFragment(location.hash); + if (!link) { + throw new Error("bad link"); + } + const hs = await validateHomeServer(xhrRequest, link.servers[0]); + if (link.kind === LinkKind.User) { + const profile = await hs.getUserProfile(link.identifier); + const imageURL = hs.mxcUrlThumbnail(profile.avatar_url, 64, 64, "crop"); + console.log(imageURL); + } +} \ No newline at end of file diff --git a/src/matrix/HomeServer.js b/src/matrix/HomeServer.js new file mode 100644 index 0000000..016825b --- /dev/null +++ b/src/matrix/HomeServer.js @@ -0,0 +1,76 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export async function validateHomeServer(request, baseURL) { + if (!baseURL.startsWith("http://") && !baseURL.startsWith("https://")) { + baseURL = `https://${baseURL}`; + } + { + const {status, body} = await request(`${baseURL}/.well-known/matrix/client`, {method: "GET"}).response(); + if (status === 200) { + const proposedBaseURL = body?.['m.homeserver']?.base_url; + if (typeof proposedBaseURL === "string") { + baseURL = proposedBaseURL; + } + } + } + { + const {status} = await request(`${baseURL}/_matrix/client/versions`, {method: "GET"}).response(); + if (status !== 200) { + throw new Error(`Invalid versions response from ${baseURL}`); + } + } + return new HomeServer(request, baseURL); +} + +export class HomeServer { + constructor(request, baseURL) { + this._request = request; + this.baseURL = baseURL; + } + + async getUserProfile(userId) { + const {body} = await this._request(`${this.baseURL}/_matrix/client/r0/profile/${userId}`, {method: "GET"}).response(); + return body; + } + + getGroupProfile(groupId) { + //`/_matrix/client/r0/groups/${groupId}/profile` + } + + getPublicRooms() { + + } + + mxcUrlThumbnail(url, width, height, method) { + const parts = parseMxcUrl(url); + if (parts) { + const [serverName, mediaId] = parts; + const httpUrl = `${this.baseURL}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + return httpUrl + `?width=${width}&height=${height}&method=${method}`; + } + return null; + } +} + +function parseMxcUrl(url) { + const prefix = "mxc://"; + if (url.startsWith(prefix)) { + return url.substr(prefix.length).split("/", 2); + } else { + return null; + } +} \ No newline at end of file diff --git a/src/utils/enum.js b/src/utils/enum.js new file mode 100644 index 0000000..4defcfd --- /dev/null +++ b/src/utils/enum.js @@ -0,0 +1,26 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function createEnum(...values) { + const obj = {}; + for (const value of values) { + if (typeof value !== "string") { + throw new Error("Invalid enum value name" + value?.toString()); + } + obj[value] = value; + } + return Object.freeze(obj); +} diff --git a/src/utils/error.js b/src/utils/error.js new file mode 100644 index 0000000..d61e803 --- /dev/null +++ b/src/utils/error.js @@ -0,0 +1,32 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class ConnectionError extends Error { + constructor(message, isTimeout) { + super(message || "ConnectionError"); + this.isTimeout = isTimeout; + } + + get name() { + return "ConnectionError"; + } +} + +export class AbortError extends Error { + get name() { + return "AbortError"; + } +} \ No newline at end of file diff --git a/src/utils/xhr.js b/src/utils/xhr.js new file mode 100644 index 0000000..555542d --- /dev/null +++ b/src/utils/xhr.js @@ -0,0 +1,97 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + AbortError, + ConnectionError +} from "./error.js"; + +function addCacheBuster(urlStr, random = Math.random) { + // XHR doesn't have a good way to disable cache, + // so add a random query param + // see https://davidtranscend.com/blog/prevent-ie11-cache-ajax-requests/ + if (urlStr.includes("?")) { + urlStr = urlStr + "&"; + } else { + urlStr = urlStr + "?"; + } + return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`; +} + +class RequestResult { + constructor(promise, xhr) { + this._promise = promise; + this._xhr = xhr; + } + + abort() { + this._xhr.abort(); + } + + response() { + return this._promise; + } +} + +function createXhr(url, {method, headers, timeout, uploadProgress}) { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + + if (headers) { + for(const [name, value] of headers.entries()) { + try { + xhr.setRequestHeader(name, value); + } catch (err) { + console.info(`Could not set ${name} header: ${err.message}`); + } + } + } + if (timeout) { + xhr.timeout = timeout; + } + + if (uploadProgress) { + xhr.upload.addEventListener("progress", evt => uploadProgress(evt.loaded)); + } + + return xhr; +} + +function xhrAsPromise(xhr, method, url) { + return new Promise((resolve, reject) => { + xhr.addEventListener("load", () => resolve(xhr)); + xhr.addEventListener("abort", () => reject(new AbortError())); + xhr.addEventListener("error", () => reject(new ConnectionError(`Error ${method} ${url}`))); + xhr.addEventListener("timeout", () => reject(new ConnectionError(`Timeout ${method} ${url}`, true))); + }); +} + +export function xhrRequest(url, options) { + let {cache, body, method} = options; + if (!cache) { + url = addCacheBuster(url); + } + const xhr = createXhr(url, options); + const promise = xhrAsPromise(xhr, method, url).then(xhr => { + const {status} = xhr; + const body = JSON.parse(xhr.responseText); + return {status, body}; + }); + + xhr.send(body || null); + + return new RequestResult(promise, xhr); +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..9a9801b --- /dev/null +++ b/yarn.lock @@ -0,0 +1,155 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +destroy@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" + integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +finalhandler@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + +http-errors@~1.7.2: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + dependencies: + ee-first "1.1.1" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +send@0.17.1: + version "0.17.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" + integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.7.2" + mime "1.6.0" + ms "2.1.1" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + +serve-static@^1.14.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" + integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.1" + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + +"statuses@>= 1.5.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=