Skip to content

Commit 3b9f093

Browse files
committed
Improve Result class
1 parent 5453f17 commit 3b9f093

4 files changed

Lines changed: 172 additions & 34 deletions

File tree

lib/websocket/frame.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ class Frame {
152152
? this.payload.subarray(2).toString(ENCODING)
153153
: '';
154154

155-
return Result.from({ code, reason });
155+
return Result.ok({ code, reason });
156156
}
157157

158158
get header() {

lib/websocket/frameParser.js

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ const PARSE_ERR_CODES = {
127127

128128
class FrameParser {
129129
static parse(buffer) {
130-
if (buffer.length < 2) return Result.empty();
130+
if (buffer.length < 2) return Result.ok(null);
131131

132132
const fin = (buffer[0] & FIN_MASK) !== 0;
133133
const rsv = buffer[0] & RSV_MASK;
@@ -137,7 +137,7 @@ class FrameParser {
137137
let offset = 2;
138138

139139
if (rsv !== 0) {
140-
return Result.from(
140+
return Result.fail(
141141
new ParseError(
142142
PARSE_ERR_CODES.PROTOCOL_ERROR_RSV,
143143
'RSV bits must be 0',
@@ -146,17 +146,17 @@ class FrameParser {
146146
}
147147

148148
if (length === LEN_16_BIT) {
149-
if (buffer.length < offset + 2) return Result.empty();
149+
if (buffer.length < offset + 2) return Result.ok(null);
150150
length = buffer.readUInt16BE(offset);
151151
offset += 2;
152152
} else if (length === LEN_64_BIT) {
153-
if (buffer.length < offset + 8) return Result.empty();
153+
if (buffer.length < offset + 8) return Result.ok(null);
154154
const high = buffer.readUInt32BE(offset);
155155
const low = buffer.readUInt32BE(offset + 4);
156156
offset += 8;
157157
const isSafeHigh = (high & MAX_SAFE_HIGH_MASK) === 0;
158158
if (!isSafeHigh) {
159-
return Result.from(
159+
return Result.fail(
160160
new ParseError(
161161
PARSE_ERR_CODES.MESSAGE_TOO_BIG,
162162
'Payload length exceeds MAX_SAFE_INTEGER',
@@ -168,36 +168,36 @@ class FrameParser {
168168

169169
let mask;
170170
if (masked) {
171-
if (buffer.length < offset + 4) return Result.empty();
171+
if (buffer.length < offset + 4) return Result.ok(null);
172172
mask = buffer.subarray(offset, offset + 4);
173173
offset += 4;
174174
}
175175

176-
if (buffer.length < offset + length) return Result.empty();
176+
if (buffer.length < offset + length) return Result.ok(null);
177177
const payload = buffer.subarray(offset, offset + length);
178178
const frame = new Frame(fin, opcode, masked, payload, mask, rsv);
179-
return Result.from({ frame, bytesUsed: offset + length });
179+
return Result.ok({ frame, bytesUsed: offset + length });
180180
}
181181

182182
static checkControlFrame(frame) {
183183
const { fin, opcode, payload } = frame;
184-
if (!CONTROL_OPCODES.has(opcode) || !fin) {
185-
return Result.from(
184+
if (!CONTROL_OPCODES.includes(opcode) || !fin) {
185+
return Result.fail(
186186
new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON, 'Protocol error'),
187187
);
188188
}
189189
if (payload.length > 125) {
190-
return Result.from(
190+
return Result.fail(
191191
new ParseError(
192192
PARSE_ERR_CODES.PROTOCOL_ERROR_CTRL_TOO_LONG,
193193
'Control frame too long',
194194
),
195195
);
196196
}
197197
if (opcode === OPCODES.CLOSE) {
198-
if (payload.length === 0) return Result.from(true);
198+
if (payload.length === 0) return Result.ok(true);
199199
if (payload.length === 1) {
200-
return Result.from(
200+
return Result.fail(
201201
new ParseError(
202202
PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON,
203203
'Protocol error',
@@ -207,42 +207,42 @@ class FrameParser {
207207
const code = payload.readUInt16BE(0);
208208
const reason = payload.subarray(2);
209209
if (!isValidCloseCode(code)) {
210-
return Result.from(
210+
return Result.fail(
211211
new ParseError(
212212
PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON,
213213
`Invalid close code: ${code}`,
214214
),
215215
);
216216
}
217217
if (!isValidUTF8(reason)) {
218-
return Result.from(
218+
return Result.fail(
219219
new ParseError(
220220
PARSE_ERR_CODES.INVALID_PAYLOAD,
221221
'Invalid UTF-8 in close reason',
222222
),
223223
);
224224
}
225225
}
226-
return Result.from(true);
226+
return Result.ok(true);
227227
}
228228

229229
static checkDataFrame(frame) {
230230
const { fin, opcode, payload } = frame;
231-
if (!DATA_OPCODES.has(opcode)) {
232-
return Result.from(
231+
if (!DATA_OPCODES.includes(opcode)) {
232+
return Result.fail(
233233
new ParseError(PARSE_ERR_CODES.PROTOCOL_ERROR_COMMON, 'Protocol error'),
234234
);
235235
}
236236
const isText = opcode === OPCODES.TEXT;
237237
if (isText && fin && !isValidUTF8(payload)) {
238-
return Result.from(
238+
return Result.fail(
239239
new ParseError(
240240
PARSE_ERR_CODES.INVALID_PAYLOAD,
241241
'Invalid UTF-8 in text frame',
242242
),
243243
);
244244
}
245-
return Result.from(true);
245+
return Result.ok(true);
246246
}
247247
}
248248

lib/websocket/result.js

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
11
'use strict';
22

3+
const NO_DEFAULT = Symbol('NoDefault');
4+
35
class Result {
4-
#value;
5-
#error;
6+
#value = null;
7+
#error = null;
8+
9+
constructor(value = null, error = null) {
10+
if (value !== null) this.#value = value;
11+
if (error !== null) this.#error = error;
12+
}
613

7-
constructor({ value, error }) {
8-
this.#value = value;
9-
this.#error = error;
14+
static ok(value = null) {
15+
return new Result(value, null);
1016
}
1117

12-
static from(input) {
13-
const res =
14-
input instanceof globalThis.Error
15-
? { value: null, error: input }
16-
: { value: input, error: null };
17-
return new this(res);
18+
static fail(error) {
19+
return new Result(null, error);
1820
}
1921

20-
static empty() {
21-
return new this({ value: null, error: null });
22+
static from(fn) {
23+
try {
24+
return Result.ok(fn());
25+
} catch (error) {
26+
return Result.fail(error);
27+
}
28+
}
29+
30+
static async fromAsync(fn) {
31+
try {
32+
return Result.ok(await fn());
33+
} catch (error) {
34+
return Result.fail(error);
35+
}
2236
}
2337

2438
get value() {
@@ -28,6 +42,21 @@ class Result {
2842
get error() {
2943
return this.#error;
3044
}
45+
46+
get ok() {
47+
return this.#error === null;
48+
}
49+
50+
unwrap(defaultValue = NO_DEFAULT) {
51+
if (this.#error === null) return this.#value;
52+
if (defaultValue === NO_DEFAULT) throw this.#error;
53+
return defaultValue;
54+
}
55+
56+
map(fn) {
57+
if (this.#error !== null) return this;
58+
return Result.from(() => fn(this.#value));
59+
}
3160
}
3261

3362
module.exports = { Result };

test/result.test.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use strict';
2+
3+
const { test } = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const { Result } = require('../lib/websocket/result.js');
7+
8+
test('Result.ok: creates success result with given value', () => {
9+
const r = Result.ok(42);
10+
assert.equal(r.value, 42);
11+
assert.equal(r.error, null);
12+
assert.equal(r.ok, true);
13+
});
14+
15+
test('Result.ok: defaults to null value', () => {
16+
const r = Result.ok();
17+
assert.equal(r.value, null);
18+
assert.equal(r.error, null);
19+
assert.equal(r.ok, true);
20+
});
21+
22+
test('Result.fail: creates error result', () => {
23+
const err = new Error('boom');
24+
const r = Result.fail(err);
25+
assert.equal(r.value, null);
26+
assert.strictEqual(r.error, err);
27+
assert.equal(r.ok, false);
28+
});
29+
30+
test('Result.from: wraps a function return value', () => {
31+
const r = Result.from(() => ({ code: 1000, reason: 'ok' }));
32+
assert.deepEqual(r.value, { code: 1000, reason: 'ok' });
33+
assert.equal(r.error, null);
34+
assert.equal(r.ok, true);
35+
});
36+
37+
test('Result.from: catches thrown errors', () => {
38+
const err = new Error('boom');
39+
const r = Result.from(() => {
40+
throw err;
41+
});
42+
assert.equal(r.value, null);
43+
assert.strictEqual(r.error, err);
44+
assert.equal(r.ok, false);
45+
});
46+
47+
test('Result.fromAsync: wraps an async return value', async () => {
48+
const r = await Result.fromAsync(async () => 99);
49+
assert.equal(r.value, 99);
50+
assert.equal(r.error, null);
51+
assert.equal(r.ok, true);
52+
});
53+
54+
test('Result.fromAsync: catches rejected promises', async () => {
55+
const err = new Error('async boom');
56+
const r = await Result.fromAsync(async () => {
57+
throw err;
58+
});
59+
assert.equal(r.value, null);
60+
assert.strictEqual(r.error, err);
61+
assert.equal(r.ok, false);
62+
});
63+
64+
test('Result.unwrap: returns value when ok', () => {
65+
assert.equal(Result.ok(7).unwrap(), 7);
66+
});
67+
68+
test('Result.unwrap: throws error when failed', () => {
69+
const err = new Error('fail');
70+
assert.throws(
71+
() => Result.fail(err).unwrap(),
72+
(e) => e === err,
73+
);
74+
});
75+
76+
test('Result.unwrap: returns defaultValue when failed', () => {
77+
const r = Result.fail(new Error('fail'));
78+
assert.equal(r.unwrap('default'), 'default');
79+
});
80+
81+
test('Result.map: transforms value when ok', () => {
82+
const r = Result.ok(3).map((v) => v * 2);
83+
assert.equal(r.value, 6);
84+
assert.equal(r.ok, true);
85+
});
86+
87+
test('Result.map: passes error through unchanged', () => {
88+
const err = new Error('fail');
89+
const r = Result.fail(err).map((v) => v * 2);
90+
assert.strictEqual(r.error, err);
91+
assert.equal(r.ok, false);
92+
});
93+
94+
test('Result.map: catches error thrown inside mapper', () => {
95+
const mapErr = new Error('map fail');
96+
const r = Result.ok(1).map(() => {
97+
throw mapErr;
98+
});
99+
assert.strictEqual(r.error, mapErr);
100+
assert.equal(r.ok, false);
101+
});
102+
103+
test('Result: value and error getters are read-only', () => {
104+
const r = Result.ok(42);
105+
assert.equal(r.value, 42);
106+
assert.throws(() => {
107+
r.value = 99;
108+
});
109+
});

0 commit comments

Comments
 (0)