Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

Replace primitive obsession and defensive validation with domain-safe values that validate themselves.

Dependency-less: zero runtime dependencies, for Node.js and browsers.

[![CI](https://github.com/haskou/value-objects/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/haskou/value-objects/actions/workflows/ci.yml?query=branch%3Amaster)
[![codecov](https://codecov.io/gh/haskou/value-objects/branch/master/graph/badge.svg)](https://codecov.io/gh/haskou/value-objects)
[![npm version](https://img.shields.io/npm/v/@haskou/value-objects.svg)](https://www.npmjs.com/package/@haskou/value-objects)
[![npm downloads](https://img.shields.io/npm/dw/@haskou/value-objects.svg)](https://www.npmjs.com/package/@haskou/value-objects)
[![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](https://www.npmjs.com/package/@haskou/value-objects?activeTab=dependencies)
[![license](https://img.shields.io/npm/l/@haskou/value-objects.svg)](LICENSE.txt)

## Installation
Expand Down
6 changes: 3 additions & 3 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ pnpm add @haskou/value-objects

## Runtime requirements

The package does not declare a Node.js engine requirement. It publishes runtime-neutral value-object code with separate ESM and CommonJS entry points.
The package is dependency-less: it installs nothing besides itself. It does not declare a Node.js engine requirement and publishes runtime-neutral value-object code with separate ESM and CommonJS entry points.

`UUID.generate()` and `ShortId.generate()` use the installed `uuid` dependency, which selects its Node or browser implementation. Node consumers do not need a global crypto object; browser bundlers select the browser entry automatically.
`UUID.generate()` and `ShortId.generate()` use the Web Crypto `globalThis.crypto.getRandomValues()`, which is available in all current browsers (including non-secure contexts) and in Node.js 20 or later.

## Importing

Expand All @@ -39,7 +39,7 @@ const { Email, Timestamp, UUID } = require('@haskou/value-objects');

The package export map resolves ESM to `dist/index.mjs` with `dist/index.d.mts` declarations, and CommonJS to `dist/index.cjs` with `dist/index.d.cts` declarations.

The ESM entry re-exports the CommonJS implementation, and its declarations re-export the same type definitions. Mixing `import` and `require` preserves class identity, equality, collection membership, and the global NullObject creation setting. Browser consumers need a bundler that handles CommonJS and the `buffer` package.
The ESM entry re-exports the CommonJS implementation, and its declarations re-export the same type definitions. Mixing `import` and `require` preserves class identity, equality, collection membership, and the global NullObject creation setting. Browser consumers need a bundler that handles CommonJS.

## Published files

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ amount.isGreaterThan(5); // true
| Media | `Media` |
| Collections | `UniqueObjectArray` |

The package provides separate ESM and CommonJS entry points and does not declare a Node.js engine requirement. Identifier generation delegates to the installed `uuid` dependency for Node and browser support.
The package is dependency-less, provides separate ESM and CommonJS entry points, and does not declare a Node.js engine requirement.

## Design expectations

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Documentation for @haskou/value-objects.

# Value Objects documentation

A TypeScript library for validated, immutable primitive wrappers and small utility value objects.
A dependency-less TypeScript library for validated, immutable primitive wrappers and small utility value objects.

::: info Documentation
This site is the main documentation for `@haskou/value-objects`.
Expand Down
18 changes: 9 additions & 9 deletions docs/reference/media.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
title: Media
description: String or Buffer-backed media value object.
description: String or Uint8Array-backed media value object.
---

# `Media`

String or Buffer-backed media value object.
String or `Uint8Array`-backed media value object.

## Import

Expand All @@ -22,40 +22,40 @@ class Media extends ValueObject<string>
## Constructor

```typescript
constructor(value: string | Buffer)
constructor(value: string | Uint8Array)
```

## Validation

Accepts strings and Node `Buffer` values.
Accepts strings and `Uint8Array` values (Node `Buffer` is a `Uint8Array`).

## Methods

| Method | Description |
| --- | --- |
| `getBuffer()` | Returns a defensive `Buffer` copy. |
| `getBuffer()` | Returns a defensive `Uint8Array` copy. |
| `getSize()` | Returns byte length. |
| `getBase64()` | Returns the content encoded as Base64. |
| `hasValue(other)` | Compares Media, Buffer, or primitive values by their underlying bytes/value. |
| `hasValue(other)` | Compares Media, `Uint8Array`, or primitive values by their underlying bytes/value. |
| `isEqual(other)` | Requires another `Media` and compares its underlying bytes. |

## Example

```typescript
import { Buffer } from 'buffer';
import { Media } from '@haskou/value-objects';

const media = new Media('hello world');

media.getSize(); // 11
media.getBase64(); // 'aGVsbG8gd29ybGQ='
media.isEqual(new Media('hello world')); // true
media.hasValue(Buffer.from('hello world')); // true
media.hasValue(new TextEncoder().encode('hello world')); // true
```

## Notes

- When constructed from a Buffer, the Buffer is copied. Returned buffers are also copies.
- When constructed from bytes, the bytes are copied. Returned arrays are also copies.
- Text decoding of byte input is UTF-8 and keeps a leading BOM. Equality and `getBuffer()` use the original bytes.
- Use `isEqual()` for Media-to-Media equality and `hasValue()` when only the underlying content matters.

## Related
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/short-id.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const existing = new ShortId('507f1f77bcf86cd799439011');
- Generation returns lowercase hex.
- Constructor validation currently allows alphanumeric characters, not only lowercase hex.

Generation uses the installed `uuid` dependency, which selects its Node or browser implementation. Consumers do not need to configure a global crypto object in Node. Browser bundlers select the browser entry automatically.
Generation uses `globalThis.crypto.getRandomValues()` (browsers and Node.js 20 or later). The package has no runtime dependencies.

## Related

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/uuid.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ id.toString(); // e.g. '3fd4c04a-8e73-4e10-aef3-f491b32ec538'

- The constructor pattern accepts lowercase `a-z`, `0-9`, and hyphens.

Generation uses the installed `uuid` dependency, which selects its Node or browser implementation. Consumers do not need to configure a global crypto object in Node. Browser bundlers select the browser entry automatically.
Generation uses `globalThis.crypto.getRandomValues()` (browsers and Node.js 20 or later) and produces an RFC 4122 version 4 UUID. The package has no runtime dependencies.

## Related

Expand Down
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@
"domain-driven-design",
"email-validation",
"uuid",
"hash"
"hash",
"zero-dependencies",
"dependency-free",
"isomorphic",
"browser",
"null-object",
"domain-model"
],
"publishConfig": {
"access": "public"
Expand All @@ -63,10 +69,6 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress build docs && vitepress preview docs"
},
"dependencies": {
"buffer": "^6.0.3",
"uuid": "^11.1.1"
},
"devDependencies": {
"@arethetypeswrong/core": "^0.18.5",
"@haskou/eslint-config": "^0.2.2",
Expand All @@ -80,7 +82,6 @@
"ts-jest": "^29.4.14",
"tsdown": "^0.23.0",
"typescript": "^6.0.3",
"vitepress": "^1.6.4",
"vue": "^3.5.43"
"vitepress": "^1.6.4"
}
}
8 changes: 4 additions & 4 deletions src/value-objects/hashes/Hash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Buffer } from 'buffer';

import { NullObject } from '../NullObject';
import { StringValueObject } from '../StringValueObject';
import { ValueObject } from '../ValueObject';
Expand All @@ -14,8 +12,10 @@ export abstract class Hash extends ValueObject<string> {
}

public toBase64(): StringValueObject {
return new StringValueObject(
Buffer.from(this.valueOf(), 'hex').toString('base64'),
const bytes = Array.from(this.valueOf().matchAll(/../g), ([pair]) =>
parseInt(pair, 16),
);

return new StringValueObject(btoa(String.fromCharCode(...bytes)));
}
}
12 changes: 2 additions & 10 deletions src/value-objects/hashes/SHA256Hash.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Buffer } from 'buffer';

import { InvalidHashError } from '../../errors/InvalidHashError';
import { assert } from '../../patterns';
import { NullObject } from '../NullObject';
import { StringValueObject } from '../StringValueObject';
import { ValueObject } from '../ValueObject';
import { Hash } from './Hash';

export class SHA256Hash extends ValueObject<string> {
export class SHA256Hash extends Hash {
public static isValid(hash: string | StringValueObject): boolean {
return !!hash.valueOf().match(/^[a-f0-9]{64}$/i);
}
Expand All @@ -23,10 +21,4 @@ export class SHA256Hash extends ValueObject<string> {
new InvalidHashError('SHA256', source.valueOf()),
);
}

public toBase64(): StringValueObject {
return new StringValueObject(
Buffer.from(this.valueOf(), 'hex').toString('base64'),
);
}
}
12 changes: 2 additions & 10 deletions src/value-objects/hashes/SHA512Hash.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Buffer } from 'buffer';

import { InvalidHashError } from '../../errors/InvalidHashError';
import { assert } from '../../patterns';
import { NullObject } from '../NullObject';
import { StringValueObject } from '../StringValueObject';
import { ValueObject } from '../ValueObject';
import { Hash } from './Hash';

export class SHA512Hash extends ValueObject<string> {
export class SHA512Hash extends Hash {
public static isValid(hash: string | StringValueObject): boolean {
return !!hash.valueOf().match(/^[a-f0-9]{128}$/i);
}
Expand All @@ -23,10 +21,4 @@ export class SHA512Hash extends ValueObject<string> {
new InvalidHashError('SHA512', source.valueOf()),
);
}

public toBase64(): StringValueObject {
return new StringValueObject(
Buffer.from(this.valueOf(), 'hex').toString('base64'),
);
}
}
16 changes: 6 additions & 10 deletions src/value-objects/ids/ShortId.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { Buffer } from 'buffer';
import { parse, v4 as uuidV4 } from 'uuid';

import { InvalidFormatError } from '../../errors/InvalidFormatError';
import { InvalidLengthError } from '../../errors/InvalidLengthError';
import { assert } from '../../patterns';
Expand All @@ -13,14 +10,13 @@ export class ShortId extends ValueObject<string> {
private static readonly PATTERN = new RegExp(`[a-zA-Z0-9]{${this.LENGTH}}$`);

private static generateObjectIdHex(): string {
const random = parse(uuidV4());
const bytes = Buffer.alloc(12);
bytes.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
// Keep eight fully random bytes, excluding the UUID version/variant bytes.
bytes.set(random.subarray(0, 6), 4);
bytes.set(random.subarray(9, 11), 10);
const bytes = new Uint8Array(12);
new DataView(bytes.buffer).setUint32(0, Math.floor(Date.now() / 1000));
bytes.set(globalThis.crypto.getRandomValues(new Uint8Array(8)), 4);

return bytes.toString('hex');
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
'',
);
}

public static generate(): ShortId {
Expand Down
20 changes: 17 additions & 3 deletions src/value-objects/ids/UUID.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { v4 as uuidV4 } from 'uuid';

import { InvalidFormatError } from '../../errors/InvalidFormatError';
import { InvalidLengthError } from '../../errors/InvalidLengthError';
import { assert } from '../../patterns';
Expand All @@ -16,7 +14,23 @@ export class UUID extends ValueObject<string> {
/^(?:00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;

public static generate(): UUID {
return new UUID(uuidV4());
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
// RFC 4122 §4.4: version 4 and variant 10xx.
bytes[6] = (bytes[6] % 16) + 64;
bytes[8] = (bytes[8] % 64) + 128;
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');

return new UUID(
[
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-'),
);
}

constructor(value: string | StringValueObject) {
Expand Down
52 changes: 38 additions & 14 deletions src/value-objects/media/Media.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,68 @@
import { Buffer } from 'buffer';

import { NullObject } from '../NullObject';
import { ValueObject } from '../ValueObject';

export class Media extends ValueObject<string> {
private readonly buffer?: Buffer;
private static readonly BASE64_CHUNK_SIZE = 0x8000;

private readonly buffer?: Uint8Array;

constructor(value: string | Buffer) {
super(value?.toString());
constructor(value: string | Uint8Array) {
super(
value instanceof Uint8Array
? new TextDecoder('utf-8', { ignoreBOM: true }).decode(value)
: value,
);

if (NullObject.isNullObject(this)) {
return this;
}

this.buffer = Buffer.isBuffer(value) ? Buffer.from(value) : undefined;
this.buffer =
value instanceof Uint8Array ? new Uint8Array(value) : undefined;
}

public getBuffer(): Buffer {
public getBuffer(): Uint8Array {
return this.buffer === undefined
? Buffer.from(this.value)
: Buffer.from(this.buffer);
? new TextEncoder().encode(this.value)
: new Uint8Array(this.buffer);
}

public getSize(): number {
return this.getBuffer().length;
}

public getBase64(): string {
return this.getBuffer().toString('base64');
const bytes = this.getBuffer();
const chunks: string[] = [];

// Chunked so large payloads do not overflow the call stack.
for (let i = 0; i < bytes.length; i += Media.BASE64_CHUNK_SIZE) {
chunks.push(
String.fromCharCode(...bytes.subarray(i, i + Media.BASE64_CHUNK_SIZE)),
);
}

return btoa(chunks.join(''));
}

public hasValue(other: unknown): boolean {
let otherBytes: Uint8Array | undefined;

if (other instanceof Media) {
return this.getBuffer().equals(other.getBuffer());
otherBytes = other.getBuffer();
} else if (other instanceof Uint8Array) {
otherBytes = other;
}

if (Buffer.isBuffer(other)) {
return this.getBuffer().equals(other);
if (otherBytes === undefined) {
return super.hasValue(other);
}

return super.hasValue(other);
const bytes = this.getBuffer();

return (
bytes.length === otherBytes.length &&
bytes.every((byte, i) => byte === otherBytes[i])
);
}
}
Loading
Loading