From 3ba6f8fba9dbd6e479f4297a5a05b51273e461a3 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 26 Sep 2023 03:29:51 +0200 Subject: feat(frontend): save in URL fragment --- subprojects/frontend/src/RootStore.ts | 34 ++++++- subprojects/frontend/src/editor/EditorStore.ts | 8 +- subprojects/frontend/src/index.tsx | 100 +------------------ subprojects/frontend/src/persistence/Compressor.ts | 108 +++++++++++++++++++++ .../src/persistence/compressionMessages.tsx | 56 +++++++++++ .../frontend/src/persistence/compressionWorker.ts | 98 +++++++++++++++++++ .../frontend/src/persistence/initialValue.ts | 103 ++++++++++++++++++++ subprojects/frontend/src/xtext/UpdateService.ts | 2 + subprojects/frontend/src/xtext/XtextClient.ts | 7 +- .../language/web/SecurityHeadersFilter.java | 3 +- 10 files changed, 413 insertions(+), 106 deletions(-) create mode 100644 subprojects/frontend/src/persistence/Compressor.ts create mode 100644 subprojects/frontend/src/persistence/compressionMessages.tsx create mode 100644 subprojects/frontend/src/persistence/compressionWorker.ts create mode 100644 subprojects/frontend/src/persistence/initialValue.ts diff --git a/subprojects/frontend/src/RootStore.ts b/subprojects/frontend/src/RootStore.ts index b84c0ce0..e277c808 100644 --- a/subprojects/frontend/src/RootStore.ts +++ b/subprojects/frontend/src/RootStore.ts @@ -9,11 +9,20 @@ import { makeAutoObservable, runInAction } from 'mobx'; import PWAStore from './PWAStore'; import type EditorStore from './editor/EditorStore'; +import Compressor from './persistence/Compressor'; import ThemeStore from './theme/ThemeStore'; const log = getLogger('RootStore'); export default class RootStore { + private readonly compressor = new Compressor((text) => + this.setInitialValue(text), + ); + + private initialValue: string | undefined; + + private editorStoreClass: typeof EditorStore | undefined; + editorStore: EditorStore | undefined; readonly pwaStore: PWAStore; @@ -22,10 +31,12 @@ export default class RootStore { disposed = false; - constructor(initialValue: string) { + constructor() { this.pwaStore = new PWAStore(); this.themeStore = new ThemeStore(); - makeAutoObservable(this, { + makeAutoObservable(this, { + compressor: false, + editorStoreClass: false, pwaStore: false, themeStore: false, }); @@ -35,11 +46,27 @@ export default class RootStore { if (this.disposed) { return; } - this.editorStore = new EditorStore(initialValue, this.pwaStore); + this.editorStoreClass = EditorStore; + if (this.initialValue !== undefined) { + this.setInitialValue(this.initialValue); + } }); })().catch((error) => { log.error('Failed to load EditorStore', error); }); + this.compressor.decompressInitial(); + } + + private setInitialValue(initialValue: string): void { + this.initialValue = initialValue; + if (this.editorStoreClass !== undefined) { + const EditorStore = this.editorStoreClass; + this.editorStore = new EditorStore( + this.initialValue, + this.pwaStore, + (text) => this.compressor.compress(text), + ); + } } dispose(): void { @@ -47,6 +74,7 @@ export default class RootStore { return; } this.editorStore?.dispose(); + this.compressor.dispose(); this.disposed = true; } } diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index 9508858d..87c4040e 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -74,7 +74,11 @@ export default class EditorStore { selectedGeneratedModel: string | undefined; - constructor(initialValue: string, pwaStore: PWAStore) { + constructor( + initialValue: string, + pwaStore: PWAStore, + onUpdate: (text: string) => void, + ) { this.id = nanoid(); this.state = createEditorState(initialValue, this); this.delayedErrors = new EditorErrors(this); @@ -86,7 +90,7 @@ export default class EditorStore { if (this.disposed) { return; } - this.client = new LazyXtextClient(this, pwaStore); + this.client = new LazyXtextClient(this, pwaStore, onUpdate); this.client.start(); }); })().catch((error) => { diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx index 60debd6b..3fd25e5c 100644 --- a/subprojects/frontend/src/index.tsx +++ b/subprojects/frontend/src/index.tsx @@ -15,104 +15,6 @@ import RootStore from './RootStore'; // https://github.com/mui/material-ui/issues/32727#issuecomment-1659945548 (window as unknown as { fixViteIssue: unknown }).fixViteIssue = styled; -const initialValue = `% Metamodel - -abstract class CompositeElement { - contains Region[] regions -} - -class Region { - contains Vertex[] vertices opposite region -} - -abstract class Vertex { - container Region region opposite vertices - contains Transition[] outgoingTransition opposite source - Transition[] incomingTransition opposite target -} - -class Transition { - container Vertex source opposite outgoingTransition - Vertex[1] target opposite incomingTransition -} - -abstract class Pseudostate extends Vertex. - -abstract class RegularState extends Vertex. - -class Entry extends Pseudostate. - -class Exit extends Pseudostate. - -class Choice extends Pseudostate. - -class FinalState extends RegularState. - -class State extends RegularState, CompositeElement. - -class Statechart extends CompositeElement. - -% Constraints - -%% Entry - -pred entryInRegion(Region r, Entry e) <-> - vertices(r, e). - -error noEntryInRegion(Region r) <-> - !entryInRegion(r, _). - -error multipleEntryInRegion(Region r) <-> - entryInRegion(r, e1), - entryInRegion(r, e2), - e1 != e2. - -error incomingToEntry(Transition t, Entry e) <-> - target(t, e). - -error noOutgoingTransitionFromEntry(Entry e) <-> - !source(_, e). - -error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <-> - outgoingTransition(e, t1), - outgoingTransition(e, t2), - t1 != t2. - -%% Exit - -error outgoingFromExit(Transition t, Exit e) <-> - source(t, e). - -%% Final - -error outgoingFromFinal(Transition t, FinalState e) <-> - source(t, e). - -%% State vs Region - -pred stateInRegion(Region r, State s) <-> - vertices(r, s). - -error noStateInRegion(Region r) <-> - !stateInRegion(r, _). - -%% Choice - -error choiceHasNoOutgoing(Choice c) <-> - !source(_, c). - -error choiceHasNoIncoming(Choice c) <-> - !target(_, c). - -% Instance model - -Statechart(sct). - -% Scope - -scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0. -`; - configure({ enforceActions: 'always', }); @@ -121,7 +23,7 @@ let HotRootStore = RootStore; let HotApp = App; function createStore(): RootStore { - return new HotRootStore(initialValue); + return new HotRootStore(); } let rootStore = createStore(); 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import getLogger from '../utils/getLogger'; + +import { + type CompressRequest, + CompressorResponse, + type DecompressRequest, +} from './compressionMessages'; +import CompressionWorker from './compressionWorker?worker'; +import initialValue from './initialValue'; + +const LOG = getLogger('persistence.Compressor'); + +const FRAGMENT_PREFIX = '#/1/'; + +export default class Compressor { + private readonly worker = new CompressionWorker(); + + private readonly hashChangeHandler = () => this.updateHash(); + + private fragment: string | undefined; + + private compressing = false; + + private toCompress: string | undefined; + + constructor(private readonly onDecompressed: (text: string) => void) { + this.worker.onerror = (error) => LOG.error('Worker error', error); + this.worker.onmessageerror = (error) => + LOG.error('Worker message error', error); + this.worker.onmessage = (event) => { + try { + const message = CompressorResponse.parse(event.data); + switch (message.response) { + case 'compressed': + this.fragment = `${FRAGMENT_PREFIX}${message.compressedText}`; + this.compressionEnded(); + window.history.replaceState(null, '', this.fragment); + break; + case 'decompressed': + this.onDecompressed(message.text); + break; + case 'error': + this.compressionEnded(); + LOG.error('Error processing compressor request', message.message); + break; + default: + LOG.error('Unknown response from compressor worker', event.data); + break; + } + } catch (error) { + LOG.error('Error processing worker message', event, error); + } + }; + window.addEventListener('hashchange', this.hashChangeHandler); + } + + decompressInitial(): void { + this.updateHash(); + if (this.fragment === undefined) { + LOG.debug('Loading default source'); + this.onDecompressed(initialValue); + } + } + + compress(text: string): void { + this.toCompress = text; + if (this.compressing) { + return; + } + this.compressing = true; + this.worker.postMessage({ + request: 'compress', + text, + } satisfies CompressRequest); + } + + dispose(): void { + window.removeEventListener('hashchange', this.hashChangeHandler); + this.worker.terminate(); + } + + private compressionEnded(): void { + this.compressing = false; + if (this.toCompress !== undefined) { + this.compress(this.toCompress); + this.toCompress = undefined; + } + } + + private updateHash(): void { + if ( + window.location.hash !== this.fragment && + window.location.hash.startsWith(FRAGMENT_PREFIX) + ) { + this.fragment = window.location.hash; + this.worker.postMessage({ + request: 'decompress', + compressedText: this.fragment.substring(FRAGMENT_PREFIX.length), + } satisfies DecompressRequest); + } + } +} 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { z } from 'zod'; + +/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */ + +export const CompressRequest = z.object({ + request: z.literal('compress'), + text: z.string(), +}); + +export type CompressRequest = z.infer; + +export const DecompressRequest = z.object({ + request: z.literal('decompress'), + compressedText: z.string(), +}); + +export type DecompressRequest = z.infer; + +export const CompressorRequest = z.union([CompressRequest, DecompressRequest]); + +export type CompressorRequest = z.infer; + +export const CompressResponse = z.object({ + response: z.literal('compressed'), + compressedText: z.string(), +}); + +export type CompressResponse = z.infer; + +export const DecompressResponse = z.object({ + response: z.literal('decompressed'), + text: z.string(), +}); + +export type DecompressResponse = z.infer; + +export const ErrorResponse = z.object({ + response: z.literal('error'), + message: z.string(), +}); + +export type ErrorResponse = z.infer; + +export const CompressorResponse = z.union([ + CompressResponse, + DecompressResponse, + ErrorResponse, +]); + +export type CompressorResponse = z.infer; 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import type { Zstd } from '@hpcc-js/wasm'; +// We need to use a deep import for proper code splitting with `vite-plugin-pwa`. +// @ts-expect-error Typescript doesn't find the declarations for the deep import. +import { Zstd as zstdLoader } from '@hpcc-js/wasm/zstd'; + +import type { + CompressResponse, + CompressorRequest, + DecompressResponse, + ErrorResponse, +} from './compressionMessages'; + +const CONTENT_TYPE = 'application/octet-stream'; + +const URI_PREFIX = `data:${CONTENT_TYPE};base64,`; + +async function base64Encode(buffer: Uint8Array): Promise { + const uri = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(new File([buffer], '', { type: CONTENT_TYPE })); + }); + if (typeof uri !== 'string') { + throw new Error(`Unexpected FileReader result type: ${typeof uri}`); + } + const base64 = uri.substring(URI_PREFIX.length); + return base64.replace(/[+/]/g, (c) => { + if (c === '+') { + return '-'; + } + if (c === '/') { + return '_'; + } + return c; + }); +} + +async function base64Decode(compressedText: string): Promise { + const base64 = compressedText.replace(/[-_]/g, (c) => { + if (c === '-') { + return '+'; + } + if (c === '_') { + return '/'; + } + return c; + }); + const result = await fetch(`${URI_PREFIX}${base64}`); + return new Uint8Array(await result.arrayBuffer()); +} + +let zstd: Awaited> | undefined; + +globalThis.onmessage = (event) => { + (async () => { + if (zstd === undefined) { + // Since we don't have types for the deep import, we have to cast here. + zstd = await (zstdLoader as { load: typeof Zstd.load }).load(); + } + // Since the render thread will only send us valid messages, + // we can save a bit of bundle size by using a cast instead of `parse` + // to avoid having to include `zod` in the worker. + const message = event.data as CompressorRequest; + if (message.request === 'compress') { + const encoder = new TextEncoder(); + const encodedBuffer = encoder.encode(message.text); + const compressedBuffer = zstd.compress(encodedBuffer, 3); + const compressedText = await base64Encode(compressedBuffer); + globalThis.postMessage({ + response: 'compressed', + compressedText, + } satisfies CompressResponse); + } else if (message.request === 'decompress') { + const decodedBuffer = await base64Decode(message.compressedText); + const uncompressedBuffer = zstd.decompress(decodedBuffer); + const decoder = new TextDecoder(); + const text = decoder.decode(uncompressedBuffer); + globalThis.postMessage({ + response: 'decompressed', + text, + } satisfies DecompressResponse); + } else { + throw new Error(`Unknown request: ${JSON.stringify(event.data)}`); + } + })().catch((error) => { + globalThis.postMessage({ + response: 'error', + message: String(error), + } satisfies ErrorResponse); + }); +}; 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 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors + * + * SPDX-License-Identifier: EPL-2.0 + */ + +export default `% Metamodel + +abstract class CompositeElement { + contains Region[] regions +} + +class Region { + contains Vertex[] vertices opposite region +} + +abstract class Vertex { + container Region region opposite vertices + contains Transition[] outgoingTransition opposite source + Transition[] incomingTransition opposite target +} + +class Transition { + container Vertex source opposite outgoingTransition + Vertex[1] target opposite incomingTransition +} + +abstract class Pseudostate extends Vertex. + +abstract class RegularState extends Vertex. + +class Entry extends Pseudostate. + +class Exit extends Pseudostate. + +class Choice extends Pseudostate. + +class FinalState extends RegularState. + +class State extends RegularState, CompositeElement. + +class Statechart extends CompositeElement. + +% Constraints + +%% Entry + +pred entryInRegion(Region r, Entry e) <-> + vertices(r, e). + +error noEntryInRegion(Region r) <-> + !entryInRegion(r, _). + +error multipleEntryInRegion(Region r) <-> + entryInRegion(r, e1), + entryInRegion(r, e2), + e1 != e2. + +error incomingToEntry(Transition t, Entry e) <-> + target(t, e). + +error noOutgoingTransitionFromEntry(Entry e) <-> + !source(_, e). + +error multipleTransitionFromEntry(Entry e, Transition t1, Transition t2) <-> + outgoingTransition(e, t1), + outgoingTransition(e, t2), + t1 != t2. + +%% Exit + +error outgoingFromExit(Transition t, Exit e) <-> + source(t, e). + +%% Final + +error outgoingFromFinal(Transition t, FinalState e) <-> + source(t, e). + +%% State vs Region + +pred stateInRegion(Region r, State s) <-> + vertices(r, s). + +error noStateInRegion(Region r) <-> + !stateInRegion(r, _). + +%% Choice + +error choiceHasNoOutgoing(Choice c) <-> + !source(_, c). + +error choiceHasNoIncoming(Choice c) <-> + !target(_, c). + +% Instance model + +Statechart(sct). + +% Scope + +scope node = 20..30, Region = 2..*, Choice = 1..*, Statechart += 0. +`; diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts index 70e79764..0532f8df 100644 --- a/subprojects/frontend/src/xtext/UpdateService.ts +++ b/subprojects/frontend/src/xtext/UpdateService.ts @@ -56,6 +56,7 @@ export default class UpdateService { constructor( private readonly store: EditorStore, private readonly webSocketClient: XtextWebSocketClient, + private readonly onUpdate: (text: string) => void, ) { this.resourceName = `${nanoid(7)}.problem`; this.tracker = new UpdateStateTracker(store); @@ -122,6 +123,7 @@ export default class UpdateService { if (!this.tracker.needsUpdate) { return; } + this.onUpdate(this.store.state.sliceDoc()); await this.tracker.runExclusive(() => this.updateExclusive()); } diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts index 7486d737..21ac5750 100644 --- a/subprojects/frontend/src/xtext/XtextClient.ts +++ b/subprojects/frontend/src/xtext/XtextClient.ts @@ -49,13 +49,18 @@ export default class XtextClient { constructor( private readonly store: EditorStore, private readonly pwaStore: PWAStore, + onUpdate: (text: string) => void, ) { this.webSocketClient = new XtextWebSocketClient( () => this.onReconnect(), () => this.onDisconnect(), this.onPush.bind(this), ); - this.updateService = new UpdateService(store, this.webSocketClient); + this.updateService = new UpdateService( + store, + this.webSocketClient, + onUpdate, + ); this.contentAssistService = new ContentAssistService(this.updateService); this.highlightingService = new HighlightingService( store, diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/SecurityHeadersFilter.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/SecurityHeadersFilter.java index fab94689..cc87917f 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/SecurityHeadersFilter.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/SecurityHeadersFilter.java @@ -23,7 +23,8 @@ public class SecurityHeadersFilter implements Filter { // Use 'data:' for displaying inline SVG backgrounds. "img-src 'self' data:; " + "font-src 'self'; " + - "connect-src 'self'; " + + // Fetch data:application/octet-stream;base64 URIs to unpack compressed URL fragments. + "connect-src 'self' data:; " + "manifest-src 'self'; " + "worker-src 'self' blob:;"); httpResponse.setHeader("X-Content-Type-Options", "nosniff"); -- cgit v1.2.3-54-g00ecf