turbo-stream 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Turbo Stream
2
+
3
+ A streaming data transport format that aims to support built-in features such as Promises, Dates, RegExps, Maps, Sets and more.
4
+
5
+ ## Shout Out!
6
+
7
+ Shout out to Rich Harris and his https://github.com/rich-harris/devalue project. Devalue has heavily influenced this project and portions
8
+ of the code have been directly lifted from it. I highly recommend checking it out if you need something more cusomizable or without streaming support.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install turbo-stream
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```js
19
+ import { decode, encode } from "turbo-stream";
20
+
21
+ const encodedStream = encode(Promise.resolve(42));
22
+ const decoded = await decode(encodedStream);
23
+ console.log(decoded.value); // a Promise
24
+ console.log(await decoded.value); // 42
25
+ await decoded.done; // wait for the stream to finish
26
+ ```
@@ -0,0 +1,14 @@
1
+ export declare const UNDEFINED = -1;
2
+ export declare const HOLE = -1;
3
+ export declare const NAN = -2;
4
+ export declare const POSITIVE_INFINITY = -3;
5
+ export declare const NEGATIVE_INFINITY = -4;
6
+ export declare const NEGATIVE_ZERO = -5;
7
+ export declare const TYPE_BIGINT = "B";
8
+ export declare const TYPE_DATE = "D";
9
+ export declare const TYPE_MAP = "M";
10
+ export declare const TYPE_SET = "S";
11
+ export declare const TYPE_REGEXP = "R";
12
+ export declare const TYPE_SYMBOL = "Y";
13
+ export declare const TYPE_NULL_OBJECT = "N";
14
+ export declare const TYPE_PROMISE = "P";
@@ -0,0 +1,14 @@
1
+ export const UNDEFINED = -1;
2
+ export const HOLE = -1;
3
+ export const NAN = -2;
4
+ export const POSITIVE_INFINITY = -3;
5
+ export const NEGATIVE_INFINITY = -4;
6
+ export const NEGATIVE_ZERO = -5;
7
+ export const TYPE_BIGINT = "B";
8
+ export const TYPE_DATE = "D";
9
+ export const TYPE_MAP = "M";
10
+ export const TYPE_SET = "S";
11
+ export const TYPE_REGEXP = "R";
12
+ export const TYPE_SYMBOL = "Y";
13
+ export const TYPE_NULL_OBJECT = "N";
14
+ export const TYPE_PROMISE = "P";
@@ -0,0 +1,7 @@
1
+ import { Deferred } from "./deferred.js";
2
+ export interface ThisDecode {
3
+ values: unknown[];
4
+ hydrated: unknown[];
5
+ deferred: Record<number, Deferred<unknown>>;
6
+ }
7
+ export declare function unflatten(this: ThisDecode, parsed: unknown): unknown;
package/dist/decode.js ADDED
@@ -0,0 +1,91 @@
1
+ import { HOLE, NAN, NEGATIVE_INFINITY, NEGATIVE_ZERO, POSITIVE_INFINITY, UNDEFINED, TYPE_BIGINT, TYPE_DATE, TYPE_MAP, TYPE_REGEXP, TYPE_SET, TYPE_SYMBOL, TYPE_PROMISE, } from "./constants.js";
2
+ import { Deferred } from "./deferred.js";
3
+ export function unflatten(parsed) {
4
+ if (typeof parsed === "number")
5
+ return hydrate.call(this, parsed, true);
6
+ if (!Array.isArray(parsed) || parsed.length === 0) {
7
+ throw new Error("Invalid input");
8
+ }
9
+ const startIndex = this.values.length;
10
+ this.values.push(...parsed);
11
+ this.hydrated.length = this.values.length;
12
+ return hydrate.call(this, startIndex);
13
+ }
14
+ function hydrate(index, standalone) {
15
+ if (index === UNDEFINED)
16
+ return undefined;
17
+ if (index === NAN)
18
+ return NaN;
19
+ if (index === POSITIVE_INFINITY)
20
+ return Infinity;
21
+ if (index === NEGATIVE_INFINITY)
22
+ return -Infinity;
23
+ if (index === NEGATIVE_ZERO)
24
+ return -0;
25
+ if (standalone)
26
+ throw new Error(`Invalid input`);
27
+ if (index in this.hydrated)
28
+ return this.hydrated[index];
29
+ const value = this.values[index];
30
+ if (!value || typeof value !== "object") {
31
+ this.hydrated[index] = value;
32
+ }
33
+ else if (Array.isArray(value)) {
34
+ if (typeof value[0] === "string") {
35
+ switch (value[0]) {
36
+ case TYPE_DATE:
37
+ this.hydrated[index] = new Date(value[1]);
38
+ break;
39
+ case TYPE_BIGINT:
40
+ this.hydrated[index] = BigInt(value[1]);
41
+ break;
42
+ case TYPE_REGEXP:
43
+ this.hydrated[index] = new RegExp(value[1], value[2]);
44
+ break;
45
+ case TYPE_SYMBOL:
46
+ this.hydrated[index] = Symbol.for(value[1]);
47
+ break;
48
+ case TYPE_SET:
49
+ const set = new Set();
50
+ this.hydrated[index] = set;
51
+ for (let i = 1; i < value.length; i += 1) {
52
+ set.add(hydrate.call(this, value[i]));
53
+ }
54
+ break;
55
+ case TYPE_MAP:
56
+ const map = new Map();
57
+ this.hydrated[index] = map;
58
+ for (let i = 1; i < value.length; i += 2) {
59
+ map.set(hydrate.call(this, value[i]), hydrate.call(this, value[i + 1]));
60
+ }
61
+ break;
62
+ case TYPE_PROMISE:
63
+ const deferred = new Deferred();
64
+ this.deferred[value[1]] = deferred;
65
+ this.hydrated[index] = deferred.promise;
66
+ break;
67
+ default:
68
+ throw new Error(`Invalid input`);
69
+ }
70
+ }
71
+ else {
72
+ const array = new Array(value.length);
73
+ this.hydrated[index] = array;
74
+ for (let i = 0; i < value.length; i += 1) {
75
+ const n = value[i];
76
+ if (n === HOLE)
77
+ continue;
78
+ array[i] = hydrate.call(this, n);
79
+ }
80
+ }
81
+ }
82
+ else {
83
+ const object = {};
84
+ this.hydrated[index] = object;
85
+ for (const key in value) {
86
+ const n = value[key];
87
+ object[key] = hydrate.call(this, n);
88
+ }
89
+ }
90
+ return this.hydrated[index];
91
+ }
@@ -0,0 +1,6 @@
1
+ export declare class Deferred<R> {
2
+ promise: Promise<R>;
3
+ resolve: (value: R | PromiseLike<R>) => void;
4
+ reject: (reason?: unknown) => void;
5
+ constructor();
6
+ }
@@ -0,0 +1,11 @@
1
+ export class Deferred {
2
+ promise;
3
+ resolve;
4
+ reject;
5
+ constructor() {
6
+ this.promise = new Promise((resolve, reject) => {
7
+ this.resolve = resolve;
8
+ this.reject = reject;
9
+ });
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ export interface ThisEncode {
2
+ index: number;
3
+ indicies: Map<unknown, number>;
4
+ stringified: string[];
5
+ deferred: [number, Promise<unknown>][];
6
+ }
7
+ export declare function flatten(this: ThisEncode, input: unknown): number;
package/dist/encode.js ADDED
@@ -0,0 +1,115 @@
1
+ import { HOLE, NAN, NEGATIVE_INFINITY, NEGATIVE_ZERO, POSITIVE_INFINITY, UNDEFINED, TYPE_BIGINT, TYPE_DATE, TYPE_MAP, TYPE_REGEXP, TYPE_SET, TYPE_SYMBOL, TYPE_PROMISE, } from "./constants.js";
2
+ export function flatten(input) {
3
+ if (this.indicies.has(input)) {
4
+ return this.indicies.get(input);
5
+ }
6
+ if (input === undefined)
7
+ return UNDEFINED;
8
+ if (Number.isNaN(input))
9
+ return NAN;
10
+ if (input === Infinity)
11
+ return POSITIVE_INFINITY;
12
+ if (input === -Infinity)
13
+ return NEGATIVE_INFINITY;
14
+ if (input === 0 && 1 / input < 0)
15
+ return NEGATIVE_ZERO;
16
+ const index = this.index++;
17
+ this.indicies.set(input, index);
18
+ stringify.call(this, input, index);
19
+ return index;
20
+ }
21
+ function stringify(input, index) {
22
+ switch (typeof input) {
23
+ case "boolean":
24
+ case "number":
25
+ case "string":
26
+ this.stringified[index] = JSON.stringify(input);
27
+ break;
28
+ case "bigint":
29
+ this.stringified[index] = `["${TYPE_BIGINT}","${input}"]`;
30
+ break;
31
+ case "symbol":
32
+ const keyFor = Symbol.keyFor(input);
33
+ if (!keyFor)
34
+ throw new Error("Cannot encode symbol unless created with Symbol.for()");
35
+ this.stringified[index] = `["${TYPE_SYMBOL}",${JSON.stringify(keyFor)}]`;
36
+ break;
37
+ case "object":
38
+ if (!input) {
39
+ this.stringified[index] = "null";
40
+ break;
41
+ }
42
+ if (Array.isArray(input)) {
43
+ let result = "[";
44
+ for (let i = 0; i < input.length; i++) {
45
+ if (i > 0)
46
+ result += ",";
47
+ if (i in input) {
48
+ result += flatten.call(this, input[i]);
49
+ }
50
+ else {
51
+ result += HOLE;
52
+ }
53
+ }
54
+ this.stringified[index] = result + "]";
55
+ break;
56
+ }
57
+ if (input instanceof Date) {
58
+ this.stringified[index] = `["${TYPE_DATE}",${input.getTime()}]`;
59
+ break;
60
+ }
61
+ if (input instanceof RegExp) {
62
+ this.stringified[index] = `["${TYPE_REGEXP}",${JSON.stringify(input.source)},${JSON.stringify(input.flags)}]`;
63
+ break;
64
+ }
65
+ if (input instanceof Set) {
66
+ let result = `["${TYPE_SET}"`;
67
+ for (const value of input) {
68
+ result += "," + flatten.call(this, value);
69
+ }
70
+ this.stringified[index] = result + "]";
71
+ break;
72
+ }
73
+ if (input instanceof Map) {
74
+ let result = `["${TYPE_MAP}"`;
75
+ for (const [key, value] of input) {
76
+ result += "," + flatten.call(this, key);
77
+ result += "," + flatten.call(this, value);
78
+ }
79
+ this.stringified[index] = result + "]";
80
+ break;
81
+ }
82
+ if (input instanceof Promise) {
83
+ this.stringified[index] = `["${TYPE_PROMISE}",${index}]`;
84
+ this.deferred.push([index, input]);
85
+ break;
86
+ }
87
+ if (!isPlainObject(input)) {
88
+ console.log(input);
89
+ throw new Error("Cannot encode object with prototype");
90
+ }
91
+ let result = "{";
92
+ let sep = false;
93
+ for (const key in input) {
94
+ if (sep)
95
+ result += ",";
96
+ sep = true;
97
+ result += JSON.stringify(key) + ":" + flatten.call(this, input[key]);
98
+ }
99
+ this.stringified[index] = result + "}";
100
+ break;
101
+ case "function":
102
+ throw new Error("Cannot encode function");
103
+ case "undefined":
104
+ throw new Error("This should never happen");
105
+ }
106
+ }
107
+ const objectProtoNames = Object.getOwnPropertyNames(Object.prototype)
108
+ .sort()
109
+ .join("\0");
110
+ function isPlainObject(thing) {
111
+ const proto = Object.getPrototypeOf(thing);
112
+ return (proto === Object.prototype ||
113
+ proto === null ||
114
+ Object.getOwnPropertyNames(proto).sort().join("\0") === objectProtoNames);
115
+ }
@@ -0,0 +1,5 @@
1
+ export declare function decode<T = unknown>(input: ReadableStream<Uint8Array>): Promise<{
2
+ value: T;
3
+ done: Promise<void>;
4
+ }>;
5
+ export declare function encode(input: unknown): ReadableStream<Uint8Array>;
@@ -0,0 +1,121 @@
1
+ import { TYPE_PROMISE } from "./constants.js";
2
+ import { unflatten } from "./decode.js";
3
+ import { Deferred } from "./deferred.js";
4
+ import { flatten } from "./encode.js";
5
+ export async function decode(input) {
6
+ const decoder = new Decoder(input);
7
+ return decoder.decode();
8
+ }
9
+ class Decoder {
10
+ reader;
11
+ decoder;
12
+ constructor(input) {
13
+ this.reader = input.getReader();
14
+ this.decoder = {
15
+ deferred: {},
16
+ hydrated: [],
17
+ values: [],
18
+ };
19
+ }
20
+ async decode() {
21
+ const iterator = makeTextFileLineIterator(this.reader);
22
+ const read = await iterator.next();
23
+ if (!read.value || read.done)
24
+ throw new Error("Invalid input");
25
+ const decoded = unflatten.call(this.decoder, JSON.parse(read.value));
26
+ const done = (async () => {
27
+ for await (const line of iterator) {
28
+ let type = line[0];
29
+ switch (type) {
30
+ case TYPE_PROMISE:
31
+ const colonIndex = line.indexOf(":");
32
+ const deferredId = Number(line.slice(1, colonIndex));
33
+ const lineData = line.slice(colonIndex + 1);
34
+ const deferredResult = unflatten.call(this.decoder, JSON.parse(lineData));
35
+ this.decoder.deferred[deferredId].resolve(deferredResult);
36
+ break;
37
+ default:
38
+ throw new Error("Invalid input");
39
+ }
40
+ }
41
+ })();
42
+ return { value: decoded, done };
43
+ }
44
+ }
45
+ export function encode(input) {
46
+ return new ReadableStream({
47
+ async start(controller) {
48
+ const textEncoder = new TextEncoder();
49
+ const encoder = {
50
+ index: 0,
51
+ indicies: new Map(),
52
+ stringified: [],
53
+ deferred: [],
54
+ };
55
+ const id = flatten.call(encoder, input);
56
+ const encoded = id < 0 ? String(id) : "[" + encoder.stringified.join(",") + "]";
57
+ controller.enqueue(textEncoder.encode(encoded + "\n"));
58
+ let activeDeferred = 0;
59
+ const done = new Deferred();
60
+ let alreadyDone = false;
61
+ if (encoder.deferred.length === 0) {
62
+ alreadyDone = true;
63
+ done.resolve();
64
+ }
65
+ else {
66
+ for (const [promiseId, promise] of encoder.deferred) {
67
+ activeDeferred++;
68
+ promise
69
+ .then((value) => {
70
+ const id = flatten.call(encoder, value);
71
+ const encoded = id < 0
72
+ ? String(id)
73
+ : "[" + encoder.stringified.slice(id).join(",") + "]";
74
+ controller.enqueue(textEncoder.encode(`${TYPE_PROMISE}${promiseId}:` + encoded + "\n"));
75
+ activeDeferred--;
76
+ if (activeDeferred === 0) {
77
+ alreadyDone = true;
78
+ done.resolve();
79
+ }
80
+ })
81
+ .catch((reason) => {
82
+ if (alreadyDone)
83
+ return;
84
+ alreadyDone = true;
85
+ done.reject(reason);
86
+ });
87
+ }
88
+ }
89
+ await done.promise;
90
+ controller.close();
91
+ },
92
+ });
93
+ }
94
+ async function* makeTextFileLineIterator(reader) {
95
+ const decoder = new TextDecoder();
96
+ let read = await reader.read();
97
+ let chunk = read.value ? decoder.decode(read.value, { stream: true }) : "";
98
+ let re = /\r\n|\n|\r/gm;
99
+ let startIndex = 0;
100
+ for (;;) {
101
+ let result = re.exec(chunk);
102
+ if (!result) {
103
+ if (read.done) {
104
+ break;
105
+ }
106
+ let remainder = chunk.slice(startIndex);
107
+ read = await reader.read();
108
+ chunk =
109
+ remainder +
110
+ (read.value ? decoder.decode(read.value, { stream: true }) : "");
111
+ startIndex = re.lastIndex = 0;
112
+ continue;
113
+ }
114
+ yield chunk.substring(startIndex, result.index);
115
+ startIndex = re.lastIndex;
116
+ }
117
+ if (startIndex < chunk.length) {
118
+ // last line didn't end in a newline char
119
+ yield chunk.slice(startIndex);
120
+ }
121
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "turbo-stream",
3
+ "version": "0.0.1",
4
+ "description": "A streaming data transport format that aims to support built-in features such as Promises, Dates, RegExps, Maps, Sets and more.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/turbo-stream.d.ts",
13
+ "default": "./dist/turbo-stream.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "keywords": [],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "devDependencies": {
21
+ "@types/node": "^20.8.7",
22
+ "expect": "^29.7.0",
23
+ "tsm": "^2.3.0",
24
+ "typescript": "^5.2.2"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc --outDir dist --project tsconfig.lib.json",
28
+ "test": "node --no-warnings --loader tsm --enable-source-maps --test-reporter tap --test src/*.spec.ts",
29
+ "test-typecheck": "tsc --noEmit --project tsconfig.spec.json"
30
+ }
31
+ }