diff options
Diffstat (limited to 'cli/js/web')
-rw-r--r-- | cli/js/web/README.md | 8 | ||||
-rw-r--r-- | cli/js/web/console.ts | 776 | ||||
-rw-r--r-- | cli/js/web/console_table.ts | 94 | ||||
-rw-r--r-- | cli/js/web/headers.ts | 2 | ||||
-rw-r--r-- | cli/js/web/performance.ts | 16 | ||||
-rw-r--r-- | cli/js/web/timers.ts | 315 | ||||
-rw-r--r-- | cli/js/web/url.ts | 2 | ||||
-rw-r--r-- | cli/js/web/workers.ts | 170 |
8 files changed, 1381 insertions, 2 deletions
diff --git a/cli/js/web/README.md b/cli/js/web/README.md new file mode 100644 index 000000000..865f4e0fb --- /dev/null +++ b/cli/js/web/README.md @@ -0,0 +1,8 @@ +# Deno Web APIs + +This directory facilities Web APIs that are available in Deno. + +Please note, that some of implementations might not be completely aligned with +specification. + +Some of the Web APIs are using ops under the hood, eg. `console`, `performance`. diff --git a/cli/js/web/console.ts b/cli/js/web/console.ts new file mode 100644 index 000000000..601d5fd09 --- /dev/null +++ b/cli/js/web/console.ts @@ -0,0 +1,776 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +import { isTypedArray } from "../util.ts"; +import { TypedArray } from "../types.ts"; +import { TextEncoder } from "./text_encoding.ts"; +import { File, stdout } from "../files.ts"; +import { cliTable } from "./console_table.ts"; +import { exposeForTest } from "../internals.ts"; + +type ConsoleContext = Set<unknown>; +type ConsoleOptions = Partial<{ + showHidden: boolean; + depth: number; + colors: boolean; + indentLevel: number; +}>; + +// Default depth of logging nested objects +const DEFAULT_MAX_DEPTH = 4; + +// Number of elements an object must have before it's displayed in appreviated +// form. +const OBJ_ABBREVIATE_SIZE = 5; + +const STR_ABBREVIATE_SIZE = 100; + +// Char codes +const CHAR_PERCENT = 37; /* % */ +const CHAR_LOWERCASE_S = 115; /* s */ +const CHAR_LOWERCASE_D = 100; /* d */ +const CHAR_LOWERCASE_I = 105; /* i */ +const CHAR_LOWERCASE_F = 102; /* f */ +const CHAR_LOWERCASE_O = 111; /* o */ +const CHAR_UPPERCASE_O = 79; /* O */ +const CHAR_LOWERCASE_C = 99; /* c */ +export class CSI { + static kClear = "\x1b[1;1H"; + static kClearScreenDown = "\x1b[0J"; +} + +/* eslint-disable @typescript-eslint/no-use-before-define */ + +function cursorTo(stream: File, _x: number, _y?: number): void { + const uint8 = new TextEncoder().encode(CSI.kClear); + stream.writeSync(uint8); +} + +function clearScreenDown(stream: File): void { + const uint8 = new TextEncoder().encode(CSI.kClearScreenDown); + stream.writeSync(uint8); +} + +function getClassInstanceName(instance: unknown): string { + if (typeof instance !== "object") { + return ""; + } + if (!instance) { + return ""; + } + + const proto = Object.getPrototypeOf(instance); + if (proto && proto.constructor) { + return proto.constructor.name; // could be "Object" or "Array" + } + + return ""; +} + +function createFunctionString(value: Function, _ctx: ConsoleContext): string { + // Might be Function/AsyncFunction/GeneratorFunction + const cstrName = Object.getPrototypeOf(value).constructor.name; + if (value.name && value.name !== "anonymous") { + // from MDN spec + return `[${cstrName}: ${value.name}]`; + } + return `[${cstrName}]`; +} + +interface IterablePrintConfig<T> { + typeName: string; + displayName: string; + delims: [string, string]; + entryHandler: ( + entry: T, + ctx: ConsoleContext, + level: number, + maxLevel: number + ) => string; +} + +function createIterableString<T>( + value: Iterable<T>, + ctx: ConsoleContext, + level: number, + maxLevel: number, + config: IterablePrintConfig<T> +): string { + if (level >= maxLevel) { + return `[${config.typeName}]`; + } + ctx.add(value); + + const entries: string[] = []; + // In cases e.g. Uint8Array.prototype + try { + for (const el of value) { + entries.push(config.entryHandler(el, ctx, level + 1, maxLevel)); + } + } catch (e) {} + ctx.delete(value); + const iPrefix = `${config.displayName ? config.displayName + " " : ""}`; + const iContent = entries.length === 0 ? "" : ` ${entries.join(", ")} `; + return `${iPrefix}${config.delims[0]}${iContent}${config.delims[1]}`; +} + +function stringify( + value: unknown, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + switch (typeof value) { + case "string": + return value; + case "number": + // Special handling of -0 + return Object.is(value, -0) ? "-0" : `${value}`; + case "boolean": + case "undefined": + case "symbol": + return String(value); + case "bigint": + return `${value}n`; + case "function": + return createFunctionString(value as Function, ctx); + case "object": + if (value === null) { + return "null"; + } + + if (ctx.has(value)) { + return "[Circular]"; + } + + return createObjectString(value, ctx, level, maxLevel); + default: + return "[Not Implemented]"; + } +} + +// Print strings when they are inside of arrays or objects with quotes +function stringifyWithQuotes( + value: unknown, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + switch (typeof value) { + case "string": + const trunc = + value.length > STR_ABBREVIATE_SIZE + ? value.slice(0, STR_ABBREVIATE_SIZE) + "..." + : value; + return JSON.stringify(trunc); + default: + return stringify(value, ctx, level, maxLevel); + } +} + +function createArrayString( + value: unknown[], + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + const printConfig: IterablePrintConfig<unknown> = { + typeName: "Array", + displayName: "", + delims: ["[", "]"], + entryHandler: (el, ctx, level, maxLevel): string => + stringifyWithQuotes(el, ctx, level + 1, maxLevel) + }; + return createIterableString(value, ctx, level, maxLevel, printConfig); +} + +function createTypedArrayString( + typedArrayName: string, + value: TypedArray, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + const printConfig: IterablePrintConfig<unknown> = { + typeName: typedArrayName, + displayName: typedArrayName, + delims: ["[", "]"], + entryHandler: (el, ctx, level, maxLevel): string => + stringifyWithQuotes(el, ctx, level + 1, maxLevel) + }; + return createIterableString(value, ctx, level, maxLevel, printConfig); +} + +function createSetString( + value: Set<unknown>, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + const printConfig: IterablePrintConfig<unknown> = { + typeName: "Set", + displayName: "Set", + delims: ["{", "}"], + entryHandler: (el, ctx, level, maxLevel): string => + stringifyWithQuotes(el, ctx, level + 1, maxLevel) + }; + return createIterableString(value, ctx, level, maxLevel, printConfig); +} + +function createMapString( + value: Map<unknown, unknown>, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + const printConfig: IterablePrintConfig<[unknown, unknown]> = { + typeName: "Map", + displayName: "Map", + delims: ["{", "}"], + entryHandler: (el, ctx, level, maxLevel): string => { + const [key, val] = el; + return `${stringifyWithQuotes( + key, + ctx, + level + 1, + maxLevel + )} => ${stringifyWithQuotes(val, ctx, level + 1, maxLevel)}`; + } + }; + return createIterableString(value, ctx, level, maxLevel, printConfig); +} + +function createWeakSetString(): string { + return "WeakSet { [items unknown] }"; // as seen in Node +} + +function createWeakMapString(): string { + return "WeakMap { [items unknown] }"; // as seen in Node +} + +function createDateString(value: Date): string { + // without quotes, ISO format + return value.toISOString(); +} + +function createRegExpString(value: RegExp): string { + return value.toString(); +} + +/* eslint-disable @typescript-eslint/ban-types */ + +function createStringWrapperString(value: String): string { + return `[String: "${value.toString()}"]`; +} + +function createBooleanWrapperString(value: Boolean): string { + return `[Boolean: ${value.toString()}]`; +} + +function createNumberWrapperString(value: Number): string { + return `[Number: ${value.toString()}]`; +} + +/* eslint-enable @typescript-eslint/ban-types */ + +// TODO: Promise, requires v8 bindings to get info +// TODO: Proxy + +function createRawObjectString( + value: { [key: string]: unknown }, + ctx: ConsoleContext, + level: number, + maxLevel: number +): string { + if (level >= maxLevel) { + return "[Object]"; + } + ctx.add(value); + + let baseString = ""; + + const className = getClassInstanceName(value); + let shouldShowClassName = false; + if (className && className !== "Object" && className !== "anonymous") { + shouldShowClassName = true; + } + const keys = Object.keys(value); + const entries: string[] = keys.map((key): string => { + if (keys.length > OBJ_ABBREVIATE_SIZE) { + return key; + } else { + return `${key}: ${stringifyWithQuotes( + value[key], + ctx, + level + 1, + maxLevel + )}`; + } + }); + + ctx.delete(value); + + if (entries.length === 0) { + baseString = "{}"; + } else { + baseString = `{ ${entries.join(", ")} }`; + } + + if (shouldShowClassName) { + baseString = `${className} ${baseString}`; + } + + return baseString; +} + +function createObjectString( + value: {}, + ...args: [ConsoleContext, number, number] +): string { + if (customInspect in value && typeof value[customInspect] === "function") { + try { + return String(value[customInspect]!()); + } catch {} + } + if (value instanceof Error) { + return String(value.stack); + } else if (Array.isArray(value)) { + return createArrayString(value, ...args); + } else if (value instanceof Number) { + return createNumberWrapperString(value); + } else if (value instanceof Boolean) { + return createBooleanWrapperString(value); + } else if (value instanceof String) { + return createStringWrapperString(value); + } else if (value instanceof RegExp) { + return createRegExpString(value); + } else if (value instanceof Date) { + return createDateString(value); + } else if (value instanceof Set) { + return createSetString(value, ...args); + } else if (value instanceof Map) { + return createMapString(value, ...args); + } else if (value instanceof WeakSet) { + return createWeakSetString(); + } else if (value instanceof WeakMap) { + return createWeakMapString(); + } else if (isTypedArray(value)) { + return createTypedArrayString( + Object.getPrototypeOf(value).constructor.name, + value, + ...args + ); + } else { + // Otherwise, default object formatting + return createRawObjectString(value, ...args); + } +} + +/** @internal */ +export function stringifyArgs( + args: unknown[], + { depth = DEFAULT_MAX_DEPTH, indentLevel = 0 }: ConsoleOptions = {} +): string { + const first = args[0]; + let a = 0; + let str = ""; + let join = ""; + + if (typeof first === "string") { + let tempStr: string; + let lastPos = 0; + + for (let i = 0; i < first.length - 1; i++) { + if (first.charCodeAt(i) === CHAR_PERCENT) { + const nextChar = first.charCodeAt(++i); + if (a + 1 !== args.length) { + switch (nextChar) { + case CHAR_LOWERCASE_S: + // format as a string + tempStr = String(args[++a]); + break; + case CHAR_LOWERCASE_D: + case CHAR_LOWERCASE_I: + // format as an integer + const tempInteger = args[++a]; + if (typeof tempInteger === "bigint") { + tempStr = `${tempInteger}n`; + } else if (typeof tempInteger === "symbol") { + tempStr = "NaN"; + } else { + tempStr = `${parseInt(String(tempInteger), 10)}`; + } + break; + case CHAR_LOWERCASE_F: + // format as a floating point value + const tempFloat = args[++a]; + if (typeof tempFloat === "symbol") { + tempStr = "NaN"; + } else { + tempStr = `${parseFloat(String(tempFloat))}`; + } + break; + case CHAR_LOWERCASE_O: + case CHAR_UPPERCASE_O: + // format as an object + tempStr = stringify(args[++a], new Set<unknown>(), 0, depth); + break; + case CHAR_PERCENT: + str += first.slice(lastPos, i); + lastPos = i + 1; + continue; + case CHAR_LOWERCASE_C: + // TODO: applies CSS style rules to the output string as specified + continue; + default: + // any other character is not a correct placeholder + continue; + } + + if (lastPos !== i - 1) { + str += first.slice(lastPos, i - 1); + } + + str += tempStr; + lastPos = i + 1; + } else if (nextChar === CHAR_PERCENT) { + str += first.slice(lastPos, i); + lastPos = i + 1; + } + } + } + + if (lastPos !== 0) { + a++; + join = " "; + if (lastPos < first.length) { + str += first.slice(lastPos); + } + } + } + + while (a < args.length) { + const value = args[a]; + str += join; + if (typeof value === "string") { + str += value; + } else { + // use default maximum depth for null or undefined argument + str += stringify(value, new Set<unknown>(), 0, depth); + } + join = " "; + a++; + } + + if (indentLevel > 0) { + const groupIndent = " ".repeat(indentLevel); + if (str.indexOf("\n") !== -1) { + str = str.replace(/\n/g, `\n${groupIndent}`); + } + str = groupIndent + str; + } + + return str; +} + +type PrintFunc = (x: string, isErr?: boolean) => void; + +const countMap = new Map<string, number>(); +const timerMap = new Map<string, number>(); +const isConsoleInstance = Symbol("isConsoleInstance"); + +export class Console { + indentLevel: number; + [isConsoleInstance] = false; + + /** @internal */ + constructor(private printFunc: PrintFunc) { + this.indentLevel = 0; + this[isConsoleInstance] = true; + + // ref https://console.spec.whatwg.org/#console-namespace + // For historical web-compatibility reasons, the namespace object for + // console must have as its [[Prototype]] an empty object, created as if + // by ObjectCreate(%ObjectPrototype%), instead of %ObjectPrototype%. + const console = Object.create({}) as Console; + Object.assign(console, this); + return console; + } + + /** Writes the arguments to stdout */ + log = (...args: unknown[]): void => { + this.printFunc( + stringifyArgs(args, { + indentLevel: this.indentLevel + }) + "\n", + false + ); + }; + + /** Writes the arguments to stdout */ + debug = this.log; + /** Writes the arguments to stdout */ + info = this.log; + + /** Writes the properties of the supplied `obj` to stdout */ + dir = (obj: unknown, options: ConsoleOptions = {}): void => { + this.printFunc(stringifyArgs([obj], options) + "\n", false); + }; + + /** From MDN: + * Displays an interactive tree of the descendant elements of + * the specified XML/HTML element. If it is not possible to display + * as an element the JavaScript Object view is shown instead. + * The output is presented as a hierarchical listing of expandable + * nodes that let you see the contents of child nodes. + * + * Since we write to stdout, we can't display anything interactive + * we just fall back to `console.dir`. + */ + dirxml = this.dir; + + /** Writes the arguments to stdout */ + warn = (...args: unknown[]): void => { + this.printFunc( + stringifyArgs(args, { + indentLevel: this.indentLevel + }) + "\n", + true + ); + }; + + /** Writes the arguments to stdout */ + error = this.warn; + + /** Writes an error message to stdout if the assertion is `false`. If the + * assertion is `true`, nothing happens. + * + * ref: https://console.spec.whatwg.org/#assert + */ + assert = (condition = false, ...args: unknown[]): void => { + if (condition) { + return; + } + + if (args.length === 0) { + this.error("Assertion failed"); + return; + } + + const [first, ...rest] = args; + + if (typeof first === "string") { + this.error(`Assertion failed: ${first}`, ...rest); + return; + } + + this.error(`Assertion failed:`, ...args); + }; + + count = (label = "default"): void => { + label = String(label); + + if (countMap.has(label)) { + const current = countMap.get(label) || 0; + countMap.set(label, current + 1); + } else { + countMap.set(label, 1); + } + + this.info(`${label}: ${countMap.get(label)}`); + }; + + countReset = (label = "default"): void => { + label = String(label); + + if (countMap.has(label)) { + countMap.set(label, 0); + } else { + this.warn(`Count for '${label}' does not exist`); + } + }; + + table = (data: unknown, properties?: string[]): void => { + if (properties !== undefined && !Array.isArray(properties)) { + throw new Error( + "The 'properties' argument must be of type Array. " + + "Received type string" + ); + } + + if (data === null || typeof data !== "object") { + return this.log(data); + } + + const objectValues: { [key: string]: string[] } = {}; + const indexKeys: string[] = []; + const values: string[] = []; + + const stringifyValue = (value: unknown): string => + stringifyWithQuotes(value, new Set<unknown>(), 0, 1); + const toTable = (header: string[], body: string[][]): void => + this.log(cliTable(header, body)); + const createColumn = (value: unknown, shift?: number): string[] => [ + ...(shift ? [...new Array(shift)].map((): string => "") : []), + stringifyValue(value) + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let resultData: any; + const isSet = data instanceof Set; + const isMap = data instanceof Map; + const valuesKey = "Values"; + const indexKey = isSet || isMap ? "(iteration index)" : "(index)"; + + if (data instanceof Set) { + resultData = [...data]; + } else if (data instanceof Map) { + let idx = 0; + resultData = {}; + + data.forEach((v: unknown, k: unknown): void => { + resultData[idx] = { Key: k, Values: v }; + idx++; + }); + } else { + resultData = data!; + } + + Object.keys(resultData).forEach((k, idx): void => { + const value: unknown = resultData[k]!; + + if (value !== null && typeof value === "object") { + Object.entries(value as { [key: string]: unknown }).forEach( + ([k, v]): void => { + if (properties && !properties.includes(k)) { + return; + } + + if (objectValues[k]) { + objectValues[k].push(stringifyValue(v)); + } else { + objectValues[k] = createColumn(v, idx); + } + } + ); + + values.push(""); + } else { + values.push(stringifyValue(value)); + } + + indexKeys.push(k); + }); + + const headerKeys = Object.keys(objectValues); + const bodyValues = Object.values(objectValues); + const header = [ + indexKey, + ...(properties || [ + ...headerKeys, + !isMap && values.length > 0 && valuesKey + ]) + ].filter(Boolean) as string[]; + const body = [indexKeys, ...bodyValues, values]; + + toTable(header, body); + }; + + time = (label = "default"): void => { + label = String(label); + + if (timerMap.has(label)) { + this.warn(`Timer '${label}' already exists`); + return; + } + + timerMap.set(label, Date.now()); + }; + + timeLog = (label = "default", ...args: unknown[]): void => { + label = String(label); + + if (!timerMap.has(label)) { + this.warn(`Timer '${label}' does not exists`); + return; + } + + const startTime = timerMap.get(label) as number; + const duration = Date.now() - startTime; + + this.info(`${label}: ${duration}ms`, ...args); + }; + + timeEnd = (label = "default"): void => { + label = String(label); + + if (!timerMap.has(label)) { + this.warn(`Timer '${label}' does not exists`); + return; + } + + const startTime = timerMap.get(label) as number; + timerMap.delete(label); + const duration = Date.now() - startTime; + + this.info(`${label}: ${duration}ms`); + }; + + group = (...label: unknown[]): void => { + if (label.length > 0) { + this.log(...label); + } + this.indentLevel += 2; + }; + + groupCollapsed = this.group; + + groupEnd = (): void => { + if (this.indentLevel > 0) { + this.indentLevel -= 2; + } + }; + + clear = (): void => { + this.indentLevel = 0; + cursorTo(stdout, 0, 0); + clearScreenDown(stdout); + }; + + trace = (...args: unknown[]): void => { + const message = stringifyArgs(args, { indentLevel: 0 }); + const err = { + name: "Trace", + message + }; + // @ts-ignore + Error.captureStackTrace(err, this.trace); + this.error((err as Error).stack); + }; + + static [Symbol.hasInstance](instance: Console): boolean { + return instance[isConsoleInstance]; + } +} + +/** A symbol which can be used as a key for a custom method which will be called + * when `Deno.inspect()` is called, or when the object is logged to the console. + */ +export const customInspect = Symbol.for("Deno.customInspect"); + +/** + * `inspect()` converts input into string that has the same format + * as printed by `console.log(...)`; + */ +export function inspect( + value: unknown, + { depth = DEFAULT_MAX_DEPTH }: ConsoleOptions = {} +): string { + if (typeof value === "string") { + return value; + } else { + return stringify(value, new Set<unknown>(), 0, depth); + } +} + +// Expose these fields to internalObject for tests. +exposeForTest("Console", Console); +exposeForTest("stringifyArgs", stringifyArgs); diff --git a/cli/js/web/console_table.ts b/cli/js/web/console_table.ts new file mode 100644 index 000000000..276d77f1d --- /dev/null +++ b/cli/js/web/console_table.ts @@ -0,0 +1,94 @@ +// Copyright Joyent, Inc. and other Node contributors. MIT license. +// Forked from Node's lib/internal/cli_table.js + +import { TextEncoder } from "./text_encoding.ts"; +import { hasOwnProperty } from "../util.ts"; + +const encoder = new TextEncoder(); + +const tableChars = { + middleMiddle: "─", + rowMiddle: "┼", + topRight: "┐", + topLeft: "┌", + leftMiddle: "├", + topMiddle: "┬", + bottomRight: "┘", + bottomLeft: "└", + bottomMiddle: "┴", + rightMiddle: "┤", + left: "│ ", + right: " │", + middle: " │ " +}; + +const colorRegExp = /\u001b\[\d\d?m/g; + +function removeColors(str: string): string { + return str.replace(colorRegExp, ""); +} + +function countBytes(str: string): number { + const normalized = removeColors(String(str)).normalize("NFC"); + + return encoder.encode(normalized).byteLength; +} + +function renderRow(row: string[], columnWidths: number[]): string { + let out = tableChars.left; + for (let i = 0; i < row.length; i++) { + const cell = row[i]; + const len = countBytes(cell); + const needed = (columnWidths[i] - len) / 2; + // round(needed) + ceil(needed) will always add up to the amount + // of spaces we need while also left justifying the output. + out += `${" ".repeat(needed)}${cell}${" ".repeat(Math.ceil(needed))}`; + if (i !== row.length - 1) { + out += tableChars.middle; + } + } + out += tableChars.right; + return out; +} + +export function cliTable(head: string[], columns: string[][]): string { + const rows: string[][] = []; + const columnWidths = head.map((h: string): number => countBytes(h)); + const longestColumn = columns.reduce( + (n: number, a: string[]): number => Math.max(n, a.length), + 0 + ); + + for (let i = 0; i < head.length; i++) { + const column = columns[i]; + for (let j = 0; j < longestColumn; j++) { + if (rows[j] === undefined) { + rows[j] = []; + } + const value = (rows[j][i] = hasOwnProperty(column, j) ? column[j] : ""); + const width = columnWidths[i] || 0; + const counted = countBytes(value); + columnWidths[i] = Math.max(width, counted); + } + } + + const divider = columnWidths.map((i: number): string => + tableChars.middleMiddle.repeat(i + 2) + ); + + let result = + `${tableChars.topLeft}${divider.join(tableChars.topMiddle)}` + + `${tableChars.topRight}\n${renderRow(head, columnWidths)}\n` + + `${tableChars.leftMiddle}${divider.join(tableChars.rowMiddle)}` + + `${tableChars.rightMiddle}\n`; + + for (const row of rows) { + result += `${renderRow(row, columnWidths)}\n`; + } + + result += + `${tableChars.bottomLeft}${divider.join(tableChars.bottomMiddle)}` + + tableChars.bottomRight; + + return result; +} diff --git a/cli/js/web/headers.ts b/cli/js/web/headers.ts index 65d52cacd..652dd2de6 100644 --- a/cli/js/web/headers.ts +++ b/cli/js/web/headers.ts @@ -2,7 +2,7 @@ import * as domTypes from "./dom_types.ts"; import { DomIterableMixin } from "./dom_iterable.ts"; import { requiredArguments } from "../util.ts"; -import { customInspect } from "../console.ts"; +import { customInspect } from "./console.ts"; // From node-fetch // Copyright (c) 2016 David Frank. MIT License. diff --git a/cli/js/web/performance.ts b/cli/js/web/performance.ts new file mode 100644 index 000000000..cb4daa846 --- /dev/null +++ b/cli/js/web/performance.ts @@ -0,0 +1,16 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +import { now as opNow } from "../ops/timers.ts"; + +export class Performance { + /** Returns a current time from Deno's start in milliseconds. + * + * Use the flag --allow-hrtime return a precise value. + * + * const t = performance.now(); + * console.log(`${t} ms since start!`); + */ + now(): number { + const res = opNow(); + return res.seconds * 1e3 + res.subsecNanos / 1e6; + } +} diff --git a/cli/js/web/timers.ts b/cli/js/web/timers.ts new file mode 100644 index 000000000..806b7c160 --- /dev/null +++ b/cli/js/web/timers.ts @@ -0,0 +1,315 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +import { assert } from "../util.ts"; +import { startGlobalTimer, stopGlobalTimer } from "../ops/timers.ts"; +import { RBTree } from "../rbtree.ts"; + +const { console } = globalThis; + +interface Timer { + id: number; + callback: () => void; + delay: number; + due: number; + repeat: boolean; + scheduled: boolean; +} + +// Timeout values > TIMEOUT_MAX are set to 1. +const TIMEOUT_MAX = 2 ** 31 - 1; + +let globalTimeoutDue: number | null = null; + +let nextTimerId = 1; +const idMap = new Map<number, Timer>(); +type DueNode = { due: number; timers: Timer[] }; +const dueTree = new RBTree<DueNode>((a, b) => a.due - b.due); + +function clearGlobalTimeout(): void { + globalTimeoutDue = null; + stopGlobalTimer(); +} + +let pendingEvents = 0; +const pendingFireTimers: Timer[] = []; +let hasPendingFireTimers = false; +let pendingScheduleTimers: Timer[] = []; + +async function setGlobalTimeout(due: number, now: number): Promise<void> { + // Since JS and Rust don't use the same clock, pass the time to rust as a + // relative time value. On the Rust side we'll turn that into an absolute + // value again. + const timeout = due - now; + assert(timeout >= 0); + // Send message to the backend. + globalTimeoutDue = due; + pendingEvents++; + // FIXME(bartlomieju): this is problematic, because `clearGlobalTimeout` + // is synchronous. That means that timer is cancelled, but this promise is still pending + // until next turn of event loop. This leads to "leaking of async ops" in tests; + // because `clearTimeout/clearInterval` might be the last statement in test function + // `opSanitizer` will immediately complain that there is pending op going on, unless + // some timeout/defer is put in place to allow promise resolution. + // Ideally `clearGlobalTimeout` doesn't return until this op is resolved, but + // I'm not if that's possible. + await startGlobalTimer(timeout); + pendingEvents--; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + fireTimers(); +} + +function setOrClearGlobalTimeout(due: number | null, now: number): void { + if (due == null) { + clearGlobalTimeout(); + } else { + setGlobalTimeout(due, now); + } +} + +function schedule(timer: Timer, now: number): void { + assert(!timer.scheduled); + assert(now <= timer.due); + // There are more timers pending firing. + // We must ensure new timer scheduled after them. + // Push them to a queue that would be depleted after last pending fire + // timer is fired. + // (This also implies behavior of setInterval) + if (hasPendingFireTimers) { + pendingScheduleTimers.push(timer); + return; + } + // Find or create the list of timers that will fire at point-in-time `due`. + const maybeNewDueNode = { due: timer.due, timers: [] }; + let dueNode = dueTree.find(maybeNewDueNode); + if (dueNode === null) { + dueTree.insert(maybeNewDueNode); + dueNode = maybeNewDueNode; + } + // Append the newly scheduled timer to the list and mark it as scheduled. + dueNode!.timers.push(timer); + timer.scheduled = true; + // If the new timer is scheduled to fire before any timer that existed before, + // update the global timeout to reflect this. + if (globalTimeoutDue === null || globalTimeoutDue > timer.due) { + setOrClearGlobalTimeout(timer.due, now); + } +} + +function unschedule(timer: Timer): void { + // Check if our timer is pending scheduling or pending firing. + // If either is true, they are not in tree, and their idMap entry + // will be deleted soon. Remove it from queue. + let index = -1; + if ((index = pendingScheduleTimers.indexOf(timer)) >= 0) { + pendingScheduleTimers.splice(index); + return; + } + if ((index = pendingFireTimers.indexOf(timer)) >= 0) { + pendingFireTimers.splice(index); + return; + } + // If timer is not in the 2 pending queues and is unscheduled, + // it is not in the tree. + if (!timer.scheduled) { + return; + } + const searchKey = { due: timer.due, timers: [] }; + // Find the list of timers that will fire at point-in-time `due`. + const list = dueTree.find(searchKey)!.timers; + if (list.length === 1) { + // Time timer is the only one in the list. Remove the entire list. + assert(list[0] === timer); + dueTree.remove(searchKey); + // If the unscheduled timer was 'next up', find when the next timer that + // still exists is due, and update the global alarm accordingly. + if (timer.due === globalTimeoutDue) { + const nextDueNode: DueNode | null = dueTree.min(); + setOrClearGlobalTimeout(nextDueNode && nextDueNode.due, Date.now()); + } + } else { + // Multiple timers that are due at the same point in time. + // Remove this timer from the list. + const index = list.indexOf(timer); + assert(index > -1); + list.splice(index, 1); + } +} + +function fire(timer: Timer): void { + // If the timer isn't found in the ID map, that means it has been cancelled + // between the timer firing and the promise callback (this function). + if (!idMap.has(timer.id)) { + return; + } + // Reschedule the timer if it is a repeating one, otherwise drop it. + if (!timer.repeat) { + // One-shot timer: remove the timer from this id-to-timer map. + idMap.delete(timer.id); + } else { + // Interval timer: compute when timer was supposed to fire next. + // However make sure to never schedule the next interval in the past. + const now = Date.now(); + timer.due = Math.max(now, timer.due + timer.delay); + schedule(timer, now); + } + // Call the user callback. Intermediate assignment is to avoid leaking `this` + // to it, while also keeping the stack trace neat when it shows up in there. + const callback = timer.callback; + callback(); +} + +function fireTimers(): void { + const now = Date.now(); + // Bail out if we're not expecting the global timer to fire. + if (globalTimeoutDue === null || pendingEvents > 0) { + return; + } + // After firing the timers that are due now, this will hold the first timer + // list that hasn't fired yet. + let nextDueNode: DueNode | null; + while ((nextDueNode = dueTree.min()) !== null && nextDueNode.due <= now) { + dueTree.remove(nextDueNode); + // Fire all the timers in the list. + for (const timer of nextDueNode.timers) { + // With the list dropped, the timer is no longer scheduled. + timer.scheduled = false; + // Place the callback to pending timers to fire. + pendingFireTimers.push(timer); + } + } + if (pendingFireTimers.length > 0) { + hasPendingFireTimers = true; + // Fire the list of pending timers as a chain of microtasks. + globalThis.queueMicrotask(firePendingTimers); + } else { + setOrClearGlobalTimeout(nextDueNode && nextDueNode.due, now); + } +} + +function firePendingTimers(): void { + if (pendingFireTimers.length === 0) { + // All timer tasks are done. + hasPendingFireTimers = false; + // Schedule all new timers pushed during previous timer executions + const now = Date.now(); + for (const newTimer of pendingScheduleTimers) { + newTimer.due = Math.max(newTimer.due, now); + schedule(newTimer, now); + } + pendingScheduleTimers = []; + // Reschedule for next round of timeout. + const nextDueNode = dueTree.min(); + const due = nextDueNode && Math.max(nextDueNode.due, now); + setOrClearGlobalTimeout(due, now); + } else { + // Fire a single timer and allow its children microtasks scheduled first. + fire(pendingFireTimers.shift()!); + // ...and we schedule next timer after this. + globalThis.queueMicrotask(firePendingTimers); + } +} + +export type Args = unknown[]; + +function checkThis(thisArg: unknown): void { + if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis) { + throw new TypeError("Illegal invocation"); + } +} + +function checkBigInt(n: unknown): void { + if (typeof n === "bigint") { + throw new TypeError("Cannot convert a BigInt value to a number"); + } +} + +function setTimer( + cb: (...args: Args) => void, + delay: number, + args: Args, + repeat: boolean +): number { + // Bind `args` to the callback and bind `this` to globalThis(global). + const callback: () => void = cb.bind(globalThis, ...args); + // In the browser, the delay value must be coercible to an integer between 0 + // and INT32_MAX. Any other value will cause the timer to fire immediately. + // We emulate this behavior. + const now = Date.now(); + if (delay > TIMEOUT_MAX) { + console.warn( + `${delay} does not fit into` + + " a 32-bit signed integer." + + "\nTimeout duration was set to 1." + ); + delay = 1; + } + delay = Math.max(0, delay | 0); + + // Create a new, unscheduled timer object. + const timer = { + id: nextTimerId++, + callback, + args, + delay, + due: now + delay, + repeat, + scheduled: false + }; + // Register the timer's existence in the id-to-timer map. + idMap.set(timer.id, timer); + // Schedule the timer in the due table. + schedule(timer, now); + return timer.id; +} + +/** Sets a timer which executes a function once after the timer expires. */ +export function setTimeout( + cb: (...args: Args) => void, + delay = 0, + ...args: Args +): number { + checkBigInt(delay); + // @ts-ignore + checkThis(this); + return setTimer(cb, delay, args, false); +} + +/** Repeatedly calls a function, with a fixed time delay between each call. */ +export function setInterval( + cb: (...args: Args) => void, + delay = 0, + ...args: Args +): number { + checkBigInt(delay); + // @ts-ignore + checkThis(this); + return setTimer(cb, delay, args, true); +} + +/** Clears a previously set timer by id. AKA clearTimeout and clearInterval. */ +function clearTimer(id: number): void { + id = Number(id); + const timer = idMap.get(id); + if (timer === undefined) { + // Timer doesn't exist any more or never existed. This is not an error. + return; + } + // Unschedule the timer if it is currently scheduled, and forget about it. + unschedule(timer); + idMap.delete(timer.id); +} + +export function clearTimeout(id = 0): void { + checkBigInt(id); + if (id === 0) { + return; + } + clearTimer(id); +} + +export function clearInterval(id = 0): void { + checkBigInt(id); + if (id === 0) { + return; + } + clearTimer(id); +} diff --git a/cli/js/web/url.ts b/cli/js/web/url.ts index 076ec81f1..6ef6b367c 100644 --- a/cli/js/web/url.ts +++ b/cli/js/web/url.ts @@ -2,7 +2,7 @@ import * as urlSearchParams from "./url_search_params.ts"; import * as domTypes from "./dom_types.ts"; import { getRandomValues } from "../ops/get_random_values.ts"; -import { customInspect } from "../console.ts"; +import { customInspect } from "./console.ts"; interface URLParts { protocol: string; diff --git a/cli/js/web/workers.ts b/cli/js/web/workers.ts new file mode 100644 index 000000000..256090d57 --- /dev/null +++ b/cli/js/web/workers.ts @@ -0,0 +1,170 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + createWorker, + hostTerminateWorker, + hostPostMessage, + hostGetMessage +} from "../ops/worker_host.ts"; +import { log } from "../util.ts"; +import { TextDecoder, TextEncoder } from "./text_encoding.ts"; +/* +import { blobURLMap } from "./web/url.ts"; +import { blobBytesWeakMap } from "./web/blob.ts"; +*/ +import { Event } from "./event.ts"; +import { EventTarget } from "./event_target.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function encodeMessage(data: any): Uint8Array { + const dataJson = JSON.stringify(data); + return encoder.encode(dataJson); +} + +function decodeMessage(dataIntArray: Uint8Array): any { + const dataJson = decoder.decode(dataIntArray); + return JSON.parse(dataJson); +} + +interface WorkerEvent { + event: "error" | "msg" | "close"; + data?: any; + error?: any; +} + +export interface Worker { + onerror?: (e: any) => void; + onmessage?: (e: { data: any }) => void; + onmessageerror?: () => void; + postMessage(data: any): void; + terminate(): void; +} + +export interface WorkerOptions { + type?: "classic" | "module"; + name?: string; +} + +export class WorkerImpl extends EventTarget implements Worker { + private readonly id: number; + private isClosing = false; + public onerror?: (e: any) => void; + public onmessage?: (data: any) => void; + public onmessageerror?: () => void; + private name: string; + private terminated = false; + + constructor(specifier: string, options?: WorkerOptions) { + super(); + const { type = "classic", name = "unknown" } = options ?? {}; + + if (type !== "module") { + throw new Error( + 'Not yet implemented: only "module" type workers are supported' + ); + } + + this.name = name; + const hasSourceCode = false; + const sourceCode = decoder.decode(new Uint8Array()); + + /* TODO(bartlomieju): + // Handle blob URL. + if (specifier.startsWith("blob:")) { + hasSourceCode = true; + const b = blobURLMap.get(specifier); + if (!b) { + throw new Error("No Blob associated with the given URL is found"); + } + const blobBytes = blobBytesWeakMap.get(b!); + if (!blobBytes) { + throw new Error("Invalid Blob"); + } + sourceCode = blobBytes!; + } + */ + + const { id } = createWorker( + specifier, + hasSourceCode, + sourceCode, + options?.name + ); + this.id = id; + this.poll(); + } + + private handleError(e: any): boolean { + // TODO: this is being handled in a type unsafe way, it should be type safe + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const event = new Event("error", { cancelable: true }) as any; + event.message = e.message; + event.lineNumber = e.lineNumber ? e.lineNumber + 1 : null; + event.columnNumber = e.columnNumber ? e.columnNumber + 1 : null; + event.fileName = e.fileName; + event.error = null; + + let handled = false; + if (this.onerror) { + this.onerror(event); + if (event.defaultPrevented) { + handled = true; + } + } + + return handled; + } + + async poll(): Promise<void> { + while (!this.terminated) { + const event = await hostGetMessage(this.id); + + // If terminate was called then we ignore all messages + if (this.terminated) { + return; + } + + const type = event.type; + + if (type === "msg") { + if (this.onmessage) { + const message = decodeMessage(new Uint8Array(event.data)); + this.onmessage({ data: message }); + } + continue; + } + + if (type === "error") { + if (!this.handleError(event.error)) { + throw Error(event.error.message); + } + continue; + } + + if (type === "close") { + log(`Host got "close" message from worker: ${this.name}`); + this.terminated = true; + return; + } + + throw new Error(`Unknown worker event: "${type}"`); + } + } + + postMessage(data: any): void { + if (this.terminated) { + return; + } + + hostPostMessage(this.id, encodeMessage(data)); + } + + terminate(): void { + if (!this.terminated) { + this.terminated = true; + hostTerminateWorker(this.id); + } + } +} |