Skip to content

Commit c0c8273

Browse files
authored
Rewrite to support content blocks & improved parser (#22)
* Move old extensible events structure to v1-old holding dir * Add initial content blocks system * Use an automated solution for index files * Reinstate NamespacedValue * Define an m.notice content block type * Add an m.emote block * Add a RoomEvent base type * Consolidate repetitive block testing * Optimize test imports * Flag the AjvContainer class just in case * Flip naming of wire modules * Format jest config because why not * Remove v1-old as it gets in the way of refactoring * Add support for message events * Use lazily loaded values for performance * Add notice and emote events Unfortunately this doesn't use class inheritance because TypeScript only allows one constructor at a time (making it hard to extend MessageEvent). Ultimately, we kinda want consumers to handle these events as special though, so maybe it's fine? * Test to ensure larger/more verbose content bodies work * Add an event parser * Expose wire types in index by not using d.ts files * Test for events being state events when they shouldn't be (and inverse) * Update the README to match new layout; expose AjvContainer * De-flag the AjvContainer in docs
1 parent 77d01f2 commit c0c8273

File tree

80 files changed

+4505
-4480
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+4505
-4480
lines changed

.ctiignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"src/events/EventParser.ts": ["addInternalKnownEventParser", "addInternalUnknownEventParser", "InternalOrderCategorization"],
3+
"**/*.d.ts": "*",
4+
"test/**": "*"
5+
}

.husky/pre-commit

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
#!/usr/bin/env sh
22
. "$(dirname -- "$0")/_/husky.sh"
33

4-
yarn format
4+
yarn idx && yarn format

README.md

+142-28
Original file line numberDiff line numberDiff line change
@@ -14,56 +14,170 @@ architecture is up to the task of handling proper extensible events.
1414
## Usage: Parsing events
1515

1616
```typescript
17-
const parsed = ExtensibleEvents.parse({
18-
type: "m.room.message",
17+
const parser = new EventParser();
18+
const parsed = parser.parse({
19+
type: "org.matrix.msc1767.message",
1920
content: {
20-
"msgtype": "m.text",
21-
"body": "Hello world!"
21+
"org.matrix.msc1767.markup": [
22+
{ "body": "this is my message text" },
23+
],
2224
},
23-
// and other fields
24-
}) as MessageEvent;
25+
// and other required fields
26+
});
2527

26-
// Using instanceof can be unsafe in some cases, but casting the
27-
// response in TypeScript (as above) should be safe given this
28-
// if statement will block non-message types anyhow.
29-
if (parsed?.isEquivalentTo(M_MESSAGE)) {
28+
if (parsed instanceof MessageEvent) {
3029
console.log(parsed.text);
3130
}
3231
```
3332

34-
*Note*: `instanceof` isn't considered safe for projects which might be running multiple copies
35-
of the library, such as in clients which have layers needing access to the events-sdk individually.
33+
It is recommended to cache your `EventParser` instance for performance reasons, and for ease of use
34+
when adding custom events.
3635

37-
If you would like to register your own handling of events, use the following:
36+
Registering your own events is easy, and we recommend creating your own block objects for handling the
37+
contents of events:
3838

3939
```typescript
40-
type MyContent = M_MESSAGE_EVENT_CONTENT & {
41-
field: string;
42-
};
40+
// There are a number of built-in block types for simple primitives
41+
// BooleanBlock, IntegerBlock, StringBlock
4342

44-
class MyEvent extends MessageEvent {
45-
public readonly field: string;
43+
// For object-based blocks, the following can be used:
44+
type MyObjectBlockWireType = {
45+
my_property: string; // or whatever your block's properties are on the wire
46+
};
4647

47-
constructor(wireFormat: IPartialEvent<MyContent>) {
48-
// Parse the text bit of the event
49-
super(wireFormat);
48+
class MyObjectBlock extends ObjectBlock<MyObjectBlockWireType> {
49+
public static readonly schema: Schema = {
50+
// This is a JSON Schema
51+
type: "object",
52+
properties: {
53+
my_property: {
54+
type: "string",
55+
nullable: false,
56+
},
57+
},
58+
required: ["my_property"],
59+
errorMessage: {
60+
properties: {
61+
my_property: "my_property should be a non-null string and is required",
62+
},
63+
},
64+
};
65+
66+
public static readonly validateFn = AjvContainer.ajv.compile(MyObjectBlock.schema);
67+
68+
public static readonly type = new UnstableValue(null, "org.example.my_custom_block");
69+
70+
public constructor(raw: MyObjectBlockWireType) {
71+
super(MyObjectBlock.type.name, raw);
72+
if (!MyObjectBlock.validateFn(raw)) {
73+
throw new InvalidBlockError(this.name, MyObjectBlock.validateFn.errors);
74+
}
75+
}
76+
}
5077

51-
this.field = wireFormat.content?.field;
78+
// For array-based blocks, we define the contents (items) slightly differently:
79+
type MyArrayItemWireType = {
80+
my_property: string; // or whatever
81+
}; // your item type can also be a primitive, like integers, booleans, and strings.
82+
83+
class MyArrayBlock extends ArrayBlock<MyArrayItemWireType> {
84+
public static readonly schema = ArrayBlock.schema;
85+
public static readonly validateFn = ArrayBlock.validateFn;
86+
87+
public static readonly itemSchema: Schema = {
88+
// This is a JSON Schema
89+
type: "object",
90+
properties: {
91+
my_property: {
92+
type: "string",
93+
nullable: false,
94+
},
95+
},
96+
required: ["my_property"],
97+
errorMessage: {
98+
properties: {
99+
my_property: "my_property should be a non-null string and is required",
100+
},
101+
},
102+
};
103+
public static readonly itemValidateFn = AjvContainer.ajv.compile(MyArrayBlock.itemSchema);
104+
105+
public static readonly type = new UnstableValue(null, "org.example.my_custom_block");
106+
107+
public constructor(raw: MyArrayItemWireType[]) {
108+
super(MyArrayBlock.type.name, raw);
109+
this.raw = raw.filter(x => {
110+
const bool = MyArrayBlock.itemValidateFn(x);
111+
if (!bool) {
112+
// Do something with the error. It might be valid to throw, as we do here, or
113+
// use `.filter()`'s ability to exclude items from the final array.
114+
throw new InvalidBlockError(this.name, MyArrayBlock.itemValidateFn.errors);
115+
}
116+
return bool;
117+
});
52118
}
53119
}
120+
```
54121

55-
function parseMyEvent(wireEvent: IPartialEvent<MyContent>): Optional<MyEvent> {
56-
// If you need to convert a legacy format, this is where you'd do it. Your
57-
// event class should be able to be instatiated outside of this parse function.
58-
return new MyEvent(wireEvent);
122+
Then, we can define a custom event:
123+
124+
```typescript
125+
type MyWireContent = EitherAnd<
126+
{ [MyObjectBlock.type.name]: MyObjectBlockWireType },
127+
{ [MyObjectBlock.type.altName]: MyObjectBlockWireType }
128+
>;
129+
130+
class MyCustomEvent extends RoomEvent<MyWireContent> {
131+
public static readonly contentSchema: Schema = AjvContainer.eitherAnd(MyObjectBlock.type, MyObjectBlock.schema);
132+
public static readonly contentValidateFn = AjvContainer.ajv.compile(MyCustomEvent.contentSchema);
133+
134+
public static readonly type = new UnstableValue(null, "org.example.my_custom_event");
135+
136+
public constructor(raw: WireEvent.RoomEvent<MyWireContent>) {
137+
super(MyCustomEvent.type.name, raw, false); // see docs
138+
if (!MyCustomEvent.contentValidateFn(this.content)) {
139+
throw new InvalidEventError(this.name, MyCustomEvent.contentValidateFn.errors);
140+
}
141+
}
59142
}
143+
```
144+
145+
and finally we can register it in a parser instance:
60146

61-
ExtensibleEvents.registerInterpreter("org.example.my_event_type", parseMyEvent);
62-
ExtensibleEvents.unknownInterpretOrder.push("org.example.my_event_type");
147+
```typescript
148+
const parser = new EventParser();
149+
parser.addKnownType(MyCustomEvent.type, x => new MyCustomEvent(x));
63150
```
64151

152+
If you'd also like to register an "unknown event type" handler, that can be done like so:
153+
154+
```typescript
155+
const myParser: UnknownEventParser<MyWireContent> = x => {
156+
const possibleBlock = MyObjectBlock.type.findIn(x.content);
157+
if (!!possibleBlock) {
158+
const block = new MyObjectBlock(possibleBlock as MyObjectBlockWireType);
159+
return new MyCustomEvent({
160+
...x,
161+
type: MyCustomEvent.type.name, // required - override the event type
162+
content: {
163+
[MyObjectBlock.name]: block.raw,
164+
}, // technically optional, but good practice: clean up the event's content for handling.
165+
});
166+
}
167+
return undefined; // else, we don't care about it
168+
};
169+
parser.setUnknownParsers([myParser, ...parser.defaultUnknownEventParsers]);
170+
```
171+
172+
Putting your parser at the start of the array will ensure it gets called first. Including the default parsers
173+
is also optional, though recommended.
174+
65175
## Usage: Making events
66176

177+
<!-- ------------------------- -->
178+
***TODO: This needs refactoring***
179+
<!-- ------------------------- -->
180+
67181
Most event objects have a `from` static function which takes common details of an event
68182
and returns an instance of that event for later serialization.
69183

jest.config.js

+11-11
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
22
module.exports = {
3-
preset: 'ts-jest',
4-
testEnvironment: 'node',
5-
collectCoverage: true,
6-
collectCoverageFrom: ["./src/**"],
7-
coverageThreshold: {
8-
global: {
9-
lines: 100,
10-
branches: 100,
11-
functions: 100,
12-
statements: -1,
3+
preset: "ts-jest",
4+
testEnvironment: "node",
5+
collectCoverage: true,
6+
collectCoverageFrom: ["./src/**"],
7+
coverageThreshold: {
8+
global: {
9+
lines: 100,
10+
branches: 100,
11+
functions: 100,
12+
statements: -1,
13+
},
1314
},
14-
},
1515
};

package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"prepare": "husky install",
1919
"prepublishOnly": "yarn build",
2020
"clean": "rimraf lib",
21-
"build": "yarn clean && tsc -p tsconfig.build.json",
21+
"idx": "ctix single -p tsconfig.build.json --startAt src --output src/index.ts --overwrite --noBackup --useComment && yarn format",
22+
"build": "yarn clean && yarn idx && tsc -p tsconfig.build.json",
2223
"start": "tsc -p tsconfig.build.json -w",
2324
"test": "jest",
2425
"format": "prettier --config .prettierrc \"{src,test}/**/*.ts\" --write",
@@ -27,11 +28,16 @@
2728
"devDependencies": {
2829
"@types/jest": "^29.2.3",
2930
"@types/node": "^16",
31+
"ctix": "^1.7.0",
3032
"husky": "^8.0.2",
3133
"jest": "^29.3.1",
3234
"prettier": "^2.8.0",
3335
"rimraf": "^3.0.2",
3436
"ts-jest": "^29.0.3",
3537
"typescript": "^4.9.3"
38+
},
39+
"dependencies": {
40+
"ajv": "^8.11.2",
41+
"ajv-errors": "^3.0.0"
3642
}
3743
}

src/AjvContainer.ts

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import Ajv, {Schema, SchemaObject} from "ajv";
18+
import AjvErrors from "ajv-errors";
19+
import {NamespacedValue} from "./NamespacedValue";
20+
21+
/**
22+
* Container for the ajv instance, the SDK's schema validator of choice.
23+
*/
24+
export class AjvContainer {
25+
public static readonly ajv = new Ajv({
26+
allErrors: true,
27+
});
28+
29+
static {
30+
AjvErrors(AjvContainer.ajv);
31+
}
32+
33+
/* istanbul ignore next */
34+
// noinspection JSUnusedLocalSymbols
35+
private constructor() {}
36+
37+
/**
38+
* Creates a JSON Schema representation of the EitherAnd<> TypeScript type.
39+
* @param ns The namespace to use in the EitherAnd<> type.
40+
* @param schema The schema to use as a value type for the namespace options.
41+
* @returns The EitherAnd<> type as a JSON Schema.
42+
*/
43+
public static eitherAnd<S extends string = string, U extends string = string>(
44+
ns: NamespacedValue<S, U>,
45+
schema: Schema,
46+
): {anyOf: SchemaObject[]; errorMessage: string} {
47+
// Dev note: ajv currently doesn't have a useful type for this stuff, but ideally it'd be smart enough to
48+
// have an "anyOf" type we can return.
49+
// Also note that we don't use oneOf: we manually construct it through a Type A, or Type B, or Type A+B list.
50+
if (!ns.altName) {
51+
throw new Error("Cannot create an EitherAnd<> JSON schema type without both stable and unstable values");
52+
}
53+
return {
54+
errorMessage: `schema does not apply to ${ns.stable} or ${ns.unstable}`,
55+
anyOf: [
56+
{
57+
type: "object",
58+
properties: {
59+
[ns.name]: schema,
60+
},
61+
required: [ns.name],
62+
errorMessage: {
63+
properties: {
64+
[ns.name]: `${ns.name} is required`,
65+
},
66+
},
67+
},
68+
{
69+
type: "object",
70+
properties: {
71+
[ns.altName]: schema,
72+
},
73+
required: [ns.altName],
74+
errorMessage: {
75+
properties: {
76+
[ns.altName]: `${ns.altName} is required`,
77+
},
78+
},
79+
},
80+
{
81+
type: "object",
82+
properties: {
83+
[ns.name]: schema,
84+
[ns.altName]: schema,
85+
},
86+
required: [ns.name, ns.altName],
87+
errorMessage: {
88+
properties: {
89+
[ns.name]: `${ns.name} is required`,
90+
[ns.altName]: `${ns.altName} is required`,
91+
},
92+
},
93+
},
94+
],
95+
};
96+
}
97+
}

0 commit comments

Comments
 (0)