(null)
+
+ const eventHandler = (eventName: string) => (e: Event) => {
+ console.log(eventName, e)
+ }
+
+ let inputBuffer = ''
+ let messageListener: ((message: unknown) => void) | undefined
+
+ return (
+ {
+ if (!el || el.hasChildNodes()) {
+ return
+ }
+
+ const ctxBridge = {
+ postMessage: (message: any) => {
+ if (message?.type === ClientMessages.terminalStdin) {
+ const input = (message.output?.input as string) ?? ''
+ for (const ch of input) {
+ if (ch === '\r' || ch === '\n') {
+ const trimmed = inputBuffer.trim()
+ messageListener?.({
+ type: ClientMessages.terminalStdout,
+ output: {
+ 'runme.dev/id': consoleID,
+ data: `\r\nGot input: ${trimmed}\r\n> `,
+ },
+ })
+ inputBuffer = ''
+ continue
+ }
+
+ // handle backspace/delete
+ if (ch === '\u0008' || ch === '\u007f') {
+ inputBuffer = inputBuffer.slice(0, -1)
+ messageListener?.({
+ type: ClientMessages.terminalStdout,
+ output: {
+ 'runme.dev/id': consoleID,
+ data: '\b \b',
+ },
+ })
+ continue
+ }
+
+ inputBuffer += ch
+ messageListener?.({
+ type: ClientMessages.terminalStdout,
+ output: {
+ 'runme.dev/id': consoleID,
+ data: ch,
+ },
+ })
+ }
+ }
+ },
+ onDidReceiveMessage: (listener: (message: unknown) => void) => {
+ messageListener = listener
+ listener({
+ type: ClientMessages.terminalStdout,
+ output: {
+ 'runme.dev/id': consoleID,
+ data: 'Welcome to the app console\n> ',
+ },
+ } as any)
+ return {
+ dispose: () => {},
+ }
+ },
+ } as RendererContext
+ setContext(ctxBridge, consoleID)
+
+ const elem = document.createElement('console-view')
+ elemRef.current = elem
+
+ elem.addEventListener('stdout', eventHandler('stdout'))
+ elem.addEventListener('stderr', eventHandler('stderr'))
+ elem.addEventListener('exitcode', eventHandler('exitcode'))
+ elem.addEventListener('pid', eventHandler('pid'))
+ elem.addEventListener('mimetype', eventHandler('mimetype'))
+
+ elem.setAttribute('id', consoleID)
+ elem.setAttribute('takeFocus', 'false')
+ elem.setAttribute('buttons', 'false')
+ elem.setAttribute('initialContent', 'Hello, world!\n')
+ elem.setAttribute('theme', 'dark')
+ elem.setAttribute('fontFamily', 'monospace')
+ elem.setAttribute('fontSize', '12')
+ elem.setAttribute('cursorStyle', 'block')
+ elem.setAttribute('cursorBlink', 'true')
+ elem.setAttribute('cursorWidth', '1')
+ elem.setAttribute('smoothScrollDuration', '0')
+ elem.setAttribute('scrollback', '4000')
+
+ el.appendChild(elem)
+
+ return () => {
+ removeContext(consoleID)
+ }
+ }}
+ >
+ )
+}
+
+export default DemoConsole
diff --git a/packages/renderers/src/components/closeCellButton.ts b/packages/renderers/src/components/closeCellButton.ts
index c0513cb..e07f18e 100644
--- a/packages/renderers/src/components/closeCellButton.ts
+++ b/packages/renderers/src/components/closeCellButton.ts
@@ -1,7 +1,8 @@
import { LitElement, css, html } from 'lit'
-import { customElement } from 'lit/decorators.js'
-@customElement('close-cell-button')
+import { safeCustomElement } from '../decorators'
+
+@safeCustomElement('close-cell-button')
export class CloseCellButton extends LitElement {
/* eslint-disable */
static styles = css`
diff --git a/packages/renderers/src/components/console/actionButton.ts b/packages/renderers/src/components/console/actionButton.ts
index 5f1d9eb..a50eced 100644
--- a/packages/renderers/src/components/console/actionButton.ts
+++ b/packages/renderers/src/components/console/actionButton.ts
@@ -1,11 +1,12 @@
import { LitElement, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
+import { safeCustomElement } from '../../decorators'
import { SaveIcon } from '../icons/save'
import { ShareIcon } from '../icons/share'
-@customElement('action-button')
+@safeCustomElement('action-button')
export class ActionButton extends LitElement {
@property({ type: String })
text: string = 'Copy'
diff --git a/packages/renderers/src/components/console/gistCell.ts b/packages/renderers/src/components/console/gistCell.ts
index 33c9aab..ccce403 100644
--- a/packages/renderers/src/components/console/gistCell.ts
+++ b/packages/renderers/src/components/console/gistCell.ts
@@ -1,10 +1,11 @@
import { LitElement, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
+import { safeCustomElement } from '../../decorators'
import { GistIcon } from '../icons/gistIcon'
-@customElement('gist-cell')
+@safeCustomElement('gist-cell')
export class GistCell extends LitElement {
@property({ type: String })
text: string = 'Preview & Gist'
diff --git a/packages/renderers/src/components/console/open.ts b/packages/renderers/src/components/console/open.ts
index c156140..284487d 100644
--- a/packages/renderers/src/components/console/open.ts
+++ b/packages/renderers/src/components/console/open.ts
@@ -1,10 +1,11 @@
import { LitElement, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
+import { safeCustomElement } from '../../decorators'
import { EyeIcon } from '../icons/eye'
-@customElement('open-cell')
+@safeCustomElement('open-cell')
export class OpenCell extends LitElement {
@property({ type: String })
openText: string = 'Open'
diff --git a/packages/renderers/src/components/console/runme.ts b/packages/renderers/src/components/console/runme.ts
index afc5c3f..88923b5 100644
--- a/packages/renderers/src/components/console/runme.ts
+++ b/packages/renderers/src/components/console/runme.ts
@@ -10,11 +10,12 @@ import {
import { create } from '@bufbuild/protobuf'
import { Interceptor } from '@connectrpc/connect'
import { LitElement, PropertyValues, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { Disposable } from 'vscode'
import { type RendererContext } from 'vscode-notebook-renderer'
import { type VSCodeEvent } from 'vscode-notebook-renderer/events'
+import { safeCustomElement } from '../../decorators'
import { setContext } from '../../messaging'
import Streams from '../../streams'
import { ClientMessages } from '../../types'
@@ -31,7 +32,7 @@ export interface RunmeConsoleStream {
export const RUNME_CONSOLE = 'runme-console'
-@customElement(RUNME_CONSOLE)
+@safeCustomElement(RUNME_CONSOLE)
export class RunmeConsole extends LitElement {
protected disposables: Disposable[] = []
diff --git a/packages/renderers/src/components/console/saveButton.ts b/packages/renderers/src/components/console/saveButton.ts
index f166e5d..905cdce 100644
--- a/packages/renderers/src/components/console/saveButton.ts
+++ b/packages/renderers/src/components/console/saveButton.ts
@@ -1,9 +1,10 @@
import { LitElement, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
+import { safeCustomElement } from '../../decorators'
import './actionButton'
-@customElement('save-button')
+@safeCustomElement('save-button')
export class SaveButton extends LitElement {
@property({ type: Boolean, reflect: true })
loading: boolean = false
diff --git a/packages/renderers/src/components/console/shareButton.ts b/packages/renderers/src/components/console/shareButton.ts
index 54dac10..17f8b36 100644
--- a/packages/renderers/src/components/console/shareButton.ts
+++ b/packages/renderers/src/components/console/shareButton.ts
@@ -1,9 +1,10 @@
import { LitElement, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
+import { safeCustomElement } from '../../decorators'
import './actionButton'
-@customElement('share-button')
+@safeCustomElement('share-button')
export class ShareButton extends LitElement {
@property({ type: Boolean, reflect: true })
loading: boolean = false
diff --git a/packages/renderers/src/components/console/view.ts b/packages/renderers/src/components/console/view.ts
index e3a999a..5b8fa05 100644
--- a/packages/renderers/src/components/console/view.ts
+++ b/packages/renderers/src/components/console/view.ts
@@ -3,7 +3,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { ITheme, Terminal as XTermJS } from '@xterm/xterm'
import { LitElement, PropertyValues, css, html, unsafeCSS } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
import { Observable } from 'rxjs'
import {
@@ -14,7 +14,9 @@ import {
share,
} from 'rxjs/operators'
import { Disposable, TerminalDimensions } from 'vscode'
+import { RendererContext } from 'vscode-notebook-renderer'
+import { safeCustomElement } from '../../decorators'
import { FitAddon, type ITerminalDimensions } from '../../fitAddon'
import { getContext, onClientMessage, postClientMessage } from '../../messaging'
import { ClientMessages, OutputType, WebViews } from '../../types'
@@ -90,7 +92,7 @@ const ANSI_COLORS = [
export const CONSOLE_VIEW = 'console-view'
-@customElement(CONSOLE_VIEW)
+@safeCustomElement(CONSOLE_VIEW)
export class ConsoleView extends LitElement {
protected copyText = 'Copy'
@@ -463,6 +465,14 @@ export class ConsoleView extends LitElement {
// For 'vscode' theme, no additional styles are applied
}
+ protected getContext(): RendererContext {
+ try {
+ return getContext(this.id)
+ } catch {
+ return getContext()
+ }
+ }
+
connectedCallback(): void {
super.connectedCallback()
@@ -525,7 +535,7 @@ export class ConsoleView extends LitElement {
this.terminal.unicode.activeVersion = '11'
this.terminal.options.drawBoldTextInBrightColors
- const ctx = getContext()
+ const ctx = this.getContext()
this.disposables.push(
// todo(sebastian): what's the type of e?
@@ -726,7 +736,7 @@ export class ConsoleView extends LitElement {
this.#subscribeSetTerminalRows(dims)
terminalContainer.appendChild(resizeDragHandle)
- const ctx = getContext()
+ const ctx = this.getContext()
ctx.postMessage &&
postClientMessage(ctx, ClientMessages.terminalOpen, {
'runme.dev/id': this.id!,
@@ -905,7 +915,7 @@ export class ConsoleView extends LitElement {
)
const sub = debounced$.subscribe(async (terminalDimensions) => {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
@@ -930,7 +940,7 @@ export class ConsoleView extends LitElement {
)
const sub = debounced$.subscribe(async (terminalRows) => {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
@@ -963,7 +973,7 @@ export class ConsoleView extends LitElement {
this.terminal?.focus()
}
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
@@ -974,7 +984,7 @@ export class ConsoleView extends LitElement {
}
async #displayShareDialog(): Promise {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage || !this.shareUrl) {
return
}
@@ -1016,7 +1026,7 @@ export class ConsoleView extends LitElement {
}
async #triggerOpenEscalation(): Promise {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!this.escalationUrl) {
return
@@ -1026,7 +1036,7 @@ export class ConsoleView extends LitElement {
}
#openSessionOutput(): Promise | undefined {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
@@ -1042,7 +1052,7 @@ export class ConsoleView extends LitElement {
async #shareCellOutput(
_isUserAction: boolean
): Promise {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
@@ -1084,17 +1094,21 @@ export class ConsoleView extends LitElement {
}
#onWebLinkClick(_event: MouseEvent, uri: string): void {
- postClientMessage(getContext(), ClientMessages.openLink, uri)
+ postClientMessage(this.getContext(), ClientMessages.openLink, uri)
}
#triggerOpenCellOutput(): void {
- postClientMessage(getContext(), ClientMessages.openLink, this.shareUrl!)
+ postClientMessage(
+ this.getContext(),
+ ClientMessages.openLink,
+ this.shareUrl!
+ )
}
#onEscalateDisabled(): void {
const message =
'There is no Slack integration configured yet. \nOpen Dashboard to configure it'
- postClientMessage(getContext(), ClientMessages.errorMessage, message)
+ postClientMessage(this.getContext(), ClientMessages.errorMessage, message)
}
// Render the UI as a function of component state
@@ -1205,7 +1219,7 @@ export class ConsoleView extends LitElement {
}
#copy() {
- const ctx = getContext()
+ const ctx = this.getContext()
if (!ctx.postMessage) {
return
}
diff --git a/packages/renderers/src/components/copyButton.ts b/packages/renderers/src/components/copyButton.ts
index 498e6a8..38e0c3b 100644
--- a/packages/renderers/src/components/copyButton.ts
+++ b/packages/renderers/src/components/copyButton.ts
@@ -1,9 +1,10 @@
import { LitElement, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
+import { safeCustomElement } from '../decorators'
import { CopyIcon } from './icons/copy'
-@customElement('copy-button')
+@safeCustomElement('copy-button')
export class CopyButton extends LitElement {
@property({ type: String })
copyText: string = 'Copy'
diff --git a/packages/renderers/src/components/dropdownlist.ts b/packages/renderers/src/components/dropdownlist.ts
index 76a7928..3f193ae 100644
--- a/packages/renderers/src/components/dropdownlist.ts
+++ b/packages/renderers/src/components/dropdownlist.ts
@@ -1,5 +1,7 @@
import { LitElement, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
+
+import { safeCustomElement } from '../decorators'
export interface DropdownListOption {
text: string
@@ -12,7 +14,7 @@ export type DropdownListEvent = {
key: string
}
-@customElement('dropdown-list')
+@safeCustomElement('dropdown-list')
export class DropdownList extends LitElement {
@property({ type: String })
label: string | undefined
diff --git a/packages/renderers/src/components/env/store.ts b/packages/renderers/src/components/env/store.ts
index 001dec9..b2d0353 100644
--- a/packages/renderers/src/components/env/store.ts
+++ b/packages/renderers/src/components/env/store.ts
@@ -1,9 +1,10 @@
import { MonitorEnvStoreResponseSnapshot_SnapshotEnv } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb'
import { MonitorEnvStoreResponseSnapshot_Status } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb'
import { LitElement, TemplateResult, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { Disposable } from 'vscode'
+import { safeCustomElement } from '../../decorators'
import { formatDate, formatDateWithTimeAgo } from '../../utils'
import '../envViewer'
import { CustomErrorIcon } from '../icons/error'
@@ -45,7 +46,7 @@ const COLUMNS = [
const HIDDEN_COLUMNS = ['resolvedValue', 'errors', 'status', 'specClass']
-@customElement('env-store')
+@safeCustomElement('env-store')
export default class Table extends LitElement {
protected disposables: Disposable[] = []
diff --git a/packages/renderers/src/components/envViewer.ts b/packages/renderers/src/components/envViewer.ts
index 13b91ba..d781d52 100644
--- a/packages/renderers/src/components/envViewer.ts
+++ b/packages/renderers/src/components/envViewer.ts
@@ -1,16 +1,17 @@
import { MonitorEnvStoreResponseSnapshot_Status } from '@buf/runmedev_runme.bufbuild_es/runme/runner/v2/runner_pb'
import { LitElement, TemplateResult, css, html } from 'lit'
-import { customElement, property, state } from 'lit/decorators.js'
+import { property, state } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
import { Disposable } from 'vscode'
+import { safeCustomElement } from '../decorators'
import { CheckIcon } from './icons/check'
import { CopyIcon } from './icons/copy'
import { EyeIcon } from './icons/eye'
import { EyeClosedIcon } from './icons/eyeClosed'
import './tooltip'
-@customElement('env-viewer')
+@safeCustomElement('env-viewer')
export class EnvViewer extends LitElement implements Disposable {
protected disposables: Disposable[] = []
diff --git a/packages/renderers/src/components/table/index.ts b/packages/renderers/src/components/table/index.ts
index 8c2e210..0d051da 100644
--- a/packages/renderers/src/components/table/index.ts
+++ b/packages/renderers/src/components/table/index.ts
@@ -1,13 +1,15 @@
import { LitElement, TemplateResult, css, html, nothing } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
+import { safeCustomElement } from '../../decorators'
+
export interface Column {
text: string
colspan?: number | undefined
}
-@customElement('table-view')
+@safeCustomElement('table-view')
export class Table extends LitElement {
@property({ type: Array })
columns?: Column[] = []
diff --git a/packages/renderers/src/components/tooltip.ts b/packages/renderers/src/components/tooltip.ts
index 59321ba..424b40d 100644
--- a/packages/renderers/src/components/tooltip.ts
+++ b/packages/renderers/src/components/tooltip.ts
@@ -1,8 +1,10 @@
import { LitElement, TemplateResult, css, html } from 'lit'
-import { customElement, property } from 'lit/decorators.js'
+import { property } from 'lit/decorators.js'
import { when } from 'lit/directives/when.js'
-@customElement('tooltip-text')
+import { safeCustomElement } from '../decorators'
+
+@safeCustomElement('tooltip-text')
export class Tooltip extends LitElement {
@property({ type: String })
tooltipText: string | TemplateResult<1> | undefined
diff --git a/packages/renderers/src/decorators.ts b/packages/renderers/src/decorators.ts
new file mode 100644
index 0000000..c427956
--- /dev/null
+++ b/packages/renderers/src/decorators.ts
@@ -0,0 +1,12 @@
+/**
+ * A safe version of @customElement that checks if the element is already defined
+ * before registering it. This prevents errors when the same module is imported multiple times.
+ */
+export function safeCustomElement(tagName: string) {
+ return function (constructor: T): T {
+ if (!customElements.get(tagName)) {
+ customElements.define(tagName, constructor)
+ }
+ return constructor
+ }
+}
diff --git a/packages/renderers/src/index.ts b/packages/renderers/src/index.ts
index 54e2f56..840d830 100644
--- a/packages/renderers/src/index.ts
+++ b/packages/renderers/src/index.ts
@@ -1,10 +1,12 @@
import './components'
-import { getContext, setContext } from './messaging'
+import { getContext, removeContext, setContext } from './messaging'
import { ClientMessages } from './types'
+export { type RendererContext } from 'vscode-notebook-renderer'
+
export { default as Streams, type Authorization } from './streams'
export { genRunID, Heartbeat, type StreamError } from './streams'
export { ConsoleView, type ConsoleViewConfig } from './components/console'
export { type RunmeConsoleStream } from './components/console/runme'
-export { setContext, getContext, ClientMessages }
+export { setContext, getContext, removeContext, ClientMessages }
diff --git a/packages/renderers/src/messaging.ts b/packages/renderers/src/messaging.ts
index a35b179..c59949f 100644
--- a/packages/renderers/src/messaging.ts
+++ b/packages/renderers/src/messaging.ts
@@ -3,7 +3,20 @@ import { RendererContext } from 'vscode-notebook-renderer'
import { ClientMessage, ClientMessagePayload } from './types'
-let context: RendererContext | undefined
+const DEFAULT_NAMESPACE = 'runme'
+// Use a global symbol to ensure the contexts Map is shared across all module instances
+// This is necessary because Vite/bundlers may create separate module instances
+const GLOBAL_CONTEXTS_KEY = Symbol.for('@runmedev/renderers:contexts')
+
+function getContextsMap(): Map> {
+ const globalObj = globalThis as any
+ if (!globalObj[GLOBAL_CONTEXTS_KEY]) {
+ globalObj[GLOBAL_CONTEXTS_KEY] = new Map>()
+ }
+ return globalObj[GLOBAL_CONTEXTS_KEY]
+}
+
+const contexts = getContextsMap()
interface Messaging {
postMessage(msg: unknown): Thenable | Thenable | void
@@ -30,13 +43,21 @@ export function onClientMessage(
return messaging.onDidReceiveMessage?.(cb) ?? { dispose: () => {} }
}
-export function getContext() {
+export function getContext(namespace?: string) {
+ const ns = namespace ?? DEFAULT_NAMESPACE
+ const context = contexts.get(ns)
if (!context) {
- throw new Error('Renderer context not defined')
+ throw new Error(`Renderer context not defined for namespace: ${ns}`)
}
return context
}
-export function setContext(c: RendererContext) {
- context = c
+export function setContext(c: RendererContext, namespace?: string) {
+ const ns = namespace ?? DEFAULT_NAMESPACE
+ contexts.set(ns, c)
+}
+
+export function removeContext(namespace?: string) {
+ const ns = namespace ?? DEFAULT_NAMESPACE
+ contexts.delete(ns)
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e2754a..d2ff7b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -232,6 +232,9 @@ importers:
'@runmedev/react-console':
specifier: workspace:*
version: link:../react-console
+ '@runmedev/renderers':
+ specifier: workspace:*
+ version: link:../renderers
packages/react-console:
dependencies:
@@ -299,7 +302,7 @@ importers:
version: 14.6.1(@testing-library/dom@10.4.1)
'@vitejs/plugin-react':
specifier: ^4.0.0
- version: 4.7.0(vite@5.4.20(@types/node@20.12.7)(lightningcss@1.30.1))
+ version: 4.7.0(vite@6.3.6(@types/node@20.12.7)(jiti@2.6.1)(lightningcss@1.30.1))
'@vitest/ui':
specifier: ^2.0.0
version: 2.1.9(vitest@2.1.9)
@@ -5738,18 +5741,6 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-react@4.7.0(vite@5.4.20(@types/node@20.12.7)(lightningcss@1.30.1))':
- dependencies:
- '@babel/core': 7.28.4
- '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4)
- '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4)
- '@rolldown/pluginutils': 1.0.0-beta.27
- '@types/babel__core': 7.20.5
- react-refresh: 0.17.0
- vite: 5.4.20(@types/node@20.12.7)(lightningcss@1.30.1)
- transitivePeerDependencies:
- - supports-color
-
'@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@20.12.7)(jiti@2.6.1)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.4