Switch to monorepo setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
build
|
||||
normalize-url
|
||||
@@ -0,0 +1,14 @@
|
||||
export type Deferred<T> = Promise<T> & {
|
||||
resolve: (arg: T) => void
|
||||
reject: (arg: T) => void
|
||||
}
|
||||
|
||||
export const defer = <T>(): Deferred<T> => {
|
||||
let resolve, reject
|
||||
const p = new Promise((resolve_, reject_) => {
|
||||
resolve = resolve_
|
||||
reject = reject_
|
||||
})
|
||||
|
||||
return (Object.assign(p, {resolve, reject}) as unknown) as Deferred<T>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {EventEmitter} from 'events'
|
||||
|
||||
export class Emitter extends EventEmitter {
|
||||
emit(type: string | number, ...args: any[]) {
|
||||
const a = super.emit(type, ...args)
|
||||
const b = super.emit('*', type, ...args)
|
||||
|
||||
return a && b
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {last} from './Tools'
|
||||
|
||||
export class Fluent<T> {
|
||||
constructor(readonly xs: T[]) {}
|
||||
|
||||
static create() {
|
||||
return this.from([])
|
||||
}
|
||||
|
||||
static from<T>(xs: Iterable<T>) {
|
||||
return new Fluent<T>(Array.from(xs))
|
||||
}
|
||||
|
||||
clone<K extends Fluent<T>>(this: K, xs: T[]): K {
|
||||
return new (this.constructor as { new (xs: T[]): K })(xs)
|
||||
}
|
||||
|
||||
valueOf = () => this.xs
|
||||
|
||||
first = () => this.xs[0]
|
||||
|
||||
nth = (i: number) => this.xs[i]
|
||||
|
||||
last = () => last(this.xs)
|
||||
|
||||
count = () => this.xs.length
|
||||
|
||||
exists = () => this.xs.length > 0
|
||||
|
||||
has = (v: T) => this.xs.includes(v)
|
||||
|
||||
every = (f: (t: T) => boolean) => this.xs.every(f)
|
||||
|
||||
some = (f: (t: T) => boolean) => this.xs.some(f)
|
||||
|
||||
find = (f: (t: T) => boolean) => this.xs.find(f)
|
||||
|
||||
uniq = () => this.clone(Array.from(new Set(this.xs)))
|
||||
|
||||
slice = (a: number, b?: number) => this.clone(this.xs.slice(a, b))
|
||||
|
||||
take = (n: number) => this.slice(0, n)
|
||||
|
||||
drop = (n: number) => this.slice(n)
|
||||
|
||||
filter = (f: (t: T) => boolean) => this.clone(this.xs.filter(f))
|
||||
|
||||
reject = (f: (t: T) => boolean) => this.clone(this.xs.filter(t => !f(t)))
|
||||
|
||||
map = (f: (t: T) => T) => this.clone(this.xs.map(f))
|
||||
|
||||
mapTo = <U>(f: (t: T) => U) => Fluent.from(this.xs.map(f))
|
||||
|
||||
flatMap = <U>(f: (t: T) => U[]) => Fluent.from(this.xs.flatMap(f))
|
||||
|
||||
forEach = (f: (t: T, i: number) => void) => this.xs.forEach(f)
|
||||
|
||||
set = (i: number, x: T) => this.clone([...this.xs.slice(0, i), x, ...this.xs.slice(i + 1)])
|
||||
|
||||
concat = (xs: T[]) => this.clone(this.xs.concat(xs))
|
||||
|
||||
append = (x: T) => this.concat([x])
|
||||
|
||||
prepend = (x: T) => this.clone([x].concat(this.xs))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export class LRUCache<T, U> {
|
||||
map = new Map<T, U>()
|
||||
keys: T[] = []
|
||||
|
||||
constructor(readonly maxSize: number) {}
|
||||
|
||||
has(k: T) {
|
||||
return this.map.has(k)
|
||||
}
|
||||
|
||||
get(k: T) {
|
||||
const v = this.map.get(k)
|
||||
|
||||
if (v !== undefined) {
|
||||
this.keys.push(k as T)
|
||||
|
||||
if (this.keys.length > this.maxSize * 2) {
|
||||
this.keys.splice(-this.maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
set(k: T, v: U) {
|
||||
this.map.set(k, v)
|
||||
this.keys.push(k)
|
||||
|
||||
if (this.map.size > this.maxSize) {
|
||||
this.map.delete(this.keys.shift() as T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cached<T, V, Args extends any[]>({
|
||||
maxSize,
|
||||
getKey,
|
||||
getValue,
|
||||
}: {
|
||||
maxSize: number
|
||||
getKey: (args: Args) => T
|
||||
getValue: (args: Args) => V
|
||||
}) {
|
||||
const cache = new LRUCache<T, V>(maxSize)
|
||||
|
||||
const get = (...args: Args) => {
|
||||
const k = getKey(args)
|
||||
|
||||
let v = cache.get(k)
|
||||
|
||||
if (!v) {
|
||||
v = getValue(args)
|
||||
|
||||
cache.set(k, v)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
get.cache = cache
|
||||
get.getKey = getKey
|
||||
get.getValue = getValue
|
||||
|
||||
return get
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export class Queue {
|
||||
timeout?: number
|
||||
messages: any[] = []
|
||||
|
||||
clear() {
|
||||
this.messages = []
|
||||
}
|
||||
|
||||
push(message: any) {
|
||||
this.messages.push(message)
|
||||
this.enqueueWork()
|
||||
}
|
||||
|
||||
handle(message: any) {
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
|
||||
shouldSend(message: any) {
|
||||
return true
|
||||
}
|
||||
|
||||
doWork() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (this.messages.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Pop the messages one at a time so handle can modify the queue
|
||||
const [message] = this.messages.splice(0, 1)
|
||||
|
||||
if (this.shouldSend(message)) {
|
||||
this.handle(message)
|
||||
} else {
|
||||
this.messages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
this.timeout = undefined
|
||||
this.enqueueWork()
|
||||
}
|
||||
|
||||
enqueueWork() {
|
||||
if (this.timeout) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => this.doWork(), 100)
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const now = () => Math.round(Date.now() / 1000)
|
||||
|
||||
export const nth = (i: number) => <T>(xs: T[]) => xs[i]
|
||||
|
||||
export const first = <T>(xs: T[]) => xs[0]
|
||||
|
||||
export const last = <T>(xs: T[]) => xs[xs.length - 1]
|
||||
|
||||
export const identity = <T>(x: T) => x
|
||||
|
||||
export const between = (low: number, high: number, n: number) => n > low && n < high
|
||||
|
||||
export const flatten = <T>(xs: T[]) => xs.flatMap(identity)
|
||||
|
||||
export const uniq = <T>(xs: T[]) => Array.from(new Set(xs))
|
||||
|
||||
export const shuffle = <T>(xs: T[]): T[] => xs.sort(() => Math.random() > 0.5 ? 1 : -1)
|
||||
|
||||
export const isIterable = (x: any) => Symbol.iterator in Object(x)
|
||||
|
||||
export const toIterable = (x: any) => isIterable(x) ? x : [x]
|
||||
|
||||
export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "")
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||
export type OmitStatics<T, S extends string> =
|
||||
T extends {new(...args: infer A): infer R} ?
|
||||
{new(...args: A): R}&Omit<T, S> :
|
||||
Omit<T, S>;
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/4628#issuecomment-1147905253
|
||||
export type OmitAllStatics<T extends {new(...args: any[]): any, prototype: any}> =
|
||||
T extends {new(...args: infer A): infer R, prototype: infer P} ?
|
||||
{new(...args: A): R, prototype: P} :
|
||||
never;
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './Deferred'
|
||||
export * from './Emitter'
|
||||
export * from './Fluent'
|
||||
export * from './LRUCache'
|
||||
export * from './Queue'
|
||||
export * from './Tools'
|
||||
export {default as normalizeUrl} from './normalize-url'
|
||||
@@ -0,0 +1,583 @@
|
||||
export type Options = {
|
||||
/**
|
||||
@default 'http'
|
||||
*/
|
||||
readonly defaultProtocol?: 'https' | 'http';
|
||||
|
||||
/**
|
||||
Prepends `defaultProtocol` to the URL if it's protocol-relative.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('//sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('//sindresorhus.com', {normalizeProtocol: false});
|
||||
//=> '//sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly normalizeProtocol?: boolean;
|
||||
|
||||
/**
|
||||
Normalizes HTTPS URLs to HTTP.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('https://sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('https://sindresorhus.com', {forceHttp: true});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly forceHttp?: boolean;
|
||||
|
||||
/**
|
||||
Normalizes HTTP URLs to HTTPS.
|
||||
|
||||
This option cannot be used with the `forceHttp` option at the same time.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('http://sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com', {forceHttps: true});
|
||||
//=> 'https://sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly forceHttps?: boolean;
|
||||
|
||||
/**
|
||||
Strip the [authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) part of a URL.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('user:password@sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('user:password@sindresorhus.com', {stripAuthentication: false});
|
||||
//=> 'https://user:password@sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly stripAuthentication?: boolean;
|
||||
|
||||
/**
|
||||
Removes hash from the URL.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('sindresorhus.com/about.html#contact');
|
||||
//=> 'http://sindresorhus.com/about.html#contact'
|
||||
|
||||
normalizeUrl('sindresorhus.com/about.html#contact', {stripHash: true});
|
||||
//=> 'http://sindresorhus.com/about.html'
|
||||
```
|
||||
*/
|
||||
readonly stripHash?: boolean;
|
||||
|
||||
/**
|
||||
Remove the protocol from the URL: `http://sindresorhus.com` → `sindresorhus.com`.
|
||||
|
||||
It will only remove `https://` and `http://` protocols.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('https://sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('sindresorhus.com', {stripProtocol: true});
|
||||
//=> 'sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly stripProtocol?: boolean;
|
||||
|
||||
/**
|
||||
Strip the [text fragment](https://web.dev/text-fragments/) part of the URL
|
||||
|
||||
__Note:__ The text fragment will always be removed if the `stripHash` option is set to `true`, as the hash contains the text fragment.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('http://sindresorhus.com/about.html#:~:text=hello');
|
||||
//=> 'http://sindresorhus.com/about.html#'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#section:~:text=hello');
|
||||
//=> 'http://sindresorhus.com/about.html#section'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#:~:text=hello', {stripTextFragment: false});
|
||||
//=> 'http://sindresorhus.com/about.html#:~:text=hello'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#section:~:text=hello', {stripTextFragment: false});
|
||||
//=> 'http://sindresorhus.com/about.html#section:~:text=hello'
|
||||
```
|
||||
*/
|
||||
readonly stripTextFragment?: boolean;
|
||||
|
||||
/**
|
||||
Removes `www.` from the URL.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('http://www.sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('http://www.sindresorhus.com', {stripWWW: false});
|
||||
//=> 'http://www.sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly stripWWW?: boolean;
|
||||
|
||||
/**
|
||||
Removes query parameters that matches any of the provided strings or regexes.
|
||||
|
||||
@default [/^utm_\w+/i]
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar&ref=test_ref', {
|
||||
removeQueryParameters: ['ref']
|
||||
});
|
||||
//=> 'http://sindresorhus.com/?foo=bar'
|
||||
```
|
||||
|
||||
If a boolean is provided, `true` will remove all the query parameters.
|
||||
|
||||
```
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar', {
|
||||
removeQueryParameters: true
|
||||
});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
|
||||
`false` will not remove any query parameter.
|
||||
|
||||
```
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar&utm_medium=test&ref=test_ref', {
|
||||
removeQueryParameters: false
|
||||
});
|
||||
//=> 'http://www.sindresorhus.com/?foo=bar&ref=test_ref&utm_medium=test'
|
||||
```
|
||||
*/
|
||||
readonly removeQueryParameters?: ReadonlyArray<RegExp | string> | boolean;
|
||||
|
||||
/**
|
||||
Keeps only query parameters that matches any of the provided strings or regexes.
|
||||
|
||||
__Note__: It overrides the `removeQueryParameters` option.
|
||||
|
||||
@default undefined
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('https://sindresorhus.com?foo=bar&ref=unicorn', {
|
||||
keepQueryParameters: ['ref']
|
||||
});
|
||||
//=> 'https://sindresorhus.com/?ref=unicorn'
|
||||
```
|
||||
*/
|
||||
readonly keepQueryParameters?: ReadonlyArray<RegExp | string>;
|
||||
|
||||
/**
|
||||
Removes trailing slash.
|
||||
|
||||
__Note__: Trailing slash is always removed if the URL doesn't have a pathname unless the `removeSingleSlash` option is set to `false`.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('http://sindresorhus.com/redirect/');
|
||||
//=> 'http://sindresorhus.com/redirect'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/redirect/', {removeTrailingSlash: false});
|
||||
//=> 'http://sindresorhus.com/redirect/'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/', {removeTrailingSlash: false});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly removeTrailingSlash?: boolean;
|
||||
|
||||
/**
|
||||
Remove a sole `/` pathname in the output. This option is independent of `removeTrailingSlash`.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('https://sindresorhus.com/');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('https://sindresorhus.com/', {removeSingleSlash: false});
|
||||
//=> 'https://sindresorhus.com/'
|
||||
```
|
||||
*/
|
||||
readonly removeSingleSlash?: boolean;
|
||||
|
||||
/**
|
||||
Removes the default directory index file from path that matches any of the provided strings or regexes.
|
||||
When `true`, the regex `/^index\.[a-z]+$/` is used.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('www.sindresorhus.com/foo/default.php', {
|
||||
removeDirectoryIndex: [/^default\.[a-z]+$/]
|
||||
});
|
||||
//=> 'http://sindresorhus.com/foo'
|
||||
```
|
||||
*/
|
||||
readonly removeDirectoryIndex?: boolean | ReadonlyArray<RegExp | string>;
|
||||
|
||||
/**
|
||||
Removes an explicit port number from the URL.
|
||||
|
||||
Port 443 is always removed from HTTPS URLs and 80 is always removed from HTTP URLs regardless of this option.
|
||||
|
||||
@default false
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('sindresorhus.com:123', {
|
||||
removeExplicitPort: true
|
||||
});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
*/
|
||||
readonly removeExplicitPort?: boolean;
|
||||
|
||||
/**
|
||||
Sorts the query parameters alphabetically by key.
|
||||
|
||||
@default true
|
||||
|
||||
@example
|
||||
```
|
||||
normalizeUrl('www.sindresorhus.com?b=two&a=one&c=three', {
|
||||
sortQueryParameters: false
|
||||
});
|
||||
//=> 'http://sindresorhus.com/?b=two&a=one&c=three'
|
||||
```
|
||||
*/
|
||||
readonly sortQueryParameters?: boolean;
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
|
||||
const DATA_URL_DEFAULT_MIME_TYPE = 'text/plain'
|
||||
const DATA_URL_DEFAULT_CHARSET = 'us-ascii'
|
||||
|
||||
const testParameter = (name: string, filters: any[]) => filters.some(filter => filter instanceof RegExp ? filter.test(name) : filter === name)
|
||||
|
||||
const supportedProtocols = new Set([
|
||||
'https:',
|
||||
'http:',
|
||||
'file:',
|
||||
])
|
||||
|
||||
const hasCustomProtocol = (urlString: string) => {
|
||||
try {
|
||||
const {protocol} = new URL(urlString)
|
||||
return protocol.endsWith(':') && !supportedProtocols.has(protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeDataURL = (urlString: string, {stripHash}: {stripHash: boolean}) => {
|
||||
const match = /^data:(?<type>[^,]*?),(?<data>[^#]*?)(?:#(?<hash>.*))?$/.exec(urlString)
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid URL: ${urlString}`)
|
||||
}
|
||||
|
||||
let {type, data, hash} = match.groups as any
|
||||
const mediaType = type.split(';')
|
||||
hash = stripHash ? '' : hash
|
||||
|
||||
let isBase64 = false
|
||||
if (mediaType[mediaType.length - 1] === 'base64') {
|
||||
mediaType.pop()
|
||||
isBase64 = true
|
||||
}
|
||||
|
||||
// Lowercase MIME type
|
||||
const mimeType = mediaType.shift()?.toLowerCase() ?? ''
|
||||
const attributes = mediaType
|
||||
.map((attribute: string) => {
|
||||
let [key, value = ''] = attribute.split('=').map((s: string) => s.trim())
|
||||
|
||||
// Lowercase `charset`
|
||||
if (key === 'charset') {
|
||||
value = value.toLowerCase()
|
||||
|
||||
if (value === DATA_URL_DEFAULT_CHARSET) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return `${key}${value ? `=${value}` : ''}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const normalizedMediaType = [
|
||||
...attributes,
|
||||
]
|
||||
|
||||
if (isBase64) {
|
||||
normalizedMediaType.push('base64')
|
||||
}
|
||||
|
||||
if (normalizedMediaType.length > 0 || (mimeType && mimeType !== DATA_URL_DEFAULT_MIME_TYPE)) {
|
||||
normalizedMediaType.unshift(mimeType)
|
||||
}
|
||||
|
||||
return `data:${normalizedMediaType.join(';')},${isBase64 ? data.trim() : data}${hash ? `#${hash}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
[Normalize](https://en.wikipedia.org/wiki/URL_normalization) a URL.
|
||||
|
||||
URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`.
|
||||
|
||||
@param url - URL to normalize, including [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs).
|
||||
|
||||
@example
|
||||
```
|
||||
import normalizeUrl from 'normalize-url';
|
||||
|
||||
normalizeUrl('sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('//www.sindresorhus.com:80/../baz?b=bar&a=foo');
|
||||
//=> 'http://sindresorhus.com/baz?a=foo&b=bar'
|
||||
```
|
||||
*/
|
||||
|
||||
export default function normalizeUrl(urlString: string, opts?: Options): string {
|
||||
const options = {
|
||||
defaultProtocol: 'http',
|
||||
normalizeProtocol: true,
|
||||
forceHttp: false,
|
||||
forceHttps: false,
|
||||
stripAuthentication: true,
|
||||
stripHash: false,
|
||||
stripTextFragment: true,
|
||||
stripWWW: true,
|
||||
removeQueryParameters: [/^utm_\w+/i],
|
||||
removeTrailingSlash: true,
|
||||
removeSingleSlash: true,
|
||||
removeDirectoryIndex: false,
|
||||
removeExplicitPort: false,
|
||||
sortQueryParameters: true,
|
||||
...opts,
|
||||
}
|
||||
|
||||
// Legacy: Append `:` to the protocol if missing.
|
||||
if (typeof options.defaultProtocol === 'string' && !options.defaultProtocol.endsWith(':')) {
|
||||
options.defaultProtocol = `${options.defaultProtocol}:`
|
||||
}
|
||||
|
||||
urlString = urlString.trim()
|
||||
|
||||
// Data URL
|
||||
if (/^data:/i.test(urlString)) {
|
||||
return normalizeDataURL(urlString, options)
|
||||
}
|
||||
|
||||
if (hasCustomProtocol(urlString)) {
|
||||
return urlString
|
||||
}
|
||||
|
||||
const hasRelativeProtocol = urlString.startsWith('//')
|
||||
const isRelativeUrl = !hasRelativeProtocol && /^\.*\//.test(urlString)
|
||||
|
||||
// Prepend protocol
|
||||
if (!isRelativeUrl) {
|
||||
urlString = urlString.replace(/^(?!(?:\w+:)?\/\/)|^\/\//, options.defaultProtocol)
|
||||
}
|
||||
|
||||
const urlObject = new URL(urlString)
|
||||
|
||||
if (options.forceHttp && options.forceHttps) {
|
||||
throw new Error('The `forceHttp` and `forceHttps` options cannot be used together')
|
||||
}
|
||||
|
||||
if (options.forceHttp && urlObject.protocol === 'https:') {
|
||||
urlObject.protocol = 'http:'
|
||||
}
|
||||
|
||||
if (options.forceHttps && urlObject.protocol === 'http:') {
|
||||
urlObject.protocol = 'https:'
|
||||
}
|
||||
|
||||
// Remove auth
|
||||
if (options.stripAuthentication) {
|
||||
urlObject.username = ''
|
||||
urlObject.password = ''
|
||||
}
|
||||
|
||||
// Remove hash
|
||||
if (options.stripHash) {
|
||||
urlObject.hash = ''
|
||||
} else if (options.stripTextFragment) {
|
||||
urlObject.hash = urlObject.hash.replace(/#?:~:text.*?$/i, '')
|
||||
}
|
||||
|
||||
// Remove duplicate slashes if not preceded by a protocol
|
||||
// NOTE: This could be implemented using a single negative lookbehind
|
||||
// regex, but we avoid that to maintain compatibility with older js engines
|
||||
// which do not have support for that feature.
|
||||
if (urlObject.pathname) {
|
||||
// TODO: Replace everything below with `urlObject.pathname = urlObject.pathname.replace(/(?<!\b[a-z][a-z\d+\-.]{1,50}:)\/{2,}/g, '/');` when Safari supports negative lookbehind.
|
||||
|
||||
// Split the string by occurrences of this protocol regex, and perform
|
||||
// duplicate-slash replacement on the strings between those occurrences
|
||||
// (if any).
|
||||
const protocolRegex = /\b[a-z][a-z\d+\-.]{1,50}:\/\//g
|
||||
|
||||
let lastIndex = 0
|
||||
let result = ''
|
||||
for (;;) {
|
||||
const match = protocolRegex.exec(urlObject.pathname)
|
||||
if (!match) {
|
||||
break
|
||||
}
|
||||
|
||||
const protocol = match[0]
|
||||
const protocolAtIndex = match.index
|
||||
const intermediate = urlObject.pathname.slice(lastIndex, protocolAtIndex)
|
||||
|
||||
result += intermediate.replace(/\/{2,}/g, '/')
|
||||
result += protocol
|
||||
lastIndex = protocolAtIndex + protocol.length
|
||||
}
|
||||
|
||||
const remnant = urlObject.pathname.slice(lastIndex, urlObject.pathname.length)
|
||||
result += remnant.replace(/\/{2,}/g, '/')
|
||||
|
||||
urlObject.pathname = result
|
||||
}
|
||||
|
||||
// Decode URI octets
|
||||
if (urlObject.pathname) {
|
||||
try {
|
||||
urlObject.pathname = decodeURI(urlObject.pathname)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove directory index
|
||||
if (options.removeDirectoryIndex === true) {
|
||||
options.removeDirectoryIndex = [/^index\.[a-z]+$/]
|
||||
}
|
||||
|
||||
if (Array.isArray(options.removeDirectoryIndex) && options.removeDirectoryIndex.length > 0) {
|
||||
let pathComponents = urlObject.pathname.split('/')
|
||||
const lastComponent = pathComponents[pathComponents.length - 1]
|
||||
|
||||
if (testParameter(lastComponent, options.removeDirectoryIndex)) {
|
||||
pathComponents = pathComponents.slice(0, -1)
|
||||
urlObject.pathname = pathComponents.slice(1).join('/') + '/'
|
||||
}
|
||||
}
|
||||
|
||||
if (urlObject.hostname) {
|
||||
// Remove trailing dot
|
||||
urlObject.hostname = urlObject.hostname.replace(/\.$/, '')
|
||||
|
||||
// Remove `www.`
|
||||
if (options.stripWWW && /^www\.(?!www\.)[a-z\-\d]{1,63}\.[a-z.\-\d]{2,63}$/.test(urlObject.hostname)) {
|
||||
// Each label should be max 63 at length (min: 1).
|
||||
// Source: https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
|
||||
// Each TLD should be up to 63 characters long (min: 2).
|
||||
// It is technically possible to have a single character TLD, but none currently exist.
|
||||
urlObject.hostname = urlObject.hostname.replace(/^www\./, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Remove query unwanted parameters
|
||||
if (Array.isArray(options.removeQueryParameters)) {
|
||||
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
|
||||
for (const key of [...urlObject.searchParams.keys()]) {
|
||||
if (testParameter(key, options.removeQueryParameters)) {
|
||||
urlObject.searchParams.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(options.keepQueryParameters) && options.removeQueryParameters === true) {
|
||||
urlObject.search = ''
|
||||
}
|
||||
|
||||
// Keep wanted query parameters
|
||||
if (Array.isArray(options.keepQueryParameters) && options.keepQueryParameters.length > 0) {
|
||||
// eslint-disable-next-line unicorn/no-useless-spread -- We are intentionally spreading to get a copy.
|
||||
for (const key of [...urlObject.searchParams.keys()]) {
|
||||
if (!testParameter(key, options.keepQueryParameters)) {
|
||||
urlObject.searchParams.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort query parameters
|
||||
if (options.sortQueryParameters) {
|
||||
urlObject.searchParams.sort()
|
||||
|
||||
// Calling `.sort()` encodes the search parameters, so we need to decode them again.
|
||||
try {
|
||||
urlObject.search = decodeURIComponent(urlObject.search)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (options.removeTrailingSlash) {
|
||||
urlObject.pathname = urlObject.pathname.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Remove an explicit port number, excluding a default port number, if applicable
|
||||
if (options.removeExplicitPort && urlObject.port) {
|
||||
urlObject.port = ''
|
||||
}
|
||||
|
||||
const oldUrlString = urlString
|
||||
|
||||
// Take advantage of many of the Node `url` normalizations
|
||||
urlString = urlObject.toString()
|
||||
|
||||
if (!options.removeSingleSlash && urlObject.pathname === '/' && !oldUrlString.endsWith('/') && urlObject.hash === '') {
|
||||
urlString = urlString.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Remove ending `/` unless removeSingleSlash is false
|
||||
if ((options.removeTrailingSlash || urlObject.pathname === '/') && urlObject.hash === '' && options.removeSingleSlash) {
|
||||
urlString = urlString.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Restore relative protocol, if applicable
|
||||
if (hasRelativeProtocol && !options.normalizeProtocol) {
|
||||
urlString = urlString.replace(/^http:\/\//, '//')
|
||||
}
|
||||
|
||||
// Remove http/https
|
||||
if (options.stripProtocol) {
|
||||
urlString = urlString.replace(/^(?:https?:)?\/\//, '')
|
||||
}
|
||||
|
||||
return urlString
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,324 @@
|
||||
# normalize-url [](https://codecov.io/gh/sindresorhus/normalize-url)
|
||||
|
||||
> [Normalize](https://en.wikipedia.org/wiki/URL_normalization) a URL
|
||||
|
||||
Useful when you need to display, store, deduplicate, sort, compare, etc, URLs.
|
||||
|
||||
**Note:** This package does **not** do URL sanitization. [Garbage in, garbage out.](https://en.wikipedia.org/wiki/Garbage_in,_garbage_out) If you use this in a server context and accept URLs as user input, it's up to you to protect against invalid URLs, [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal), etc.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install normalize-url
|
||||
```
|
||||
|
||||
*If you need Safari support, use version 4: `npm i normalize-url@4`*
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import normalizeUrl from 'normalize-url';
|
||||
|
||||
normalizeUrl('sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('//www.sindresorhus.com:80/../baz?b=bar&a=foo');
|
||||
//=> 'http://sindresorhus.com/baz?a=foo&b=bar'
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### normalizeUrl(url, options?)
|
||||
|
||||
URLs with custom protocols are not normalized and just passed through by default. Supported protocols are: `https`, `http`, `file`, and `data`.
|
||||
|
||||
#### url
|
||||
|
||||
Type: `string`
|
||||
|
||||
URL to normalize, including [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs).
|
||||
|
||||
#### options
|
||||
|
||||
Type: `object`
|
||||
|
||||
##### defaultProtocol
|
||||
|
||||
Type: `string`\
|
||||
Default: `'http'`\
|
||||
Values: `'https' | 'http'`
|
||||
|
||||
##### normalizeProtocol
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Prepend `defaultProtocol` to the URL if it's protocol-relative.
|
||||
|
||||
```js
|
||||
normalizeUrl('//sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('//sindresorhus.com', {normalizeProtocol: false});
|
||||
//=> '//sindresorhus.com'
|
||||
```
|
||||
|
||||
##### forceHttp
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `false`
|
||||
|
||||
Normalize HTTPS to HTTP.
|
||||
|
||||
```js
|
||||
normalizeUrl('https://sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('https://sindresorhus.com', {forceHttp: true});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
|
||||
##### forceHttps
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `false`
|
||||
|
||||
Normalize HTTP to HTTPS.
|
||||
|
||||
```js
|
||||
normalizeUrl('http://sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com', {forceHttps: true});
|
||||
//=> 'https://sindresorhus.com'
|
||||
```
|
||||
|
||||
This option cannot be used with the `forceHttp` option at the same time.
|
||||
|
||||
##### stripAuthentication
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Strip the [authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) part of the URL.
|
||||
|
||||
```js
|
||||
normalizeUrl('user:password@sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('user:password@sindresorhus.com', {stripAuthentication: false});
|
||||
//=> 'https://user:password@sindresorhus.com'
|
||||
```
|
||||
|
||||
##### stripHash
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `false`
|
||||
|
||||
Strip the hash part of the URL.
|
||||
|
||||
```js
|
||||
normalizeUrl('sindresorhus.com/about.html#contact');
|
||||
//=> 'http://sindresorhus.com/about.html#contact'
|
||||
|
||||
normalizeUrl('sindresorhus.com/about.html#contact', {stripHash: true});
|
||||
//=> 'http://sindresorhus.com/about.html'
|
||||
```
|
||||
|
||||
##### stripProtocol
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `false`
|
||||
|
||||
Remove the protocol from the URL: `http://sindresorhus.com` → `sindresorhus.com`.
|
||||
|
||||
It will only remove `https://` and `http://` protocols.
|
||||
|
||||
```js
|
||||
normalizeUrl('https://sindresorhus.com');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('https://sindresorhus.com', {stripProtocol: true});
|
||||
//=> 'sindresorhus.com'
|
||||
```
|
||||
|
||||
##### stripTextFragment
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Strip the [text fragment](https://web.dev/text-fragments/) part of the URL.
|
||||
|
||||
**Note:** The text fragment will always be removed if the `stripHash` option is set to `true`, as the hash contains the text fragment.
|
||||
|
||||
```js
|
||||
normalizeUrl('http://sindresorhus.com/about.html#:~:text=hello');
|
||||
//=> 'http://sindresorhus.com/about.html#'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#section:~:text=hello');
|
||||
//=> 'http://sindresorhus.com/about.html#section'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#:~:text=hello', {stripTextFragment: false});
|
||||
//=> 'http://sindresorhus.com/about.html#:~:text=hello'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/about.html#section:~:text=hello', {stripTextFragment: false});
|
||||
//=> 'http://sindresorhus.com/about.html#section:~:text=hello'
|
||||
```
|
||||
|
||||
##### stripWWW
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Remove `www.` from the URL.
|
||||
|
||||
```js
|
||||
normalizeUrl('http://www.sindresorhus.com');
|
||||
//=> 'http://sindresorhus.com'
|
||||
|
||||
normalizeUrl('http://www.sindresorhus.com', {stripWWW: false});
|
||||
//=> 'http://www.sindresorhus.com'
|
||||
```
|
||||
|
||||
##### removeQueryParameters
|
||||
|
||||
Type: `Array<RegExp | string> | boolean`\
|
||||
Default: `[/^utm_\w+/i]`
|
||||
|
||||
Remove query parameters that matches any of the provided strings or regexes.
|
||||
|
||||
```js
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar&ref=test_ref', {
|
||||
removeQueryParameters: ['ref']
|
||||
});
|
||||
//=> 'http://sindresorhus.com/?foo=bar'
|
||||
```
|
||||
|
||||
If a boolean is provided, `true` will remove all the query parameters.
|
||||
|
||||
```js
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar', {
|
||||
removeQueryParameters: true
|
||||
});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
|
||||
`false` will not remove any query parameter.
|
||||
|
||||
```js
|
||||
normalizeUrl('www.sindresorhus.com?foo=bar&utm_medium=test&ref=test_ref', {
|
||||
removeQueryParameters: false
|
||||
});
|
||||
//=> 'http://www.sindresorhus.com/?foo=bar&ref=test_ref&utm_medium=test'
|
||||
```
|
||||
|
||||
##### keepQueryParameters
|
||||
|
||||
Type: `Array<RegExp | string>`\
|
||||
Default: `undefined`
|
||||
|
||||
Keeps only query parameters that matches any of the provided strings or regexes.
|
||||
|
||||
**Note:** It overrides the `removeQueryParameters` option.
|
||||
|
||||
```js
|
||||
normalizeUrl('https://sindresorhus.com?foo=bar&ref=unicorn', {
|
||||
keepQueryParameters: ['ref']
|
||||
});
|
||||
//=> 'https://sindresorhus.com/?ref=unicorn'
|
||||
```
|
||||
|
||||
##### removeTrailingSlash
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Remove trailing slash.
|
||||
|
||||
**Note:** Trailing slash is always removed if the URL doesn't have a pathname unless the `removeSingleSlash` option is set to `false`.
|
||||
|
||||
```js
|
||||
normalizeUrl('http://sindresorhus.com/redirect/');
|
||||
//=> 'http://sindresorhus.com/redirect'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/redirect/', {removeTrailingSlash: false});
|
||||
//=> 'http://sindresorhus.com/redirect/'
|
||||
|
||||
normalizeUrl('http://sindresorhus.com/', {removeTrailingSlash: false});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
|
||||
##### removeSingleSlash
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Remove a sole `/` pathname in the output. This option is independent of `removeTrailingSlash`.
|
||||
|
||||
```js
|
||||
normalizeUrl('https://sindresorhus.com/');
|
||||
//=> 'https://sindresorhus.com'
|
||||
|
||||
normalizeUrl('https://sindresorhus.com/', {removeSingleSlash: false});
|
||||
//=> 'https://sindresorhus.com/'
|
||||
```
|
||||
|
||||
##### removeDirectoryIndex
|
||||
|
||||
Type: `boolean | Array<RegExp | string>`\
|
||||
Default: `false`
|
||||
|
||||
Removes the default directory index file from path that matches any of the provided strings or regexes. When `true`, the regex `/^index\.[a-z]+$/` is used.
|
||||
|
||||
```js
|
||||
normalizeUrl('www.sindresorhus.com/foo/default.php', {
|
||||
removeDirectoryIndex: [/^default\.[a-z]+$/]
|
||||
});
|
||||
//=> 'http://sindresorhus.com/foo'
|
||||
```
|
||||
|
||||
##### removeExplicitPort
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `false`
|
||||
|
||||
Removes an explicit port number from the URL.
|
||||
|
||||
Port 443 is always removed from HTTPS URLs and 80 is always removed from HTTP URLs regardless of this option.
|
||||
|
||||
```js
|
||||
normalizeUrl('sindresorhus.com:123', {
|
||||
removeExplicitPort: true
|
||||
});
|
||||
//=> 'http://sindresorhus.com'
|
||||
```
|
||||
|
||||
##### sortQueryParameters
|
||||
|
||||
Type: `boolean`\
|
||||
Default: `true`
|
||||
|
||||
Sorts the query parameters alphabetically by key.
|
||||
|
||||
```js
|
||||
normalizeUrl('www.sindresorhus.com?b=two&a=one&c=three', {
|
||||
sortQueryParameters: false
|
||||
});
|
||||
//=> 'http://sindresorhus.com/?b=two&a=one&c=three'
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [compare-urls](https://github.com/sindresorhus/compare-urls) - Compare URLs by first normalizing them
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<b>
|
||||
<a href="https://tidelift.com/subscription/pkg/npm-normalize-url?utm_source=npm-normalize-url&utm_medium=referral&utm_campaign=readme">Get professional support for this package with a Tidelift subscription</a>
|
||||
</b>
|
||||
<br>
|
||||
<sub>
|
||||
Tidelift helps make open source sustainable for maintainers while giving companies<br>assurances about security, maintenance, and licensing for their dependencies.
|
||||
</sub>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@coracle.social/lib",
|
||||
"version": "0.0.1",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "A collection of utilities.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"types": "./build/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./build/index.d.ts",
|
||||
"import": "./build/index.mjs",
|
||||
"require": "./build/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build": "tsc-multi",
|
||||
"clean": "gts clean",
|
||||
"lint": "gts lint",
|
||||
"fix": "gts fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"gts": "^5.0.1",
|
||||
"tsc-multi": "^1.1.0",
|
||||
"typescript": "~5.1.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"targets": [
|
||||
{"extname": ".cjs", "module": "commonjs"},
|
||||
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||
],
|
||||
"projects": ["tsconfig.json"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
build
|
||||
normalize-url
|
||||
@@ -0,0 +1,119 @@
|
||||
import {Emitter, Queue} from '@coracle.social/lib'
|
||||
import {AuthStatus, ConnectionMeta} from './ConnectionMeta'
|
||||
import {Socket, isMessage, asMessage} from './Socket'
|
||||
import type {SocketMessage} from './Socket'
|
||||
|
||||
class SendQueue extends Queue {
|
||||
constructor(readonly cxn: Connection) {
|
||||
super()
|
||||
}
|
||||
|
||||
shouldSend(message: SocketMessage) {
|
||||
if (!this.cxn.socket.isReady()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [verb, ...extra] = asMessage(message)
|
||||
|
||||
if (['AUTH', 'CLOSE'].includes(verb)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Allow relay requests through
|
||||
if (verb === 'EVENT' && extra[0].kind === 28934) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Only defer for auth if we're not multiplexing
|
||||
if (isMessage(message) && ![AuthStatus.Ok, AuthStatus.Pending].includes(this.cxn.meta.authStatus)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (verb === 'REQ') {
|
||||
return this.cxn.meta.pendingRequests.size < 8
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
handle(message: SocketMessage) {
|
||||
// If we ended up handling a CLOSE before we handled the REQ, don't send the REQ
|
||||
if (message[0] === 'CLOSE') {
|
||||
this.messages = this.messages.filter(m => !(m[0] === 'REQ' && m[1] === message[1]))
|
||||
}
|
||||
|
||||
this.cxn.onSend(message)
|
||||
}
|
||||
}
|
||||
|
||||
class ReceiveQueue extends Queue {
|
||||
constructor(readonly cxn: Connection) {
|
||||
super()
|
||||
}
|
||||
|
||||
handle(message: SocketMessage) {
|
||||
this.cxn.onReceive(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class Connection extends Emitter {
|
||||
url: string
|
||||
socket: Socket
|
||||
sendQueue: SendQueue
|
||||
receiveQueue: ReceiveQueue
|
||||
meta: ConnectionMeta
|
||||
|
||||
constructor(url: string) {
|
||||
super()
|
||||
|
||||
this.url = url
|
||||
this.socket = new Socket(url, this)
|
||||
this.sendQueue = new SendQueue(this)
|
||||
this.receiveQueue = new ReceiveQueue(this)
|
||||
this.meta = new ConnectionMeta(this)
|
||||
this.setMaxListeners(100)
|
||||
}
|
||||
|
||||
send = (m: SocketMessage) => this.sendQueue.push(m)
|
||||
|
||||
onOpen = () => this.emit('open', this)
|
||||
|
||||
onClose = () => this.emit('close', this)
|
||||
|
||||
onError = () => this.emit('fault', this)
|
||||
|
||||
onMessage = (m: SocketMessage) => this.receiveQueue.push(m)
|
||||
|
||||
onSend = (message: SocketMessage) => {
|
||||
this.emit('send', this, message)
|
||||
this.socket.send(message)
|
||||
}
|
||||
|
||||
onReceive = (message: SocketMessage) => {
|
||||
this.emit('receive', this, message)
|
||||
}
|
||||
|
||||
ensureConnected = ({shouldReconnect = true}) => {
|
||||
if (shouldReconnect && !this.socket.isHealthy()) {
|
||||
this.disconnect()
|
||||
}
|
||||
|
||||
if (this.socket.isPending()) {
|
||||
this.socket.connect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.socket.disconnect()
|
||||
this.sendQueue.clear()
|
||||
this.receiveQueue.clear()
|
||||
this.meta.clearPending()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disconnect()
|
||||
this.removeAllListeners()
|
||||
this.sendQueue.stop()
|
||||
this.receiveQueue.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {Event, Filter} from 'nostr-tools'
|
||||
import type {Connection} from './Connection'
|
||||
import type {Message} from './Socket'
|
||||
|
||||
export type PublishMeta = {
|
||||
sent: number
|
||||
event: Event
|
||||
}
|
||||
|
||||
export type RequestMeta = {
|
||||
sent: number
|
||||
filters: Filter[]
|
||||
eoseReceived: boolean
|
||||
}
|
||||
|
||||
export enum AuthStatus {
|
||||
Ok = 'ok',
|
||||
Pending = 'pending',
|
||||
Unauthorized = 'unauthorized',
|
||||
Forbidden = 'forbidden',
|
||||
}
|
||||
|
||||
export enum ConnectionStatus {
|
||||
Unauthorized = 'unauthorized',
|
||||
Forbidden = 'forbidden',
|
||||
Error = 'error',
|
||||
Closed = 'closed',
|
||||
Slow = 'slow',
|
||||
Ok = 'ok',
|
||||
}
|
||||
|
||||
export class ConnectionMeta {
|
||||
authStatus = AuthStatus.Pending
|
||||
pendingPublishes = new Map<string, PublishMeta>()
|
||||
pendingRequests = new Map<string, RequestMeta>()
|
||||
publishCount = 0
|
||||
requestCount = 0
|
||||
eventCount = 0
|
||||
lastOpen = 0
|
||||
lastClose = 0
|
||||
lastFault = 0
|
||||
lastPublish = 0
|
||||
lastRequest = 0
|
||||
lastEvent = 0
|
||||
responseCount = 0
|
||||
responseTimer = 0
|
||||
|
||||
constructor(readonly cxn: Connection) {
|
||||
cxn.on('open', () => {
|
||||
this.lastOpen = Date.now()
|
||||
})
|
||||
|
||||
cxn.on('close', () => {
|
||||
this.lastClose = Date.now()
|
||||
})
|
||||
|
||||
cxn.on('fault', () => {
|
||||
this.lastFault = Date.now()
|
||||
})
|
||||
|
||||
cxn.on('send', (cxn: Connection, message: Message) => {
|
||||
if (message[0] === 'REQ') this.onSendRequest(message)
|
||||
if (message[0] === 'CLOSE') this.onSendClose(message)
|
||||
if (message[0] === 'EVENT') this.onSendEvent(message)
|
||||
})
|
||||
|
||||
cxn.on('receive', (cxn: Connection, message: Message) => {
|
||||
if (message[0] === 'OK') this.onReceiveOk(message)
|
||||
if (message[0] === 'AUTH') this.onReceiveAuth(message)
|
||||
if (message[0] === 'EVENT') this.onReceiveEvent(message)
|
||||
if (message[0] === 'EOSE') this.onReceiveEose(message)
|
||||
if (message[0] === 'CLOSED') this.onReceiveClosed(message)
|
||||
if (message[0] === 'NOTICE') this.onReceiveNotice(message)
|
||||
})
|
||||
}
|
||||
|
||||
onSendRequest([verb, subId, ...filters]: Message) {
|
||||
this.requestCount++
|
||||
this.lastRequest = Date.now()
|
||||
this.pendingRequests.set(subId, {
|
||||
filters,
|
||||
sent: Date.now(),
|
||||
eoseReceived: false,
|
||||
})
|
||||
}
|
||||
|
||||
onSendClose([verb, subId]: Message) {
|
||||
this.pendingRequests.delete(subId)
|
||||
}
|
||||
|
||||
onSendEvent([verb, event]: Message) {
|
||||
this.publishCount++
|
||||
this.lastPublish = Date.now()
|
||||
this.pendingPublishes.set(event.id, {sent: Date.now(), event})
|
||||
}
|
||||
|
||||
onReceiveOk([verb, eventId, ok, notice]: Message) {
|
||||
const publish = this.pendingPublishes.get(eventId)
|
||||
|
||||
if (ok) {
|
||||
this.authStatus = AuthStatus.Ok
|
||||
} else if (notice.startsWith('auth-required:')) {
|
||||
// Re-enqueue pending reqs when auth challenge is received
|
||||
const pub = this.pendingPublishes.get(eventId)
|
||||
|
||||
if (pub) {
|
||||
this.cxn.send(['EVENT', pub.event])
|
||||
}
|
||||
}
|
||||
|
||||
if (publish) {
|
||||
this.responseCount++
|
||||
this.responseTimer += Date.now() - publish.sent
|
||||
this.pendingPublishes.delete(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
onReceiveAuth([verb, eventId]: Message) {
|
||||
this.authStatus = AuthStatus.Unauthorized
|
||||
}
|
||||
|
||||
onReceiveEvent([verb, event]: Message) {
|
||||
this.eventCount++
|
||||
this.lastEvent = Date.now()
|
||||
}
|
||||
|
||||
onReceiveEose([verb, subId]: Message) {
|
||||
const request = this.pendingRequests.get(subId)
|
||||
|
||||
// Only count the first eose
|
||||
if (request && !request.eoseReceived) {
|
||||
request.eoseReceived = true
|
||||
|
||||
this.responseCount++
|
||||
this.responseTimer += Date.now() - request.sent
|
||||
}
|
||||
}
|
||||
|
||||
onReceiveClosed([verb, id, notice]: Message) {
|
||||
if (notice.startsWith('auth-required:')) {
|
||||
// Re-enqueue pending reqs when auth challenge is received
|
||||
const req = this.pendingRequests.get(id)
|
||||
|
||||
if (req) {
|
||||
this.cxn.send(['REQ', id, ...req.filters])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReceiveNotice([verb, notice]: Message) {
|
||||
console.warn('NOTICE', this.cxn.url, notice)
|
||||
}
|
||||
|
||||
clearPending = () => {
|
||||
this.pendingPublishes.clear()
|
||||
this.pendingRequests.clear()
|
||||
}
|
||||
|
||||
getSpeed = () => this.responseCount ? this.responseTimer / this.responseCount : 0
|
||||
|
||||
getStatus = () => {
|
||||
if (this.authStatus === AuthStatus.Unauthorized) return ConnectionStatus.Unauthorized
|
||||
if (this.authStatus === AuthStatus.Forbidden) return ConnectionStatus.Forbidden
|
||||
if (this.lastFault > this.lastOpen) return ConnectionStatus.Error
|
||||
if (this.lastClose > this.lastOpen) return ConnectionStatus.Closed
|
||||
if (this.getSpeed() > 1000) return ConnectionStatus.Slow
|
||||
|
||||
return ConnectionStatus.Ok
|
||||
}
|
||||
|
||||
getDescription = () => {
|
||||
switch (this.getStatus()) {
|
||||
case ConnectionStatus.Unauthorized: return 'Logging in'
|
||||
case ConnectionStatus.Forbidden: return 'Failed to log in'
|
||||
case ConnectionStatus.Error: return 'Failed to connect'
|
||||
case ConnectionStatus.Closed: return 'Waiting to reconnect'
|
||||
case ConnectionStatus.Slow: return 'Slow Connection'
|
||||
case ConnectionStatus.Ok: return 'Connected'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {Event, Filter} from 'nostr-tools'
|
||||
import type {Emitter} from '@coracle.social/lib'
|
||||
import type {Connection} from './Connection'
|
||||
import type {Message} from './Socket'
|
||||
|
||||
export type Target = Emitter & {
|
||||
connections: Connection[]
|
||||
send: (...args: Message) => void
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
type EventCallback = (url: string, event: Event) => void
|
||||
type EoseCallback = (url: string) => void
|
||||
type AuthCallback = (url: string, challenge: string) => void
|
||||
type OkCallback = (url: string, id: string, ...extra: any[]) => void
|
||||
type ErrorCallback = (url: string, id: string, ...extra: any[]) => void
|
||||
type SubscribeOpts = {onEvent?: EventCallback, onEose?: EoseCallback}
|
||||
type PublishOpts = {verb?: string, onOk?: OkCallback, onError?: ErrorCallback}
|
||||
type AuthOpts = {onAuth: AuthCallback, onOk: OkCallback}
|
||||
|
||||
const createSubId = (prefix: string) => [prefix, Math.random().toString().slice(2, 10)].join('-')
|
||||
|
||||
export class Executor {
|
||||
|
||||
constructor(readonly target: Target) {}
|
||||
|
||||
subscribe(filters: Filter[], {onEvent, onEose}: SubscribeOpts = {}) {
|
||||
let closed = false
|
||||
|
||||
const id = createSubId('REQ')
|
||||
const eventListener = (url: string, subid: string, e: Event) => subid === id && onEvent?.(url, e)
|
||||
const eoseListener = (url: string, subid: string) => subid === id && onEose?.(url)
|
||||
|
||||
this.target.on('EVENT', eventListener)
|
||||
this.target.on('EOSE', eoseListener)
|
||||
this.target.send("REQ", id, ...filters)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
if (!closed) {
|
||||
this.target.send("CLOSE", id)
|
||||
this.target.off('EVENT', eventListener)
|
||||
this.target.off('EOSE', eoseListener)
|
||||
}
|
||||
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
publish(event: Event, {verb = 'EVENT', onOk, onError}: PublishOpts = {}) {
|
||||
const okListener = (url: string, id: string, ...payload: any[]) => id === event.id && onOk?.(url, id, ...payload)
|
||||
const errorListener = (url: string, id: string, ...payload: any[]) => id === event.id && onError?.(url, id, ...payload)
|
||||
|
||||
this.target.on('OK', okListener)
|
||||
this.target.on('ERROR', errorListener)
|
||||
this.target.send(verb, event)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.target.off('OK', okListener)
|
||||
this.target.off('ERROR', errorListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAuth({onAuth, onOk}: AuthOpts) {
|
||||
this.target.on('AUTH', onAuth)
|
||||
this.target.on('OK', onOk)
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.target.off('AUTH', onAuth)
|
||||
this.target.off('OK', onOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import {Connection} from "./Connection"
|
||||
|
||||
export class Pool extends Emitter {
|
||||
data: Map<string, Connection>
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.data = new Map()
|
||||
}
|
||||
has(url: string) {
|
||||
return this.data.has(url)
|
||||
}
|
||||
get(url: string, {autoConnect = true, reconnectAfter = 3000} = {}): Connection {
|
||||
let connection = this.data.get(url)
|
||||
|
||||
if (autoConnect) {
|
||||
if (!connection) {
|
||||
connection = new Connection(url)
|
||||
|
||||
this.data.set(url, connection)
|
||||
this.emit('init', connection)
|
||||
|
||||
connection.on('open', () => this.emit('open', connection))
|
||||
connection.on('close', () => this.emit('close', connection))
|
||||
}
|
||||
|
||||
connection.ensureConnected({
|
||||
shouldReconnect: connection.meta.lastClose < Date.now() - reconnectAfter,
|
||||
})
|
||||
}
|
||||
|
||||
return connection!
|
||||
}
|
||||
remove(url: string) {
|
||||
const connection = this.data.get(url)
|
||||
|
||||
if (connection) {
|
||||
connection.destroy()
|
||||
|
||||
this.data.delete(url)
|
||||
}
|
||||
}
|
||||
clear() {
|
||||
for (const url of this.data.keys()) {
|
||||
this.remove(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import WebSocket from "isomorphic-ws"
|
||||
import {Deferred, defer} from '@coracle.social/lib'
|
||||
|
||||
export type Message = [string, ...any[]]
|
||||
|
||||
export type PlexMessage = [{relays: string[]}, Message]
|
||||
|
||||
export type SocketMessage = Message | PlexMessage
|
||||
|
||||
export const isMessage = (m: SocketMessage): boolean => typeof m[0] === 'string'
|
||||
|
||||
export const asMessage = (m: SocketMessage): Message => isMessage(m) ? m : m[1]
|
||||
|
||||
export type SocketOpts = {
|
||||
onOpen: () => void
|
||||
onClose: () => void
|
||||
onError: () => void
|
||||
onMessage: (message: SocketMessage) => void
|
||||
}
|
||||
|
||||
export class Socket {
|
||||
url: string
|
||||
ws?: WebSocket
|
||||
ready: Deferred<boolean>
|
||||
failedToConnect = false
|
||||
|
||||
constructor(url: string, readonly opts: SocketOpts) {
|
||||
this.url = url
|
||||
this.ready = defer()
|
||||
}
|
||||
|
||||
isPending() {
|
||||
return !this.ws && !this.failedToConnect
|
||||
}
|
||||
|
||||
isConnecting() {
|
||||
return this.ws?.readyState === WebSocket.CONNECTING
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
isClosing() {
|
||||
return this.ws?.readyState === WebSocket.CLOSING
|
||||
}
|
||||
|
||||
isClosed() {
|
||||
return this.ws?.readyState === WebSocket.CLOSED
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
return this.isPending() || this.isConnecting() || this.isReady()
|
||||
}
|
||||
|
||||
onOpen = () => {
|
||||
this.ready.resolve(true)
|
||||
this.opts.onOpen()
|
||||
}
|
||||
|
||||
onError = () => {
|
||||
this.opts.onError()
|
||||
this.disconnect()
|
||||
}
|
||||
|
||||
onMessage = (event: {data: string}) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string)
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
this.opts.onMessage(message as Message)
|
||||
} else {
|
||||
console.warn("Invalid messages received:", message)
|
||||
}
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
send = (message: any) => {
|
||||
if (!this.ws) {
|
||||
throw new Error('Send attempted before socket was opened')
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
connect = () => {
|
||||
if (this.ws) {
|
||||
throw new Error(`Already attempted connection for ${this.url}`)
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.ws.onopen = this.onOpen
|
||||
this.ws.onerror = this.onError
|
||||
this.ws.onmessage = this.onMessage
|
||||
this.ws.onclose = this.disconnect
|
||||
} catch (e) {
|
||||
this.failedToConnect = true
|
||||
}
|
||||
}
|
||||
|
||||
disconnect = () => {
|
||||
if (this.ws) {
|
||||
const currentWs = this.ws
|
||||
|
||||
this.ready.then(() => currentWs.close())
|
||||
this.ready = defer()
|
||||
this.opts.onClose()
|
||||
this.ws = undefined
|
||||
|
||||
// Resolve a different instance of the promise
|
||||
this.ready.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import type {Filter} from '@coracle.social/util'
|
||||
import {matchFilters, hasValidSignature} from '@coracle.social/util'
|
||||
import type {Event} from 'nostr-tools'
|
||||
import type {Executor} from "./Executor"
|
||||
import type {Connection} from './Connection'
|
||||
|
||||
export type SubscriptionOpts = {
|
||||
executor: Executor
|
||||
filters: Filter[]
|
||||
timeout?: number
|
||||
closeOnEose?: boolean
|
||||
hasSeen?: (e: Event, url: string) => boolean
|
||||
shouldValidate?: (e: Event, url: string) => boolean
|
||||
}
|
||||
|
||||
export class Subscription extends Emitter {
|
||||
unsubscribe: () => void
|
||||
dead = new Set<string>()
|
||||
seen = new Set<string>()
|
||||
eose = new Set<string>()
|
||||
closeHandlers = new Map()
|
||||
opened = Date.now()
|
||||
closed?: number
|
||||
|
||||
constructor(readonly opts: SubscriptionOpts) {
|
||||
super()
|
||||
|
||||
const {executor, timeout, filters} = this.opts
|
||||
|
||||
// If we have a timeout, close the subscription automatically
|
||||
if (timeout) {
|
||||
setTimeout(this.close, timeout)
|
||||
}
|
||||
|
||||
// If one of our connections gets closed make sure to kill our sub
|
||||
executor.target.connections.forEach(con => {
|
||||
const handler = () => {
|
||||
this.dead.add(con.url)
|
||||
|
||||
if (this.dead.size === executor.target.connections.length) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
this.closeHandlers.set(con.url, handler)
|
||||
|
||||
con.on("close", handler)
|
||||
})
|
||||
|
||||
// Start our subscription
|
||||
const sub = executor.subscribe(filters, {
|
||||
onEvent: this.onEvent,
|
||||
onEose: this.onEose,
|
||||
})
|
||||
|
||||
this.unsubscribe = sub.unsubscribe
|
||||
}
|
||||
|
||||
hasSeen = (event: Event, url: string) => {
|
||||
if (this.opts.hasSeen) {
|
||||
return this.opts.hasSeen(event, url)
|
||||
}
|
||||
|
||||
if (this.seen.has(event.id)) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.seen.add(event.id)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
hasValidSignature = (event: Event, url: string) => {
|
||||
if (this.opts.shouldValidate && !this.opts.shouldValidate(event, url)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hasValidSignature(event)
|
||||
}
|
||||
|
||||
onEvent = (url: string, event: Event) => {
|
||||
// If we've seen this event, don't re-validate
|
||||
// Otherwise, check the signature and filters
|
||||
if (this.hasSeen(event, url)) {
|
||||
this.emit("duplicate", event, url)
|
||||
} else {
|
||||
if (!this.hasValidSignature(event, url)) {
|
||||
this.emit("invalid-signature", event, url)
|
||||
} else if (!matchFilters(this.opts.filters, event)) {
|
||||
this.emit("failed-filter", event, url)
|
||||
} else {
|
||||
this.emit("event", event, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onEose = (url: string) => {
|
||||
const {executor, closeOnEose} = this.opts
|
||||
|
||||
this.emit("eose", url)
|
||||
|
||||
this.eose.add(url)
|
||||
|
||||
if (closeOnEose && this.eose.size >= executor.target.connections.length) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
close = () => {
|
||||
if (!this.closed) {
|
||||
const {target} = this.opts.executor
|
||||
|
||||
this.closed = Date.now()
|
||||
this.unsubscribe()
|
||||
this.emit("close")
|
||||
this.removeAllListeners()
|
||||
|
||||
target.connections.forEach((con: Connection) => con.off("close", this.closeHandlers.get(con.url)))
|
||||
target.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from "./Connection"
|
||||
export * from "./ConnectionMeta"
|
||||
export * from "./Executor"
|
||||
export * from "./Pool"
|
||||
export * from "./Socket"
|
||||
export * from "./Subscription"
|
||||
export * from "./target/Multi"
|
||||
export * from "./target/Plex"
|
||||
export * from "./target/Relay"
|
||||
export * from "./target/Relays"
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@coracle.social/network",
|
||||
"version": "0.0.2",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "Utilities for connecting with nostr relays.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"types": "./build/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./build/index.d.ts",
|
||||
"import": "./build/index.mjs",
|
||||
"require": "./build/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build": "tsc-multi",
|
||||
"clean": "gts clean",
|
||||
"lint": "gts lint",
|
||||
"fix": "gts fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"gts": "^5.0.1",
|
||||
"tsc-multi": "^1.1.0",
|
||||
"typescript": "~5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coracle.social/util": "^0.0.2",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import type {Target} from '../Executor'
|
||||
import type {Message} from '../Socket'
|
||||
|
||||
export class Multi extends Emitter {
|
||||
constructor(readonly targets: Target[]) {
|
||||
super()
|
||||
|
||||
targets.forEach(t => {
|
||||
t.on('*', (verb, ...args) => this.emit(verb, ...args))
|
||||
})
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return this.targets.flatMap(t => t.connections)
|
||||
}
|
||||
|
||||
send(...payload: Message) {
|
||||
this.targets.forEach(t => t.send(...payload))
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.targets.forEach(t => t.cleanup())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import type {PlexMessage, Message} from '../Socket'
|
||||
import type {Connection} from '../Connection'
|
||||
|
||||
export class Plex extends Emitter {
|
||||
constructor(readonly urls: string[], readonly connection: Connection) {
|
||||
super()
|
||||
|
||||
this.connection.on('receive', this.onMessage)
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return [this.connection]
|
||||
}
|
||||
|
||||
send = (...payload: Message) => {
|
||||
this.connection.send([{relays: this.urls}, payload])
|
||||
}
|
||||
|
||||
onMessage = (connection: Connection, [{relays}, [verb, ...payload]]: PlexMessage) => {
|
||||
this.emit(verb, relays[0], ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.connection.off('receive', this.onMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import type {Message} from '../Socket'
|
||||
import type {Connection} from '../Connection'
|
||||
|
||||
export class Relay extends Emitter {
|
||||
constructor(readonly connection: Connection) {
|
||||
super()
|
||||
|
||||
this.connection.on('receive', this.onMessage)
|
||||
}
|
||||
|
||||
get connections() {
|
||||
return [this.connection]
|
||||
}
|
||||
|
||||
send(...payload: Message) {
|
||||
this.connection.send(payload)
|
||||
}
|
||||
|
||||
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||
this.emit(verb, connection.url, ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.connection.off('receive', this.onMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {Emitter} from '@coracle.social/lib'
|
||||
import type {Message} from '../Socket'
|
||||
import type {Connection} from '../Connection'
|
||||
|
||||
export class Relays extends Emitter {
|
||||
constructor(readonly connections: Connection[]) {
|
||||
super()
|
||||
|
||||
connections.forEach(connection => {
|
||||
connection.on('receive', this.onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
send = (...payload: Message) => {
|
||||
this.connections.forEach(connection => {
|
||||
connection.send(payload)
|
||||
})
|
||||
}
|
||||
|
||||
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||
this.emit(verb, connection.url, ...payload)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
this.removeAllListeners()
|
||||
this.connections.forEach(connection => {
|
||||
connection.off('receive', this.onMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"targets": [
|
||||
{"extname": ".cjs", "module": "commonjs"},
|
||||
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||
],
|
||||
"projects": ["tsconfig.json"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019", "dom"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
build
|
||||
normalize-url
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {UnsignedEvent} from 'nostr-tools'
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
|
||||
export type Address = {
|
||||
kind: number,
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
// Plain text format
|
||||
|
||||
export const decodeAddress = (a: string, relays: string[] = []): Address => {
|
||||
const [kind, pubkey, identifier = ""] = a.split(":")
|
||||
|
||||
return {kind: parseInt(kind), pubkey, identifier, relays}
|
||||
}
|
||||
|
||||
export const encodeAddress = (a: Address) => [a.kind, a.pubkey, a.identifier].join(":")
|
||||
|
||||
// Naddr encoding
|
||||
|
||||
export const addressFromNaddr = (naddr: string): Address => {
|
||||
let type
|
||||
let data = {} as any
|
||||
try {
|
||||
({type, data} = nip19.decode(naddr) as {
|
||||
type: "naddr"
|
||||
data: any
|
||||
})
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
|
||||
if (type !== "naddr") {
|
||||
throw new Error(`Invalid naddr ${naddr}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const addressToNaddr = (a: Address): string => nip19.naddrEncode(a)
|
||||
|
||||
// Get from event, encode to filter
|
||||
|
||||
export const getIdentifier = (e: UnsignedEvent) => e.tags.find(t => t[0] === "d")?.[1] || ""
|
||||
|
||||
export const addressFromEvent = (e: UnsignedEvent, relays: string[] = []) =>
|
||||
({kind: e.kind, pubkey: e.pubkey, identifier: getIdentifier(e), relays})
|
||||
|
||||
export const addressToFilter = (a: Address) =>
|
||||
({kinds: [a.kind], authors: [a.pubkey], "#d": [a.identifier]})
|
||||
|
||||
// Utils
|
||||
|
||||
export const isGroupAddress = (a: Address) => a.kind === GROUP_DEFINITION
|
||||
|
||||
export const isCommunityAddress = (a: Address) => a.kind === COMMUNITY_DEFINITION
|
||||
|
||||
export const isContextAddress = (a: Address) => [GROUP_DEFINITION, COMMUNITY_DEFINITION].includes(a.kind)
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {Event, EventTemplate, UnsignedEvent} from 'nostr-tools'
|
||||
import {verifyEvent, getEventHash} from 'nostr-tools'
|
||||
import {cached, now} from '@coracle.social/lib'
|
||||
import {Tags} from './Tags'
|
||||
import {addressFromEvent, encodeAddress} from './Address'
|
||||
import {isEphemeralKind, isReplaceableKind, isPlainReplaceableKind, isParameterizedReplaceableKind} from './Kinds'
|
||||
|
||||
export type Rumor = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey' | 'id'>
|
||||
|
||||
export type CreateEventOpts = {
|
||||
content?: string
|
||||
tags?: string[][]
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export const createEvent = (kind: number, {content = "", tags = [], created_at = now()}: CreateEventOpts) =>
|
||||
({kind, content, tags, created_at})
|
||||
|
||||
export const asEventTemplate = ({kind, tags, content, created_at}: EventTemplate): EventTemplate =>
|
||||
({kind, tags, content, created_at})
|
||||
|
||||
export const asUnsignedEvent = ({kind, tags, content, created_at, pubkey}: UnsignedEvent): UnsignedEvent =>
|
||||
({kind, tags, content, created_at, pubkey})
|
||||
|
||||
export const asRumor = ({kind, tags, content, created_at, pubkey, id}: Rumor): Rumor =>
|
||||
({kind, tags, content, created_at, pubkey, id})
|
||||
|
||||
export const asEvent = ({kind, tags, content, created_at, pubkey, id, sig}: Event): Event =>
|
||||
({kind, tags, content, created_at, pubkey, id, sig})
|
||||
|
||||
export const hasValidSignature = cached<string, boolean, [Event]>({
|
||||
maxSize: 10000,
|
||||
getKey: ([e]: [Event]) => {
|
||||
try {
|
||||
return [getEventHash(e), e.sig].join(":")
|
||||
} catch (err) {
|
||||
return 'invalid'
|
||||
}
|
||||
},
|
||||
getValue: ([e]: [Event]) => {
|
||||
try {
|
||||
return verifyEvent(e)
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getAddress = (e: UnsignedEvent) => encodeAddress(addressFromEvent(e))
|
||||
|
||||
export const getIdOrAddress = (e: Rumor) => isReplaceable(e) ? getAddress(e) : e.id
|
||||
|
||||
export const getIdAndAddress = (e: Rumor) => isReplaceable(e) ? [e.id, getAddress(e)] : [e.id]
|
||||
|
||||
export const isEphemeral = (e: EventTemplate) => isEphemeralKind(e.kind)
|
||||
|
||||
export const isReplaceable = (e: EventTemplate) => isReplaceableKind(e.kind)
|
||||
|
||||
export const isPlainReplaceable = (e: EventTemplate) => isPlainReplaceableKind(e.kind)
|
||||
|
||||
export const isParameterizedReplaceable = (e: EventTemplate) => isParameterizedReplaceableKind(e.kind)
|
||||
|
||||
export const isChildOf = (child: EventTemplate, parent: Rumor) => {
|
||||
const {roots, replies} = Tags.fromEvent(child).ancestors()
|
||||
const parentIds = (replies.exists() ? replies : roots).values().valueOf()
|
||||
|
||||
return getIdAndAddress(parent).some(x => parentIds.includes(x))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {Event} from 'nostr-tools'
|
||||
import {matchFilter as nostrToolsMatchFilter} from 'nostr-tools'
|
||||
|
||||
export type Filter = {
|
||||
ids?: string[]
|
||||
kinds?: number[]
|
||||
authors?: string[]
|
||||
since?: number
|
||||
until?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
[key: `#${string}`]: string[]
|
||||
}
|
||||
|
||||
export const matchFilter = (filter: Filter, event: Event) => {
|
||||
if (!nostrToolsMatchFilter(filter, event)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const content = event.content.toLowerCase()
|
||||
const terms = filter.search.toLowerCase().split(/\s+/g)
|
||||
|
||||
for (const term of terms) {
|
||||
if (content.includes(term)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const matchFilters = (filters: Filter[], event: Event) => {
|
||||
for (const filter of filters) {
|
||||
if (matchFilter(filter, event)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {between} from '@coracle.social/lib'
|
||||
|
||||
export const isEphemeralKind = (kind: number) => between(19999, 29999, kind)
|
||||
|
||||
export const isPlainReplaceableKind = (kind: number) => between(9999, 20000, kind)
|
||||
|
||||
export const isParameterizedReplaceableKind = (kind: number) => between(29999, 40000, kind)
|
||||
|
||||
export const isReplaceableKind = (kind: number) => isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind)
|
||||
|
||||
export const PROFILE = 0
|
||||
export const NOTE = 1
|
||||
export const RELAY = 2
|
||||
export const DM = 4
|
||||
export const EVENT_DELETION = 5
|
||||
export const REPOST = 6
|
||||
export const REACTION = 7
|
||||
export const BADGE_AWARD = 8
|
||||
export const GENERIC_REPOST = 16
|
||||
export const CHANNEL_CREATION = 40
|
||||
export const CHANNEL_METADATA = 41
|
||||
export const CHANNEL_MESSAGE = 42
|
||||
export const CHANNEL_HIDE_MESSAGE = 43
|
||||
export const CHANNEL_MUTE_USER = 44
|
||||
export const OPEN_TIMESTAMP = 1040
|
||||
export const GIFT_WRAP = 1059
|
||||
export const FILE_METADATA = 1063
|
||||
export const LIVE_CHAT_MESSAGE = 1311
|
||||
export const PROBLEM_TRACKER = 1971
|
||||
export const REPORT = 1984
|
||||
export const LABEL = 1985
|
||||
export const COMMUNITY_POST_APPROVAL = 4550
|
||||
export const JOB_REQUEST = 5999
|
||||
export const JOB_RESULT = 6999
|
||||
export const JOB_FEEDBACK = 7000
|
||||
export const ZAP_GOAL = 9041
|
||||
export const ZAP_REQUEST = 9734
|
||||
export const ZAP_RESPONSE = 9735
|
||||
export const HIGHLIGHT = 9802
|
||||
export const USER_LIST_MUTES = 10000
|
||||
export const USER_LIST_PINS = 10001
|
||||
export const USER_LIST_RELAYS = 10002
|
||||
export const USER_LIST_BOOKMARKS = 10003
|
||||
export const USER_LIST_COMMUNITIES = 10004
|
||||
export const USER_LIST_PUBLIC_CHATS = 10005
|
||||
export const USER_LIST_BLOCKED_RELAYS = 10006
|
||||
export const USER_LIST_SEARCH_RELAYS = 10007
|
||||
export const USER_LIST_INTERESTS = 10015
|
||||
export const USER_LIST_EMOJIS = 10030
|
||||
export const LIGHTNING_PUB_RPC = 21000
|
||||
export const CLIENT_AUTH = 22242
|
||||
export const NWC_INFO = 13194
|
||||
export const NWC_REQUEST = 23194
|
||||
export const NWC_RESPONSE = 23195
|
||||
export const NOSTR_CONNECT = 24133
|
||||
export const HTTP_AUTH = 27235
|
||||
export const LIST_FOLLOWS = 3
|
||||
export const LIST_PEOPLE = 30000
|
||||
export const LIST_GENERIC = 30001
|
||||
export const LIST_RELAYS = 30002
|
||||
export const LIST_BOOKMARKS = 30003
|
||||
export const LIST_CURATIONS = 30004
|
||||
export const PROFILE_BADGES = 30008
|
||||
export const BADGE_DEFINITION = 30009
|
||||
export const LIST_EMOJIS = 30030
|
||||
export const LIST_INTERESTS = 30015
|
||||
export const LONG_FORM_ARTICLE = 30023
|
||||
export const LONG_FORM_ARTICLE_DRAFT = 30024
|
||||
export const APPLICATION = 30078
|
||||
export const LIVE_EVENT = 30311
|
||||
export const USER_STATUSES = 30315
|
||||
export const CLASSIFIED_LISTING = 30402
|
||||
export const DRAFT_CLASSIFIED_LISTING = 30403
|
||||
export const CALENDAR = 31924
|
||||
export const CALENDAR_EVENT_DATE = 31922
|
||||
export const CALENDAR_EVENT_TIME = 31923
|
||||
export const CALENDAR_EVENT_RSVP = 31925
|
||||
export const HANDLER_RECOMMENDATION = 31989
|
||||
export const HANDLER_INFORMATION = 31990
|
||||
export const COMMUNITY_DEFINITION = 34550
|
||||
export const GROUP_DEFINITION = 35834
|
||||
@@ -0,0 +1,3 @@
|
||||
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
|
||||
|
||||
export const toNostrURI = (s: string) => `nostr:${s}`
|
||||
@@ -0,0 +1,33 @@
|
||||
import {normalizeUrl, stripProtocol} from '@coracle.social/lib'
|
||||
|
||||
export const isShareableRelayUrl = (url: string) =>
|
||||
Boolean(
|
||||
typeof url === 'string' &&
|
||||
// Is it actually a websocket url and has a dot
|
||||
url.match(/^wss:\/\/.+\..+/) &&
|
||||
// Sometimes bugs cause multiple relays to get concatenated
|
||||
url.match(/:\/\//g)?.length === 1 &&
|
||||
// It shouldn't have any whitespace, url-encoded or otherwise
|
||||
!url.match(/\s|%/) &&
|
||||
// Don't match stuff with a port number
|
||||
!url.slice(6).match(/:\d+/) &&
|
||||
// Don't match raw ip addresses
|
||||
!url.slice(6).match(/\d+\.\d+\.\d+\.\d+/) &&
|
||||
// Skip nostr.wine's virtual relays
|
||||
!url.slice(6).match(/\/npub/)
|
||||
)
|
||||
|
||||
export const normalizeRelayUrl = (url: string) => {
|
||||
// Use our library to normalize
|
||||
url = normalizeUrl(url, {stripHash: true, stripAuthentication: false})
|
||||
|
||||
// Strip the protocol since only wss works
|
||||
url = stripProtocol(url)
|
||||
|
||||
// Urls without pathnames are supposed to have a trailing slash
|
||||
if (!url.includes("/")) {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
return "wss://" + url
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type {EventTemplate, UnsignedEvent} from 'nostr-tools'
|
||||
import {first, uniq, shuffle} from '@coracle.social/lib'
|
||||
import type {Rumor} from './Events'
|
||||
import {getAddress, isReplaceable} from './Events'
|
||||
import {Tag, Tags} from './Tags'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
import {addressFromEvent, decodeAddress} from './Address'
|
||||
|
||||
const isGroupAddress = (a: string) => decodeAddress(a).kind === GROUP_DEFINITION
|
||||
|
||||
const isCommunityAddress = (a: string) => decodeAddress(a).kind === COMMUNITY_DEFINITION
|
||||
|
||||
export enum RelayMode {
|
||||
Read = "read",
|
||||
Write = "write",
|
||||
}
|
||||
|
||||
export type RouterOptions = {
|
||||
getUserPubkey: () => string | null
|
||||
getGroupRelays: (address: string) => string[]
|
||||
getCommunityRelays: (address: string) => string[]
|
||||
getPubkeyRelays: (pubkey: string, mode?: RelayMode) => string[]
|
||||
getDefaultRelays: (mode?: RelayMode) => string[]
|
||||
getRelayQuality?: (url: string) => number
|
||||
getDefaultLimit: () => number
|
||||
}
|
||||
|
||||
export type RouteScores = Record<string, {score: number, count: number}>
|
||||
|
||||
export type FallbackPolicy = (urls: string[], limit: number) => number
|
||||
|
||||
export class Router {
|
||||
constructor(readonly options: RouterOptions) {}
|
||||
|
||||
// Utilities derived from options
|
||||
|
||||
getUserRelays = (mode?: RelayMode) => {
|
||||
const pubkey = this.options.getUserPubkey()
|
||||
|
||||
return pubkey ? this.options.getPubkeyRelays(pubkey, mode) : []
|
||||
}
|
||||
|
||||
getContextRelayGroups = (event: EventTemplate) => {
|
||||
const addresses = Tags.fromEvent(event).context().values().valueOf()
|
||||
|
||||
return [
|
||||
...addresses.filter(isCommunityAddress).map(this.options.getCommunityRelays),
|
||||
...addresses.filter(isGroupAddress).map(this.options.getGroupRelays),
|
||||
]
|
||||
}
|
||||
|
||||
// Utilities for processing hints
|
||||
|
||||
scoreGroups = (groups: string[][]) => {
|
||||
const scores: RouteScores = {}
|
||||
|
||||
groups.filter(g => g?.length > 0).forEach((urls, i) => {
|
||||
for (const url of shuffle(uniq(urls))) {
|
||||
if (!scores[url]) {
|
||||
scores[url] = {score: 0, count: 0}
|
||||
}
|
||||
|
||||
scores[url].score += 1 / (i + 1)
|
||||
scores[url].count += 1
|
||||
}
|
||||
})
|
||||
|
||||
for (const [url, score] of Object.entries(scores)) {
|
||||
const quality = this.options.getRelayQuality?.(url) || 1
|
||||
|
||||
score.score = score.score * Math.cbrt(score.count) * quality
|
||||
}
|
||||
|
||||
const items = Object.entries(scores)
|
||||
.filter(([url, {score}]) => score > 0)
|
||||
.sort((a, b) => b[1].score - a[1].score)
|
||||
.map(([url, {score, count}]) => ({url, score, count}))
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
scenario = (groups: string[][]) => new RouterScenario(this, groups)
|
||||
|
||||
merge = (scenarios: RouterScenario[]) =>
|
||||
this.scenario(scenarios.map(scenario => scenario.policy(this.addNoFallbacks).getUrls()))
|
||||
|
||||
// Routing scenarios
|
||||
|
||||
User = () => this.scenario([this.getUserRelays()])
|
||||
|
||||
ReadRelays = () => this.scenario([this.getUserRelays()]).mode(RelayMode.Read)
|
||||
|
||||
WriteRelays = () => this.scenario([this.getUserRelays()]).mode(RelayMode.Write)
|
||||
|
||||
AllMessages = () => this.scenario([this.getUserRelays()])
|
||||
|
||||
Messages = (pubkeys: string[]) =>
|
||||
this.scenario([
|
||||
this.getUserRelays(),
|
||||
...pubkeys.map(pubkey => this.options.getPubkeyRelays(pubkey))
|
||||
])
|
||||
|
||||
PublishMessage = (pubkey: string) =>
|
||||
this.scenario([
|
||||
this.getUserRelays(RelayMode.Write),
|
||||
this.options.getPubkeyRelays(pubkey, RelayMode.Read)
|
||||
]).policy(this.addMinimalFallbacks)
|
||||
|
||||
Event = (event: UnsignedEvent) =>
|
||||
this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Write),
|
||||
...this.getContextRelayGroups(event),
|
||||
])
|
||||
|
||||
EventChildren = (event: UnsignedEvent) =>
|
||||
this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
...this.getContextRelayGroups(event),
|
||||
])
|
||||
|
||||
EventParent = (event: UnsignedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
|
||||
return this.scenario([
|
||||
tags.replies().relays().valueOf(),
|
||||
tags.roots().relays().valueOf(),
|
||||
...this.getContextRelayGroups(event),
|
||||
...tags.whereKey("p").values().valueOf()
|
||||
.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)),
|
||||
tags.whereKey("p").relays().valueOf(),
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
])
|
||||
}
|
||||
|
||||
EventRoot = (event: UnsignedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
|
||||
return this.scenario([
|
||||
tags.roots().relays().valueOf(),
|
||||
tags.replies().relays().valueOf(),
|
||||
...this.getContextRelayGroups(event),
|
||||
...tags.whereKey("p").values().valueOf()
|
||||
.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)),
|
||||
tags.whereKey("p").relays().valueOf(),
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Read),
|
||||
])
|
||||
}
|
||||
|
||||
PublishEvent = (event: UnsignedEvent) => {
|
||||
const tags = Tags.fromEvent(event)
|
||||
const mentions = tags.values("p").valueOf()
|
||||
const addresses = tags.context().values().valueOf()
|
||||
const groupAddresses = addresses.filter(isGroupAddress)
|
||||
const communityAddresses = addresses.filter(isCommunityAddress)
|
||||
|
||||
// If we're publishing only to private groups, only publish to those groups' relays.
|
||||
// Otherwise, publish to all relays, because it's essentially public.
|
||||
if (groupAddresses.length > 0 && communityAddresses.length === 0) {
|
||||
return this.scenario(groupAddresses.map(this.options.getGroupRelays))
|
||||
}
|
||||
|
||||
return this.scenario([
|
||||
this.options.getPubkeyRelays(event.pubkey, RelayMode.Write),
|
||||
...groupAddresses.map(this.options.getGroupRelays),
|
||||
...communityAddresses.map(this.options.getCommunityRelays),
|
||||
...mentions.map((pk: string) => this.options.getPubkeyRelays(pk, RelayMode.Read)),
|
||||
])
|
||||
}
|
||||
|
||||
FromPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(pubkeys.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Write)))
|
||||
|
||||
ForPubkeys = (pubkeys: string[]) =>
|
||||
this.scenario(pubkeys.map(pk => this.options.getPubkeyRelays(pk, RelayMode.Read)))
|
||||
|
||||
WithinGroup = (address: string) =>
|
||||
this.scenario([this.options.getGroupRelays(address)]).policy(this.addNoFallbacks)
|
||||
|
||||
WithinCommunity = (address: string) =>
|
||||
this.scenario([this.options.getCommunityRelays(address)])
|
||||
|
||||
WithinContext = (address: string) => {
|
||||
if (isGroupAddress(address)) {
|
||||
return this.WithinGroup(address)
|
||||
}
|
||||
|
||||
if (isCommunityAddress(address)) {
|
||||
return this.WithinCommunity(address)
|
||||
}
|
||||
|
||||
throw new Error(`Unknown context ${address}`)
|
||||
}
|
||||
|
||||
WithinMultipleContexts = (addresses: string[]) =>
|
||||
this.merge(addresses.map(this.WithinContext))
|
||||
|
||||
// Fallback policies
|
||||
|
||||
addNoFallbacks = (urls: string[], limit: number) => 0
|
||||
|
||||
addMinimalFallbacks = (urls: string[], limit: number) => Math.max(0, 1 - urls.length)
|
||||
|
||||
addMaximalFallbacks = (urls: string[], limit: number) => Math.max(0, limit - urls.length)
|
||||
|
||||
// Higher level utils that use hints
|
||||
|
||||
tagPubkey = (pubkey: string) =>
|
||||
Tag.from(["p", pubkey, this.FromPubkeys([pubkey]).getUrl()])
|
||||
|
||||
tagEventId = (event: Rumor, ...extra: string[]) =>
|
||||
Tag.from(["e", event.id, this.Event(event).getUrl(), ...extra])
|
||||
|
||||
tagEventAddress = (event: UnsignedEvent, ...extra: string[]) =>
|
||||
Tag.from(["a", getAddress(event), this.Event(event).getUrl(), ...extra])
|
||||
|
||||
tagEvent = (event: Rumor, ...extra: string[]) => {
|
||||
const tags = [this.tagEventId(event, ...extra)]
|
||||
|
||||
if (isReplaceable(event)) {
|
||||
tags.push(this.tagEventAddress(event, ...extra))
|
||||
}
|
||||
|
||||
return new Tags(tags)
|
||||
}
|
||||
|
||||
address = (event: UnsignedEvent) =>
|
||||
addressFromEvent(event, this.Event(event).limit(3).getUrls())
|
||||
}
|
||||
|
||||
// Router Scenario
|
||||
|
||||
export type RouterScenarioOptions = {
|
||||
mode?: RelayMode
|
||||
limit?: number
|
||||
policy?: FallbackPolicy
|
||||
}
|
||||
|
||||
export class RouterScenario {
|
||||
constructor(readonly router: Router, readonly groups: string[][], readonly options: RouterScenarioOptions = {}) {}
|
||||
|
||||
clone = (options: RouterScenarioOptions) =>
|
||||
new RouterScenario(this.router, this.groups, {...this.options, ...options})
|
||||
|
||||
limit = (limit: number) => this.clone({limit})
|
||||
|
||||
mode = (mode: RelayMode) => this.clone({mode})
|
||||
|
||||
policy = (policy: FallbackPolicy) => this.clone({policy})
|
||||
|
||||
getLimit = () => this.options.limit || this.router.options.getDefaultLimit()
|
||||
|
||||
getPolicy = () => this.options.policy || this.router.addMaximalFallbacks
|
||||
|
||||
getFallbackRelays = () =>
|
||||
shuffle(this.router.options.getDefaultRelays(this.options.mode))
|
||||
|
||||
getUrls = () => {
|
||||
const fallbackPolicy = this.getPolicy()
|
||||
const urls = this.router.scoreGroups(this.groups).map(s => s.url)
|
||||
const limit = this.getLimit()
|
||||
const limitWithFallbacks = Math.min(limit, urls.length) + fallbackPolicy(urls, limit)
|
||||
|
||||
for (const url of this.getFallbackRelays()) {
|
||||
if (urls.length >= limitWithFallbacks) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!urls.includes(url)) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return urls.slice(0, limitWithFallbacks)
|
||||
}
|
||||
|
||||
getUrl = () => first(this.limit(1).getUrls())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {EventTemplate} from 'nostr-tools'
|
||||
import type {OmitStatics} from '@coracle.social/lib'
|
||||
import {Fluent, last} from '@coracle.social/lib'
|
||||
import {isShareableRelayUrl, normalizeRelayUrl} from './Relays'
|
||||
import type {Address} from './Address'
|
||||
import {encodeAddress, decodeAddress} from './Address'
|
||||
import {GROUP_DEFINITION, COMMUNITY_DEFINITION} from './Kinds'
|
||||
|
||||
export class Tag extends (Fluent<string> as OmitStatics<typeof Fluent<string>, 'from'>) {
|
||||
static from(xs: Iterable<string>) {
|
||||
return new Tag(Array.from(xs))
|
||||
}
|
||||
|
||||
static fromAddress = (a: Address) => new Tag(["a", encodeAddress(a), a.relays[0] || ""])
|
||||
|
||||
valueOf = () => this.xs
|
||||
|
||||
key = () => this.xs[0]
|
||||
|
||||
value = () => this.xs[1]
|
||||
|
||||
mark = () => last(this.xs.slice(2))
|
||||
|
||||
entry = () => this.xs.slice(0, 2)
|
||||
|
||||
setKey = (k: string) => this.set(0, k)
|
||||
|
||||
setValue = (v: string) => this.set(1, v)
|
||||
|
||||
setMark = (m: string) => this.xs.length > 2 ? this.set(this.xs.length - 2, m) : this.append(m)
|
||||
|
||||
asAddress = () => decodeAddress(this.value())
|
||||
|
||||
isAddress = (kind?: number) => this.key() === "a" && this.value()?.startsWith(`${kind}:`)
|
||||
|
||||
isGroup = () => this.isAddress(GROUP_DEFINITION)
|
||||
|
||||
isCommunity = () => this.isAddress(COMMUNITY_DEFINITION)
|
||||
|
||||
isContext = () => this.isAddress(GROUP_DEFINITION) || this.isAddress(COMMUNITY_DEFINITION)
|
||||
}
|
||||
|
||||
export class Tags extends (Fluent<Tag> as OmitStatics<typeof Fluent<Tag>, 'from'>) {
|
||||
static from(p: Iterable<string[]>) {
|
||||
return new Tags(Array.from(p).map(Tag.from))
|
||||
}
|
||||
|
||||
static fromEvent(event: EventTemplate) {
|
||||
return Tags.from(event.tags || [])
|
||||
}
|
||||
|
||||
static fromEvents(events: EventTemplate[]) {
|
||||
return Tags.from(events.flatMap(e => e.tags || []))
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
valueOf = () => this.xs.map(tag => tag.valueOf())
|
||||
|
||||
whereKey = (key: string) => this.filter(t => t.key() === key)
|
||||
|
||||
whereValue = (value: string) => this.filter(t => t.value() === value)
|
||||
|
||||
whereMark = (mark: string) => this.filter(t => t.mark() === mark)
|
||||
|
||||
removeKey = (key: string) => this.reject(t => t.key() === key)
|
||||
|
||||
removeValue = (value: string) => this.reject(t => t.value() === value)
|
||||
|
||||
removeMark = (mark: string) => this.reject(t => t.mark() === mark)
|
||||
|
||||
get = (key: string) => this.whereKey(key).first()
|
||||
|
||||
keys = () => this.mapTo(t => t.key())
|
||||
|
||||
values = (key?: string) => (key ? this.whereKey(key) : this).mapTo(t => t.value())
|
||||
|
||||
marks = () => this.mapTo(t => t.mark())
|
||||
|
||||
entries = () => this.mapTo(t => t.entry())
|
||||
|
||||
relays = () => this.flatMap((t: Tag) => t.valueOf().filter(isShareableRelayUrl).map(normalizeRelayUrl)).uniq()
|
||||
|
||||
topics = () => this.whereKey("t").values().map((t: string) => t.replace(/^#/, ""))
|
||||
|
||||
ancestors = () => {
|
||||
const tags = this.filter(t => ["a", "e", "q"].includes(t.key()) && !t.isContext())
|
||||
const parentTags = tags.filter(t => ["a", "e"].includes(t.key()))
|
||||
const mentionTags = tags.whereKey("q")
|
||||
const roots: string[][] = []
|
||||
const replies: string[][] = []
|
||||
const mentions: string[][] = []
|
||||
|
||||
parentTags
|
||||
.forEach((t: Tag, i: number) => {
|
||||
if (t.mark() === 'root') {
|
||||
roots.push(t.valueOf())
|
||||
} else if (t.mark() === 'reply') {
|
||||
replies.push(t.valueOf())
|
||||
} else if (t.mark() === 'mention') {
|
||||
mentions.push(t.valueOf())
|
||||
} else if (i === 0) {
|
||||
roots.push(t.valueOf())
|
||||
} else if (i === parentTags.count() - 1) {
|
||||
replies.push(t.valueOf())
|
||||
} else {
|
||||
mentions.push(t.valueOf())
|
||||
}
|
||||
})
|
||||
|
||||
// Add quotes as mentions separately so positional logic above works
|
||||
mentionTags.forEach((t: Tag) => mentions.push(t.valueOf()))
|
||||
|
||||
return {
|
||||
roots: Tags.from(roots),
|
||||
replies: Tags.from(replies),
|
||||
mentions: Tags.from(mentions),
|
||||
}
|
||||
}
|
||||
|
||||
roots = () => this.ancestors().roots
|
||||
|
||||
replies = () => this.ancestors().replies
|
||||
|
||||
mentions = () => this.ancestors().mentions
|
||||
|
||||
root = () => {
|
||||
const roots = this.roots()
|
||||
|
||||
return roots.get("e") || roots.get("a")
|
||||
}
|
||||
|
||||
reply = () => {
|
||||
const replies = this.replies()
|
||||
|
||||
return replies.get("e") || replies.get("a")
|
||||
}
|
||||
|
||||
parents = () => {
|
||||
const {roots, replies} = this.ancestors()
|
||||
|
||||
return replies.exists() ? replies: roots
|
||||
}
|
||||
|
||||
parent = () => {
|
||||
const parents = this.parents()
|
||||
|
||||
return parents.get("e") || parents.get("a")
|
||||
}
|
||||
|
||||
groups = () => this.whereKey("a").filter(t => t.isGroup())
|
||||
|
||||
communities = () => this.whereKey("a").filter(t => t.isCommunity())
|
||||
|
||||
context = () => this.whereKey("a").filter(t => t.isContext())
|
||||
|
||||
asObject = () => {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const t of this.xs) {
|
||||
result[t.key()] = t.value()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
imeta = (url: string) => {
|
||||
for (const tag of this.whereKey("imeta").xs) {
|
||||
const tags = Tags.from(tag.drop(1).valueOf().map((m: string) => m.split(" ")))
|
||||
|
||||
if (tags.get("url")?.value() === url) {
|
||||
return tags
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Generic setters
|
||||
|
||||
addTag = (...args: string[]) => this.append(Tag.from(args))
|
||||
|
||||
setTag = (k: string, ...args: string[]) => this.removeKey(k).addTag(k, ...args)
|
||||
|
||||
// Context
|
||||
|
||||
addContext = (addresses: string[]) => this.concat(addresses.map(a => Tag.from(["a", a])))
|
||||
|
||||
removeContext = () => this.reject(t => t.isContext())
|
||||
|
||||
setContext = (addresses: string[]) => this.removeContext().addContext(addresses)
|
||||
|
||||
// Images
|
||||
|
||||
addImages = (imeta: Tags[]) =>
|
||||
this.concat(imeta.map(tags => Tag.from(["image", tags.get("url").value()])))
|
||||
|
||||
removeImages = () => this.removeKey('image')
|
||||
|
||||
setImages = (imeta: Tags[]) => this.removeImages().addImages(imeta)
|
||||
|
||||
// IMeta
|
||||
|
||||
addIMeta = (imeta: Tags[]) =>
|
||||
this.concat(imeta.map(tags => Tag.from(["imeta", ...tags.valueOf().map(xs => xs.join(" "))])))
|
||||
|
||||
removeIMeta = () => this.removeKey('imeta')
|
||||
|
||||
setIMeta = (imeta: Tags[]) => this.removeIMeta().addIMeta(imeta)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './Address'
|
||||
export * from './Events'
|
||||
export * from './Filters'
|
||||
export * from './Kinds'
|
||||
export * from './Links'
|
||||
export * from './Relays'
|
||||
export * from './Router'
|
||||
export * from './Tags'
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@coracle.social/util",
|
||||
"version": "0.0.2",
|
||||
"author": "hodlbod",
|
||||
"license": "MIT",
|
||||
"description": "A collection of utilities.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"types": "./build/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./build/index.d.ts",
|
||||
"import": "./build/index.mjs",
|
||||
"require": "./build/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"pub": "npm run lint && npm run rebuild && npm publish",
|
||||
"rebuild": "npm run clean && npm run build",
|
||||
"build": "tsc-multi",
|
||||
"clean": "gts clean",
|
||||
"lint": "gts lint",
|
||||
"fix": "gts fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"gts": "^5.0.1",
|
||||
"tsc-multi": "^1.1.0",
|
||||
"typescript": "~5.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coracle.social/lib": "^0.0.1",
|
||||
"nostr-tools": "^2.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"targets": [
|
||||
{"extname": ".cjs", "module": "commonjs"},
|
||||
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
|
||||
],
|
||||
"projects": ["tsconfig.json"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../node_modules/gts/tsconfig-google.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "build",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["es2019"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user