remix-typedjson
Version:
This package is a replacement for superjson to use in your Remix app. It handles a subset of types that `superjson` supports, but is faster and smaller.
249 lines (248 loc) • 7.81 kB
JavaScript
let customTypeMap = new Map();
export function registerCustomType(entry) {
customTypeMap.set(entry.type, entry);
}
function serialize(data) {
if (data === null)
return { json: 'null' };
if (data === undefined)
return { json: undefined };
const stack = [];
const keys = [''];
const meta = new Map();
const customTypeMapValues = Array.from(customTypeMap.values());
function replacer(key, value) {
function unwindStack() {
while (stack.length > 0) {
const top = stack[stack.length - 1];
if (top.iteration < top.count) {
top.iteration++;
return top;
}
if (top.type === 'object') {
keys.pop();
}
stack.pop();
}
}
let entry = unwindStack();
if (entry) {
value = entry.value[key];
}
// handle dotted keys
if (key.includes('.')) {
key = `[${key}]`;
}
let metaKey = `${keys[keys.length - 1]}${key}`;
const valueType = typeof value;
if (valueType === 'object' && value !== null) {
let count = 0;
let t = 'undefined';
if (value instanceof Date) {
t = 'date';
value = value.toISOString();
}
else if (value instanceof Set) {
value = Array.from(value);
count = value.length;
t = 'set';
}
else if (value instanceof Map) {
value = Object.fromEntries(value);
count = Object.keys(value).length;
t = 'map';
}
else if (value instanceof Array) {
t = 'object';
count = value.length;
}
else if (value instanceof RegExp) {
t = 'regexp';
value = String(value);
}
else if (value instanceof Error) {
t = 'error';
value = { name: value.name, message: value.message, stack: value.stack };
// push error value to stack
stack.push({ type: 'object', value, count: 3, iteration: 0 });
}
else {
// check for custom types
let customType;
if (customTypeMapValues.length > 0) {
customType = customTypeMapValues.find(entry => entry.is(value));
}
if (customType) {
t = customType.type;
value = customType.serialize(value);
}
else {
count = Object.keys(value).length;
t = 'object';
}
}
if (t !== 'object') {
meta.set(metaKey, t);
}
if (count !== 0) {
stack.push({ type: t, value, count, iteration: 0 });
if (key && t === 'object') {
keys.push(`${metaKey}.`);
}
return value;
}
}
// handle non-object types
if (valueType === 'bigint') {
meta.set(metaKey, 'bigint');
return String(value);
}
if (valueType === 'number') {
if (value === Number.POSITIVE_INFINITY) {
meta.set(metaKey, 'infinity');
return 'Infinity';
}
if (value === Number.NEGATIVE_INFINITY) {
meta.set(metaKey, '-infinity');
return '-Infinity';
}
if (Number.isNaN(value)) {
meta.set(metaKey, 'nan');
return 'NaN';
}
}
if (typeof value === 'undefined') {
meta.set(metaKey, 'undefined');
return null;
}
return value;
}
const json = JSON.stringify(data, replacer);
return {
json,
meta: meta.size === 0 ? undefined : Object.fromEntries(meta.entries()),
};
}
function deserialize({ json, meta }) {
if (typeof json === 'undefined') {
return undefined;
}
if (!json)
return null;
const result = JSON.parse(json);
if (meta) {
applyMeta(result, meta);
}
return result;
}
export const splitKey = (key) => {
// key is a dotted path
// may contain escaped dots which are keys wrapped in []
// example [b.c].d => ['b.c', 'd']
const keys = [];
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
if (parts[i].startsWith('[')) {
let k = parts[i].substring(1);
let j = i + 1;
while (!parts[j].endsWith(']')) {
k += `.${parts[j]}`;
j++;
}
k += `.${parts[j].slice(0, -1)}`;
keys.push(k);
i = j;
}
else {
keys.push(parts[i]);
}
}
return keys;
};
function applyMeta(data, meta) {
const customTypeMapValues = Array.from(customTypeMap.values());
for (const key of Object.keys(meta)) {
const keys = splitKey(key);
applyConversion(data, keys, meta[key]);
}
return data;
function applyConversion(data, keys, type, depth = 0) {
const key = keys[depth];
if (depth < keys.length - 1) {
applyConversion(data[key], keys, type, depth + 1);
return;
}
const value = data[key];
switch (type) {
case 'date':
data[key] = new Date(value);
break;
case 'set':
data[key] = new Set(value);
break;
case 'map':
data[key] = new Map(Object.entries(value));
break;
case 'regexp':
const match = /^\/(.*)\/([dgimsuy]*)$/.exec(value);
if (match) {
data[key] = new RegExp(match[1], match[2]);
}
else {
throw new Error(`Invalid regexp: ${value}`);
}
break;
case 'bigint':
data[key] = BigInt(value);
break;
case 'undefined':
data[key] = undefined;
break;
case 'infinity':
data[key] = Number.POSITIVE_INFINITY;
break;
case '-infinity':
data[key] = Number.NEGATIVE_INFINITY;
break;
case 'nan':
data[key] = NaN;
break;
case 'error':
const err = new Error(value.message);
err.name = value.name;
err.stack = value.stack;
data[key] = err;
break;
default:
// custom types
let customType = customTypeMap.get(type);
if (customType) {
data[key] = customType.deserialize(value);
}
}
}
}
function stringify(data, replacer, space) {
if (replacer || space) {
const { json, meta } = serialize(data);
const jsonObj = deserialize({ json });
return JSON.stringify({
json: jsonObj,
meta,
}, replacer, space);
}
return JSON.stringify(serialize(data));
}
function parse(json) {
const result = JSON.parse(json);
return result ? deserialize(result) : null;
}
const typedjson = {
serialize,
stringify,
deserialize,
parse,
applyMeta,
};
export { applyMeta, deserialize, parse, serialize, stringify };
export default typedjson;