Switch to monorepo setup

This commit is contained in:
Jon Staab
2024-03-25 14:22:33 -07:00
parent 74b926e227
commit 54e0775453
49 changed files with 3677 additions and 2321 deletions
+2
View File
@@ -0,0 +1,2 @@
build
normalize-url
+14
View File
@@ -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>
}
+10
View File
@@ -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
}
}
+65
View File
@@ -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))
}
+65
View File
@@ -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
}
+57
View File
@@ -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)
}
}
+35
View File
@@ -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;
+7
View File
@@ -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'
+583
View File
@@ -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
}
+9
View File
@@ -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.
+324
View File
@@ -0,0 +1,324 @@
# normalize-url [![Coverage Status](https://codecov.io/gh/sindresorhus/normalize-url/branch/main/graph/badge.svg)](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>
+36
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"targets": [
{"extname": ".cjs", "module": "commonjs"},
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
],
"projects": ["tsconfig.json"]
}
+11
View File
@@ -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"]
}
+2
View File
@@ -0,0 +1,2 @@
build
normalize-url
+119
View File
@@ -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()
}
}
+181
View File
@@ -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'
}
}
}
+79
View File
@@ -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)
}
}
}
}
+49
View File
@@ -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)
}
}
}
+117
View File
@@ -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)
}
}
}
+123
View File
@@ -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()
}
}
}
+10
View File
@@ -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"
+41
View File
@@ -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"
}
}
+26
View File
@@ -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())
}
}
+28
View File
@@ -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)
}
}
+28
View File
@@ -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)
}
}
+30
View File
@@ -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)
})
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"targets": [
{"extname": ".cjs", "module": "commonjs"},
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
],
"projects": ["tsconfig.json"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["es2019", "dom"]
},
"include": ["**/*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
build
normalize-url
+61
View File
@@ -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)
+69
View File
@@ -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))
}
+44
View File
@@ -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
}
+81
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, "")
export const toNostrURI = (s: string) => `nostr:${s}`
+33
View File
@@ -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
}
+277
View File
@@ -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())
}
+210
View File
@@ -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)
}
+8
View File
@@ -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'
+40
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"targets": [
{"extname": ".cjs", "module": "commonjs"},
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
],
"projects": ["tsconfig.json"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["es2019"]
},
"include": ["**/*.ts"]
}