Skip to content

Commit

Permalink
feat: encode errors and promise rejections
Browse files Browse the repository at this point in the history
  • Loading branch information
jacob-ebey committed Oct 25, 2023
1 parent 315c8b6 commit fa120e9
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 12 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ npm install turbo-stream
```js
import { decode, encode } from "turbo-stream";

const encodedStream = encode({ answer: Promise.resolve(42) });
const encodedStream = encode(Promise.resolve(42));
const decoded = await decode(encodedStream);
console.log(decoded.value.answer); // a Promise
console.log(await decoded.value.answer); // 42
console.log(decoded.value); // a Promise
console.log(await decoded.value); // 42
await decoded.done; // wait for the stream to finish
```

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "turbo-stream",
"version": "0.0.6",
"version": "0.0.7",
"description": "A streaming data transport format that aims to support built-in features such as Promises, Dates, RegExps, Maps, Sets and more.",
"type": "module",
"files": [
Expand Down
7 changes: 7 additions & 0 deletions src/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
POSITIVE_INFINITY,
TYPE_BIGINT,
TYPE_DATE,
TYPE_ERROR,
TYPE_MAP,
TYPE_PROMISE,
TYPE_REGEXP,
Expand Down Expand Up @@ -80,6 +81,12 @@ function stringify(this: ThisEncode, input: unknown, index: number) {
} else if (input instanceof Promise) {
str[index] = `["${TYPE_PROMISE}",${index}]`;
this.deferred[index] = input;
} else if (input instanceof Error) {
str[index] = `["${TYPE_ERROR}",${JSON.stringify(input.message)}`;
if (input.name !== "Error") {
str[index] += `,${JSON.stringify(input.name)}`;
}
str[index] += "]";
} else if (isPlainObject(input)) {
const parts = [];
for (const key in input)
Expand Down
88 changes: 87 additions & 1 deletion src/turbo-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,81 @@ test("should encode and decode string", async () => {
expect(output).toEqual(input);
});

test("should encode and decode Date", async () => {
const input = new Date();
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode NaN", async () => {
const input = NaN;
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode Infinity", async () => {
const input = Infinity;
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode -Infinity", async () => {
const input = -Infinity;
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode -0", async () => {
const input = -0;
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode BigInt", async () => {
const input = BigInt(42);
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode RegExp", async () => {
const input = /foo/g;
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode Symbol", async () => {
const input = Symbol.for("foo");
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode Map", async () => {
const input = new Map([
["foo", "bar"],
["baz", "qux"],
]);
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode Set", async () => {
const input = new Set(["foo", "bar"]);
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode an Error", async () => {
const input = new Error("foo");
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode an EvalError", async () => {
const input = new EvalError("foo");
const output = await quickDecode(encode(input));
expect(output).toEqual(input);
});

test("should encode and decode array", async () => {
const input = [1, 2, 3];
const output = await quickDecode(encode(input));
Expand Down Expand Up @@ -71,7 +146,18 @@ test("should encode and decode object with undefined", async () => {
test("should encode and decode promise", async () => {
const input = Promise.resolve("foo");
const decoded = await decode(encode(input));
expect(decoded.value).toEqual(await input);
expect(decoded.value).toBeInstanceOf(Promise);
expect(await decoded.value).toEqual(await input);
await decoded.done;
});

test("should encode and decode rejected promise", async () => {
const input = Promise.reject(new Error("foo"));
const decoded = await decode(encode(input));
expect(decoded.value).toBeInstanceOf(Promise);
await expect(decoded.value).rejects.toEqual(
await input.catch((reason) => reason)
);
await decoded.done;
});

Expand Down
53 changes: 46 additions & 7 deletions src/turbo-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createLineSplittingTransform,
Deferred,
TYPE_PROMISE,
TYPE_ERROR,
type ThisDecode,
type ThisEncode,
} from "./utils.js";
Expand Down Expand Up @@ -75,7 +76,7 @@ async function decodeDeferred(
if (!read.value) continue;
const line = read.value;
switch (line[0]) {
case TYPE_PROMISE:
case TYPE_PROMISE: {
const colonIndex = line.indexOf(":");
const deferredId = Number(line.slice(1, colonIndex));
const deferred = this.deferred[deferredId];
Expand All @@ -92,9 +93,25 @@ async function decodeDeferred(
const value = unflatten.call(this, jsonLine);
deferred.resolve(value);
break;
// case TYPE_PROMISE_ERROR:
// // TODO: transport promise rejections
// break;
}
case TYPE_ERROR: {
const colonIndex = line.indexOf(":");
const deferredId = Number(line.slice(1, colonIndex));
const deferred = this.deferred[deferredId];
if (!deferred) {
throw new Error(`Deferred ID ${deferredId} not found in stream`);
}
const lineData = line.slice(colonIndex + 1);
let jsonLine;
try {
jsonLine = JSON.parse(lineData);
} catch (reason) {
throw new SyntaxError();
}
const value = unflatten.call(this, jsonLine);
deferred.reject(value);
break;
}
default:
throw new SyntaxError();
}
Expand All @@ -113,7 +130,7 @@ export function encode(input: unknown) {
let lastSentIndex = 0;
const readable = new ReadableStream<Uint8Array>({
async start(controller) {
const id = flatten.call(encoder, await input);
const id = flatten.call(encoder, input);
if (id < 0) {
controller.enqueue(textEncoder.encode(`${id}\n`));
} else {
Expand Down Expand Up @@ -149,8 +166,30 @@ export function encode(input: unknown) {
}
},
(reason) => {
// TODO: Encode and send errors
throw reason;
if (
!reason ||
typeof reason !== "object" ||
!(reason instanceof Error)
) {
reason = new Error("An unknown error occurred");
}

const id = flatten.call(encoder, reason);
if (id < 0) {
controller.enqueue(
textEncoder.encode(`${TYPE_ERROR}${deferredId}:${id}\n`)
);
} else {
const values = encoder.stringified
.slice(lastSentIndex + 1)
.join(",");
controller.enqueue(
textEncoder.encode(
`${TYPE_ERROR}${deferredId}:[${values}]\n`
)
);
lastSentIndex = encoder.stringified.length - 1;
}
}
)
.finally(() => {
Expand Down
17 changes: 17 additions & 0 deletions src/unflatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@ import {
TYPE_SYMBOL,
UNDEFINED,
type ThisDecode,
TYPE_ERROR,
} from "./utils.js";

const globalObj = (
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: undefined
) as Record<string, typeof Error> | undefined;

export function unflatten(this: ThisDecode, parsed: unknown): unknown {
if (typeof parsed === "number") return hydrate.call(this, parsed);

Expand Down Expand Up @@ -84,6 +93,14 @@ function hydrate(this: ThisDecode, index: number) {
deferred[value[1]] = d;
return (hydrated[index] = d.promise);
}
case TYPE_ERROR:
const [, message, errorType] = value;
let error =
errorType && globalObj && globalObj[errorType]
? new globalObj[errorType](message)
: new Error(message);
hydrated[index] = error;
return error;
default:
throw new SyntaxError();
}
Expand Down
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const TYPE_REGEXP = "R";
export const TYPE_SYMBOL = "Y";
export const TYPE_NULL_OBJECT = "N";
export const TYPE_PROMISE = "P";
export const TYPE_ERROR = "E";

export interface ThisDecode {
values: unknown[];
Expand Down

0 comments on commit fa120e9

Please sign in to comment.