Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ node_modules
package-lock.json
yarn.lock
pnpm-lock.yaml

# Generated framework wrappers
js/hang-ui/src/wrappers/
js/hang/src/wrappers/

# Generated manifest (created during build)
custom-elements.json
251 changes: 251 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

143 changes: 134 additions & 9 deletions js/hang-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,11 @@ Here's how you can use them (see also @moq/hang-demo for a complete example):
```

```html
<hang-publish-ui>
<hang-publish url="<MOQ relay URL>" path="<relay path>">
<video
style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;"
muted
autoplay
></video>
</hang-publish>
</hang-publish-ui>
<hang-publish-ui>
<hang-publish url="<MOQ relay URL>" path="<relay path>">
<video style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add a comment above this explaining that using the video tag causes MSE to be used with MOQ.

</hang-publish>
</hang-publish-ui>
```

## Project Structure
Expand Down Expand Up @@ -108,3 +104,132 @@ Common components and utilities used across the package.
- **Icon**: Icon wrapper component
- **Stats**: Provides real-time statistics monitoring for both audio and video streams. Uses a provider pattern to collect and display metrics.
- **CSS utilities**: Shared styles, variables, and flexbox utilities

---

## Build System & Code Generation

This package uses Custom Elements Manifest (CEM) to automatically generate framework-specific wrappers.

### How It Works

1. **CEM analysis** (`cem analyze`) scans Web Components and creates `custom-elements.json`
2. **JSDoc enhancement** extracts `@tag`, `@summary`, `@example` from source files
3. **Wrapper generation** creates typed framework components in `src/wrappers/<framework>/`

### Available Scripts

```bash
bun run prebuild # Generate CEM + framework wrappers
bun run build # Build package with Vite + TypeScript declarations
```

The code generator lives in `../scripts/element-wrappers/`:

### React Wrappers

Auto-generated from CEM, exported from `@moq/hang-ui/react`:

```tsx
import { HangWatchUI, HangPublishUI } from '@moq/hang-ui/react';

<HangWatchUI>
<hang-watch url="..." path="...">
<canvas />
</hang-watch>
</HangWatchUI>
```

### Generating Wrappers for Other Frameworks

To add Vue, Angular, or other frameworks:

#### 1. Create Generator

Create `../scripts/element-wrappers/generators/vue.ts`:

```ts
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { extractCustomElements, loadManifest, tagNameToComponentName } from "../utils/manifest";
import { formatCode, generateJSDoc } from "../utils/codegen";

function generateVueComponent(element) {
const name = tagNameToComponentName(element.tagName);
const jsDoc = generateJSDoc(
element.summary, element.description,
element.slots, element.events,
element.attributes, element.properties,
element.examples
);

return `${jsDoc}
export const ${name} = defineComponent({
name: '${name}',
template: '<${element.tagName}><slot /></${element.tagName}>',
});`;
}

export function generateVueWrappers(basePath = process.cwd()) {
console.log("\n🔧 Generating Vue wrappers...");

const manifest = loadManifest(basePath);
const elements = extractCustomElements(manifest);
if (elements.length === 0) return;

const output = `import { defineComponent } from 'vue';\n\n${
elements.map(generateVueComponent).join("\n")
}`;

const outputDir = join(basePath, "src", "wrappers", "vue");
mkdirSync(outputDir, { recursive: true });
writeFileSync(join(outputDir, "index.ts"), formatCode(output));

console.log(`✅ Generated ${elements.length} Vue wrapper(s)`);
}
```

#### 2. Register Generator

In `../scripts/element-wrappers/index.ts`:

```ts
import { generateVueWrappers } from "./generators/vue";

const generators = [
{ name: "React", fn: generateReactWrappers, enabled: true },
{ name: "Vue", fn: generateVueWrappers, enabled: true },
];
```

#### 3. Update Package Configuration

**package.json** - Add export:
```json
{
"exports": {
"./vue": {
"types": "./wrappers/vue/index.d.ts",
"default": "./src/wrappers/vue/index.ts"
}
}
}
```

**vite.config.ts** - Add build entry:
```ts
entry: {
"wrappers/vue/index": resolve(__dirname, "src/wrappers/vue/index.ts"),
}
```

#### 4. Run

```bash
bun run prebuild # Generates src/wrappers/vue/index.ts
bun run build # Compiles to dist/
```

For more details, see [`../scripts/element-wrappers/README.md`](../scripts/element-wrappers/README.md).

---
5 changes: 5 additions & 0 deletions js/hang-ui/cem.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
globs: ["src/publish/index.tsx", "src/watch/index.tsx"],
exclude: ["src/**/components/**", "src/**/hooks/**", "src/shared/**"],
outdir: ".",
};
22 changes: 18 additions & 4 deletions js/hang-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@
"license": "(MIT OR Apache-2.0)",
"repository": "github:moq-dev/moq",
"exports": {
"./publish": "./src/publish/index.tsx",
"./watch": "./src/watch/index.tsx"
"./publish": {
"types": "./publish/index.d.ts",
"default": "./src/publish/index.tsx"
},
"./watch": {
"types": "./watch/index.d.ts",
"default": "./src/watch/index.tsx"
},
"./react": {
"types": "./wrappers/react/index.d.ts",
"default": "./src/wrappers/react/index.ts"
}
Comment on lines +9 to +20
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check vite config for DTS plugin output settings
fd -t f 'vite.config' js/hang-ui/ --exec cat {}

Repository: moq-dev/moq

Length of output: 824


🏁 Script executed:

#!/bin/bash
# Check tsconfig for declaration output paths
fd -t f 'tsconfig*.json' js/hang-ui/ --exec cat {}

Repository: moq-dev/moq

Length of output: 258


🏁 Script executed:

#!/bin/bash
# Also check the actual package.json to see full context
cat js/hang-ui/package.json

Repository: moq-dev/moq

Length of output: 1518


🏁 Script executed:

#!/bin/bash
# Check if the package.ts script modifies the exports
fd -t f 'package.ts' js/scripts/ --exec cat {}

Repository: moq-dev/moq

Length of output: 2598


Fix the type declaration path transformation in the build script.

The vite-plugin-dts correctly outputs types to dist/ with .d.ts extensions (e.g., dist/publish/index.d.ts). However, the scripts/package.ts build script incorrectly transforms these paths: the rewritePath() function matches .d.ts with its .ts(x)?$ regex and converts it to .d.js. This causes ./publish/index.d.ts to become ./publish/index.d.js in the generated dist/package.json, while the actual files remain as .d.ts. Consumers will fail to resolve types.

Update scripts/package.ts to preserve .d.ts extensions—only .ts/.tsx sources (not declaration files) should convert to .js.

🤖 Prompt for AI Agents
In `@js/hang-ui/package.json` around lines 9 - 20, The rewritePath() function in
scripts/package.ts is incorrectly treating emitted declaration files as source
files and turning .d.ts into .d.js; update rewritePath to first detect and
return declaration filenames unchanged (e.g., if path matches /\.d\.tsx?$/ or
endsWith('.d.ts') return the original path), and only apply the source-file
transformation (replace /\.(ts|tsx)$/ -> '.js') for non-declaration .ts/.tsx
inputs so that emitted types remain as .d.ts in the generated package.json.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pzanella have you verified this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

},
"sideEffects": [
"./src/publish/index.tsx",
"./src/watch/index.tsx"
],
"scripts": {
"build": "bun run clean && vite build && bun ../scripts/package.ts",
"prebuild": "bunx cem analyze --config cem.config.js && bun ../scripts/element-wrappers/index.ts",
"build": "bun run clean && vite build && cp custom-elements.json dist/ && bun ../scripts/package.ts",
"check": "tsc --noEmit",
"clean": "rimraf dist",
"fix": "biome check src --fix",
Expand All @@ -25,6 +36,7 @@
"@moq/signals": "workspace:^0.1.0"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.11.0",
"@types/audioworklet": "^0.0.77",
"@typescript/lib-dom": "npm:@types/web@^0.0.241",
"rimraf": "^6.0.1",
Expand All @@ -33,6 +45,8 @@
"typescript": "^5.9.2",
"unplugin-solid": "^1.0.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-solid": "^2.11.10"
}
},
"customElements": "custom-elements.json"
}
11 changes: 3 additions & 8 deletions js/hang-ui/src/publish/element.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import type HangPublish from "@moq/hang/publish/element";
import PublishControls from "./components/PublishControls";
import PublishControlsContextProvider from "./context";
import styles from "./styles/index.css?inline";

export function PublishUI(props: { publish: HangPublish }) {
return (
<>
<style>{styles}</style>
<slot></slot>
<PublishControlsContextProvider hangPublish={props.publish}>
<PublishControls />
</PublishControlsContextProvider>
</>
<PublishControlsContextProvider hangPublish={props.publish}>
<PublishControls />
</PublishControlsContextProvider>
);
}
85 changes: 66 additions & 19 deletions js/hang-ui/src/publish/index.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,74 @@
import type HangPublish from "@moq/hang/publish/element";
import { customElement } from "solid-element";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to get rid of solid-element to support CEM? I thought it just needs the more detailed JS Docs comment? Although we're not making much use of it currently, solid-element makes handling setters and getters a lot easier than the vanilla Custom Element syntax, so I'd prefer to keep it if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, keeping solid-element for getters/setters makes sense. I'll revert those changes and focus on fixing the JSDoc comments for CEM support instead.

import { createSignal, onMount } from "solid-js";
import { Show } from "solid-js/web";
import { render } from "solid-js/web";
import { PublishUI } from "./element.tsx";
import styles from "./styles/index.css?inline";

customElement("hang-publish-ui", (_, { element }) => {
const [nested, setNested] = createSignal<HangPublish | undefined>();
/**
* @tag hang-publish-ui
* @summary Publish audio/video stream with UI controls
* @description A custom element that provides a complete user interface for publishing live video or audio streams
* over Media over QUIC (MOQ). Includes media source selection (camera, microphone, screen, file) and publishing controls.
*
* @slot default - Container for the hang-publish element
*
* @example HTML
* ```html
* <hang-publish-ui>
* <hang-publish url="https://example.com/relay" path="/stream">
* </hang-publish>
* </hang-publish-ui>
* ```
*
* @example React
* ```tsx
* import '@moq/hang/publish/element';
* import '@moq/hang-ui/publish';
* import { HangPublishUI } from '@moq/hang-ui/react';
*
* export function HangPublishComponent({ url, path }) {
* return (
* <HangPublishUI>
* <hang-publish url={url} path={path} />
* </HangPublishUI>
* );
* }
* ```
*/
class HangPublishComponent extends HTMLElement {
#root?: ShadowRoot;
#dispose?: () => void;
#connected = false;

onMount(async () => {
await customElements.whenDefined("hang-publish");
const publishEl = element.querySelector("hang-publish");
setNested(publishEl ? (publishEl as HangPublish) : undefined);
});
connectedCallback() {
this.#connected = true;

return (
<Show when={nested()} keyed>
{(publish: HangPublish) => <PublishUI publish={publish} />}
</Show>
);
});
// Reuse existing shadow root on reconnect (attachShadow throws if called twice)
this.#root ??= this.attachShadow({ mode: "open" });

declare global {
interface HTMLElementTagNameMap {
"hang-publish-ui": HTMLElement;
// Defer render to allow frameworks to append children first
queueMicrotask(() => {
if (!this.#connected || !this.#root) return;

const publish = this.querySelector("hang-publish") as HangPublish | null;
this.#dispose = render(
() => (
<>
<style>{styles}</style>
<slot />
{publish ? <PublishUI publish={publish} /> : null}
</>
),
this.#root,
);
});
}

disconnectedCallback() {
this.#connected = false;
this.#dispose?.();
this.#dispose = undefined;
}
}

customElements.define("hang-publish-ui", HangPublishComponent);
export { HangPublishComponent };
6 changes: 6 additions & 0 deletions js/hang-ui/src/publish/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@
@import "./source-button.css";
@import "./media-selector.css";
@import "./status-indicator.css";

/* Host element default styles */
:host {
position: relative;
display: block;
}
3 changes: 0 additions & 3 deletions js/hang-ui/src/watch/element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ import { Stats } from "../shared/components/stats";
import BufferingIndicator from "./components/BufferingIndicator";
import WatchControls from "./components/WatchControls";
import WatchUIContextProvider, { WatchUIContext } from "./context";
import styles from "./styles/index.css?inline";

export function WatchUI(props: { watch: HangWatch }) {
return (
<WatchUIContextProvider hangWatch={props.watch}>
<style>{styles}</style>
<div class="watchVideoContainer">
<slot />
{(() => {
const context = useContext(WatchUIContext);
if (!context) return null;
Expand Down
Loading
Loading