Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .changeset/few-monkeys-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---

---
87 changes: 84 additions & 3 deletions packages/genui/a2ui-playground/lynx-src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Resource } from '@lynx-js/a2ui-reactlynx/core';
import '@lynx-js/a2ui-reactlynx/catalog/all';
import {
useEffect,
useGlobalProps,
useInitData,
useMemo,
useRef,
Expand All @@ -32,6 +33,73 @@ function randomId(prefix: string) {
+ Math.random().toString(36).slice(2, 10);
}

function parseJsonLikeString(input: string): unknown {
try {
return JSON.parse(input) as unknown;
} catch {
// ignore
}

// Query params may arrive URL-encoded one or more times in native globalProps.
let current = input;
for (let i = 0; i < 3; i++) {
try {
const decoded = decodeURIComponent(current);
if (decoded === current) break;
current = decoded;
try {
return JSON.parse(current) as unknown;
} catch {
// keep decoding
}
} catch {
break;
}
}

return input;
}

function normalizeInitDataLike(raw: unknown): InitData {
if (raw === null || raw === undefined) return {};

if (typeof raw !== 'object') return {};

const obj = raw as Record<string, unknown>;
const out: InitData = {};

const messagesUrl = obj.messagesUrl;
if (typeof messagesUrl === 'string') out.messagesUrl = messagesUrl;

const actionMocksUrl = obj.actionMocksUrl;
if (typeof actionMocksUrl === 'string') out.actionMocksUrl = actionMocksUrl;

const messages = obj.messages;
if (messages !== undefined) {
out.messages = typeof messages === 'string'
? parseJsonLikeString(messages)
: messages;
}

const actionMocks = obj.actionMocks;
if (actionMocks !== undefined) {
out.actionMocks = typeof actionMocks === 'string'
? parseJsonLikeString(actionMocks)
: actionMocks;
Comment thread
Sherry-hue marked this conversation as resolved.
}

return out;
}

function mergeInitDataPreferLeft(a: InitData, b: InitData): InitData {
return {
messagesUrl: a.messagesUrl ?? b.messagesUrl,
messages: a.messages ?? b.messages,
actionMocksUrl: a.actionMocksUrl ?? b.actionMocksUrl,
actionMocks: a.actionMocks ?? b.actionMocks,
};
}

function normalizePayloadToMessages(payload: unknown): ResponseMessages {
if (payload === null || payload === undefined) {
return [];
Expand Down Expand Up @@ -105,6 +173,7 @@ async function loadActionMocks(initData: InitData): Promise<ActionMocks> {
}

export function App() {
const globalProps = useGlobalProps();
const rawInitData = useInitData();

const initData = useMemo(() => {
Expand All @@ -118,6 +187,18 @@ export function App() {
return (rawInitData ?? {}) as InitData;
}, [rawInitData]);

const globalPropsData = useMemo(
() => normalizeInitDataLike(globalProps),
[globalProps],
);

// Native in-app preview passes A2UI payload via `globalProps` (often from URL query).
// Web preview may still provide `initData`, so keep fallback for compatibility.
const effectiveData = useMemo(
() => mergeInitDataPreferLeft(globalPropsData, initData),
[globalPropsData, initData],
);

// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const clientRef = useRef<any>(null);

Expand All @@ -133,8 +214,8 @@ export function App() {
setError('');

const [rawMessages, actionMocks] = await Promise.all([
loadMessages(initData ?? {}),
loadActionMocks(initData ?? {}),
loadMessages(effectiveData ?? {}),
loadActionMocks(effectiveData ?? {}),
]);

const messageId = randomId('demo_');
Expand Down Expand Up @@ -228,7 +309,7 @@ export function App() {
return () => {
cancelled = true;
};
}, [initData]);
}, [effectiveData]);

return (
<view
Expand Down
129 changes: 129 additions & 0 deletions packages/genui/a2ui-playground/lynx.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,138 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';

import type { RsbuildPlugin } from '@rsbuild/core';

import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin';
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
import { defineConfig } from '@lynx-js/rspeedy';

// In-memory A2UI payload store. Keeps the dev-bundle / render URLs short
// enough to fit inside a scannable QR code.
interface StoredPayload {
messages: unknown;
actionMocks?: unknown;
createdAt: number;
}
const payloadStore = new Map<string, StoredPayload>();
const PAYLOAD_TTL_MS = 30 * 60 * 1000; // 30 minutes

function gcPayloads(): void {
const now = Date.now();
for (const [id, p] of payloadStore) {
if (now - p.createdAt > PAYLOAD_TTL_MS) {
payloadStore.delete(id);
}
}
}

function readJsonBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8');
resolve(raw ? JSON.parse(raw) : {});
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
});
req.on('error', reject);
});
}
Comment thread
Sherry-hue marked this conversation as resolved.

function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(body));
}

function a2uiPayloadMiddleware(
req: IncomingMessage,
res: ServerResponse,
next: () => void,
): void {
const url = req.url ?? '';

if (
req.method === 'OPTIONS'
&& (url.startsWith('/__a2ui_payload') || url.startsWith('/__a2ui/'))
) {
res.statusCode = 204;
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.end();
return;
}

if (req.method === 'POST' && url.startsWith('/__a2ui_payload')) {
void (async () => {
try {
gcPayloads();
const body = (await readJsonBody(req)) as {
messages?: unknown;
actionMocks?: unknown;
};
const id = randomUUID();
payloadStore.set(id, {
messages: body.messages,
actionMocks: body.actionMocks,
createdAt: Date.now(),
});
sendJson(res, 200, {
id,
messagesUrl: `/__a2ui/${id}/messages`,
actionMocksUrl: body.actionMocks === undefined
? undefined
: `/__a2ui/${id}/actionMocks`,
});
} catch (e) {
sendJson(res, 400, {
error: e instanceof Error ? e.message : 'bad request',
});
}
})();
return;
}

if (req.method === 'GET' && url.startsWith('/__a2ui/')) {
const m = /^\/__a2ui\/([^/]+)\/(messages|actionMocks)(?:\?|$)/.exec(url);
if (m) {
const [, id, field] = m;
const entry = id ? payloadStore.get(id) : undefined;
if (entry) {
const value = field === 'messages'
? entry.messages
: entry.actionMocks;
sendJson(res, 200, value ?? null);
return;
}
}
sendJson(res, 404, { error: 'not found' });
return;
}

next();
}

const a2uiPayloadPlugin: RsbuildPlugin = {
name: 'a2ui-playground:payload-store',
setup(api) {
api.onBeforeStartDevServer(({ server }) => {
server.middlewares.use(a2uiPayloadMiddleware);
});
},
};

export default defineConfig({
plugins: [
pluginQRCode({
Expand All @@ -18,6 +146,7 @@ export default defineConfig({
pluginReactLynx({
defaultDisplayLinear: false,
}),
a2uiPayloadPlugin,
],
source: {
entry: {
Expand Down
2 changes: 1 addition & 1 deletion packages/genui/a2ui-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "rsbuild build",
"build:lynx": "rspeedy build",
"dev": "rsbuild dev",
"dev": "pnpm run build:lynx && rsbuild dev",
"dev:lynx": "rspeedy dev",
"preview": "rsbuild preview",
"preview:lynx": "rspeedy preview"
Expand Down
48 changes: 48 additions & 0 deletions packages/genui/a2ui-playground/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { networkInterfaces } from 'node:os';

import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

const PORT = Number(process.env.PORT ?? 3000);

function findLocalIp(): string {
const ifaces = networkInterfaces();
for (const name of Object.keys(ifaces)) {
const list = ifaces[name] ?? [];
for (
const net of list as Array<{
address: string;
family: string | number;
internal: boolean;
}>
) {
const family = typeof net.family === 'string'
? net.family
: `IPv${net.family}`;
if (family === 'IPv4' && !net.internal) {
return net.address;
}
}
}
return '127.0.0.1';
}

function buildRspeedyBundleUrl(port: number): string {
const ip = findLocalIp();
return `http://${ip}:${port}/main.lynx.js`;
}

export default defineConfig({
plugins: [pluginReact()],
source: {
Expand All @@ -13,6 +44,7 @@ export default defineConfig({
},
},
server: {
port: PORT,
host: '0.0.0.0',
cors: {
origin: '*',
Expand All @@ -25,4 +57,20 @@ export default defineConfig({
},
],
},
dev: {
setupMiddlewares: [
(middlewares) => {
middlewares.unshift((req, res, next) => {
if (req.url?.startsWith('/__rspeedy_url')) {
const url = buildRspeedyBundleUrl(req.socket.localPort ?? PORT);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ url }));
return;
}
next();
});
},
],
},
});
Loading
Loading