chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "@sim/utils",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./id": {
|
||||
"types": "./src/id.ts",
|
||||
"default": "./src/id.ts"
|
||||
},
|
||||
"./random": {
|
||||
"types": "./src/random.ts",
|
||||
"default": "./src/random.ts"
|
||||
},
|
||||
"./errors": {
|
||||
"types": "./src/errors.ts",
|
||||
"default": "./src/errors.ts"
|
||||
},
|
||||
"./helpers": {
|
||||
"types": "./src/helpers.ts",
|
||||
"default": "./src/helpers.ts"
|
||||
},
|
||||
"./media-embed": {
|
||||
"types": "./src/media-embed.ts",
|
||||
"default": "./src/media-embed.ts"
|
||||
},
|
||||
"./formatting": {
|
||||
"types": "./src/formatting.ts",
|
||||
"default": "./src/formatting.ts"
|
||||
},
|
||||
"./fractional-indexing": {
|
||||
"types": "./src/fractional-indexing.ts",
|
||||
"default": "./src/fractional-indexing.ts"
|
||||
},
|
||||
"./object": {
|
||||
"types": "./src/object.ts",
|
||||
"default": "./src/object.ts"
|
||||
},
|
||||
"./string": {
|
||||
"types": "./src/string.ts",
|
||||
"default": "./src/string.ts"
|
||||
},
|
||||
"./retry": {
|
||||
"types": "./src/retry.ts",
|
||||
"default": "./src/retry.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeError, getPostgresErrorCode, toError } from './errors.js'
|
||||
|
||||
describe('toError', () => {
|
||||
it('returns the same Error when given an Error', () => {
|
||||
const err = new Error('test')
|
||||
expect(toError(err)).toBe(err)
|
||||
})
|
||||
|
||||
it('wraps a string into an Error', () => {
|
||||
const err = toError('msg')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toBe('msg')
|
||||
})
|
||||
|
||||
it('wraps a number into an Error', () => {
|
||||
const err = toError(42)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toBe('42')
|
||||
})
|
||||
|
||||
it('wraps null into an Error', () => {
|
||||
const err = toError(null)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toBe('null')
|
||||
})
|
||||
|
||||
it('wraps undefined into an Error', () => {
|
||||
const err = toError(undefined)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toBe('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPostgresErrorCode', () => {
|
||||
it('reads code from Error.code', () => {
|
||||
const err = new Error('fail') as Error & { code: string }
|
||||
err.code = '23505'
|
||||
expect(getPostgresErrorCode(err)).toBe('23505')
|
||||
})
|
||||
|
||||
it('reads code from plain object', () => {
|
||||
expect(getPostgresErrorCode({ code: '23505' })).toBe('23505')
|
||||
})
|
||||
|
||||
it('reads code from Error.cause', () => {
|
||||
const err = new Error('fail', { cause: { code: '23505' } })
|
||||
expect(getPostgresErrorCode(err)).toBe('23505')
|
||||
})
|
||||
|
||||
it('walks nested Error causes', () => {
|
||||
const pgErr = new Error('unique_violation') as Error & { code: string }
|
||||
pgErr.code = '23505'
|
||||
const err = new Error('outer', { cause: new Error('inner', { cause: pgErr }) })
|
||||
expect(getPostgresErrorCode(err)).toBe('23505')
|
||||
})
|
||||
|
||||
it('returns undefined for non-errors', () => {
|
||||
expect(getPostgresErrorCode(undefined)).toBeUndefined()
|
||||
expect(getPostgresErrorCode(null)).toBeUndefined()
|
||||
expect(getPostgresErrorCode('23505')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no code is present', () => {
|
||||
expect(getPostgresErrorCode(new Error('no code'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not loop forever on circular cause chains', () => {
|
||||
const err1 = new Error('a')
|
||||
const err2 = new Error('b', { cause: err1 })
|
||||
// Create circular reference
|
||||
;(err1 as { cause?: unknown }).cause = err2
|
||||
expect(getPostgresErrorCode(err1)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeError', () => {
|
||||
it('reports name and message for a plain error, omitting causeChain', () => {
|
||||
const described = describeError(new Error('boom'))
|
||||
expect(described).toEqual({ name: 'Error', message: 'boom' })
|
||||
expect(described.causeChain).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces the deepest cause for a wrapped driver error', () => {
|
||||
const driver = Object.assign(new Error('read ECONNRESET'), {
|
||||
code: 'ECONNRESET',
|
||||
errno: 'ECONNRESET',
|
||||
syscall: 'read',
|
||||
})
|
||||
const wrapped = new Error('Failed query: select ...', { cause: driver })
|
||||
const described = describeError(wrapped)
|
||||
expect(described.message).toBe('read ECONNRESET')
|
||||
expect(described.code).toBe('ECONNRESET')
|
||||
expect(described.errno).toBe('ECONNRESET')
|
||||
expect(described.syscall).toBe('read')
|
||||
expect(described.causeChain).toEqual([
|
||||
'Error: Failed query: select ...',
|
||||
'Error: read ECONNRESET',
|
||||
])
|
||||
})
|
||||
|
||||
it('always returns the cause for unclassified errors (AbortError)', () => {
|
||||
const aborted = Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })
|
||||
expect(describeError(aborted)).toEqual({
|
||||
name: 'AbortError',
|
||||
message: 'The operation was aborted',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a populated description for non-Error input without throwing', () => {
|
||||
expect(describeError('just a string')).toEqual({ name: 'Error', message: 'just a string' })
|
||||
expect(() => describeError({ weird: true })).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops at depth 10 and does not loop on a cyclic cause', () => {
|
||||
const a = new Error('a')
|
||||
const b = new Error('b')
|
||||
;(a as { cause?: unknown }).cause = b
|
||||
;(b as { cause?: unknown }).cause = a
|
||||
let described: ReturnType<typeof describeError> | undefined
|
||||
expect(() => {
|
||||
described = describeError(a)
|
||||
}).not.toThrow()
|
||||
expect(described?.causeChain?.length).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Normalizes an unknown caught value into an Error instance.
|
||||
* Replaces the common `e instanceof Error ? e : new Error(String(e))` pattern in catch clauses.
|
||||
*/
|
||||
export function toError(value: unknown): Error {
|
||||
if (value instanceof Error) return value
|
||||
if (typeof value === 'string') return new Error(value)
|
||||
return new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string message from an unknown caught value.
|
||||
* Use instead of `e instanceof Error ? e.message : 'fallback'` in catch clauses.
|
||||
*
|
||||
* - Error instance → `error.message`
|
||||
* - Non-empty string → the string itself (handles `throw 'msg'` patterns)
|
||||
* - Otherwise → `fallback` if provided, or `String(value)`
|
||||
*/
|
||||
export function getErrorMessage(value: unknown, fallback?: string): string {
|
||||
if (value instanceof Error) return value.message
|
||||
if (typeof value === 'string' && value.length > 0) return value
|
||||
return fallback ?? String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns PostgreSQL error code (e.g. `23505` for unique_violation) when present on a thrown value.
|
||||
* Normalizes common Drizzle / `postgres` driver shapes and walks `cause` chains.
|
||||
*/
|
||||
export function getPostgresErrorCode(error: unknown): string | undefined {
|
||||
return readPgErrorField(error, 'code')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the PostgreSQL constraint that triggered the error (e.g. the unique index
|
||||
* name on a `23505`), when present on a thrown value. Mirrors the field populated by the
|
||||
* `postgres` / `pg` drivers, walking `cause` chains the same way as `getPostgresErrorCode`.
|
||||
*/
|
||||
export function getPostgresConstraintName(error: unknown): string | undefined {
|
||||
return readPgErrorField(error, 'constraint_name') ?? readPgErrorField(error, 'constraint')
|
||||
}
|
||||
|
||||
export interface DescribedError {
|
||||
name: string
|
||||
message: string
|
||||
code?: string
|
||||
errno?: string
|
||||
syscall?: string
|
||||
/** `"Name: message"` per link in the `.cause` chain, outermost first. Present only when the chain has more than one link. */
|
||||
causeChain?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on diagnostic view of an error and its `.cause` chain.
|
||||
*
|
||||
* Reports the fields of the DEEPEST `.cause` link, because a wrapped driver
|
||||
* error (e.g. Drizzle's `"Failed query: ..."` wrapping an `ECONNRESET`) carries
|
||||
* the real reason there, not on the outer wrapper. Always returns a populated
|
||||
* object — including for non-`Error` throws and unclassified errors like
|
||||
* `AbortError`. Cycle-safe and depth-bounded.
|
||||
*
|
||||
* Loggers do not serialize the non-enumerable `Error.prototype.cause`, so pass
|
||||
* the result as an explicit structured field rather than the raw error.
|
||||
*/
|
||||
export function describeError(error: unknown): DescribedError {
|
||||
const chain: Error[] = []
|
||||
const seen = new Set<unknown>()
|
||||
let current: unknown = error
|
||||
while (current instanceof Error && !seen.has(current) && chain.length < 10) {
|
||||
seen.add(current)
|
||||
chain.push(current)
|
||||
current = current.cause
|
||||
}
|
||||
|
||||
if (chain.length === 0) {
|
||||
const normalized = toError(error)
|
||||
return { name: normalized.name, message: normalized.message }
|
||||
}
|
||||
|
||||
const deepest = chain[chain.length - 1] as Error & Record<string, unknown>
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' ? value : undefined
|
||||
const code = asString(deepest.code)
|
||||
const errno = asString(deepest.errno)
|
||||
const syscall = asString(deepest.syscall)
|
||||
|
||||
return {
|
||||
name: deepest.name,
|
||||
message: deepest.message,
|
||||
...(code ? { code } : {}),
|
||||
...(errno ? { errno } : {}),
|
||||
...(syscall ? { syscall } : {}),
|
||||
...(chain.length > 1 ? { causeChain: chain.map((e) => `${e.name}: ${e.message}`) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function readPgErrorField(error: unknown, field: string): string | undefined {
|
||||
const seen = new Set<unknown>()
|
||||
let current: unknown = error
|
||||
|
||||
while (current !== undefined && current !== null) {
|
||||
if (seen.has(current)) {
|
||||
break
|
||||
}
|
||||
seen.add(current)
|
||||
|
||||
if (typeof current === 'object') {
|
||||
const value = (current as Record<string, unknown>)[field]
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if (current instanceof Error && current.cause !== undefined) {
|
||||
current = current.cause
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatAbsoluteDate,
|
||||
formatCompactTimestamp,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatRelativeTime,
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
getTimezoneAbbreviation,
|
||||
} from './formatting.js'
|
||||
|
||||
describe('getTimezoneAbbreviation', () => {
|
||||
it('returns UTC for UTC timezone', () => {
|
||||
expect(getTimezoneAbbreviation('UTC')).toBe('UTC')
|
||||
})
|
||||
|
||||
it('returns JST for Tokyo (no DST)', () => {
|
||||
expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2023-01-15'))).toBe('JST')
|
||||
expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2023-07-15'))).toBe('JST')
|
||||
})
|
||||
|
||||
it('returns the timezone string for unknown timezones', () => {
|
||||
expect(getTimezoneAbbreviation('Unknown/Zone')).toBe('Unknown/Zone')
|
||||
})
|
||||
|
||||
it('resolves a valid IANA timezone outside the hardcoded map via Intl instead of the raw string', () => {
|
||||
const result = getTimezoneAbbreviation('Europe/Berlin', new Date('2023-01-15'))
|
||||
expect(result).not.toBe('Europe/Berlin')
|
||||
})
|
||||
|
||||
it('returns PST or PDT for Los Angeles', () => {
|
||||
const result = getTimezoneAbbreviation('America/Los_Angeles', new Date('2023-01-15'))
|
||||
expect(['PST', 'PDT']).toContain(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('formats a date with time', () => {
|
||||
const date = new Date('2023-05-15T14:30:00')
|
||||
const result = formatDateTime(date)
|
||||
expect(result).toMatch(/May 15, 2023/)
|
||||
})
|
||||
|
||||
it('appends timezone abbreviation when timezone is provided', () => {
|
||||
const date = new Date('2023-05-15T14:30:00Z')
|
||||
const result = formatDateTime(date, 'UTC')
|
||||
expect(result).toContain('UTC')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a date without time', () => {
|
||||
const date = new Date('2023-05-15T14:30:00')
|
||||
const result = formatDate(date)
|
||||
expect(result).toMatch(/May 15, 2023/)
|
||||
expect(result).not.toMatch(/14:30/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAbsoluteDate', () => {
|
||||
it('formats an ISO date string', () => {
|
||||
const result = formatAbsoluteDate('2023-05-15T14:30:00Z')
|
||||
expect(result).toMatch(/May/)
|
||||
expect(result).toMatch(/2023/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats time only', () => {
|
||||
const date = new Date('2023-05-15T14:30:00')
|
||||
const result = formatTime(date)
|
||||
expect(result).toMatch(/2:30 PM|14:30/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTimeWithSeconds', () => {
|
||||
it('formats time with seconds and timezone', () => {
|
||||
const date = new Date('2023-05-15T14:30:45')
|
||||
const result = formatTimeWithSeconds(date)
|
||||
expect(result).toMatch(/2:30:45 PM|14:30:45/)
|
||||
})
|
||||
|
||||
it('omits timezone when includeTimezone is false', () => {
|
||||
const date = new Date('2023-05-15T14:30:45')
|
||||
const withTz = formatTimeWithSeconds(date, true)
|
||||
const withoutTz = formatTimeWithSeconds(date, false)
|
||||
expect(withoutTz.length).toBeLessThanOrEqual(withTz.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCompactTimestamp', () => {
|
||||
it('produces MM-DD HH:mm pattern', () => {
|
||||
const result = formatCompactTimestamp('2023-05-15T14:30:00')
|
||||
expect(result).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('returns a formatted string even for invalid dates (no throw)', () => {
|
||||
const result = formatCompactTimestamp('not-a-date')
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('returns "0ms" for 0', () => {
|
||||
expect(formatDuration(0)).toBe('0ms')
|
||||
})
|
||||
|
||||
it('formats milliseconds', () => {
|
||||
expect(formatDuration(500)).toBe('500ms')
|
||||
})
|
||||
|
||||
it('formats seconds', () => {
|
||||
expect(formatDuration(5000)).toBe('5s')
|
||||
})
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatDuration(65000)).toBe('1m 5s')
|
||||
})
|
||||
|
||||
it('formats hours and minutes', () => {
|
||||
expect(formatDuration(3725000)).toBe('1h 2m')
|
||||
})
|
||||
|
||||
it('returns null for null', () => {
|
||||
expect(formatDuration(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(formatDuration(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('parses string durations', () => {
|
||||
expect(formatDuration('500ms')).toBe('500ms')
|
||||
})
|
||||
|
||||
it('returns em dash for NaN', () => {
|
||||
expect(formatDuration(Number.NaN)).toBe('\u2014')
|
||||
})
|
||||
|
||||
it('returns em dash for Infinity', () => {
|
||||
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe('\u2014')
|
||||
})
|
||||
|
||||
it('supports precision option for seconds', () => {
|
||||
expect(formatDuration(1500, { precision: 1 })).toBe('1.5s')
|
||||
expect(formatDuration(5000, { precision: 1 })).toBe('5s')
|
||||
})
|
||||
|
||||
it('formats sub-millisecond durations', () => {
|
||||
expect(formatDuration(0.5)).toBe('0.50ms')
|
||||
expect(formatDuration(0.001)).toBe('0ms')
|
||||
})
|
||||
|
||||
it('returns original string for non-numeric strings', () => {
|
||||
expect(formatDuration('not-a-number')).toBe('not-a-number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('returns "just now" for recent dates', () => {
|
||||
const now = new Date()
|
||||
expect(formatRelativeTime(now.toISOString())).toBe('just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago', () => {
|
||||
const date = new Date(Date.now() - 5 * 60 * 1000)
|
||||
expect(formatRelativeTime(date.toISOString())).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago', () => {
|
||||
const date = new Date(Date.now() - 3 * 60 * 60 * 1000)
|
||||
expect(formatRelativeTime(date.toISOString())).toBe('3h ago')
|
||||
})
|
||||
|
||||
it('returns days ago', () => {
|
||||
const date = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
|
||||
expect(formatRelativeTime(date.toISOString())).toBe('2d ago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Get a user-friendly timezone abbreviation
|
||||
* @param timezone - IANA timezone string
|
||||
* @param date - Date to check for DST
|
||||
* @returns A simplified timezone string (e.g., "PST" instead of "America/Los_Angeles")
|
||||
*/
|
||||
export function getTimezoneAbbreviation(timezone: string, date: Date = new Date()): string {
|
||||
if (timezone === 'UTC') return 'UTC'
|
||||
|
||||
const timezoneMap: Record<string, { standard: string; daylight: string }> = {
|
||||
'America/Los_Angeles': { standard: 'PST', daylight: 'PDT' },
|
||||
'America/Denver': { standard: 'MST', daylight: 'MDT' },
|
||||
'America/Chicago': { standard: 'CST', daylight: 'CDT' },
|
||||
'America/New_York': { standard: 'EST', daylight: 'EDT' },
|
||||
'Europe/London': { standard: 'GMT', daylight: 'BST' },
|
||||
'Europe/Paris': { standard: 'CET', daylight: 'CEST' },
|
||||
'Asia/Tokyo': { standard: 'JST', daylight: 'JST' }, // Japan doesn't use DST
|
||||
'Australia/Sydney': { standard: 'AEST', daylight: 'AEDT' },
|
||||
'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST
|
||||
}
|
||||
|
||||
if (timezone in timezoneMap) {
|
||||
const januaryDate = new Date(date.getFullYear(), 0, 1)
|
||||
const julyDate = new Date(date.getFullYear(), 6, 1)
|
||||
|
||||
const januaryFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const julyFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate)
|
||||
|
||||
if (isDSTObserved) {
|
||||
const currentFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const isDST = currentFormatter.format(date) !== januaryFormatter.format(januaryDate)
|
||||
return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard
|
||||
}
|
||||
|
||||
return timezoneMap[timezone].standard
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
}).formatToParts(date)
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value || timezone
|
||||
} catch {
|
||||
return timezone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date into a human-readable format
|
||||
* @param date - The date to format
|
||||
* @param timezone - Optional IANA timezone string (e.g., 'America/Los_Angeles', 'UTC')
|
||||
* @returns A formatted date string in the format "MMM D, YYYY h:mm A"
|
||||
*/
|
||||
export function formatDateTime(date: Date, timezone?: string): string {
|
||||
const formattedDate = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: timezone || undefined,
|
||||
})
|
||||
|
||||
if (timezone) {
|
||||
const tzAbbr = getTimezoneAbbreviation(timezone, date)
|
||||
return `${formattedDate} ${tzAbbr}`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date into a short format
|
||||
* @param date - The date to format
|
||||
* @returns A formatted date string in the format "MMM D, YYYY"
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to absolute format for tooltip display
|
||||
* @param dateString - ISO date string to format
|
||||
* @returns A formatted date string (e.g., "Jan 22, 2026, 01:30 PM")
|
||||
*/
|
||||
export function formatAbsoluteDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time into a short format
|
||||
* @param date - The date to format
|
||||
* @returns A formatted time string in the format "h:mm A"
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time with seconds and timezone
|
||||
* @param date - The date to format
|
||||
* @param includeTimezone - Whether to include the timezone abbreviation
|
||||
* @returns A formatted time string in the format "h:mm:ss AM/PM TZ"
|
||||
*/
|
||||
export function formatTimeWithSeconds(date: Date, includeTimezone = true): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: includeTimezone ? 'short' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO timestamp into a compact format for UI display
|
||||
* @param iso - ISO timestamp string
|
||||
* @returns A formatted string in "MM-DD HH:mm" format
|
||||
*/
|
||||
export function formatCompactTimestamp(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const min = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${min}`
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration to a human-readable format
|
||||
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms")
|
||||
* @param options - Optional formatting options
|
||||
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped
|
||||
* @returns A formatted duration string, or null if input is null/undefined
|
||||
*/
|
||||
export function formatDuration(
|
||||
duration: number | string | undefined | null,
|
||||
options?: { precision?: number }
|
||||
): string | null {
|
||||
if (duration === undefined || duration === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
|
||||
let ms: number
|
||||
if (typeof duration === 'string') {
|
||||
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
|
||||
if (!Number.isFinite(ms)) {
|
||||
return duration
|
||||
}
|
||||
} else {
|
||||
ms = duration
|
||||
// Handle NaN/Infinity (e.g., cancelled blocks with no end time)
|
||||
if (!Number.isFinite(ms)) {
|
||||
return '\u2014'
|
||||
}
|
||||
}
|
||||
|
||||
const precision = options?.precision ?? 0
|
||||
|
||||
if (ms < 1) {
|
||||
// Zero or near-zero: show "0ms" instead of "0.00ms"
|
||||
if (ms === 0 || ms < 0.005) {
|
||||
return '0ms'
|
||||
}
|
||||
// Sub-millisecond: show with 2 decimal places
|
||||
return `${ms.toFixed(2)}ms`
|
||||
}
|
||||
|
||||
if (ms < 1000) {
|
||||
// Milliseconds: round to integer
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) {
|
||||
if (precision > 0) {
|
||||
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
|
||||
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
|
||||
}
|
||||
return `${Math.floor(seconds)}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ${remainingSeconds}s`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
|
||||
* @param dateString - ISO date string to format
|
||||
* @returns A human-readable relative time string
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'just now'
|
||||
}
|
||||
if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60)
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400)
|
||||
return `${days}d ago`
|
||||
}
|
||||
if (diffInSeconds < 2592000) {
|
||||
const weeks = Math.floor(diffInSeconds / 604800)
|
||||
return `${weeks}w ago`
|
||||
}
|
||||
if (diffInSeconds < 31536000) {
|
||||
const months = Math.floor(diffInSeconds / 2592000)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
const years = Math.floor(diffInSeconds / 31536000)
|
||||
return `${years}y ago`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Locks in the BYTEWISE ordering the rest of the stack depends on: `order_key` is
|
||||
* stored `COLLATE "C"` (migration 0228) so Postgres compares keys the same way this
|
||||
* library does. If the library's order ever diverged from ASCII byte order — e.g.
|
||||
* across the `Z` (0x5A) < `a` (0x61) integer-head boundary, exactly where the
|
||||
* `en_US.UTF-8` locale disagrees — inserts would mint keys that fail the `a >= b`
|
||||
* assertion and rows would display out of order. These tests would catch that.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BASE_62_DIGITS, generateKeyBetween, generateNKeysBetween } from './fractional-indexing'
|
||||
|
||||
/** Bytewise (ASCII / UTF-16 code-unit) comparator — what `COLLATE "C"` and the library use. */
|
||||
const byteCompare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
describe('fractional-indexing bytewise ordering', () => {
|
||||
it('uses an alphabet in ascending char-code order with uppercase before lowercase', () => {
|
||||
for (let i = 1; i < BASE_62_DIGITS.length; i++) {
|
||||
expect(BASE_62_DIGITS.charCodeAt(i)).toBeGreaterThan(BASE_62_DIGITS.charCodeAt(i - 1))
|
||||
}
|
||||
// The boundary en_US.UTF-8 inverts: bytewise 'Z' < 'a', locale 'a' < 'Z'.
|
||||
expect('Z' < 'a').toBe(true)
|
||||
expect(BASE_62_DIGITS.indexOf('Z')).toBeLessThan(BASE_62_DIGITS.indexOf('a'))
|
||||
})
|
||||
|
||||
it('produces strictly bytewise-increasing keys on repeated append', () => {
|
||||
let prev: string | null = null
|
||||
let last = ''
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const key: string = generateKeyBetween(prev, null)
|
||||
if (last) expect(byteCompare(last, key)).toBe(-1)
|
||||
last = key
|
||||
prev = key
|
||||
}
|
||||
})
|
||||
|
||||
it('stays bytewise-ordered when prepends cross the Z/a integer-head boundary', () => {
|
||||
// Repeated prepend walks the integer head down out of 'a' into 'Z','Y',… —
|
||||
// the exact uppercase region en_US.UTF-8 sorts wrong. The first prepend before
|
||||
// "a0" already yields a 'Z'-headed key ("Zz").
|
||||
const keys: string[] = []
|
||||
let next: string | null = null
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const key: string = generateKeyBetween(null, next)
|
||||
keys.push(key)
|
||||
next = key
|
||||
}
|
||||
const ascending = [...keys].reverse() // prepends are emitted largest → smallest
|
||||
expect([...ascending].sort(byteCompare)).toEqual(ascending)
|
||||
expect(new Set(keys).size).toBe(keys.length) // all distinct
|
||||
// An uppercase-headed key sorts before the lowercase-headed "a0" at the tail.
|
||||
expect(ascending.some((k) => k[0] >= 'A' && k[0] <= 'Z')).toBe(true)
|
||||
expect(ascending[ascending.length - 1][0]).toBe('a')
|
||||
})
|
||||
|
||||
it('mints a contiguous run that is bytewise-sorted and distinct', () => {
|
||||
const keys = generateNKeysBetween(null, null, 500)
|
||||
expect([...keys].sort(byteCompare)).toEqual(keys)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
|
||||
it('throws when bounds are out of order — the contract COLLATE "C" must satisfy', () => {
|
||||
expect(() => generateKeyBetween('a1', 'a0')).toThrow()
|
||||
expect(() => generateKeyBetween('a0', 'a0')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Fractional indexing — generate ordering strings that sort lexicographically.
|
||||
*
|
||||
* In-house port of David Greenspan's algorithm
|
||||
* (https://observablehq.com/@dgreensp/implementing-fractional-indexing),
|
||||
* behavior-identical to the `fractional-indexing` npm package (CC0). A key is a
|
||||
* variable-length base-62 string: between any two keys there is always room for
|
||||
* another, so inserts never renumber existing rows. The only cost is gradual
|
||||
* length growth under repeated same-spot inserts.
|
||||
*
|
||||
* A key is `<integer part><fraction>`. The integer part's first character
|
||||
* encodes its own length (`a..z` → 2..27, `A..Z` → 27..2), letting integers
|
||||
* grow without bound in both directions. The fraction is plain base-62 digits
|
||||
* with no trailing zero.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default digit alphabet. Must be in ascending character-code order. */
|
||||
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integer-part helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Length the integer part must have, derived from its first character. */
|
||||
function getIntegerLength(head: string): number {
|
||||
if (head >= 'a' && head <= 'z') {
|
||||
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
|
||||
}
|
||||
if (head >= 'A' && head <= 'Z') {
|
||||
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
|
||||
}
|
||||
throw new Error(`invalid order key head: ${head}`)
|
||||
}
|
||||
|
||||
function validateInteger(int: string): void {
|
||||
if (int.length !== getIntegerLength(int[0])) {
|
||||
throw new Error(`invalid integer part of order key: ${int}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getIntegerPart(key: string): string {
|
||||
const integerPartLength = getIntegerLength(key[0])
|
||||
if (integerPartLength > key.length) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
return key.slice(0, integerPartLength)
|
||||
}
|
||||
|
||||
function validateOrderKey(key: string, digits: string): void {
|
||||
if (key === `A${digits[0].repeat(26)}`) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
// getIntegerPart throws if the head is bad or the key is too short.
|
||||
const i = getIntegerPart(key)
|
||||
const f = key.slice(i.length)
|
||||
if (f.slice(-1) === digits[0]) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Increment the integer part; returns null past the largest integer. */
|
||||
function incrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x)
|
||||
const [head, ...digs] = x.split('')
|
||||
let carry = true
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) + 1
|
||||
if (d === digits.length) {
|
||||
digs[i] = digits[0]
|
||||
} else {
|
||||
digs[i] = digits[d]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
if (carry) {
|
||||
if (head === 'Z') {
|
||||
return `a${digits[0]}`
|
||||
}
|
||||
if (head === 'z') {
|
||||
return null
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1)
|
||||
if (h > 'a') {
|
||||
digs.push(digits[0])
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
/** Decrement the integer part; returns null past the smallest integer. */
|
||||
function decrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x)
|
||||
const [head, ...digs] = x.split('')
|
||||
let borrow = true
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) - 1
|
||||
if (d === -1) {
|
||||
digs[i] = digits.slice(-1)
|
||||
} else {
|
||||
digs[i] = digits[d]
|
||||
borrow = false
|
||||
}
|
||||
}
|
||||
if (borrow) {
|
||||
if (head === 'a') {
|
||||
return `Z${digits.slice(-1)}`
|
||||
}
|
||||
if (head === 'A') {
|
||||
return null
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1)
|
||||
if (h < 'Z') {
|
||||
digs.push(digits.slice(-1))
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Midpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fraction strictly between `a` and `b` (both without integer parts). `a` may be
|
||||
* empty; `b` is null (open end) or non-empty and `> a`. No trailing zeros.
|
||||
*/
|
||||
function midpoint(a: string, b: string | null | undefined, digits: string): string {
|
||||
const zero = digits[0]
|
||||
if (b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`)
|
||||
}
|
||||
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
|
||||
throw new Error('trailing zero')
|
||||
}
|
||||
if (b) {
|
||||
// Strip the longest common prefix, padding `a` with zeros as we go. `b`
|
||||
// needs no padding — it can't end before `a` within the common prefix.
|
||||
let n = 0
|
||||
while ((a[n] || zero) === b[n]) {
|
||||
n++
|
||||
}
|
||||
if (n > 0) {
|
||||
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits)
|
||||
}
|
||||
}
|
||||
// First digits (or lack thereof) differ.
|
||||
const digitA = a ? digits.indexOf(a[0]) : 0
|
||||
const digitB = b != null ? digits.indexOf(b[0]) : digits.length
|
||||
if (digitB - digitA > 1) {
|
||||
const midDigit = Math.round(0.5 * (digitA + digitB))
|
||||
return digits[midDigit]
|
||||
}
|
||||
// First digits are consecutive.
|
||||
if (b && b.length > 1) {
|
||||
return b.slice(0, 1)
|
||||
}
|
||||
// `b` is null or a single digit; recurse into `a`'s tail.
|
||||
return digits[digitA] + midpoint(a.slice(1), null, digits)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a key that sorts strictly between `a` and `b`. Either may be null for
|
||||
* an open end. `a < b` lexicographically when both are non-null.
|
||||
*
|
||||
* @throws if `a`/`b` are invalid keys or `a >= b`.
|
||||
*/
|
||||
export function generateKeyBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
digits: string = BASE_62_DIGITS
|
||||
): string {
|
||||
if (a != null) {
|
||||
validateOrderKey(a, digits)
|
||||
}
|
||||
if (b != null) {
|
||||
validateOrderKey(b, digits)
|
||||
}
|
||||
if (a != null && b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`)
|
||||
}
|
||||
if (a == null) {
|
||||
if (b == null) {
|
||||
return `a${digits[0]}`
|
||||
}
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ib === `A${digits[0].repeat(26)}`) {
|
||||
return ib + midpoint('', fb, digits)
|
||||
}
|
||||
if (ib < b) {
|
||||
return ib
|
||||
}
|
||||
const res = decrementInteger(ib, digits)
|
||||
if (res == null) {
|
||||
throw new Error('cannot decrement any more')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
if (b == null) {
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const i = incrementInteger(ia, digits)
|
||||
return i == null ? ia + midpoint(fa, null, digits) : i
|
||||
}
|
||||
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ia === ib) {
|
||||
return ia + midpoint(fa, fb, digits)
|
||||
}
|
||||
const i = incrementInteger(ia, digits)
|
||||
if (i == null) {
|
||||
throw new Error('cannot increment any more')
|
||||
}
|
||||
if (i < b) {
|
||||
return i
|
||||
}
|
||||
return ia + midpoint(fa, null, digits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `n` distinct keys in sorted order, strictly between `a` and `b` (same
|
||||
* open-end semantics as {@link generateKeyBetween}). When both ends are null,
|
||||
* returns a contiguous run of "integer" keys.
|
||||
*/
|
||||
export function generateNKeysBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
n: number,
|
||||
digits: string = BASE_62_DIGITS
|
||||
): string[] {
|
||||
if (n === 0) {
|
||||
return []
|
||||
}
|
||||
if (n === 1) {
|
||||
return [generateKeyBetween(a, b, digits)]
|
||||
}
|
||||
if (b == null) {
|
||||
let c = generateKeyBetween(a, b, digits)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(c, b, digits)
|
||||
result.push(c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (a == null) {
|
||||
let c = generateKeyBetween(a, b, digits)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(a, c, digits)
|
||||
result.push(c)
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
}
|
||||
const mid = Math.floor(n / 2)
|
||||
const c = generateKeyBetween(a, b, digits)
|
||||
return [
|
||||
...generateNKeysBetween(a, c, mid, digits),
|
||||
c,
|
||||
...generateNKeysBetween(c, b, n - mid - 1, digits),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { noop, sleep } from './helpers.js'
|
||||
|
||||
describe('sleep', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves after the specified delay', async () => {
|
||||
const promise = sleep(1000)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await expect(promise).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not resolve before the delay', async () => {
|
||||
let resolved = false
|
||||
sleep(1000).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
vi.advanceTimersByTime(999)
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('noop', () => {
|
||||
it('is a function', () => {
|
||||
expect(typeof noop).toBe('function')
|
||||
})
|
||||
|
||||
it('returns undefined', () => {
|
||||
expect(noop()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Returns a promise that resolves after the specified duration.
|
||||
* Replaces the common `new Promise(resolve => setTimeout(resolve, ms))` pattern.
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** No-operation function for use as default callback. */
|
||||
export const noop = () => {}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateId, generateShortId, isValidUuid } from './id.js'
|
||||
|
||||
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('generateId', () => {
|
||||
it('returns a valid UUID v4', () => {
|
||||
const id = generateId()
|
||||
expect(id).toMatch(UUID_V4_RE)
|
||||
})
|
||||
|
||||
it('returns unique values across 100 calls', () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateId()))
|
||||
expect(ids.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateShortId', () => {
|
||||
it('returns default length of 21', () => {
|
||||
const id = generateShortId()
|
||||
expect(id).toHaveLength(21)
|
||||
})
|
||||
|
||||
it('returns custom length when specified', () => {
|
||||
const id = generateShortId(8)
|
||||
expect(id).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('uses only URL-safe characters', () => {
|
||||
const id = generateShortId(100)
|
||||
expect(id).toMatch(/^[a-zA-Z0-9_-]+$/)
|
||||
})
|
||||
|
||||
it('returns unique values', () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateShortId()))
|
||||
expect(ids.size).toBe(100)
|
||||
})
|
||||
|
||||
it('supports a custom alphabet', () => {
|
||||
const alphabet = 'abcdef0123456789'
|
||||
const id = generateShortId(32, alphabet)
|
||||
expect(id).toHaveLength(32)
|
||||
expect(id).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
|
||||
it('throws for an alphabet shorter than 2 characters', () => {
|
||||
expect(() => generateShortId(8, 'a')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidUuid', () => {
|
||||
it('returns true for valid UUIDs', () => {
|
||||
expect(isValidUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true)
|
||||
expect(isValidUuid('6ba7b810-9dad-11d1-80b4-00c04fd430c8')).toBe(true)
|
||||
expect(isValidUuid(generateId())).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for invalid strings', () => {
|
||||
expect(isValidUuid('')).toBe(false)
|
||||
expect(isValidUuid('not-a-uuid')).toBe(false)
|
||||
expect(isValidUuid('550e8400-e29b-41d4-a716')).toBe(false)
|
||||
expect(isValidUuid('550e8400e29b41d4a716446655440000')).toBe(false)
|
||||
expect(isValidUuid('550e8400-e29b-41d4-a716-44665544000g')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Generates a UUID v4 string safe for all contexts.
|
||||
*
|
||||
* `crypto.randomUUID()` requires a secure context (HTTPS or localhost) in
|
||||
* browsers. Self-hosted deployments served over plain HTTP will throw
|
||||
* `TypeError: crypto.randomUUID is not a function`. This utility falls back
|
||||
* to `crypto.getRandomValues()`, which does not require a secure context.
|
||||
*/
|
||||
export function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Validates whether a string is a well-formed UUID (any version).
|
||||
*/
|
||||
export function isValidUuid(value: string): boolean {
|
||||
return UUID_RE.test(value)
|
||||
}
|
||||
|
||||
const URL_SAFE_ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
|
||||
|
||||
/**
|
||||
* Generates a short, URL-safe random ID.
|
||||
*
|
||||
* Replaces `nanoid` — uses `crypto.getRandomValues()` which works in all
|
||||
* contexts including non-secure (HTTP) browsers.
|
||||
*
|
||||
* @param size - Length of the generated ID (default: 21)
|
||||
* @param alphabet - Optional custom alphabet (replaces nanoid's `customAlphabet`).
|
||||
* Length must be in [2, 256].
|
||||
* @returns A random string drawn from the alphabet
|
||||
*/
|
||||
export function generateShortId(size = 21, alphabet: string = URL_SAFE_ALPHABET): string {
|
||||
const alphabetLength = alphabet.length
|
||||
if (alphabetLength < 2 || alphabetLength > 256) {
|
||||
throw new Error('generateShortId alphabet length must be between 2 and 256')
|
||||
}
|
||||
|
||||
const mask = (2 << (31 - Math.clz32((alphabetLength - 1) | 1))) - 1
|
||||
const step = Math.ceil((1.6 * mask * size) / alphabetLength)
|
||||
|
||||
let id = ''
|
||||
while (id.length < size) {
|
||||
const bytes = new Uint8Array(step)
|
||||
crypto.getRandomValues(bytes)
|
||||
for (let i = 0; i < step && id.length < size; i++) {
|
||||
const index = bytes[i] & mask
|
||||
if (index < alphabetLength) {
|
||||
id += alphabet[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export { getErrorMessage, getPostgresErrorCode, toError } from './errors.js'
|
||||
export {
|
||||
formatAbsoluteDate,
|
||||
formatCompactTimestamp,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatRelativeTime,
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
getTimezoneAbbreviation,
|
||||
} from './formatting.js'
|
||||
export { noop, sleep } from './helpers.js'
|
||||
export { generateId, generateShortId, isValidUuid } from './id.js'
|
||||
export type { EmbedInfo } from './media-embed.js'
|
||||
export { getEmbedInfo } from './media-embed.js'
|
||||
export {
|
||||
filterUndefined,
|
||||
isPlainRecord,
|
||||
isRecordLike,
|
||||
omit,
|
||||
sortObjectKeysDeep,
|
||||
} from './object.js'
|
||||
export {
|
||||
generateRandomBytes,
|
||||
generateRandomHex,
|
||||
generateRandomString,
|
||||
LOWERCASE_ALPHANUMERIC_ALPHABET,
|
||||
randomFloat,
|
||||
randomInt,
|
||||
randomItem,
|
||||
} from './random.js'
|
||||
export type { BackoffOptions } from './retry.js'
|
||||
export { backoffWithJitter, parseRetryAfter } from './retry.js'
|
||||
export { normalizeEmail, truncate } from './string.js'
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getEmbedInfo } from './media-embed'
|
||||
|
||||
describe('getEmbedInfo', () => {
|
||||
it('maps YouTube watch/short/embed URLs to the embed iframe', () => {
|
||||
const expected = { url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', type: 'iframe' }
|
||||
expect(getEmbedInfo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://www.youtube.com/watch?list=RD&v=dQw4w9WgXcQ&t=5')).toEqual(
|
||||
expected
|
||||
)
|
||||
expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ?si=abc')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ/')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ?v=notAnId')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://www.youtube.com/watch?v=short')).toBeNull()
|
||||
})
|
||||
|
||||
it('maps Facebook and fb.watch video links to the video plugin', () => {
|
||||
expect(getEmbedInfo('https://www.facebook.com/some.page/videos/1234567890')).toEqual({
|
||||
url: 'https://www.facebook.com/plugins/video.php?href=https%3A%2F%2Fwww.facebook.com%2Fsome.page%2Fvideos%2F1234567890&show_text=false',
|
||||
type: 'iframe',
|
||||
})
|
||||
expect(getEmbedInfo('https://fb.watch/abc123')?.type).toBe('iframe')
|
||||
expect(getEmbedInfo('https://www.facebook.com/some.page/about')).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts the Giphy id from the trailing slug token', () => {
|
||||
const expected = { url: 'https://giphy.com/embed/abc123', type: 'iframe', aspectRatio: '1/1' }
|
||||
expect(getEmbedInfo('https://giphy.com/gifs/funny-cat-abc123')).toEqual(expected)
|
||||
expect(getEmbedInfo('https://giphy.com/embed/abc123')).toEqual(expected)
|
||||
})
|
||||
|
||||
it('maps Vimeo and Spotify URLs with their aspect ratios', () => {
|
||||
expect(getEmbedInfo('https://vimeo.com/123456')).toEqual({
|
||||
url: 'https://player.vimeo.com/video/123456',
|
||||
type: 'iframe',
|
||||
})
|
||||
expect(getEmbedInfo('https://open.spotify.com/track/abc123')).toEqual({
|
||||
url: 'https://open.spotify.com/embed/track/abc123',
|
||||
type: 'iframe',
|
||||
aspectRatio: '3.7/1',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats bare media file extensions as native video/audio', () => {
|
||||
expect(getEmbedInfo('https://cdn.example.com/clip.mp4')).toEqual({
|
||||
url: 'https://cdn.example.com/clip.mp4',
|
||||
type: 'video',
|
||||
})
|
||||
expect(getEmbedInfo('https://cdn.example.com/sound.mp3')).toEqual({
|
||||
url: 'https://cdn.example.com/sound.mp3',
|
||||
type: 'audio',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-embeddable URLs', () => {
|
||||
expect(getEmbedInfo('https://example.com/article')).toBeNull()
|
||||
expect(getEmbedInfo('not a url')).toBeNull()
|
||||
})
|
||||
|
||||
it('only embeds when the parsed host belongs to the provider', () => {
|
||||
expect(getEmbedInfo('https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull()
|
||||
expect(getEmbedInfo('https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ')).toBeNull()
|
||||
expect(getEmbedInfo('https://evil.com/open.spotify.com/track/abc123')).toBeNull()
|
||||
expect(getEmbedInfo('https://vimeo.com.evil.com/123456')).toBeNull()
|
||||
expect(getEmbedInfo('https://m.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual({
|
||||
url: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
||||
type: 'iframe',
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropbox', () => {
|
||||
it('rewrites a Dropbox video share link to a direct streamable URL', () => {
|
||||
expect(getEmbedInfo('https://www.dropbox.com/s/abc/clip.mp4?dl=0')).toEqual({
|
||||
url: 'https://dl.dropboxusercontent.com/s/abc/clip.mp4',
|
||||
type: 'video',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles non-www and scheme-less Dropbox hosts', () => {
|
||||
expect(getEmbedInfo('https://m.dropbox.com/s/abc/clip.mov')).toEqual({
|
||||
url: 'https://dl.dropboxusercontent.com/s/abc/clip.mov',
|
||||
type: 'video',
|
||||
})
|
||||
expect(getEmbedInfo('dropbox.com/s/abc/clip.webm')).toEqual({
|
||||
url: 'https://dl.dropboxusercontent.com/s/abc/clip.webm',
|
||||
type: 'video',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply the Dropbox direct-link rewrite to look-alike hosts', () => {
|
||||
expect(getEmbedInfo('https://dropbox.com.evil.com/clip.mp4')?.url).not.toContain(
|
||||
'dropboxusercontent.com'
|
||||
)
|
||||
expect(getEmbedInfo('https://evil.com/?x=dropbox.com/clip.mp4')?.url).not.toContain(
|
||||
'dropboxusercontent.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Resolved embed for a media URL: the iframe/video/audio source to render plus
|
||||
* an optional aspect ratio hint. Renderers own the surrounding markup; this
|
||||
* module only decides whether a URL is embeddable and what source to use.
|
||||
*/
|
||||
export interface EmbedInfo {
|
||||
url: string
|
||||
type: 'iframe' | 'video' | 'audio'
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The `parent` query param required by Twitch embeds. Reads the current host in
|
||||
* the browser and falls back to `localhost` during SSR.
|
||||
*/
|
||||
function getTwitchParent(): string {
|
||||
return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
||||
}
|
||||
|
||||
/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */
|
||||
function parseUrl(url: string): URL | null {
|
||||
for (const candidate of [url, `https://${url}`]) {
|
||||
try {
|
||||
return new URL(candidate)
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `host` is one of `domains` or a subdomain of one (e.g. `m.youtube.com`
|
||||
* matches `youtube.com`). A null host (unparseable URL) never matches. This is the
|
||||
* security boundary for provider detection: a link is only treated as a given
|
||||
* platform when its parsed host actually belongs to that platform, so look-alikes
|
||||
* like `youtube.com.evil.com` or `evil.com/youtube.com/...` are rejected.
|
||||
*/
|
||||
function hostMatches(host: string | null, ...domains: string[]): boolean {
|
||||
if (host === null) return false
|
||||
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a Dropbox share URL's host to `dl.dropboxusercontent.com` so the file
|
||||
* streams as media, returning null for a non-video path. The caller has already
|
||||
* verified the host is Dropbox.
|
||||
*/
|
||||
function toDropboxDirectVideoUrl(parsed: URL): string | null {
|
||||
if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null
|
||||
parsed.hostname = 'dl.dropboxusercontent.com'
|
||||
parsed.searchParams.delete('dl')
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a URL to its embeddable form across supported media platforms (YouTube,
|
||||
* Vimeo, Spotify, Apple Music, Twitch, Dropbox, Giphy, and many more), plus
|
||||
* generic video/audio file extensions. Returns null when the URL is not a
|
||||
* recognized embeddable source.
|
||||
*
|
||||
* Each platform is gated on its parsed hostname via {@link hostMatches} before its
|
||||
* id-extracting regex runs. The generic file-extension fallbacks are intentionally
|
||||
* host-agnostic — any direct media file URL is embeddable.
|
||||
*/
|
||||
export function getEmbedInfo(url: string): EmbedInfo | null {
|
||||
const parsed = parseUrl(url)
|
||||
const host = parsed?.hostname.toLowerCase() ?? null
|
||||
if (parsed && hostMatches(host, 'youtube.com', 'youtu.be')) {
|
||||
const segments = parsed.pathname.split('/')
|
||||
let id: string | null | undefined
|
||||
if (hostMatches(host, 'youtu.be')) id = segments[1]
|
||||
else if (segments[1] === 'embed') id = segments[2]
|
||||
else id = parsed.searchParams.get('v')
|
||||
if (id && /^[a-zA-Z0-9_-]{11}$/.test(id)) {
|
||||
return { url: `https://www.youtube.com/embed/${id}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'vimeo.com')) {
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
|
||||
if (vimeoMatch) {
|
||||
return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'dailymotion.com')) {
|
||||
const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
|
||||
if (dailymotionMatch) {
|
||||
return {
|
||||
url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'twitch.tv')) {
|
||||
const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
|
||||
if (twitchVideoMatch) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const twitchClipMatch =
|
||||
url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
|
||||
url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
|
||||
if (twitchClipMatch) {
|
||||
return {
|
||||
url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
|
||||
const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
|
||||
if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
|
||||
return {
|
||||
url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'streamable.com')) {
|
||||
const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
|
||||
if (streamableMatch) {
|
||||
return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'wistia.com', 'wistia.net')) {
|
||||
const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
|
||||
if (wistiaMatch) {
|
||||
return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'tiktok.com')) {
|
||||
const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
|
||||
if (tiktokMatch) {
|
||||
return {
|
||||
url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'soundcloud.com')) {
|
||||
const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
|
||||
if (soundcloudMatch) {
|
||||
return {
|
||||
url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'spotify.com')) {
|
||||
const spotifyMatch = url.match(
|
||||
/open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/
|
||||
)
|
||||
if (spotifyMatch) {
|
||||
const [, kind, id] = spotifyMatch
|
||||
const aspectRatio =
|
||||
kind === 'track' || kind === 'show' ? '3.7/1' : kind === 'episode' ? '2.5/1' : '2/3'
|
||||
return { url: `https://open.spotify.com/embed/${kind}/${id}`, type: 'iframe', aspectRatio }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'apple.com')) {
|
||||
const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
|
||||
if (appleMusicSongMatch) {
|
||||
const [, country, songId] = appleMusicSongMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/song/${songId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/2',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicAlbumMatch = url.match(
|
||||
/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/
|
||||
)
|
||||
if (appleMusicAlbumMatch) {
|
||||
const [, country, albumId] = appleMusicAlbumMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/album/${albumId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
|
||||
const appleMusicPlaylistMatch = url.match(
|
||||
/music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
|
||||
)
|
||||
if (appleMusicPlaylistMatch) {
|
||||
const [, country, playlistId] = appleMusicPlaylistMatch
|
||||
return {
|
||||
url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/3',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'loom.com')) {
|
||||
const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
|
||||
if (loomMatch) {
|
||||
return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && hostMatches(host, 'facebook.com', 'fb.watch')) {
|
||||
const isFacebookVideo = hostMatches(host, 'fb.watch')
|
||||
? /^\/[a-zA-Z0-9_-]+/.test(parsed.pathname)
|
||||
: /\/videos\/\d+/.test(parsed.pathname)
|
||||
if (isFacebookVideo) {
|
||||
return {
|
||||
url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'instagram.com')) {
|
||||
const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramReelMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '9/16',
|
||||
}
|
||||
}
|
||||
|
||||
const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
|
||||
if (instagramPostMatch) {
|
||||
return {
|
||||
url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '4/5',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'twitter.com', 'x.com')) {
|
||||
const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
|
||||
if (twitterMatch) {
|
||||
return {
|
||||
url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '3/4',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'rumble.com')) {
|
||||
const rumbleMatch =
|
||||
url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
|
||||
if (rumbleMatch) {
|
||||
return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'bilibili.com')) {
|
||||
const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
|
||||
if (bilibiliMatch) {
|
||||
return {
|
||||
url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'vidyard.com')) {
|
||||
const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
|
||||
if (vidyardMatch) {
|
||||
return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'cloudflarestream.com', 'videodelivery.net')) {
|
||||
const cfStreamMatch =
|
||||
url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
|
||||
url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
|
||||
if (cfStreamMatch) {
|
||||
return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'mixcloud.com')) {
|
||||
const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
|
||||
if (mixcloudMatch) {
|
||||
return {
|
||||
url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
|
||||
type: 'iframe',
|
||||
aspectRatio: '2/1',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'google.com')) {
|
||||
const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
|
||||
if (googleDriveMatch) {
|
||||
return {
|
||||
url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`,
|
||||
type: 'iframe',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && hostMatches(host, 'dropbox.com')) {
|
||||
const dropboxDirectVideoUrl = toDropboxDirectVideoUrl(parsed)
|
||||
if (dropboxDirectVideoUrl) {
|
||||
return { url: dropboxDirectVideoUrl, type: 'video' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hostMatches(host, 'tenor.com')) {
|
||||
const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
|
||||
if (tenorMatch) {
|
||||
return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && hostMatches(host, 'giphy.com')) {
|
||||
const segment = parsed.pathname.match(/^\/(?:gifs|embed)\/([^/]+)/)?.[1]
|
||||
const giphyId = segment?.split('-').pop()
|
||||
if (giphyId && /^[a-zA-Z0-9]+$/.test(giphyId)) {
|
||||
return { url: `https://giphy.com/embed/${giphyId}`, type: 'iframe', aspectRatio: '1/1' }
|
||||
}
|
||||
}
|
||||
|
||||
if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'video' }
|
||||
}
|
||||
|
||||
if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
|
||||
return { url, type: 'audio' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainRecord, isRecordLike, sortObjectKeysDeep } from './object.js'
|
||||
|
||||
class Sample {
|
||||
value = 1
|
||||
}
|
||||
|
||||
describe('isRecordLike', () => {
|
||||
it('returns true for plain objects, Date, and class instances', () => {
|
||||
expect(isRecordLike({})).toBe(true)
|
||||
expect(isRecordLike(new Date())).toBe(true)
|
||||
expect(isRecordLike(new Sample())).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for arrays, null, and primitives', () => {
|
||||
expect(isRecordLike([])).toBe(false)
|
||||
expect(isRecordLike(null)).toBe(false)
|
||||
expect(isRecordLike('not-a-record')).toBe(false)
|
||||
expect(isRecordLike(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPlainRecord', () => {
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isPlainRecord({})).toBe(true)
|
||||
expect(isPlainRecord(Object.create(null))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for Date, class instances, arrays, and null', () => {
|
||||
expect(isPlainRecord(new Date())).toBe(false)
|
||||
expect(isPlainRecord(new Sample())).toBe(false)
|
||||
expect(isPlainRecord([])).toBe(false)
|
||||
expect(isPlainRecord(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortObjectKeysDeep', () => {
|
||||
it('sorts keys deeply and recurses into array elements', () => {
|
||||
const input = {
|
||||
b: 1,
|
||||
a: { d: 4, c: 3 },
|
||||
list: [{ z: 26, y: 25 }],
|
||||
}
|
||||
const sorted = sortObjectKeysDeep(input)
|
||||
expect(JSON.stringify(sorted)).toBe(
|
||||
JSON.stringify({ a: { c: 3, d: 4 }, b: 1, list: [{ y: 25, z: 26 }] })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns primitives and null unchanged', () => {
|
||||
expect(sortObjectKeysDeep(null)).toBe(null)
|
||||
expect(sortObjectKeysDeep(42)).toBe(42)
|
||||
expect(sortObjectKeysDeep('x')).toBe('x')
|
||||
})
|
||||
|
||||
it('preserves array order while sorting element keys', () => {
|
||||
const sorted = sortObjectKeysDeep([
|
||||
{ b: 1, a: 2 },
|
||||
{ d: 3, c: 4 },
|
||||
])
|
||||
expect(JSON.stringify(sorted)).toBe(
|
||||
JSON.stringify([
|
||||
{ a: 2, b: 1 },
|
||||
{ c: 4, d: 3 },
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Returns a new object with the given keys removed.
|
||||
*
|
||||
* @example
|
||||
* omit({ a: 1, b: 2, c: 3 }, ['b', 'c']) // { a: 1 }
|
||||
*/
|
||||
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj }
|
||||
for (const key of keys) {
|
||||
delete result[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `obj` with all `undefined`-valued keys removed.
|
||||
* Useful for building query-param or request-body objects where undefined
|
||||
* fields should not be serialized.
|
||||
*
|
||||
* Replaces the common `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` pattern.
|
||||
*/
|
||||
export function filterUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only for object-map values, excluding arrays and null.
|
||||
*/
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for any non-null, non-array object — the LOOSE record check.
|
||||
* Unlike {@link isPlainRecord}, this does NOT inspect the prototype, so it also
|
||||
* accepts class instances, Date, Map, etc. Use this when you only need to know a
|
||||
* value is an indexable object (e.g. before spreading or Object.entries), and
|
||||
* reach for isPlainRecord when you must exclude exotic objects.
|
||||
*/
|
||||
export function isRecordLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sorts the keys of every plain object reachable from {@link value},
|
||||
* preserving array order while recursing into array elements. Primitives and
|
||||
* `null` are returned unchanged. Produces a structurally equivalent value with
|
||||
* deterministic key ordering, suitable for stable comparison or serialization.
|
||||
*
|
||||
* @remarks Only string keys are sorted and retained; symbol keys are dropped,
|
||||
* matching the stable-serialization callers this serves.
|
||||
*/
|
||||
export function sortObjectKeysDeep(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortObjectKeysDeep)
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.reduce((result: Record<string, unknown>, key: string) => {
|
||||
result[key] = sortObjectKeysDeep(obj[key])
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Cryptographically secure random utilities built on `crypto.getRandomValues()`.
|
||||
* Works in all contexts including non-secure (HTTP) browser environments.
|
||||
*/
|
||||
|
||||
/** Lowercase alphanumeric characters used as the default alphabet for random strings. */
|
||||
export const LOWERCASE_ALPHANUMERIC_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
/**
|
||||
* Generates cryptographically secure random bytes.
|
||||
* @param length - Number of bytes to generate
|
||||
* @returns Uint8Array of random bytes
|
||||
*/
|
||||
export function generateRandomBytes(length: number): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(length))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random hex string.
|
||||
* @param length - Number of hex characters (default: 16)
|
||||
* @returns Lowercase hex string of the given length
|
||||
*/
|
||||
export function generateRandomHex(length = 16): string {
|
||||
const bytes = generateRandomBytes(Math.ceil(length / 2))
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random alphanumeric string.
|
||||
* @param length - Number of characters (default: 16)
|
||||
* @returns Random string composed of A-Z, a-z, 0-9
|
||||
*/
|
||||
export function generateRandomString(length = 16): string {
|
||||
const bytes = generateRandomBytes(length)
|
||||
return Array.from(bytes)
|
||||
.map((b) => CHARS[b % CHARS.length])
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cryptographically secure random float in [0, 1).
|
||||
* Drop-in replacement for `Math.random()`.
|
||||
*/
|
||||
export function randomFloat(): number {
|
||||
const [value] = crypto.getRandomValues(new Uint32Array(1))
|
||||
return value / 0x100000000
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cryptographically secure random integer in [min, max).
|
||||
* Uses rejection sampling for uniform distribution (no modulo bias).
|
||||
* @param min - Inclusive lower bound
|
||||
* @param max - Exclusive upper bound
|
||||
*/
|
||||
export function randomInt(min: number, max: number): number {
|
||||
const range = max - min
|
||||
if (range <= 0) throw new RangeError(`randomInt: max (${max}) must be greater than min (${min})`)
|
||||
const threshold = (0x100000000 - (0x100000000 % range)) >>> 0
|
||||
let value: number
|
||||
do {
|
||||
;[value] = crypto.getRandomValues(new Uint32Array(1))
|
||||
} while (value >= threshold)
|
||||
return min + (value % range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a uniformly random element from a non-empty array.
|
||||
* @param items - Array to sample from (must have at least one element)
|
||||
*/
|
||||
export function randomItem<T>(items: readonly T[]): T {
|
||||
if (items.length === 0) throw new RangeError('randomItem: array must not be empty')
|
||||
return items[randomInt(0, items.length)]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Default retry pacing: 500 ms floor, 30 s ceiling. */
|
||||
const DEFAULT_BACKOFF_BASE_MS = 500
|
||||
const DEFAULT_BACKOFF_MAX_MS = 30_000
|
||||
|
||||
export interface BackoffOptions {
|
||||
baseMs?: number
|
||||
maxMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next delay for a retry loop.
|
||||
*
|
||||
* When `retryAfterMs` is non-null (from a `Retry-After` response header), the
|
||||
* value is clamped to `[baseMs, maxMs]` so a malformed `Retry-After: 0` cannot
|
||||
* pin the loop into a tight retry. Otherwise returns exponential backoff with
|
||||
* ±20% jitter to avoid thundering-herd alignment across concurrent callers.
|
||||
* Attempt is 1-indexed.
|
||||
*/
|
||||
export function backoffWithJitter(
|
||||
attempt: number,
|
||||
retryAfterMs: number | null,
|
||||
options: BackoffOptions = {}
|
||||
): number {
|
||||
const baseMs = options.baseMs ?? DEFAULT_BACKOFF_BASE_MS
|
||||
const maxMs = options.maxMs ?? DEFAULT_BACKOFF_MAX_MS
|
||||
if (retryAfterMs !== null) {
|
||||
return Math.min(Math.max(retryAfterMs, baseMs), maxMs)
|
||||
}
|
||||
const exponential = Math.min(baseMs * 2 ** (attempt - 1), maxMs)
|
||||
// Inline crypto float to avoid cross-file imports within the package (Turbopack limitation)
|
||||
const jitter = crypto.getRandomValues(new Uint32Array(1))[0] / 0x100000000
|
||||
return exponential * (0.8 + jitter * 0.4)
|
||||
}
|
||||
|
||||
/** Default maximum `Retry-After` value honored: 30 s. Prevents a misconfigured upstream from stalling callers. */
|
||||
const RETRY_AFTER_MAX_MS = 30_000
|
||||
|
||||
/**
|
||||
* Parses an HTTP `Retry-After` header (either delta-seconds or an HTTP-date)
|
||||
* into a millisecond delay, capped at `maxMs` (default 30 s).
|
||||
* Returns `null` when the header is absent or unparseable so callers can fall
|
||||
* back to their own backoff. Pass the caller's own `maxDelayMs` as `maxMs` when
|
||||
* that value needs to be compared against the parsed delay (e.g. to decide
|
||||
* whether to skip a retry) — otherwise the default cap silently truncates the
|
||||
* comparison.
|
||||
*/
|
||||
export function parseRetryAfter(header: string | null, maxMs = RETRY_AFTER_MAX_MS): number | null {
|
||||
if (!header) return null
|
||||
const trimmed = header.trim()
|
||||
if (trimmed.length === 0) return null
|
||||
const seconds = Number(trimmed)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.min(Math.floor(seconds * 1000), maxMs)
|
||||
}
|
||||
const dateMs = Date.parse(trimmed)
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
const delta = dateMs - Date.now()
|
||||
if (delta <= 0) return 0
|
||||
return Math.min(delta, maxMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js'
|
||||
|
||||
describe('truncate', () => {
|
||||
it('appends the suffix when the string exceeds the slice length', () => {
|
||||
expect(truncate('hello world', 8)).toBe('hello wo...')
|
||||
})
|
||||
|
||||
it('uses a custom suffix when provided', () => {
|
||||
expect(truncate('hello world', 8, ' …')).toBe('hello wo …')
|
||||
})
|
||||
|
||||
it('returns the original string when within the slice length', () => {
|
||||
expect(truncate('hi', 10)).toBe('hi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripVersionSuffix', () => {
|
||||
it('strips a trailing _vN suffix', () => {
|
||||
expect(stripVersionSuffix('notion_search_v2')).toBe('notion_search')
|
||||
expect(stripVersionSuffix('github_create_pr_v3')).toBe('github_create_pr')
|
||||
})
|
||||
|
||||
it('strips multi-digit versions', () => {
|
||||
expect(stripVersionSuffix('x_v10')).toBe('x')
|
||||
})
|
||||
|
||||
it('leaves plain values unchanged', () => {
|
||||
expect(stripVersionSuffix('plain')).toBe('plain')
|
||||
})
|
||||
|
||||
it('does not strip a non-version trailing token', () => {
|
||||
expect(stripVersionSuffix('a_version')).toBe('a_version')
|
||||
})
|
||||
|
||||
it('only strips the single trailing suffix', () => {
|
||||
expect(stripVersionSuffix('a_v2_v3')).toBe('a_v2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVersionedType', () => {
|
||||
it('returns true for trailing _vN suffixes', () => {
|
||||
expect(isVersionedType('notion_search_v2')).toBe(true)
|
||||
expect(isVersionedType('github_create_pr_v3')).toBe(true)
|
||||
expect(isVersionedType('x_v10')).toBe(true)
|
||||
expect(isVersionedType('a_v2_v3')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when there is no trailing version suffix', () => {
|
||||
expect(isVersionedType('plain')).toBe(false)
|
||||
expect(isVersionedType('a_version')).toBe(false)
|
||||
expect(isVersionedType('x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeEmail', () => {
|
||||
it('trims surrounding whitespace and lowercases', () => {
|
||||
expect(normalizeEmail(' USER@Example.COM ')).toBe('user@example.com')
|
||||
})
|
||||
|
||||
it('leaves an already-normalized email unchanged', () => {
|
||||
expect(normalizeEmail('user@example.com')).toBe('user@example.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Truncates `str` if it exceeds `sliceLength` characters, appending `suffix`.
|
||||
* The total output length when truncated is `sliceLength + suffix.length`.
|
||||
* Defaults suffix to `'...'`.
|
||||
*
|
||||
* @example
|
||||
* truncate('hello world', 8) // 'hello wo...' (11 chars)
|
||||
* truncate('hello world', 8, ' …') // 'hello wo …'
|
||||
* truncate('hi', 10) // 'hi'
|
||||
*/
|
||||
export function truncate(str: string, sliceLength: number, suffix = '...'): string {
|
||||
return str.length > sliceLength ? str.slice(0, sliceLength) + suffix : str
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a trailing `_vN` version suffix from `value`, yielding the base type.
|
||||
* Only the single trailing suffix is removed; leading occurrences are left intact.
|
||||
*
|
||||
* @example
|
||||
* stripVersionSuffix('notion_search_v2') // 'notion_search'
|
||||
* stripVersionSuffix('x') // 'x'
|
||||
* stripVersionSuffix('a_v2_v3') // 'a_v2'
|
||||
*/
|
||||
export function stripVersionSuffix(value: string): string {
|
||||
return value.replace(/_v\d+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether `value` ends with a `_vN` version suffix.
|
||||
* Only a trailing suffix counts; a leading or embedded `_vN` does not match.
|
||||
*
|
||||
* @example
|
||||
* isVersionedType('notion_search_v2') // true
|
||||
* isVersionedType('plain') // false
|
||||
* isVersionedType('a_version') // false
|
||||
*/
|
||||
export function isVersionedType(value: string): boolean {
|
||||
return /_v\d+$/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an email address for comparison and storage by trimming
|
||||
* surrounding whitespace and lowercasing.
|
||||
*/
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user