Skip to content
This repository has been archived by the owner on Feb 22, 2024. It is now read-only.

Framework Blocking #5

Merged
merged 15 commits into from
Nov 23, 2023
2 changes: 1 addition & 1 deletion .documentation/Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This repository provides multiple tools to make co-development of packages easie

## Workspaces

When new packages are created they should be defined in the `workspaces` field in the root `package.json` for the repository so that npm will resolve internal dependancies correctly.
All packages are captured by the `workspaces` field in the root `package.json` for the repository so that npm will resolve internal dependancies correctly.

## Package versioning

Expand Down
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
"no-debugger": "error",
"no-undef": "off",
quotes: [1, "double"],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-namespace": "off",
Expand Down
2 changes: 2 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
- [ ] Includes breaking changes **(Requires major version bump)**
- [ ] Includes changes which modify the shape of the exposed api, in a non breaking way _(Internal packages don't need to be updated)_ **(Requires minor version bump)**
- [ ] If relevant, version has been bumped

For co-development, ensure you link to destination project(s) using `yarn dev:link-project -a PATH` after checking out this PR.
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@
],
"importOrderGroupNamespaceSpecifiers": true,
"importOrderSeparation": false,
"importOrderSortSpecifiers": true
"importOrderSortSpecifiers": true,
"importOrderParserPlugins": ["typescript", "decorators-legacy"]
}
1 change: 1 addition & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This package requires typescript transpiling by the end user, ensure that your typescript config is compatable with this package's `tsconfig.json`.
24 changes: 24 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@seventv/api",
"version": "0.1.0",
"scripts": {
"type-check": "tsc --noEmit --composite false"
},
"repository": {
"type": "git",
"url": "https://github.com/SevenTV/WebComponents.git"
},
"main": "src/index.ts",
"files": [
"src/",
"tsconfig.json"
],
"dependencies": {
"typescript": "^5.1.6",
"@seventv/util": "~0"
},
"devDependencies": {
"@tsconfig/node18": "^18.2.0",
"@types/node": "^20.4.9"
}
}
86 changes: 86 additions & 0 deletions api/src/hydrator/HydratedObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { cloneValue, hydrateValue } from "./hydration";
import type { HydratorSchema, HydratorValue } from "../types/hydrator";
import type { DefiniteKey } from "@seventv/util/modules/types";

type BaseHydratedObjectConstructor = typeof BaseHydratedObject;
export class BaseHydratedObject {
static SCHEMA: HydratedObjectSchema = {};

constructor(input: object | BaseHydratedObject | HydratedObjectCloningContext) {
const cls = <BaseHydratedObjectConstructor>this.constructor;

if (input instanceof HydratedObjectCloningContext) {
cls.fromClone(this, input.source, input.seen);
} else if (input instanceof cls) {
cls.fromClone(this, input, new WeakMap());
} else {
cls.fromJSON(this, input);
}
}

protected static fromJSON(instance: BaseHydratedObject, json: object) {
const cls = <BaseHydratedObjectConstructor>instance.constructor;

for (const [prop, schema] of Object.entries(cls.SCHEMA)) {
const isOwn = Object.prototype.hasOwnProperty.call(json, prop);
const raw = isOwn ? Reflect.get(json, prop) : undefined;

Reflect.set(instance, prop, hydrateValue(raw, schema, prop, [json]));
}
}

protected static fromClone(
instance: BaseHydratedObject,
source: BaseHydratedObject,
seen: WeakMap<object, object>,
) {
const cls = <BaseHydratedObjectConstructor>instance.constructor;

seen.set(source, instance);

for (const prop of Object.keys(cls.SCHEMA)) {
const raw = Reflect.get(source, prop);

Reflect.set(instance, prop, cloneValue(raw, seen));
}
}

clone() {
const cls = <new (...args: unknown[]) => typeof this>this.constructor;

return new cls(this);
}
}

export class HydratedObjectCloningContext {
constructor(
public source: BaseHydratedObject,
public seen: WeakMap<object, object>,
) {}
}

/* Assert to TypeScript that the implementation will always define props from the schema
*
* It is impossible to genericly extend the final type, as that would require the compiler to know ahead of time what generic the constructor
* would be instantiated with, so we export a mapper type with a generic so downstream abstract classes can also exhibit this property
* This does not affect implementors, only classes which wish to "pass along" the final implementor to the constructor's generic if they are abstract
*/
type HydratedObjectSchema = Record<string, HydratorSchema>;

type HydratedObjectSchemaFields<S extends HydratedObjectSchema> = {
[Key in keyof S as DefiniteKey<Key>]: HydratorValue<S[Key]>;
};

type MappedHydratedObjectClass<Inst extends BaseHydratedObject, Impl extends BaseHydratedObjectConstructor> = Inst &
HydratedObjectSchemaFields<Impl["SCHEMA"]>;

export type MappedHydratedObjectConstructor<
Ctor extends BaseHydratedObjectConstructor = BaseHydratedObjectConstructor,
> = Ctor & {
new <Impl extends Ctor>(...args: ConstructorParameters<Ctor>): MappedHydratedObjectClass<InstanceType<Ctor>, Impl>;
};

export const HydratedObject = <MappedHydratedObjectConstructor>BaseHydratedObject;
export type HydratedObject = InstanceType<MappedHydratedObjectConstructor>;

export default HydratedObject;
183 changes: 183 additions & 0 deletions api/src/hydrator/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import HydratedObject, { HydratedObjectCloningContext } from "./HydratedObject";
import type { HydratedObjectConstructor, HydratorSchema, HydratorSchemaFull, HydratorValue } from "../types/hydrator";
import { isPrototypeOf } from "@seventv/util/modules/prototype";

export function hydrateValue<S extends HydratorSchema>(
input: unknown,
schema: S,
throwPath?: string,
ancestors?: unknown[],
): HydratorValue<S> {
const resolved = resolveHydratorSchema(schema, input, ancestors);

try {
const type = resolved.type;
switch (typeof type) {
case "string": {
switch (type) {
case "object": {
if (typeof input !== "object" || !input) throw void 0;

const hydrated = {};
const newAncestors = ancestors ? [input, ...ancestors] : [input];

if ("schema" in resolved) {
for (const [prop, schema] of Object.entries(resolved.schema)) {
const isOwn = Object.prototype.hasOwnProperty.call(input, prop);
const raw = isOwn ? Reflect.get(input, prop) : undefined;

Reflect.set(hydrated, prop, hydrateValue(raw, schema, prop, newAncestors));
}
} else if ("children" in resolved) {
for (const prop of Reflect.ownKeys(input)) {
const raw = Reflect.get(input, prop);

Reflect.set(
hydrated,
prop,
hydrateValue(raw, resolved.children, prop.toString(), newAncestors),
);
}
} else {
throw void 0;
}

return <HydratorValue<S>>hydrated;
}

case "array": {
if (input instanceof Array) {
const hydrated: unknown[] = [];
const newAncestors = ancestors ? [input, ...ancestors] : [input];

for (let x = 0; x < input.length; x++) {
try {
hydrated.push(hydrateValue(input[x], resolved.children, `[${x}]`, newAncestors));
} catch (err) {
if (resolved.skipInvalid) continue;
throw err;
}
}

return <HydratorValue<S>>hydrated;
}

throw void 0;
}

case "never": {
throw void 0;
}

case "null": {
if (input === null) return <HydratorValue<S>>input;
throw void 0;
}

default: {
if (typeof input === type) return <HydratorValue<S>>input;
throw void 0;
}
}
}

case "function": {
if (isPrototypeOf<HydratedObjectConstructor>(HydratedObject, type)) {
if (typeof input !== "object" || !input) throw void 0;

return <HydratorValue<S>>new type(input);
}

throw void 0;
}
}
} catch (err) {
if (resolved.required === false) {
if (resolved.default !== undefined) return <HydratorValue<S>>resolved.default;
return <HydratorValue<S>>undefined;
}

if (err instanceof HydrationError) {
if (throwPath) {
if (err.throwPath) err.throwPath = `${throwPath}.${err.throwPath}`;
else err.throwPath = throwPath;
}

throw err;
} else {
throw new HydrationError(throwPath, err);
}
}
}

export function resolveHydratorSchema(
schema: HydratorSchema,
current: unknown,
ancestors?: unknown[],
): HydratorSchemaFull {
if (typeof schema == "function" && !isPrototypeOf<HydratedObjectConstructor>(HydratedObject, schema)) {
schema = schema(current, ancestors ?? []);
}

if (typeof schema === "string" || typeof schema === "function") {
return { type: schema };
}

return schema;
}

export class HydrationError extends Error {
constructor(
public throwPath?: string,
public subError?: unknown,
) {
super();
this.name = "Hydration Error";
}

get message() {
let msg = "Failed to parse required property";
if (this.throwPath) msg += ` at path '${this.throwPath}'`;
if (this.subError instanceof Error) msg += ": " + this.subError.message;

return msg;
}
}

export function cloneValue<V extends HydratorValue>(value: V, seen?: WeakMap<object, object>): V {
// Primitives always share identity
if (typeof value !== "object" || value === null) return value;

// Clones of objects that share identity, will share identity
seen = seen ?? new WeakMap();
if (seen.has(value)) return <V>seen.get(value);

if (value instanceof HydratedObject) {
const cls = <HydratedObjectConstructor>value.constructor;
const context = new HydratedObjectCloningContext(value, seen);

return <V>new cls(context);
}

if (value instanceof Array) {
const cloned: HydratorValue[] = [];
seen.set(value, cloned);

for (let x = 0; x < value.length; x++) {
cloned[x] = cloneValue(value[x], seen);
}

return <V>cloned;
} else {
const cloned: Record<PropertyKey, HydratorValue> = {};
seen.set(value, cloned);

for (const prop of Reflect.ownKeys(value)) {
const raw = <HydratorValue>Reflect.get(value, prop);

Reflect.set(cloned, prop, cloneValue(raw, seen));
}

return <V>cloned;
}
}
5 changes: 5 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { HydratedObject } from "./hydrator/HydratedObject";

export * from "./hydrator/hydration";

export type * from "./types/hydrator";
Loading