diff --git a/packages/tdev/pyodide-code/README.mdx b/packages/tdev/pyodide-code/README.mdx index 54d493841..465ab64f5 100644 --- a/packages/tdev/pyodide-code/README.mdx +++ b/packages/tdev/pyodide-code/README.mdx @@ -151,123 +151,99 @@ while True: ``` -### Implementations-Details - -Die Implementierung kann im Seitenspezifischen [`./website`](https://github.com/GBSL-Informatik/teaching-dev/tree/main/tdev-website/website)-Ordner nachvollzogen werden. Die wesentlichen Teile sind: - -- Nachrichten, die von Pyodide empfangen werden, werden an den `siteStore#handleMessage` weitergeleitet und können so beliebig verarbeitet werden. -- Die Python `clock`-Bibliothek wird über `./website/packages/pyodide-code/pyodideJsModules/siteModules.ts` registriert und stellt die `use_clock`-Funktion zur Verfügung. - :::details[`siteModules.ts`] - ```ts - import type { ModuleType } from '@tdev/pyodide-code/pyodideJsModules'; - /** - * this file is to add custom pyodide js modules for the teaching-dev website - * ensure to remove this file from the updateTdev.config.yaml to avoid overwriting - */ - - declare module '@tdev/pyodide-code/pyodideJsModules' { - export interface MessageTypeMap { - clock: { - type: 'clock'; - id: string; - clockType: 'hours' | 'minutes' | 'seconds'; - value: number; - timeStamp: number; - }; - } - } - - export const siteModules: Partial = { - clock: (ctx) => { - const { sendMessage, getTime } = ctx; - return { - use_clock: (id: string) => { - let hours = 0; - let minutes = 0; - let seconds = 0; - return { - get minutes() { - return minutes; - }, - get hours() { - return hours; - }, - get seconds() { - return seconds; - }, - reset: () => { - hours = 0; - minutes = 0; - seconds = 0; - ['hours', 'minutes', 'seconds'].forEach((clockType) => { - sendMessage({ - type: 'clock', - clockType: clockType as 'hours' | 'minutes' | 'seconds', - value: 0, - id: id, - timeStamp: getTime() - }); - }); - }, - set_time: (h: number, m: number, s: number) => { - hours = h; - minutes = m; - seconds = s; - [ - ['hours', h], - ['minutes', m], - ['seconds', s] - ].forEach(([clockType, value]) => { - sendMessage({ - type: 'clock', - clockType: clockType as 'hours' | 'minutes' | 'seconds', - value: value as number, - id: id, - timeStamp: getTime() - }); - }); - }, - set_hours: (deg: number) => { - hours = deg; - sendMessage({ - type: 'clock', - clockType: 'hours', - value: deg, - id: id, - timeStamp: getTime() - }); - }, - set_minutes: (deg: number) => { - minutes = deg; - sendMessage({ - type: 'clock', - clockType: 'minutes', - value: deg, - id: id, - timeStamp: getTime() - }); - }, - set_seconds: (deg: number) => { - seconds = deg; - sendMessage({ - type: 'clock', - clockType: 'seconds', - value: deg, - id: id, - timeStamp: getTime() - }); - } - }; +### Erweiterungen registrieren + +Eine Erweiterung hat zwei getrennte Einstiegspunkte: `worker.ts` stellt Python-Module bereit, `client.ts` verarbeitet deren Nachrichten im Browser. Beide verwenden denselben Payload-Typ aus `protocol.ts`. React-Komponenten und MobX-Stores bleiben normale Bestandteile der Website. + +Die Einstiegspunkte werden explizit im bestehenden Pyodide-Plugin konfiguriert: + +```ts title="siteConfig.ts" +import type { PluginOptions } from '@tdev/pyodide-code/plugin'; + +plugins: [ + [ + require.resolve('@tdev/pyodide-code/plugin'), + { + extensions: [ + { + name: 'clock', + worker: require.resolve('./website/pyodide/clock/worker'), + client: require.resolve('./website/pyodide/clock/client') } - }; - } - }; + ] + } satisfies PluginOptions + ] +] +``` - ``` - ::: -- In `./website/packages/pyodide-code/models/Clock.ts` wird ein MobX-Modell für die Uhr definiert. Es kann über `@tdev/pyodide-code/models/Clock` importiert und in React-Komponenten verwendet werden. -- In `./website/packages/pyodide-code/components/Clock` wird die eigentliche Uhr-Komponente implementiert, die das `Clock`-Modell verwendet, um die Zeiger zu positionieren. -- In `./website/stores/ClockStore.ts` wird ein MobX-Store definiert, der die verschiedenen Uhren verwaltet und die Nachrichten von Pyodide verarbeitet. Dieser Store wird im `./website/stores/SiteStore.ts` registriert und initialisiert. +Die Namen der Erweiterungen müssen eindeutig sein. `require.resolve()` liefert absolute Dateipfade; relative Einstiegspfade in den Optionen werden relativ zum Docusaurus-Seitenverzeichnis aufgelöst. Die Konfiguration enthält nur Dateipfade und Namen, keine React-Komponenten oder Store-Instanzen. Nach Änderungen an der Erweiterungsliste muss der Entwicklungsserver neu gestartet werden. + +Das Plugin erzeugt getrennte Registries für Worker und Browser. Es braucht weder ein Alias-Override von Package-Dateien noch eine globale Registrierung beim App-Start. `worker.ts` darf keine React-, DOM- oder Store-Abhängigkeiten importieren; gemeinsame Payload-Typen werden mit `import type` eingebunden. + +Beim Laden der jeweiligen Registry wird einmalig geprüft, ob die Worker-Einträge `createModules()` und die Browser-Einträge `handleMessage()` bereitstellen. Ein ungültiger Export verhindert die Initialisierung dieser Registry; die Fehlermeldung nennt die Erweiterung und den betroffenen Einstiegspunkt. + +### Uhr: gemeinsamer Vertrag + +```ts title="website/pyodide/clock/protocol.ts" +export interface ClockTime { + hours: number; + minutes: number; + seconds: number; +} + +export type ClockUpdate = Partial; +``` + +Alle Winkel sind in Grad angegeben. Eine Nachricht kann einzelne Zeiger oder die ganze Uhr ändern. Die beiden Einstiegspunkte verwenden `ClockUpdate` als Typparameter. Die Typen prüfen den selbst geschriebenen Worker und Handler, es gibt keine automatische Laufzeitvalidierung des Payloads. + +### Uhr: Python-Modul im Worker + +import clockWorkerSource from '!!raw-loader!@tdev/pyodide/clock/worker.ts'; + + + {clockWorkerSource} + + +`defineWorkerExtension()` erhält eine Factory. Diese liefert ein Objekt, dessen Schlüssel die Python-Modulnamen sind, hier `clock`. Eine Erweiterung kann mehrere Module bereitstellen; Modulnamen und Nachrichtentypen sind unabhängig voneinander. Doppelte Modulnamen und der interne Name `browser_input` werden mit einer Fehlermeldung abgelehnt. + +Der Factory-Kontext bietet: + +- `editorId`: die ID des ausführenden Editors. +- `send(targetId, payload)`: sendet eine Nachricht an den Browser-Handler dieser Erweiterung. Das Framework ergänzt Editor-ID, Erweiterungsname und Zeitstempel. Der Payload muss durch strukturiertes Klonen übertragbar sein, beispielsweise ein Objekt aus Zahlen, Strings und Arrays. + +Die Factory wird bei **jeder Ausführung** aufgerufen und ihre Module werden vor dem Python-Code registriert. Damit funktionieren auch dynamische Imports wie `importlib.import_module('clock')`. Der Worker wartet vor Abschluss auf ausstehende Nachrichten an die Browser-Handler. Ein Handler-Fehler wird als Ausführungsfehler im Editor angezeigt. Bereits zugestellte Änderungen werden nicht zurückgerollt. + +Schlagen sowohl die Ausführung als auch deren Abschluss fehl, erscheint der ursprüngliche Ausführungsfehler zuerst. Zusätzliche Fehler beim Zustellen von Nachrichten oder beim Aufräumen werden darunter angezeigt. Die weiteren Aufräumschritte werden auch nach einem Fehler ausgeführt. + +### Uhr: Browser-Handler und React + +import clockClientSource from '!!raw-loader!@tdev/pyodide/clock/client.ts'; + + + {clockClientSource} + + +`defineClientExtension()` erhält einen synchronen Handler. Der Kontext enthält den aktuellen `root`-Store. Die Nachricht enthält `editorId`, `targetId`, `timeStamp` und `payload`. Der Handler läuft innerhalb einer MobX-Action; er aktualisiert die bestehenden Website-Stores. + +Die Uhr verwendet weiterhin den [ClockStore](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/stores/ClockStore.ts), das [Clock-Modell](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/packages/pyodide-code/models/Clock.ts) und die [React-Komponente](https://github.com/GBSL-Informatik/teaching-dev/blob/main/tdev-website/website/packages/pyodide-code/components/Clock/index.tsx). Nur der Store muss im `SiteStore` vorhanden sein; ein `handleMessage`-Override ist nicht nötig. Die Komponente kann wie bisher unabhängig vom Editor im MDX platziert werden: + +```mdx +import Clock from '@tdev/packages/pyodide-code/components/Clock'; + + +``` + +Python verwendet weiterhin `from clock import use_clock` und dieselbe Ziel-ID. Mehrere Komponenten oder Editoren können absichtlich dieselbe Ziel-ID verwenden; die Ziel-IDs gelten für den gemeinsamen Website-Store, nicht nur für einen Editor. + +### Zustand und Lebensdauer + +Die Browser-Stores gehören weiterhin der Website und leben mit deren Root-Store. Der Zustand der Uhr bleibt nach Ende oder Abbruch eines Skripts erhalten; ein Neustart setzt ihn nicht automatisch zurück. Ein unbekanntes Ziel wird beim ersten Update im `ClockStore` angelegt, auch wenn noch keine React-Komponente dafür angezeigt wird. + +Jeder Aufruf von `use_clock()` erzeugt einen eigenen Python-Handle mit zunächst lokalen Nullwerten. Seine Getter lesen die letzten Werte dieses Handles, nicht den Browser-Store und nicht andere Handles. `set_seconds()` ändert im Browser nur den Sekundenzeiger. `reset()` setzt alle Zeiger zurück. Diese Erweiterung bietet keine Rückfragen vom Browser an Python. + +Worker können zwischen Editoren wiederverwendet werden. Die registrierten JavaScript-Module erhalten pro Ausführung einen neuen Kontext. Bereits importierte Python-Hilfsmodule können jedoch alte Referenzen auf Module oder Handles behalten; deren Cache wird nicht rekursiv geleert. Solche Hilfsmodule sollen das Modul oder die Factory innerhalb der pro Ausführung aufgerufenen Funktion neu importieren und erst dann Handles anfordern. Ein auf Modulebene gespeichertes `from clock import use_clock` bleibt sonst an den alten, bereits geschlossenen Kontext gebunden. + +Erweiterungen müssen eigene Timer und Hintergrundaufgaben vor Ende des Skripts beenden. Nachrichten nach Abschluss werden verworfen. **Abbrechen** beendet den Worker; ausstehende Nachrichten einer abgebrochenen oder ersetzten Ausführung werden durch die bestehende Ausführungsprüfung ignoriert. Das Framework stellt keine automatische Ressourcenverwaltung oder Hintergrunddienste für Erweiterungen bereit. ## Installation diff --git a/packages/tdev/pyodide-code/extensions/client.ts b/packages/tdev/pyodide-code/extensions/client.ts new file mode 100644 index 000000000..3f739eccd --- /dev/null +++ b/packages/tdev/pyodide-code/extensions/client.ts @@ -0,0 +1,15 @@ +import extensions from '@tdev-pyodide/client-extensions'; +import type { ClientContext, ExtensionMessage } from './index'; + +export const handleExtensionMessage = (context: ClientContext, message: ExtensionMessage) => { + const registration = extensions.find(({ name }) => name === message.extension); + if (!registration) { + throw new Error(`Unknown Pyodide extension: ${message.extension}`); + } + registration.extension.handleMessage(context, { + editorId: message.id, + targetId: message.targetId, + timeStamp: message.timeStamp, + payload: message.payload + }); +}; diff --git a/packages/tdev/pyodide-code/extensions/config.ts b/packages/tdev/pyodide-code/extensions/config.ts new file mode 100644 index 000000000..ce72f5c81 --- /dev/null +++ b/packages/tdev/pyodide-code/extensions/config.ts @@ -0,0 +1,63 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +export interface ExtensionConfig { + name: string; + worker: string; + client: string; +} + +export interface PluginOptions { + extensions?: ExtensionConfig[]; +} + +export const resolveExtensions = (siteDir: string, options: PluginOptions): ExtensionConfig[] => { + const extensions = options.extensions ?? []; + if (!Array.isArray(extensions)) { + throw new Error('Pyodide extensions must be an array.'); + } + const names = new Set(); + const resolve = createRequire(path.join(siteDir, 'package.json')).resolve; + return extensions.map((extension) => { + if (!extension || typeof extension.name !== 'string' || !/^[a-zA-Z][\w-]*$/.test(extension.name)) { + throw new Error('Each Pyodide extension needs a name starting with a letter.'); + } + if (names.has(extension.name)) { + throw new Error(`Duplicate Pyodide extension: ${extension.name}`); + } + names.add(extension.name); + const resolveEntry = (entry: string, side: string) => { + try { + return resolve(entry); + } catch { + throw new Error( + `Cannot resolve ${side} entry for Pyodide extension ${extension.name}: ${entry}` + ); + } + }; + return { + name: extension.name, + worker: resolveEntry(extension.worker, 'worker'), + client: resolveEntry(extension.client, 'client') + }; + }); +}; + +export const createRegistrySource = (extensions: ExtensionConfig[], side: 'worker' | 'client'): string => { + const imports = extensions.map( + (extension, index) => `import extension${index} from ${JSON.stringify(extension[side])};` + ); + const method = side === 'worker' ? 'createModules' : 'handleMessage'; + const validations = extensions.map((extension, index) => { + const message = `Pyodide extension "${extension.name}": ${side} entry must default-export an object with ${method}().`; + return [ + `if (typeof extension${index}?.${method} !== 'function') {`, + ` throw new Error(${JSON.stringify(message)});`, + '}' + ].join('\n'); + }); + const registrations = extensions.map( + (extension, index) => `{ name: ${JSON.stringify(extension.name)}, extension: extension${index} }` + ); + return [...imports, ...validations, `export default [${registrations.join(', ')}];`].join('\n'); +}; diff --git a/packages/tdev/pyodide-code/extensions/generated.d.ts b/packages/tdev/pyodide-code/extensions/generated.d.ts new file mode 100644 index 000000000..ec4cda421 --- /dev/null +++ b/packages/tdev/pyodide-code/extensions/generated.d.ts @@ -0,0 +1,9 @@ +declare module '@tdev-pyodide/worker-extensions' { + const extensions: import('./index').RegisteredExtension[]; + export default extensions; +} + +declare module '@tdev-pyodide/client-extensions' { + const extensions: import('./index').RegisteredExtension[]; + export default extensions; +} diff --git a/packages/tdev/pyodide-code/extensions/index.ts b/packages/tdev/pyodide-code/extensions/index.ts new file mode 100644 index 000000000..e2acdf971 --- /dev/null +++ b/packages/tdev/pyodide-code/extensions/index.ts @@ -0,0 +1,56 @@ +import type { RootStore } from '@tdev-stores/rootStore'; + +export interface ExtensionMessage { + type: 'extension'; + extension: string; + id: string; + targetId: string; + timeStamp: number; + payload: unknown; +} + +export interface WorkerContext { + editorId: string; + send: (targetId: string, payload: Payload) => void; +} + +export interface ClientContext { + root: RootStore; +} + +export interface ExtensionDelivery { + editorId: string; + targetId: string; + timeStamp: number; + payload: Payload; +} + +export interface WorkerExtension { + createModules: (context: WorkerContext) => Record; +} + +export interface ClientExtension { + handleMessage: (context: ClientContext, message: ExtensionDelivery) => undefined; +} + +export interface RegisteredExtension { + name: string; + extension: Extension; +} + +export const defineWorkerExtension = ( + createModules: (context: WorkerContext) => Record +): WorkerExtension => ({ + createModules: (context) => + createModules({ + editorId: context.editorId, + send: (targetId, payload) => context.send(targetId, payload) + }) +}); + +export const defineClientExtension = ( + handleMessage: (context: ClientContext, message: ExtensionDelivery) => undefined +): ClientExtension => ({ + // The configured worker and client entries share their payload contract. + handleMessage: (context, message) => handleMessage(context, message as ExtensionDelivery) +}); diff --git a/packages/tdev/pyodide-code/extensions/worker.ts b/packages/tdev/pyodide-code/extensions/worker.ts new file mode 100644 index 000000000..ae3d62124 --- /dev/null +++ b/packages/tdev/pyodide-code/extensions/worker.ts @@ -0,0 +1,49 @@ +import type { PyodideAPI } from 'pyodide'; +import type { Context } from '../messages'; +import type { RegisteredExtension, WorkerExtension } from './index'; +import { registerJsModule } from '../workers/registerJsModule'; +import { createMessageDelivery } from '../workers/messageDelivery'; + +export const createWorkerExtensions = ( + context: Context & { id: string }, + extensions: readonly RegisteredExtension[] +) => { + const delivery = createMessageDelivery((message) => context.sendMessage(message)); + let isActive = true; + + const register = (pyodide: PyodideAPI) => { + const moduleNames = new Set(['browser_input']); + for (const { name, extension } of extensions) { + const modules = extension.createModules({ + editorId: context.id, + send: (targetId, payload) => { + if (!isActive) { + return; + } + delivery.send({ + type: 'extension', + extension: name, + id: context.id, + targetId, + timeStamp: context.getTime(), + payload + }); + } + }); + for (const [moduleName, module] of Object.entries(modules)) { + if (moduleNames.has(moduleName)) { + throw new Error(`Duplicate or reserved Pyodide module: ${moduleName}`); + } + moduleNames.add(moduleName); + registerJsModule(pyodide, moduleName, module); + } + } + }; + + const finish = async () => { + isActive = false; + await delivery.finish(); + }; + + return { register, finish }; +}; diff --git a/packages/tdev/pyodide-code/messages.ts b/packages/tdev/pyodide-code/messages.ts new file mode 100644 index 000000000..b7e7715c9 --- /dev/null +++ b/packages/tdev/pyodide-code/messages.ts @@ -0,0 +1,27 @@ +import type { ExtensionMessage } from './extensions'; + +export interface LogMessage { + id: string; + timeStamp: number; + type: 'log'; + message: string; +} +export interface ErrorMessage { + id: string; + timeStamp: number; + type: 'error'; + message: string; +} + +export interface ClearMessage { + id: string; + timeStamp: number; + type: 'clear'; +} + +export type Message = LogMessage | ErrorMessage | ClearMessage | ExtensionMessage; + +export interface Context { + sendMessage: (message: Message) => void | Promise; + getTime: () => number; +} diff --git a/packages/tdev/pyodide-code/models/PyodideCode.ts b/packages/tdev/pyodide-code/models/PyodideCode.ts index 548494104..cab1b2525 100644 --- a/packages/tdev/pyodide-code/models/PyodideCode.ts +++ b/packages/tdev/pyodide-code/models/PyodideCode.ts @@ -1,7 +1,7 @@ import { Document as DocumentProps, Factory } from '@tdev-api/document'; import iCode from '@tdev-models/documents/iCode'; import DocumentStore from '@tdev-stores/DocumentStore'; -import { ErrorMessage, LogMessage, Message } from '@tdev/pyodide-code/pyodideJsModules'; +import type { ErrorMessage, LogMessage, Message } from '../messages'; import { orderBy } from 'es-toolkit/array'; import { action, computed, observable } from 'mobx'; import { ModelMeta } from './ModelMeta'; diff --git a/packages/tdev/pyodide-code/plugin.ts b/packages/tdev/pyodide-code/plugin.ts index 1460041c2..d63e6b210 100644 --- a/packages/tdev/pyodide-code/plugin.ts +++ b/packages/tdev/pyodide-code/plugin.ts @@ -1,39 +1,63 @@ -import type { Plugin } from '@docusaurus/types'; -import type { Chunk, Configuration, OptimizationSplitChunksOptions } from '@rspack/core'; +import type { LoadContext, Plugin } from '@docusaurus/types'; +import type { Chunk, Configuration } from 'webpack'; import { PYODIDE_VERSION } from './config'; +import { createRegistrySource, resolveExtensions, type PluginOptions } from './extensions/config'; -const excludePyodideWorker = - (configuredChunks?: OptimizationSplitChunksOptions['chunks']) => (chunk: Chunk) => { - if (chunk.name === 'pyodide-worker') { - return false; - } - if (typeof configuredChunks === 'function') { - return configuredChunks(chunk); - } - if (configuredChunks instanceof RegExp) { - return configuredChunks.test(chunk.name || ''); - } - if (configuredChunks === 'all') { - return true; - } - if (configuredChunks === 'initial') { - return chunk.canBeInitial(); - } - return !chunk.canBeInitial(); - }; +export type { ExtensionConfig, PluginOptions } from './extensions/config'; + +type SplitChunksOptions = Exclude< + NonNullable['splitChunks'], + false | undefined +>; + +const excludePyodideWorker = (configuredChunks?: SplitChunksOptions['chunks']) => (chunk: Chunk) => { + if (chunk.name === 'pyodide-worker') { + return false; + } + if (typeof configuredChunks === 'function') { + return configuredChunks(chunk); + } + if (configuredChunks instanceof RegExp) { + return configuredChunks.test(chunk.name || ''); + } + if (configuredChunks === 'all') { + return true; + } + if (configuredChunks === 'initial') { + return chunk.canBeInitial(); + } + return !chunk.canBeInitial(); +}; -const pyodidePlugin: Plugin = () => { +const pyodidePlugin = (context: LoadContext, options: PluginOptions = {}): Plugin => { if (!/^\d+\.\d+\.\d+$/.test(PYODIDE_VERSION)) { throw new Error( 'Pin the pyodide dependency to an exact stable version in pyodide-code/package.json.' ); } + const extensions = resolveExtensions(context.siteDir, options); + let registryAliases: Record = {}; + return { name: 'pyodide-code', + async contentLoaded({ actions }) { + const worker = await actions.createData( + 'worker-extensions.js', + createRegistrySource(extensions, 'worker') + ); + const client = await actions.createData( + 'client-extensions.js', + createRegistrySource(extensions, 'client') + ); + registryAliases = { + '@tdev-pyodide/worker-extensions$': worker, + '@tdev-pyodide/client-extensions$': client + }; + }, configureWebpack(config: Configuration, isServer: boolean): Configuration { if (isServer) { - return {}; + return { resolve: { alias: registryAliases } }; } const splitChunks = config.optimization?.splitChunks; const configuredChunks = splitChunks ? splitChunks.chunks : undefined; @@ -47,6 +71,7 @@ const pyodidePlugin: Plugin = () => { ); return { + resolve: { alias: registryAliases }, module: { parser: { javascript: { diff --git a/packages/tdev/pyodide-code/pyodideJsModules/index.ts b/packages/tdev/pyodide-code/pyodideJsModules/index.ts deleted file mode 100644 index ef1a18809..000000000 --- a/packages/tdev/pyodide-code/pyodideJsModules/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { siteModules } from '@tdev/pyodide-code/pyodideJsModules/siteModules'; - -export interface LogMessage { - id: string; - timeStamp: number; - type: 'log'; - message: string; -} -export interface ErrorMessage { - id: string; - timeStamp: number; - type: 'error'; - message: string; -} - -export interface ClearMessage { - id: string; - timeStamp: number; - type: 'clear'; -} - -export interface MessageTypeMap { - log: LogMessage; - error: ErrorMessage; - clear: ClearMessage; -} - -export type MessageType = keyof MessageTypeMap; - -export type Message = MessageTypeMap[MessageType]; - -export interface Context { - sendMessage: (message: Message) => void; - getTime: () => number; -} - -export type ModuleType = { [key in MessageType]: (ctx: Context) => object }; -export const pyodideJsModules: Partial = { - ...siteModules -}; diff --git a/packages/tdev/pyodide-code/pyodideJsModules/siteModules.ts b/packages/tdev/pyodide-code/pyodideJsModules/siteModules.ts deleted file mode 100644 index 89bc5d2d8..000000000 --- a/packages/tdev/pyodide-code/pyodideJsModules/siteModules.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ModuleType } from '@tdev/pyodide-code/pyodideJsModules'; - -/** - * this file is to add custom pyodide js modules for the teaching-dev website - * ensure to remove this file from the updateTdev.config.yaml to avoid overwriting - */ - -// declare module '@tdev/pyodide-code/pyodideJsModules' { -// export interface MessageTypeMap { -// foo: { -// timeStamp: number; -// }; -// } -// } - -export const siteModules: Partial = {}; diff --git a/packages/tdev/pyodide-code/stores/PyodideStore.ts b/packages/tdev/pyodide-code/stores/PyodideStore.ts index d1b76cd7f..7ac909a6d 100644 --- a/packages/tdev/pyodide-code/stores/PyodideStore.ts +++ b/packages/tdev/pyodide-code/stores/PyodideStore.ts @@ -1,13 +1,14 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import siteConfig from '@generated/docusaurus.config'; import type ViewStore from '@tdev-stores/ViewStores'; -import { Message } from '@tdev/pyodide-code/pyodideJsModules'; +import type { Message } from '../messages'; import * as Comlink from 'comlink'; import { action, computed, observable, observableRef, runInAction } from 'mobx'; import { PY_AWAIT_INPUT, PY_CANCEL_ALL, PY_CANCEL_INPUT, PY_INPUT } from '../config'; import { initializeInputServiceWorker } from '../helpers/inputServiceWorker'; import type PyodideCode from '../models/PyodideCode'; import PyodideRuntime from '../models/PyodideRuntime'; +import { handleExtensionMessage } from '../extensions/client'; const BASE_URL = siteConfig.baseUrl || '/'; // Bound unused heaps while keeping two recent runtimes warm. const MAX_IDLE_RUNTIMES = 2; @@ -149,6 +150,9 @@ export default class PyodideStore { @action handleMessage(code: PyodideCode, message: Message) { switch (message.type) { + case 'extension': + handleExtensionMessage({ root: this.viewStore.root }, message); + break; case 'clear': code.clearMessages(); break; @@ -159,11 +163,6 @@ export default class PyodideStore { this.retainedRuntimes.add(code.id); code.addLogMessage(message); break; - default: - if ('handleMessage' in this.viewStore.root.siteStore) { - this.viewStore.root.siteStore.handleMessage('pyodide', message); - } - break; } } diff --git a/packages/tdev/pyodide-code/tests/extensions.test.ts b/packages/tdev/pyodide-code/tests/extensions.test.ts new file mode 100644 index 000000000..3ac0c6dd2 --- /dev/null +++ b/packages/tdev/pyodide-code/tests/extensions.test.ts @@ -0,0 +1,131 @@ +import type { LoadContext } from '@docusaurus/types'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { createRegistrySource, resolveExtensions, type ExtensionConfig } from '../extensions/config'; +import pyodidePlugin from '../plugin'; + +const siteDir = path.resolve(import.meta.dirname, 'fixtures'); +const clock: ExtensionConfig = { + name: 'clock', + worker: './worker.ts', + client: './client.ts' +}; + +describe('Pyodide extension configuration', () => { + it('resolves explicit entries relative to the site directory', () => { + expect(resolveExtensions(siteDir, { extensions: [clock] })).toEqual([ + { + name: 'clock', + worker: path.resolve(siteDir, clock.worker), + client: path.resolve(siteDir, clock.client) + } + ]); + }); + + it('supports an empty registry for websites without extensions', () => { + expect(resolveExtensions(siteDir, {})).toEqual([]); + expect(createRegistrySource([], 'worker')).toBe('export default [];'); + }); + + it('rejects duplicate extension names', () => { + expect(() => resolveExtensions(siteDir, { extensions: [clock, clock] })).toThrow( + 'Duplicate Pyodide extension: clock' + ); + }); + + it.each(['', 'clock/time', '1clock', undefined])('rejects invalid name %s', (name) => { + const extension = { ...clock, name } as ExtensionConfig; + expect(() => resolveExtensions(siteDir, { extensions: [extension] })).toThrow( + 'Each Pyodide extension needs a name' + ); + }); + + it.each(['worker', 'client'] as const)('identifies an unresolved %s entry', (side) => { + const extension = { ...clock, [side]: './missing-pyodide-extension.ts' }; + expect(() => resolveExtensions(siteDir, { extensions: [extension] })).toThrow( + `Cannot resolve ${side} entry for Pyodide extension clock` + ); + }); + + it('keeps worker and browser dependency graphs separate', () => { + const [extension] = resolveExtensions(siteDir, { extensions: [clock] }); + const worker = createRegistrySource([extension], 'worker'); + const client = createRegistrySource([extension], 'client'); + expect(worker).toContain(JSON.stringify(extension.worker)); + expect(worker).not.toContain(extension.client); + expect(client).toContain(JSON.stringify(extension.client)); + expect(client).not.toContain(extension.worker); + }); + + it('quotes paths and extension names in generated source', () => { + const source = createRegistrySource( + [{ name: 'clock', worker: '/a/"clock.js', client: '/b.js' }], + 'worker' + ); + expect(source).toBe( + 'import extension0 from "/a/\\"clock.js";\n' + + "if (typeof extension0?.createModules !== 'function') {\n" + + ' throw new Error("Pyodide extension \\"clock\\": worker entry must default-export an object with createModules().");\n' + + '}\n' + + 'export default [{ name: "clock", extension: extension0 }];' + ); + }); + + it.each(['worker', 'client'] as const)( + 'validates %s exports when the registry initializes', + async (side) => { + const entry = 'data:text/javascript,export default null;'; + const source = createRegistrySource([{ name: 'clock', worker: entry, client: entry }], side); + const registryUrl = `data:text/javascript,${encodeURIComponent(source)}`; + const method = side === 'worker' ? 'createModules' : 'handleMessage'; + + await expect(import(/* @vite-ignore */ registryUrl)).rejects.toThrow( + `Pyodide extension "clock": ${side} entry must default-export an object with ${method}().` + ); + } + ); + + it.each(['worker', 'client'] as const)( + 'loads a valid %s registry without invoking its entry', + async (side) => { + const method = side === 'worker' ? 'createModules' : 'handleMessage'; + const entry = `data:text/javascript,${encodeURIComponent( + `export default { ${method}() { throw new Error('Must not run during initialization'); } };` + )}`; + const source = createRegistrySource([{ name: 'clock', worker: entry, client: entry }], side); + const registryUrl = `data:text/javascript,${encodeURIComponent(source)}`; + + const { default: registry } = await import(/* @vite-ignore */ registryUrl); + + expect(registry).toHaveLength(1); + expect(registry[0].name).toBe('clock'); + expect(typeof registry[0].extension[method]).toBe('function'); + } + ); + + it('makes generated registries available to SSR and preserves browser worker configuration', async () => { + const plugin = pyodidePlugin({ siteDir } as LoadContext, { extensions: [clock] }); + const createData = vi.fn(async (name: string) => `/generated/${name}`); + await plugin.contentLoaded?.({ content: undefined, actions: { createData } as never }); + + const aliases = { + '@tdev-pyodide/worker-extensions$': '/generated/worker-extensions.js', + '@tdev-pyodide/client-extensions$': '/generated/client-extensions.js' + }; + expect(plugin.configureWebpack?.({}, true, {} as never, undefined)).toEqual({ + resolve: { alias: aliases } + }); + expect(plugin.configureWebpack?.({}, false, {} as never, undefined)).toMatchObject({ + resolve: { alias: aliases }, + module: { + parser: { + javascript: { + worker: ['...', 'ModuleWorker from @tdev/pyodide-code/workers/ModuleWorker'] + } + } + } + }); + expect(createData).toHaveBeenCalledWith('worker-extensions.js', expect.stringContaining('worker.ts')); + expect(createData).toHaveBeenCalledWith('client-extensions.js', expect.stringContaining('client.ts')); + }); +}); diff --git a/packages/tdev/pyodide-code/tests/fixtures/client.ts b/packages/tdev/pyodide-code/tests/fixtures/client.ts new file mode 100644 index 000000000..a714a832f --- /dev/null +++ b/packages/tdev/pyodide-code/tests/fixtures/client.ts @@ -0,0 +1,3 @@ +import { defineClientExtension } from '../../extensions'; + +export default defineClientExtension(() => {}); diff --git a/packages/tdev/pyodide-code/tests/fixtures/contracts.ts b/packages/tdev/pyodide-code/tests/fixtures/contracts.ts new file mode 100644 index 000000000..f9905851a --- /dev/null +++ b/packages/tdev/pyodide-code/tests/fixtures/contracts.ts @@ -0,0 +1,18 @@ +import { defineClientExtension, defineWorkerExtension } from '../../extensions'; + +declare const workerRegistry: typeof import('@tdev-pyodide/worker-extensions').default; +declare const clientRegistry: typeof import('@tdev-pyodide/client-extensions').default; + +// @ts-expect-error Worker registries expose factories, not browser handlers. +workerRegistry[0].extension.handleMessage; +// @ts-expect-error Browser registries expose handlers, not worker factories. +clientRegistry[0].extension.createModules; + +// @ts-expect-error Async handlers would leave the surrounding MobX action. +defineClientExtension(async () => {}); + +defineWorkerExtension((context) => { + // @ts-expect-error The worker must send the shared payload type. + context.send('target', 'wrong payload'); + return {}; +}); diff --git a/packages/tdev/pyodide-code/tests/fixtures/worker.ts b/packages/tdev/pyodide-code/tests/fixtures/worker.ts new file mode 100644 index 000000000..c553e9d44 --- /dev/null +++ b/packages/tdev/pyodide-code/tests/fixtures/worker.ts @@ -0,0 +1,3 @@ +import { defineWorkerExtension } from '../../extensions'; + +export default defineWorkerExtension(() => ({ example: { value: 42 } })); diff --git a/packages/tdev/pyodide-code/tests/workerExtensions.test.ts b/packages/tdev/pyodide-code/tests/workerExtensions.test.ts new file mode 100644 index 000000000..fdce8ec8b --- /dev/null +++ b/packages/tdev/pyodide-code/tests/workerExtensions.test.ts @@ -0,0 +1,138 @@ +import type { PyodideAPI } from 'pyodide'; +import { describe, expect, it, vi } from 'vitest'; +import { defineWorkerExtension, type WorkerContext } from '../extensions'; +import { createWorkerExtensions } from '../extensions/worker'; +import { registerJsModule } from '../workers/registerJsModule'; + +vi.mock('../workers/registerJsModule', () => ({ registerJsModule: vi.fn() })); + +const pyodide = {} as PyodideAPI; + +describe('Pyodide worker extensions', () => { + it('adds transport metadata without confusing editor and target IDs', async () => { + const sendMessage = vi.fn(); + const extension = defineWorkerExtension((context) => ({ + clock: { update: () => context.send('target-clock', 42) } + })); + const session = createWorkerExtensions({ id: 'source-editor', getTime: () => 123, sendMessage }, [ + { name: 'example', extension } + ]); + session.register(pyodide); + const module = vi.mocked(registerJsModule).mock.lastCall?.[2] as { update: () => void }; + module.update(); + await session.finish(); + expect(sendMessage).toHaveBeenCalledWith({ + type: 'extension', + extension: 'example', + id: 'source-editor', + targetId: 'target-clock', + timeStamp: 123, + payload: 42 + }); + }); + + it('creates fresh module objects and context for each execution', async () => { + const createModules = vi.fn((context: WorkerContext) => ({ + counter: { editor: context.editorId, value: 0 } + })); + const extensions = [{ name: 'example', extension: defineWorkerExtension(createModules) }]; + const modules: object[] = []; + for (const id of ['first', 'second']) { + const session = createWorkerExtensions( + { id, getTime: Date.now, sendMessage: vi.fn() }, + extensions + ); + session.register(pyodide); + modules.push(vi.mocked(registerJsModule).mock.lastCall![2]); + await session.finish(); + } + expect(createModules).toHaveBeenCalledTimes(2); + expect(modules).toEqual([ + { editor: 'first', value: 0 }, + { editor: 'second', value: 0 } + ]); + expect(modules[0]).not.toBe(modules[1]); + }); + + it('rejects the reserved browser_input module', async () => { + const name = 'browser_input'; + const session = createWorkerExtensions({ id: 'editor', getTime: Date.now, sendMessage: vi.fn() }, [ + { name: 'example', extension: defineWorkerExtension(() => ({ [name]: {} })) } + ]); + expect(() => session.register(pyodide)).toThrow(`Duplicate or reserved Pyodide module: ${name}`); + await session.finish(); + }); + + it('drains messages and closes contexts after a later factory fails', async () => { + let context: WorkerContext; + let completeDelivery!: () => void; + const delivery = new Promise((resolve) => { + completeDelivery = resolve; + }); + const sendMessage = vi.fn(() => delivery); + const session = createWorkerExtensions({ id: 'editor', getTime: Date.now, sendMessage }, [ + { + name: 'first', + extension: defineWorkerExtension((ctx) => { + context = ctx; + ctx.send('clock', 1); + return { clock: {} }; + }) + }, + { + name: 'second', + extension: defineWorkerExtension(() => { + throw new Error('factory failed'); + }) + } + ]); + expect(() => session.register(pyodide)).toThrow('factory failed'); + let finished = false; + const finish = session.finish().then(() => { + finished = true; + }); + await Promise.resolve(); + expect(finished).toBe(false); + context!.send('clock', 2); + expect(sendMessage).toHaveBeenCalledTimes(1); + completeDelivery(); + await finish; + expect(finished).toBe(true); + }); + + it('waits for all deliveries before reporting a rejected handler', async () => { + let completeDelivery!: () => void; + const delayed = new Promise((resolve) => { + completeDelivery = resolve; + }); + const failure = new Error('handler failed'); + const sendMessage = vi.fn().mockRejectedValueOnce(failure).mockReturnValueOnce(delayed); + const session = createWorkerExtensions({ id: 'editor', getTime: Date.now, sendMessage }, [ + { + name: 'example', + extension: defineWorkerExtension((ctx) => { + ctx.send('clock', 1); + ctx.send('clock', 2); + return {}; + }) + } + ]); + session.register(pyodide); + let finished = false; + const finish = session.finish(); + const rejection = expect(finish).rejects.toBe(failure); + void finish.then( + () => { + finished = true; + }, + () => { + finished = true; + } + ); + await Promise.resolve(); + expect(finished).toBe(false); + completeDelivery(); + await rejection; + expect(finished).toBe(true); + }); +}); diff --git a/packages/tdev/pyodide-code/workers/finalizeExecution.ts b/packages/tdev/pyodide-code/workers/finalizeExecution.ts new file mode 100644 index 000000000..42dd0b473 --- /dev/null +++ b/packages/tdev/pyodide-code/workers/finalizeExecution.ts @@ -0,0 +1,29 @@ +type CleanupStep = () => void | Promise; + +export const finalizeExecution = async ( + cleanupSteps: readonly CleanupStep[], + executionErrors: readonly unknown[] = [] +) => { + const errors = [...executionErrors]; + for (const cleanup of cleanupSteps) { + try { + await cleanup(); + } catch (error: unknown) { + errors.push(error); + } + } + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + const [primaryError, ...additionalErrors] = errors; + const details = additionalErrors.map(formatError).join('\n'); + throw new AggregateError( + errors, + `${formatError(primaryError)}\n\nAdditional errors during finalization:\n${details}`, + { cause: primaryError } + ); + } +}; + +const formatError = (error: unknown) => (error instanceof Error ? error.message : String(error)); diff --git a/packages/tdev/pyodide-code/workers/helper.loadPackages.ts b/packages/tdev/pyodide-code/workers/helper.loadPackages.ts index ddcf77e01..9719d399e 100644 --- a/packages/tdev/pyodide-code/workers/helper.loadPackages.ts +++ b/packages/tdev/pyodide-code/workers/helper.loadPackages.ts @@ -1,9 +1,7 @@ import type { PyodideAPI } from 'pyodide'; -import { type Context, pyodideJsModules } from '../pyodideJsModules'; -import { registerJsModule } from './registerJsModule'; +import type { Context } from '../messages'; -const siteModules = new Set(Object.keys(pyodideJsModules)); -const internalModules = new Set(['tdev', 'browser_input']); +const internalModules = new Set(['browser_input']); const packageAliases = new Map([ ['PIL', 'Pillow'], ['cv2', 'opencv-python'], @@ -25,12 +23,6 @@ export const loadPackages = async ( requirements: readonly string[] = [] ) => { const imports = getImports(pyodide, code); - for (const name of imports) { - const factory = pyodideJsModules[name as keyof typeof pyodideJsModules]; - if (siteModules.has(name) && factory) { - registerJsModule(pyodide, name, factory(context)); - } - } if (requirements.length > 0) { await installPackages(pyodide, requirements); } @@ -78,12 +70,7 @@ const getMissingImports = (pyodide: PyodideAPI, imports: string[]): string[] => try { const standardNames: Set = stdlib.toJs(); return imports.filter((name) => { - if ( - siteModules.has(name) || - internalModules.has(name) || - standardNames.has(name) || - modules.has(name) - ) { + if (internalModules.has(name) || standardNames.has(name) || modules.has(name)) { return false; } const spec = findSpec(name); diff --git a/packages/tdev/pyodide-code/workers/messageDelivery.ts b/packages/tdev/pyodide-code/workers/messageDelivery.ts new file mode 100644 index 000000000..d39faeed8 --- /dev/null +++ b/packages/tdev/pyodide-code/workers/messageDelivery.ts @@ -0,0 +1,29 @@ +import type { Context, Message } from '../messages'; + +export const createMessageDelivery = (sendMessage: Context['sendMessage']) => { + const pending = new Set>(); + let failure: { error: unknown } | undefined; + + const send = (message: Message) => { + const delivery = Promise.resolve(sendMessage(message)); + pending.add(delivery); + delivery.then( + () => { + pending.delete(delivery); + }, + (error: unknown) => { + failure ??= { error }; + pending.delete(delivery); + } + ); + }; + + const finish = async () => { + await Promise.allSettled([...pending]); + if (failure) { + throw failure.error; + } + }; + + return { send, finish }; +}; diff --git a/packages/tdev/pyodide-code/workers/output.ts b/packages/tdev/pyodide-code/workers/output.ts index 390511ca3..730da378d 100644 --- a/packages/tdev/pyodide-code/workers/output.ts +++ b/packages/tdev/pyodide-code/workers/output.ts @@ -1,25 +1,14 @@ import type { PyodideAPI } from 'pyodide'; -import type { Context, Message } from '../pyodideJsModules'; +import type { Context } from '../messages'; +import { createMessageDelivery } from './messageDelivery'; export const captureOutput = (pyodide: PyodideAPI, id: string, context: Context) => { - const pending = new Set>(); - let deliveryError: unknown; - const send = (message: Message) => { - const delivery = Promise.resolve(context.sendMessage(message)); - pending.add(delivery); - delivery.then( - () => pending.delete(delivery), - (error: unknown) => { - deliveryError = error; - pending.delete(delivery); - } - ); - }; + const delivery = createMessageDelivery((message) => context.sendMessage(message)); const stdout = createOutputWriter((message) => - send({ type: 'log', message, id, timeStamp: context.getTime() }) + delivery.send({ type: 'log', message, id, timeStamp: context.getTime() }) ); const stderr = createOutputWriter((message) => - send({ type: 'error', message, id, timeStamp: context.getTime() }) + delivery.send({ type: 'error', message, id, timeStamp: context.getTime() }) ); pyodide.setStdout(stdout); pyodide.setStderr(stderr); @@ -33,10 +22,7 @@ export const captureOutput = (pyodide: PyodideAPI, id: string, context: Context) pyodide.setStdout(undefined); pyodide.setStderr(undefined); } - await Promise.all([...pending]); - if (deliveryError !== undefined) { - throw deliveryError; - } + await delivery.finish(); }; return { @@ -44,7 +30,7 @@ export const captureOutput = (pyodide: PyodideAPI, id: string, context: Context) clear() { stdout.flush(); stderr.flush(); - send({ type: 'clear', id, timeStamp: context.getTime() }); + delivery.send({ type: 'clear', id, timeStamp: context.getTime() }); } }; }; diff --git a/packages/tdev/pyodide-code/workers/pyodide.worker.ts b/packages/tdev/pyodide-code/workers/pyodide.worker.ts index d9c8c2475..bc4b81125 100644 --- a/packages/tdev/pyodide-code/workers/pyodide.worker.ts +++ b/packages/tdev/pyodide-code/workers/pyodide.worker.ts @@ -1,13 +1,15 @@ -import type { Context, Message } from '@tdev/pyodide-code/pyodideJsModules'; +import type { Context, Message } from '../messages'; +import registeredExtensions from '@tdev-pyodide/worker-extensions'; import * as Comlink from 'comlink'; import type { PyodideAPI } from 'pyodide'; import type { PyProxy } from 'pyodide/ffi'; import { loadPyodide } from '../loadPyodide'; import { loadPackages } from './helper.loadPackages'; +import { finalizeExecution } from './finalizeExecution'; import { configureInput } from './input'; import { captureOutput } from './output'; -import { registerJsModule } from './registerJsModule'; import { configureShell } from './shell'; +import { createWorkerExtensions } from '../extensions/worker'; interface Execution extends Context { id: string; @@ -35,7 +37,7 @@ export class PyWorker { async run( id: string, code: string, - sendMessage: (message: Message) => void, + sendMessage: Context['sendMessage'], filename = 'snippet.py', clientId = '', executionId = 0, @@ -74,18 +76,17 @@ export class PyWorker { private async execute(pyodide: PyodideAPI, execution: Execution): Promise { const { id, code, filename, clientId, executionId } = execution; pyodide.registerComlink(Comlink); - registerJsModule(pyodide, 'tdev', { - sendMessage: (message: Message) => { - execution.sendMessage(message); - } - }); - await this.preparePackages(pyodide, execution); - const output = captureOutput(pyodide, id, execution); + const extensions = createWorkerExtensions(execution, registeredExtensions); + let output: ReturnType | undefined; let restoreInput: (() => void) | undefined; let restoreShell: (() => void) | undefined; let globals: PyProxy | undefined; let result: unknown; + const errors: unknown[] = []; try { + extensions.register(pyodide); + await this.preparePackages(pyodide, execution); + output = captureOutput(pyodide, id, execution); const fname = filename.endsWith('.py') ? filename : `${filename}.py`; const dict = pyodide.globals.get('dict'); try { @@ -103,23 +104,25 @@ export class PyWorker { restoreShell = configureShell(pyodide, output.clear); result = await pyodide.runPythonAsync(code, { globals, filename: fname }); return result === undefined ? '' : `${result}`; + } catch (error: unknown) { + errors.push(error); + throw error; } finally { - try { - await output.finish(); - } finally { - try { - restoreShell?.(); - } finally { - try { - restoreInput?.(); - } finally { - globals?.destroy(); + await finalizeExecution( + [ + () => output?.finish(), + () => extensions.finish(), + () => restoreShell?.(), + () => restoreInput?.(), + () => globals?.destroy(), + () => { if (result instanceof pyodide.ffi.PyProxy) { result.destroy(); } } - } - } + ], + errors + ); } } @@ -134,10 +137,14 @@ export class PyWorker { } } }); + const errors: unknown[] = []; try { await loadPackages(pyodide, execution, execution.code, execution.packages); + } catch (error: unknown) { + errors.push(error); + throw error; } finally { - await output.finish(); + await finalizeExecution([() => output.finish()], errors); } } diff --git a/tdev-website/siteConfig.ts b/tdev-website/siteConfig.ts index 19e3718f9..5a7520214 100644 --- a/tdev-website/siteConfig.ts +++ b/tdev-website/siteConfig.ts @@ -1,6 +1,7 @@ // This file is used to configure the teaching-dev page. import { SiteConfigProvider } from '@tdev/siteConfig/siteConfig'; +import type { PluginOptions as PyodidePluginOptions } from '@tdev/pyodide-code/plugin'; import { themes as prismThemes } from 'prism-react-renderer'; import githubCmsPlugin from '../packages/hfr/github-cms/plugin'; import { @@ -111,7 +112,18 @@ const getSiteConfig: SiteConfigProvider = () => { }, plugins: [ brythonCodePluginConfig(), - require.resolve('@tdev/pyodide-code/plugin'), + [ + require.resolve('@tdev/pyodide-code/plugin'), + { + extensions: [ + { + name: 'clock', + worker: require.resolve('./website/pyodide/clock/worker'), + client: require.resolve('./website/pyodide/clock/client') + } + ] + } satisfies PyodidePluginOptions + ], yamlLoaderPluginConfig, githubCmsPlugin({ pages: {}, diff --git a/tdev-website/website/packages/pyodide-code/pyodideJsModules/siteModules.ts b/tdev-website/website/packages/pyodide-code/pyodideJsModules/siteModules.ts deleted file mode 100644 index 4e308dfde..000000000 --- a/tdev-website/website/packages/pyodide-code/pyodideJsModules/siteModules.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ModuleType } from '@tdev/pyodide-code/pyodideJsModules'; - -/** - * this file is to add custom pyodide js modules for the teaching-dev website - * ensure to remove this file from the updateTdev.config.yaml to avoid overwriting - */ - -declare module '@tdev/pyodide-code/pyodideJsModules' { - export interface MessageTypeMap { - clock: { - type: 'clock'; - id: string; - clockType: 'hours' | 'minutes' | 'seconds'; - value: number; - timeStamp: number; - }; - } -} - -export const siteModules: Partial = { - clock: (ctx) => { - const { sendMessage, getTime } = ctx; - return { - use_clock: (id: string) => { - let hours = 0; - let minutes = 0; - let seconds = 0; - return { - get minutes() { - return minutes; - }, - get hours() { - return hours; - }, - get seconds() { - return seconds; - }, - reset: () => { - hours = 0; - minutes = 0; - seconds = 0; - ['hours', 'minutes', 'seconds'].forEach((clockType) => { - sendMessage({ - type: 'clock', - clockType: clockType as 'hours' | 'minutes' | 'seconds', - value: 0, - id: id, - timeStamp: getTime() - }); - }); - }, - set_time: (h: number, m: number, s: number) => { - hours = h; - minutes = m; - seconds = s; - [ - ['hours', h], - ['minutes', m], - ['seconds', s] - ].forEach(([clockType, value]) => { - sendMessage({ - type: 'clock', - clockType: clockType as 'hours' | 'minutes' | 'seconds', - value: value as number, - id: id, - timeStamp: getTime() - }); - }); - }, - set_hours: (deg: number) => { - hours = deg; - sendMessage({ - type: 'clock', - clockType: 'hours', - value: deg, - id: id, - timeStamp: getTime() - }); - }, - set_minutes: (deg: number) => { - minutes = deg; - sendMessage({ - type: 'clock', - clockType: 'minutes', - value: deg, - id: id, - timeStamp: getTime() - }); - }, - set_seconds: (deg: number) => { - seconds = deg; - sendMessage({ - type: 'clock', - clockType: 'seconds', - value: deg, - id: id, - timeStamp: getTime() - }); - } - }; - } - }; - } -}; diff --git a/tdev-website/website/pyodide/clock/client.ts b/tdev-website/website/pyodide/clock/client.ts new file mode 100644 index 000000000..07d3691f5 --- /dev/null +++ b/tdev-website/website/pyodide/clock/client.ts @@ -0,0 +1,15 @@ +import { defineClientExtension } from '@tdev/pyodide-code/extensions'; +import type { ClockUpdate } from './protocol'; + +export default defineClientExtension(({ root }, { targetId, payload }) => { + const clock = root.siteStore.clockStore.useClock(targetId); + if (payload.hours !== undefined) { + clock.setHours(payload.hours); + } + if (payload.minutes !== undefined) { + clock.setMinutes(payload.minutes); + } + if (payload.seconds !== undefined) { + clock.setSeconds(payload.seconds); + } +}); diff --git a/tdev-website/website/pyodide/clock/protocol.ts b/tdev-website/website/pyodide/clock/protocol.ts new file mode 100644 index 000000000..36316c5e9 --- /dev/null +++ b/tdev-website/website/pyodide/clock/protocol.ts @@ -0,0 +1,7 @@ +export interface ClockTime { + hours: number; + minutes: number; + seconds: number; +} + +export type ClockUpdate = Partial; diff --git a/tdev-website/website/pyodide/clock/worker.ts b/tdev-website/website/pyodide/clock/worker.ts new file mode 100644 index 000000000..d6981f261 --- /dev/null +++ b/tdev-website/website/pyodide/clock/worker.ts @@ -0,0 +1,31 @@ +import { defineWorkerExtension } from '@tdev/pyodide-code/extensions'; +import type { ClockTime, ClockUpdate } from './protocol'; + +export default defineWorkerExtension((context) => ({ + clock: { + use_clock: (id: string) => { + const time: ClockTime = { hours: 0, minutes: 0, seconds: 0 }; + const update = (values: Partial) => { + Object.assign(time, values); + context.send(id, values); + }; + return { + get hours() { + return time.hours; + }, + get minutes() { + return time.minutes; + }, + get seconds() { + return time.seconds; + }, + reset: () => update({ hours: 0, minutes: 0, seconds: 0 }), + set_time: (hours: number, minutes: number, seconds: number) => + update({ hours, minutes, seconds }), + set_hours: (hours: number) => update({ hours }), + set_minutes: (minutes: number) => update({ minutes }), + set_seconds: (seconds: number) => update({ seconds }) + }; + } + } +})); diff --git a/tdev-website/website/stores/SiteStore.ts b/tdev-website/website/stores/SiteStore.ts index b65f3771e..09b6fdf30 100644 --- a/tdev-website/website/stores/SiteStore.ts +++ b/tdev-website/website/stores/SiteStore.ts @@ -1,38 +1,8 @@ import { RootStore } from '@tdev-stores/rootStore'; -import { Message } from '@tdev/pyodide-code/pyodideJsModules'; -import { action, observableRef } from 'mobx'; +import { observableRef } from 'mobx'; import ClockStore from './ClockStore'; export default class SiteStore { @observableRef accessor clockStore = new ClockStore(); constructor(root: RootStore) {} - - @action - handleMessage(from: string, message: any) { - switch (from) { - case 'pyodide': - const pyodideMessage = message as Message; - switch (pyodideMessage.type) { - case 'clock': - const clock = this.clockStore.useClock(message.id); - switch (message.clockType) { - case 'hours': - clock.setHours(message.value); - break; - case 'minutes': - clock.setMinutes(message.value); - break; - case 'seconds': - clock.setSeconds(message.value); - break; - } - break; - } - - break; - default: - // handle other messages here - break; - } - } }