diff --git a/README.md b/README.md index 4bf71a1..ab2bb06 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/package.json b/package.json index 83c0b0e..2cc6118 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/flatten.ts b/src/flatten.ts index c0d058c..a546137 100644 --- a/src/flatten.ts +++ b/src/flatten.ts @@ -6,6 +6,7 @@ import { POSITIVE_INFINITY, TYPE_BIGINT, TYPE_DATE, + TYPE_ERROR, TYPE_MAP, TYPE_PROMISE, TYPE_REGEXP, @@ -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) diff --git a/src/turbo-stream.spec.ts b/src/turbo-stream.spec.ts index f79c087..8ffacd0 100644 --- a/src/turbo-stream.spec.ts +++ b/src/turbo-stream.spec.ts @@ -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)); @@ -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; }); diff --git a/src/turbo-stream.ts b/src/turbo-stream.ts index 47da5c5..73da218 100644 --- a/src/turbo-stream.ts +++ b/src/turbo-stream.ts @@ -4,6 +4,7 @@ import { createLineSplittingTransform, Deferred, TYPE_PROMISE, + TYPE_ERROR, type ThisDecode, type ThisEncode, } from "./utils.js"; @@ -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]; @@ -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(); } @@ -113,7 +130,7 @@ export function encode(input: unknown) { let lastSentIndex = 0; const readable = new ReadableStream({ 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 { @@ -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(() => { diff --git a/src/unflatten.ts b/src/unflatten.ts index 2f5b7f6..eb8e339 100644 --- a/src/unflatten.ts +++ b/src/unflatten.ts @@ -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 | undefined; + export function unflatten(this: ThisDecode, parsed: unknown): unknown { if (typeof parsed === "number") return hydrate.call(this, parsed); @@ -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(); } diff --git a/src/utils.ts b/src/utils.ts index 73e5d35..6c6bd5b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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[];