Skip to content

Commit

Permalink
feat: added plugin support
Browse files Browse the repository at this point in the history
  • Loading branch information
jacob-ebey committed Oct 27, 2023
1 parent b088026 commit 3ddff56
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 40 deletions.
96 changes: 59 additions & 37 deletions src/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function flatten(this: ThisEncode, input: unknown): number {
}

function stringify(this: ThisEncode, input: unknown, index: number) {
const { deferred } = this;
const { deferred, plugins } = this;
const str = this.stringified;

const partsForObj = (obj: any) =>
Expand Down Expand Up @@ -67,43 +67,65 @@ function stringify(this: ThisEncode, input: unknown, index: number) {
break;
}

let result = Array.isArray(input) ? "[" : "{";
if (Array.isArray(input)) {
for (let i = 0; i < input.length; i++)
result +=
(i ? "," : "") + (i in input ? flatten.call(this, input[i]) : HOLE);
str[index] = result + "]";
} else if (input instanceof Date) {
str[index] = `["${TYPE_DATE}",${input.getTime()}]`;
} else if (input instanceof URL) {
str[index] = `["${TYPE_URL}",${JSON.stringify(input.href)}]`;
} else if (input instanceof RegExp) {
str[index] = `["${TYPE_REGEXP}",${JSON.stringify(
input.source
)},${JSON.stringify(input.flags)}]`;
} else if (input instanceof Set) {
str[index] = `["${TYPE_SET}",${[...input]
.map((val) => flatten.call(this, val))
.join(",")}]`;
} else if (input instanceof Map) {
str[index] = `["${TYPE_MAP}",${[...input]
.flatMap(([k, v]) => [flatten.call(this, k), flatten.call(this, v)])
.join(",")}]`;
} else if (input instanceof Promise) {
str[index] = `["${TYPE_PROMISE}",${index}]`;
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)}`;
const isArray = Array.isArray(input);
let pluginHandled = false;
if (!isArray && plugins) {
for (const plugin of plugins) {
const pluginResult = plugin(input);
if (Array.isArray(pluginResult)) {
pluginHandled = true;
const [pluginIdentifier, ...rest] = pluginResult;
str[index] = `[${JSON.stringify(pluginIdentifier)}`;
if (rest.length > 0) {
str[index] +=
"," + rest.map((v) => flatten.call(this, v)).join(",");
}
str[index] += "]";
break;
}
}
}

if (!pluginHandled) {
let result = isArray ? "[" : "{";
if (isArray) {
for (let i = 0; i < input.length; i++)
result +=
(i ? "," : "") +
(i in input ? flatten.call(this, input[i]) : HOLE);
str[index] = result + "]";
} else if (input instanceof Date) {
str[index] = `["${TYPE_DATE}",${input.getTime()}]`;
} else if (input instanceof URL) {
str[index] = `["${TYPE_URL}",${JSON.stringify(input.href)}]`;
} else if (input instanceof RegExp) {
str[index] = `["${TYPE_REGEXP}",${JSON.stringify(
input.source
)},${JSON.stringify(input.flags)}]`;
} else if (input instanceof Set) {
str[index] = `["${TYPE_SET}",${[...input]
.map((val) => flatten.call(this, val))
.join(",")}]`;
} else if (input instanceof Map) {
str[index] = `["${TYPE_MAP}",${[...input]
.flatMap(([k, v]) => [flatten.call(this, k), flatten.call(this, v)])
.join(",")}]`;
} else if (input instanceof Promise) {
str[index] = `["${TYPE_PROMISE}",${index}]`;
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 (Object.getPrototypeOf(input) === null) {
str[index] = `["${TYPE_NULL_OBJECT}",{${partsForObj(input)}}]`;
} else if (isPlainObject(input)) {
str[index] = `{${partsForObj(input)}}`;
} else {
throw new Error("Cannot encode object with prototype");
}
str[index] += "]";
} else if (Object.getPrototypeOf(input) === null) {
str[index] = `["${TYPE_NULL_OBJECT}",{${partsForObj(input)}}]`;
} else if (isPlainObject(input)) {
str[index] = `{${partsForObj(input)}}`;
} else {
throw new Error("Cannot encode object with prototype");
}
break;
default:
Expand Down
25 changes: 25 additions & 0 deletions src/turbo-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,28 @@ test("should encode and decode set with promises as values", async () => {
expect(await proms[0]).toEqual(await Array.from(input)[0]);
await decoded.done;
});

test("should encode and decode custom type", async () => {
class Custom {
constructor(public foo: string) {}
}
const input = new Custom("bar");
const decoded = await decode(
encode(input, [
(value) => {
if (value instanceof Custom) {
return ["Custom", value.foo];
}
},
]),
[
(type, foo) => {
if (type === "Custom") {
return { value: new Custom(foo as string) };
}
},
]
);
expect(decoded.value).toBeInstanceOf(Custom);
expect(decoded.value).toEqual(input);
});
11 changes: 9 additions & 2 deletions src/turbo-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import {
TYPE_ERROR,
TYPE_PROMISE,
createLineSplittingTransform,
type DecodePlugin,
type EncodePlugin,
type ThisDecode,
type ThisEncode,
} from "./utils.js";

export async function decode(readable: ReadableStream<Uint8Array>) {
export async function decode(
readable: ReadableStream<Uint8Array>,
plugins?: DecodePlugin[]
) {
const done = new Deferred<void>();
const reader = readable
.pipeThrough(createLineSplittingTransform())
Expand All @@ -19,6 +24,7 @@ export async function decode(readable: ReadableStream<Uint8Array>) {
values: [],
hydrated: [],
deferred: {},
plugins,
};

const decoded = await decodeInitial.call(decoder, reader);
Expand Down Expand Up @@ -119,12 +125,13 @@ async function decodeDeferred(
}
}

export function encode(input: unknown) {
export function encode(input: unknown, plugins?: EncodePlugin[]) {
const encoder: ThisEncode = {
deferred: {},
index: 0,
indicies: new Map(),
stringified: [],
plugins,
};
const textEncoder = new TextEncoder();
let lastSentIndex = 0;
Expand Down
10 changes: 9 additions & 1 deletion src/unflatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function unflatten(this: ThisDecode, parsed: unknown): unknown {
}

function hydrate(this: ThisDecode, index: number) {
const { hydrated, values, deferred } = this;
const { hydrated, values, deferred, plugins } = this;

switch (index) {
case UNDEFINED:
Expand All @@ -63,6 +63,14 @@ function hydrate(this: ThisDecode, index: number) {

if (Array.isArray(value)) {
if (typeof value[0] === "string") {
if (Array.isArray(plugins)) {
const args = value.slice(1).map((i) => hydrate.call(this, i));
for (const plugin of plugins) {
const result = plugin(value[0], ...args);
if (result) return (hydrated[index] = result.value);
}
}

const [type, b, c] = value;
switch (type) {
case TYPE_DATE:
Expand Down
11 changes: 11 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,28 @@ export const TYPE_SET = "S";
export const TYPE_SYMBOL = "Y";
export const TYPE_URL = "U";

export type DecodePlugin = (
type: string,
...data: unknown[]
) => { value: unknown } | false | null | undefined;

export type EncodePlugin = (
value: unknown
) => [string, ...unknown[]] | false | null | undefined;

export interface ThisDecode {
values: unknown[];
hydrated: unknown[];
deferred: Record<number, Deferred<unknown>>;
plugins?: DecodePlugin[];
}

export interface ThisEncode {
index: number;
indicies: Map<unknown, number>;
stringified: string[];
deferred: Record<number, Promise<unknown>>;
plugins?: EncodePlugin[];
}

export class Deferred<T = unknown> {
Expand Down

0 comments on commit 3ddff56

Please sign in to comment.