@kokimoki/app 0.0.0

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/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@kokimoki/app",
3
+ "version": "0.0.0",
4
+ "description": "Kokimoki app",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "build": "tsc",
9
+ "dev": "tsc -w"
10
+ },
11
+ "author": "Loquiz OÜ",
12
+ "license": "Apache-2.0",
13
+ "devDependencies": {
14
+ "typescript": "^5.1.6"
15
+ },
16
+ "dependencies": {
17
+ "@hocuspocus/provider": "^2.2.3",
18
+ "@syncedstore/core": "^0.5.2",
19
+ "events": "^3.3.0",
20
+ "typed-emitter": "^2.1.0"
21
+ }
22
+ }
package/src/fields.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { Task } from "./tasks";
2
+
3
+ export interface FieldOptions {
4
+ label?: string;
5
+ optional: boolean;
6
+ }
7
+
8
+ const defaultFieldOptions: FieldOptions = {
9
+ optional: false,
10
+ };
11
+
12
+ export abstract class Field<T> {
13
+ constructor(public readonly options: FieldOptions) {}
14
+
15
+ abstract get value(): T;
16
+ abstract get schema(): any;
17
+ }
18
+
19
+ export class ImageField extends Field<string> {
20
+ constructor(
21
+ public value: string,
22
+ options: FieldOptions = defaultFieldOptions
23
+ ) {
24
+ super(options);
25
+ }
26
+
27
+ get schema() {
28
+ return {
29
+ type: "string",
30
+ default: this.value,
31
+ };
32
+ }
33
+ }
34
+
35
+ export class TextField extends Field<string> {
36
+ constructor(
37
+ public value: string,
38
+ options: FieldOptions = defaultFieldOptions
39
+ ) {
40
+ super(options);
41
+ }
42
+
43
+ get schema() {
44
+ return {
45
+ type: "string",
46
+ default: this.value,
47
+ };
48
+ }
49
+ }
50
+
51
+ export class IntegerField extends Field<number> {
52
+ constructor(
53
+ public value: number,
54
+ options: FieldOptions = defaultFieldOptions
55
+ ) {
56
+ super(options);
57
+ }
58
+
59
+ get schema() {
60
+ return {
61
+ type: "integer",
62
+ default: this.value,
63
+ };
64
+ }
65
+ }
66
+
67
+ export class FormGroup<T extends Record<string, any>> extends Field<{
68
+ [key in keyof T]: Field<T>["value"];
69
+ }> {
70
+ constructor(public fields: T, options: FieldOptions = defaultFieldOptions) {
71
+ super(options);
72
+ }
73
+
74
+ get value(): { [key in keyof T]: T[key]["value"] } {
75
+ return Object.entries(this.fields).reduce((acc, [key, field]) => {
76
+ acc[key] = field.value;
77
+ return acc;
78
+ }, {} as any);
79
+ }
80
+
81
+ get schema() {
82
+ return {
83
+ type: "object",
84
+ properties: Object.entries(this.fields).reduce((acc, [key, field]) => {
85
+ acc[key] = field.schema;
86
+ return acc;
87
+ }, {} as any),
88
+ };
89
+ }
90
+ }
91
+
92
+ export class FormArray<T> extends Field<T[]> {
93
+ constructor(
94
+ private factory: () => Field<T>,
95
+ public value: Field<T>["value"][],
96
+ options: FieldOptions = defaultFieldOptions
97
+ ) {
98
+ super(options);
99
+ }
100
+
101
+ get schema() {
102
+ const field = this.factory();
103
+
104
+ return {
105
+ type: "array",
106
+ items: field.schema,
107
+ };
108
+ }
109
+ }
110
+
111
+ export class TaskField extends Field<Task> {
112
+ constructor(public value: Task, options: FieldOptions = defaultFieldOptions) {
113
+ super(options);
114
+ }
115
+
116
+ get schema() {
117
+ // TODO: validate task interface, not important right now
118
+ return {
119
+ type: "object",
120
+ properties: {},
121
+ };
122
+ }
123
+ }
124
+
125
+ export class Form<T extends Record<string, Field<any>>> extends FormGroup<T> {}
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./tasks";
2
+ export * from "./fields";
3
+ export * from "./kokimoki-client";
4
+ export * from "./synced-store";
5
+ export * from "@syncedstore/core";
@@ -0,0 +1,134 @@
1
+ import { HocuspocusProvider } from "@hocuspocus/provider";
2
+ import EventEmitter from "events";
3
+ import type TypedEmitter from "typed-emitter";
4
+ import { SyncedStore } from "./synced-store";
5
+ import type { DocTypeDescription } from "@syncedstore/core/types/doc";
6
+
7
+ type KokimokiClientEvents<T> = {
8
+ stateless: (room: string, from: string, data: T) => void;
9
+ };
10
+
11
+ export class KokimokiClient<StatelessDataT = any> extends (EventEmitter as {
12
+ new <T>(): TypedEmitter<KokimokiClientEvents<T>>;
13
+ })<StatelessDataT> {
14
+ public serverTimeOffset = 0;
15
+ public wsUrl: string;
16
+ public apiUrl: string;
17
+
18
+ private _id = "";
19
+ private _token = "";
20
+ private _providers = new Map<string, HocuspocusProvider>();
21
+
22
+ constructor(host: string, public readonly appId: string) {
23
+ super();
24
+ const secure = !host.match(/^localhost/);
25
+ this.wsUrl = `ws${secure ? "s" : ""}://${host}`;
26
+ this.apiUrl = `http${secure ? "s" : ""}://${host}`;
27
+ }
28
+
29
+ get id() {
30
+ if (!this._id) {
31
+ throw new Error("Client not connected");
32
+ }
33
+
34
+ return this._id;
35
+ }
36
+
37
+ get token() {
38
+ if (!this._token) {
39
+ throw new Error("Client not connected");
40
+ }
41
+
42
+ return this._token;
43
+ }
44
+
45
+ async connect() {
46
+ let clientToken = localStorage.getItem("KM_TOKEN");
47
+
48
+ const startTime = Date.now();
49
+ const res = await fetch(`${this.apiUrl}/auth/token?appId=${this.appId}`, {
50
+ headers: clientToken
51
+ ? new Headers({ Authorization: `Bearer ${clientToken}` })
52
+ : new Headers(),
53
+ });
54
+ const body = await res.json();
55
+ const endTime = Date.now();
56
+ const ping = Math.round((endTime - startTime) / 2);
57
+
58
+ this._id = body.clientId;
59
+ this._token = body.appToken;
60
+ this.serverTimeOffset = Date.now() - body.serverTime - ping;
61
+ localStorage.setItem("KM_TOKEN", body.token);
62
+ }
63
+
64
+ async setProvider<T extends DocTypeDescription>(
65
+ name: string,
66
+ store: SyncedStore<T>
67
+ ) {
68
+ const provider = new HocuspocusProvider({
69
+ url: `${this.wsUrl}/connection`,
70
+ name: `${this.appId}/${name}`,
71
+ document: store.doc,
72
+ token: this.token,
73
+ });
74
+
75
+ this._providers.set(name, provider);
76
+
77
+ // Handle incoming stateless messages
78
+ provider.on("stateless", (e: { payload: string }) => {
79
+ const payload: { from: string; data: StatelessDataT } = JSON.parse(
80
+ e.payload
81
+ );
82
+ this.emit("stateless", name, payload.from, payload.data);
83
+ });
84
+
85
+ // Wait for initial sync
86
+ await new Promise<void>((resolve) => {
87
+ const handler = () => {
88
+ provider.off("synced", handler);
89
+ resolve();
90
+ };
91
+
92
+ provider.on("synced", handler);
93
+ });
94
+ }
95
+
96
+ removeProvider(name: string) {
97
+ const provider = this._providers.get(name);
98
+
99
+ if (!provider) {
100
+ throw new Error(`No provider for room ${name}`);
101
+ }
102
+
103
+ provider.destroy();
104
+ this._providers.delete(name);
105
+ }
106
+
107
+ getProvider(name: string) {
108
+ return this._providers.get(name);
109
+ }
110
+
111
+ serverTimestamp() {
112
+ return Date.now() - this.serverTimeOffset;
113
+ }
114
+
115
+ sendStatelessToClient(room: string, clientId: string, data: StatelessDataT) {
116
+ const provider = this._providers.get(room);
117
+
118
+ if (!provider) {
119
+ throw new Error(`No provider for room ${room}`);
120
+ }
121
+
122
+ provider.sendStateless(JSON.stringify({ to: clientId, data }));
123
+ }
124
+
125
+ sendStatelessToRoom(room: string, data: StatelessDataT, self = false) {
126
+ const provider = this._providers.get(room);
127
+
128
+ if (!provider) {
129
+ throw new Error(`No provider for room ${room}`);
130
+ }
131
+
132
+ provider.sendStateless(JSON.stringify({ data, self }));
133
+ }
134
+ }
@@ -0,0 +1,16 @@
1
+ import * as Y from "yjs";
2
+ import { syncedStore, getYjsDoc } from "@syncedstore/core";
3
+ import type {
4
+ DocTypeDescription,
5
+ MappedTypeDescription,
6
+ } from "@syncedstore/core/types/doc";
7
+
8
+ export class SyncedStore<T extends DocTypeDescription> {
9
+ public readonly data: MappedTypeDescription<T>;
10
+ public readonly doc: Y.Doc;
11
+
12
+ constructor(initialState: T) {
13
+ this.data = syncedStore(initialState);
14
+ this.doc = getYjsDoc(this.data);
15
+ }
16
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,62 @@
1
+ export interface Answer<T = any> {
2
+ value?: T;
3
+ correct: boolean;
4
+ }
5
+
6
+ export interface TaskBase {
7
+ id: string;
8
+ text: string;
9
+ points: number;
10
+ metadata?: Record<string, any>;
11
+ }
12
+
13
+ export interface TaskOption {
14
+ text: string;
15
+ correct: boolean;
16
+ }
17
+
18
+ export interface MultipleChoiceTask extends TaskBase {
19
+ type: "multiple-choice";
20
+ answers: TaskOption[];
21
+ }
22
+
23
+ export interface SingleChoiceTask extends TaskBase {
24
+ type: "single-choice";
25
+ answers: TaskOption[];
26
+ }
27
+
28
+ export interface TextTask extends TaskBase {
29
+ type: "text";
30
+ answer: string;
31
+ }
32
+
33
+ export interface TextSurveyTask extends TaskBase {
34
+ type: "text-survey";
35
+ }
36
+
37
+ export interface NumericTask extends TaskBase {
38
+ type: "numeric";
39
+ answer: number;
40
+ }
41
+
42
+ export interface PhotoTask extends TaskBase {
43
+ type: "photo";
44
+ }
45
+
46
+ export interface VideoTask extends TaskBase {
47
+ type: "video";
48
+ }
49
+
50
+ export interface InfoTask extends TaskBase {
51
+ type: "info";
52
+ }
53
+
54
+ export type Task =
55
+ | MultipleChoiceTask
56
+ | SingleChoiceTask
57
+ | TextTask
58
+ | TextSurveyTask
59
+ | NumericTask
60
+ | PhotoTask
61
+ | VideoTask
62
+ | InfoTask;
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "dist",
4
+ "esModuleInterop": true,
5
+ "declaration": true
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }