Files
upage-git/app/lib/runtime/message-parser.ts
2025-09-24 17:02:44 +08:00

326 lines
9.3 KiB
TypeScript

import type { UPageAction, UPageActionData } from '~/types/actions';
import type { UPageArtifactData } from '~/types/artifact';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
const ARTIFACT_TAG_OPEN = '<uPageArtifact';
const ARTIFACT_TAG_CLOSE = '</uPageArtifact>';
const ARTIFACT_ACTION_TAG_OPEN = '<uPageAction';
const ARTIFACT_ACTION_TAG_CLOSE = '</uPageAction>';
const logger = createScopedLogger('MessageParser');
export interface ArtifactCallbackData extends UPageArtifactData {
messageId: string;
}
export interface ActionCallbackData {
artifactId: string;
messageId: string;
actionId: string;
action: UPageAction;
}
export type ArtifactCallback = (data: ArtifactCallbackData) => void;
export type ActionCallback = (data: ActionCallbackData) => void;
export interface ParserCallbacks {
onArtifactOpen?: ArtifactCallback;
onArtifactClose?: ArtifactCallback;
onActionOpen?: ActionCallback;
onActionStream?: ActionCallback;
onActionClose?: ActionCallback;
}
interface ElementFactoryProps {
messageId: string;
}
type ElementFactory = (props: ElementFactoryProps) => string;
export interface StreamingMessageParserOptions {
callbacks?: ParserCallbacks;
artifactElement?: ElementFactory;
}
interface MessageState {
position: number;
insideArtifact: boolean;
insideAction: boolean;
currentArtifact?: UPageArtifactData;
currentAction: UPageActionData;
actionId: number;
}
export class StreamingMessageParser {
#messages = new Map<string, MessageState>();
constructor(private _options: StreamingMessageParserOptions = {}) {}
parse(messageId: string, input: string) {
let state = this.#messages.get(messageId);
if (!state) {
state = {
position: 0,
insideAction: false,
insideArtifact: false,
currentAction: { content: '' },
actionId: 0,
};
this.#messages.set(messageId, state);
}
let output = '';
let i = state.position;
let earlyBreak = false;
while (i < input.length) {
if (state.insideArtifact) {
const currentArtifact = state.currentArtifact;
if (currentArtifact === undefined) {
unreachable('Artifact not initialized');
}
if (state.insideAction) {
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
const currentAction = state.currentAction;
if (closeIndex !== -1) {
currentAction.content += input.slice(i, closeIndex);
currentAction.content = currentAction.content.trim();
this._options.callbacks?.onActionClose?.({
artifactId: currentArtifact.id,
messageId,
/**
* We decrement the id because it's been incremented already
* when `onActionOpen` was emitted to make sure the ids are
* the same.
*/
actionId: String(state.actionId - 1),
action: currentAction as UPageAction,
});
state.insideAction = false;
state.currentAction = { content: '' };
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
} else {
const content = input.slice(i);
this._options.callbacks?.onActionStream?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId - 1),
action: {
...(currentAction as UPageAction),
content,
},
});
break;
}
} else {
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
const actionEndIndex = input.indexOf('>', actionOpenIndex);
if (actionEndIndex !== -1) {
state.insideAction = true;
state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
this._options.callbacks?.onActionOpen?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId++),
action: state.currentAction as UPageAction,
});
i = actionEndIndex + 1;
} else {
break;
}
} else if (artifactCloseIndex !== -1) {
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
state.insideArtifact = false;
state.currentArtifact = undefined;
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
} else {
break;
}
}
} else if (input[i] === '<' && input[i + 1] !== '/') {
let j = i;
let potentialTag = '';
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
potentialTag += input[j];
if (potentialTag === ARTIFACT_TAG_OPEN) {
const nextChar = input[j + 1];
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
const openTagEnd = input.indexOf('>', j);
if (openTagEnd !== -1) {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
const artifactName = this.#extractAttribute(artifactTag, 'name') as string;
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
if (!artifactId || !artifactName) {
logger.warn('Artifact id or name missing');
}
if (!artifactTitle) {
logger.warn('Artifact title missing');
}
state.insideArtifact = true;
const currentArtifact = {
id: artifactId,
name: artifactName,
title: artifactTitle,
} satisfies UPageArtifactData;
state.currentArtifact = currentArtifact;
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
const artifactFactory = this._options.artifactElement ?? createArtifactElement;
output += artifactFactory({ messageId });
i = openTagEnd + 1;
} else {
earlyBreak = true;
}
break;
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
j++;
}
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
break;
}
} else {
output += input[i];
i++;
}
if (earlyBreak) {
break;
}
}
state.position = i;
return output;
}
reset() {
this.#messages.clear();
}
#parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) {
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
const actionAttributes: UPageAction = {
id: '',
pageName: '',
action: 'add',
domId: '',
content: '',
rootDomId: '',
validRootDomId: false,
};
const id = this.#extractAttribute(actionTag, 'id') as string;
if (!id) {
logger.warn('Page id not specified');
throw new Error('Page id not specified');
}
const pageName = this.#extractAttribute(actionTag, 'pageName') as string;
if (!pageName) {
logger.warn('Page Name not specified');
}
const action = this.#extractAttribute(actionTag, 'action') as UPageAction['action'];
if (!action) {
logger.warn('Action not specified');
}
if (!['add', 'remove', 'update'].includes(action)) {
logger.warn(`Invalid action '${action}'`);
throw new Error(`Invalid action: ${action}`);
}
const domId = this.#extractAttribute(actionTag, 'domId') as string;
if (!domId) {
logger.warn('domId not specified');
}
const rootDomId = this.#extractAttribute(actionTag, 'rootDomId') as string;
if (!rootDomId) {
logger.warn('rootDomId not specified');
} else {
actionAttributes.validRootDomId = true;
}
const sort = this.#extractAttribute(actionTag, 'sort');
actionAttributes.id = id;
actionAttributes.pageName = pageName;
actionAttributes.action = action;
actionAttributes.domId = domId;
actionAttributes.rootDomId = rootDomId;
actionAttributes.sort = sort ? parseInt(sort) : undefined;
return actionAttributes;
}
#extractAttribute(tag: string, attributeName: string): string | undefined {
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
return match ? match[1] : undefined;
}
}
const createArtifactElement: ElementFactory = (props) => {
const elementProps = [
'class="__uPageArtifact__"',
...Object.entries(props).map(([key, value]) => {
return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`;
}),
];
return `<div ${elementProps.join(' ')}></div>`;
};
function camelToDashCase(input: string) {
return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}