aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/persistence
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/frontend/src/persistence')
-rw-r--r--subprojects/frontend/src/persistence/Compressor.ts108
-rw-r--r--subprojects/frontend/src/persistence/compressionMessages.tsx56
-rw-r--r--subprojects/frontend/src/persistence/compressionWorker.ts98
-rw-r--r--subprojects/frontend/src/persistence/initialValue.ts103
4 files changed, 365 insertions, 0 deletions
diff --git a/subprojects/frontend/src/persistence/Compressor.ts b/subprojects/frontend/src/persistence/Compressor.ts
new file mode 100644
index 00000000..94878370
--- /dev/null
+++ b/subprojects/frontend/src/persistence/Compressor.ts
@@ -0,0 +1,108 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import getLogger from '../utils/getLogger';
8
9import {
10 type CompressRequest,
11 CompressorResponse,
12 type DecompressRequest,
13} from './compressionMessages';
14import CompressionWorker from './compressionWorker?worker';
15import initialValue from './initialValue';
16
17const LOG = getLogger('persistence.Compressor');
18
19const FRAGMENT_PREFIX = '#/1/';
20
21export default class Compressor {
22 private readonly worker = new CompressionWorker();
23
24 private readonly hashChangeHandler = () => this.updateHash();
25
26 private fragment: string | undefined;
27
28 private compressing = false;
29
30 private toCompress: string | undefined;
31
32 constructor(private readonly onDecompressed: (text: string) => void) {
33 this.worker.onerror = (error) => LOG.error('Worker error', error);
34 this.worker.onmessageerror = (error) =>
35 LOG.error('Worker message error', error);
36 this.worker.onmessage = (event) => {
37 try {
38 const message = CompressorResponse.parse(event.data);
39 switch (message.response) {
40 case 'compressed':
41 this.fragment = `${FRAGMENT_PREFIX}${message.compressedText}`;
42 this.compressionEnded();
43 window.history.replaceState(null, '', this.fragment);
44 break;
45 case 'decompressed':
46 this.onDecompressed(message.text);
47 break;
48 case 'error':
49 this.compressionEnded();
50 LOG.error('Error processing compressor request', message.message);
51 break;
52 default:
53 LOG.error('Unknown response from compressor worker', event.data);
54 break;
55 }
56 } catch (error) {
57 LOG.error('Error processing worker message', event, error);
58 }
59 };
60 window.addEventListener('hashchange', this.hashChangeHandler);
61 }
62
63 decompressInitial(): void {
64 this.updateHash();
65 if (this.fragment === undefined) {
66 LOG.debug('Loading default source');
67 this.onDecompressed(initialValue);
68 }
69 }
70
71 compress(text: string): void {
72 this.toCompress = text;
73 if (this.compressing) {
74 return;
75 }
76 this.compressing = true;
77 this.worker.postMessage({
78 request: 'compress',
79 text,
80 } satisfies CompressRequest);
81 }
82
83 dispose(): void {
84 window.removeEventListener('hashchange', this.hashChangeHandler);
85 this.worker.terminate();
86 }
87
88 private compressionEnded(): void {
89 this.compressing = false;
90 if (this.toCompress !== undefined) {
91 this.compress(this.toCompress);
92 this.toCompress = undefined;
93 }
94 }
95
96 private updateHash(): void {
97 if (
98 window.location.hash !== this.fragment &&
99 window.location.hash.startsWith(FRAGMENT_PREFIX)
100 ) {
101 this.fragment = window.location.hash;
102 this.worker.postMessage({
103 request: 'decompress',
104 compressedText: this.fragment.substring(FRAGMENT_PREFIX.length),
105 } satisfies DecompressRequest);
106 }
107 }
108}
diff --git a/subprojects/frontend/src/persistence/compressionMessages.tsx b/subprojects/frontend/src/persistence/compressionMessages.tsx
new file mode 100644
index 00000000..37c6e8cd
--- /dev/null
+++ b/subprojects/frontend/src/persistence/compressionMessages.tsx
@@ -0,0 +1,56 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import { z } from 'zod';
8
9/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */
10
11export const CompressRequest = z.object({
12 request: z.literal('compress'),
13 text: z.string(),
14});
15
16export type CompressRequest = z.infer<typeof CompressRequest>;
17
18export const DecompressRequest = z.object({
19 request: z.literal('decompress'),
20 compressedText: z.string(),
21});
22
23export type DecompressRequest = z.infer<typeof DecompressRequest>;
24
25export const CompressorRequest = z.union([CompressRequest, DecompressRequest]);
26
27export type CompressorRequest = z.infer<typeof CompressorRequest>;
28
29export const CompressResponse = z.object({
30 response: z.literal('compressed'),
31 compressedText: z.string(),
32});
33
34export type CompressResponse = z.infer<typeof CompressResponse>;
35
36export const DecompressResponse = z.object({
37 response: z.literal('decompressed'),
38 text: z.string(),
39});
40
41export type DecompressResponse = z.infer<typeof DecompressResponse>;
42
43export const ErrorResponse = z.object({
44 response: z.literal('error'),
45 message: z.string(),
46});
47
48export type ErrorResponse = z.infer<typeof ErrorResponse>;
49
50export const CompressorResponse = z.union([
51 CompressResponse,
52 DecompressResponse,
53 ErrorResponse,
54]);
55
56export type CompressorResponse = z.infer<typeof CompressorResponse>;
diff --git a/subprojects/frontend/src/persistence/compressionWorker.ts b/subprojects/frontend/src/persistence/compressionWorker.ts
new file mode 100644
index 00000000..7b93b20b
--- /dev/null
+++ b/subprojects/frontend/src/persistence/compressionWorker.ts
@@ -0,0 +1,98 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7import type { Zstd } from '@hpcc-js/wasm';
8// We need to use a deep import for proper code splitting with `vite-plugin-pwa`.
9// @ts-expect-error Typescript doesn't find the declarations for the deep import.
10import { Zstd as zstdLoader } from '@hpcc-js/wasm/zstd';
11
12import type {
13 CompressResponse,
14 CompressorRequest,
15 DecompressResponse,
16 ErrorResponse,
17} from './compressionMessages';
18
19const CONTENT_TYPE = 'application/octet-stream';
20
21const URI_PREFIX = `data:${CONTENT_TYPE};base64,`;
22
23async function base64Encode(buffer: Uint8Array): Promise<string> {
24 const uri = await new Promise((resolve, reject) => {
25 const reader = new FileReader();
26 reader.onload = () => resolve(reader.result);
27 reader.onerror = () => reject(reader.error);
28 reader.readAsDataURL(new File([buffer], '', { type: CONTENT_TYPE }));
29 });
30 if (typeof uri !== 'string') {
31 throw new Error(`Unexpected FileReader result type: ${typeof uri}`);
32 }
33 const base64 = uri.substring(URI_PREFIX.length);
34 return base64.replace(/[+/]/g, (c) => {
35 if (c === '+') {
36 return '-';
37 }
38 if (c === '/') {
39 return '_';
40 }
41 return c;
42 });
43}
44
45async function base64Decode(compressedText: string): Promise<Uint8Array> {
46 const base64 = compressedText.replace(/[-_]/g, (c) => {
47 if (c === '-') {
48 return '+';
49 }
50 if (c === '_') {
51 return '/';
52 }
53 return c;
54 });
55 const result = await fetch(`${URI_PREFIX}${base64}`);
56 return new Uint8Array(await result.arrayBuffer());
57}
58
59let zstd: Awaited<ReturnType<typeof Zstd.load>> | undefined;
60
61globalThis.onmessage = (event) => {
62 (async () => {
63 if (zstd === undefined) {
64 // Since we don't have types for the deep import, we have to cast here.
65 zstd = await (zstdLoader as { load: typeof Zstd.load }).load();
66 }
67 // Since the render thread will only send us valid messages,
68 // we can save a bit of bundle size by using a cast instead of `parse`
69 // to avoid having to include `zod` in the worker.
70 const message = event.data as CompressorRequest;
71 if (message.request === 'compress') {
72 const encoder = new TextEncoder();
73 const encodedBuffer = encoder.encode(message.text);
74 const compressedBuffer = zstd.compress(encodedBuffer, 3);
75 const compressedText = await base64Encode(compressedBuffer);
76 globalThis.postMessage({
77 response: 'compressed',
78 compressedText,
79 } satisfies CompressResponse);
80 } else if (message.request === 'decompress') {
81 const decodedBuffer = await base64Decode(message.compressedText);
82 const uncompressedBuffer = zstd.decompress(decodedBuffer);
83 const decoder = new TextDecoder();
84 const text = decoder.decode(uncompressedBuffer);
85 globalThis.postMessage({
86 response: 'decompressed',
87 text,
88 } satisfies DecompressResponse);
89 } else {
90 throw new Error(`Unknown request: ${JSON.stringify(event.data)}`);
91 }
92 })().catch((error) => {
93 globalThis.postMessage({
94 response: 'error',
95 message: String(error),
96 } satisfies ErrorResponse);
97 });
98};
diff --git a/subprojects/frontend/src/persistence/initialValue.ts b/subprojects/frontend/src/persistence/initialValue.ts
new file mode 100644
index 00000000..25b24813
--- /dev/null
+++ b/subprojects/frontend/src/persistence/initialValue.ts
@@ -0,0 +1,103 @@
1/*
2 * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/>
3 *
4 * SPDX-License-Identifier: EPL-2.0
5 */
6
7export default `% Metamodel
8
9abstract class CompositeElement {
10 contains Region[] regions
11}
12
13class Region {
14 contains Vertex[] vertices opposite region
15}
16
17abstract class Vertex {
18 container Region region opposite vertices
19 contains Transition[] outgoingTransition opposite source
20 Transition[] incomingTransition opposite target
21}
22
23class Transition {
24 container Vertex source opposite outgoingTransition
25 Vertex[1] target opposite incomingTransition
26}
27
28abstract class Pseudostate extends Vertex.
29
30abstract class RegularState extends Vertex.
31
32class Entry extends Pseudostate.
33
34class Exit extends Pseudostate.
35
36class Choice extends Pseudostate.
37
38class FinalState extends RegularState.
39
40class State extends RegularState, CompositeElement.
41
42class Statechart extends CompositeElement.
43
44% Constraints
45
46%% Entry
47
48pred entryInRegion(Region r, Entry e) <->
49 vertices(r, e).
50
51error noEntryInRegion(Region r) <->
52 !entryInRegion(r, _).
53
54error multipleEntryInRegion(Region r) <->
55 entryInRegion(r, e1),
56 entryInRegion(r, e2),
57 e1 != e2.
58
59error incomingToEntry(Transition t, Entry e) <->
60 target(t, e).
61
62error noOutgoingTransitionFromEntry(Entry e) <->
63 !source(_, e).
64
65error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <->
66 outgoingTransition(e, t1),
67 outgoingTransition(e, t2),
68 t1 != t2.
69
70%% Exit
71
72error outgoingFromExit(Transition t, Exit e) <->
73 source(t, e).
74
75%% Final
76
77error outgoingFromFinal(Transition t, FinalState e) <->
78 source(t, e).
79
80%% State vs Region
81
82pred stateInRegion(Region r, State s) <->
83 vertices(r, s).
84
85error noStateInRegion(Region r) <->
86 !stateInRegion(r, _).
87
88%% Choice
89
90error choiceHasNoOutgoing(Choice c) <->
91 !source(_, c).
92
93error choiceHasNoIncoming(Choice c) <->
94 !target(_, c).
95
96% Instance model
97
98Statechart(sct).
99
100% Scope
101
102scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0.
103`;