@omnicajs/vue-remote
Version:
Proxy renderer for Vue.js based on @remote-ui
268 lines (224 loc) • 7.7 kB
text/typescript
import type {
SchemaType,
PropertiesOf,
UnknownType, ChildrenOf,
} from '@/dom/remote/schema'
import type {
RemoteComponentData,
TreeContext,
} from '@/dom/remote/context'
import type { FunctionProxyUpdate } from '@/dom/remote/proxy'
import type {
RemoteComment,
RemoteComponent,
RemoteComponentDescriptor,
RemoteRoot,
RemoteText,
SupportedBy,
} from '@/dom/remote/tree'
import type {
None,
Unknown,
UnknownMethods,
} from '~types/scaffolding'
import {
prepareProxies,
proxyFunctionsIn,
updateProxies,
} from '@/dom/remote/proxy'
import {
isRemoteFragment,
normalizeChild,
normalizeChildren,
} from '@/dom/remote/tree'
import {
capture,
keysOf,
} from '@/common/scaffolding'
import { ACTION_UPDATE_PROPERTIES } from '@/dom/common/channel'
import { KIND_COMPONENT } from '@/dom/common/tree'
// eslint-disable-next-line max-lines-per-function
export function createRemoteComponent <R extends RemoteRoot, T extends SupportedBy<R>>(
type: T | RemoteComponentDescriptor<T>,
properties: PropertiesOf<T> | null | undefined,
children: Array<
| RemoteComment<R>
| RemoteComponent<ChildrenOf<T>, R>
| RemoteText<R>
| string
>,
root: R,
context: TreeContext
) {
const id = context.nextId()
const descriptor = typeof type === 'object' && 'type' in type ? type : null
const data = createRemoteComponentData(properties, children, root, context)
const node = {
kind: KIND_COMPONENT,
get id () { return id },
get type () { return descriptor ? descriptor.type : type as string },
get root () { return root },
get children () { return data.children },
get properties () { return data.properties.original },
append: (...children) => context.append(
node, normalizeChildren(children, root)
),
insertBefore: (child, before) => context.insert(
node, normalizeChild(child, root), before
),
updateProperties: (properties) => updateProperties(
context,
node,
properties
),
replace: (...children) => context.replace(
node, normalizeChildren(children, root)
),
removeChild: (child) => context.removeChild(node, child),
remove: () => node.parent ? context.removeChild(node.parent, node) : null,
invoke: (method, ...payload) => !descriptor || descriptor?.hasMethod(method)
? context.invoke(node, method, payload)
: Promise.reject(`Method ${method} is not supported`),
serialize: () => ({
id,
kind: KIND_COMPONENT,
type: type as string,
properties: data.properties.serializable,
children: data.children.map(c => c.serialize()),
}),
print: () => _print(id, type, data.properties.original as PropertiesOf<T>, data.children),
} as RemoteComponent<T, R>
context.collect(node)
context.components.set(node, data)
data.children.forEach(c => context.attach(node, c))
return node
}
export function defineRemoteComponent<
Type extends string,
Properties extends Unknown = None,
Methods extends UnknownMethods = None,
Children extends UnknownType | false = false
>(
type: Type,
properties: Array<keyof Properties> = [],
methods: Array<keyof Methods> = [],
children: Supported<Children> = false as Supported<Children>
): RemoteComponentDescriptor<SchemaType<Type, Properties, Methods, Children extends false ? never : Children>> {
return {
type,
hasProperty (name): name is keyof keyof Properties {
return properties.includes(name as keyof keyof Properties)
},
hasMethod (name): name is keyof Methods {
return methods.includes(name as keyof Methods)
},
supports: type => typeof children === 'boolean'
? children
: children.length === 0 || (children as SchemaType<string>[]).includes(type as SchemaType<string>),
}
}
type Supported<
Children extends UnknownType | boolean
> = Children extends boolean ? boolean : Array<Children>
// "children" as a prop can be extremely confusing with the "children" of
// a component. In React, a "child" can be anything, but once it reaches
// a host environment (like this remote `Root`), we want "children" to have
// only one meaning: the actual, resolved children components and text.
//
// To enforce this, we delete any prop named "children". We don’t take a copy
// of the properties for performance, so a user calling this function must do so
// with an object that can handle being mutated.
const RESERVED = ['children']
const notReserved = (name: string) => !RESERVED.includes(name)
function createRemoteComponentData <S extends SupportedBy<RemoteRoot>, T extends S>(
properties: PropertiesOf<T> | null | undefined,
children: Array<
| RemoteComment<RemoteRoot<S>>
| RemoteComponent<ChildrenOf<T>, RemoteRoot<S>>
| RemoteText<RemoteRoot<S>>
| string
>,
root: RemoteRoot<S>,
context: TreeContext
): RemoteComponentData {
const original: Unknown = properties ?? {}
const serializable: Unknown = {}
for (const key of keysOf(original).filter(notReserved)) {
serializable[key] = proxyFunctionsIn(serializeProperty(original[key]))
}
return {
properties: {
original: capture(original, context.strict),
serializable,
},
children: capture(normalizeChildren(children, root), context.strict),
}
}
// eslint-disable-next-line max-lines-per-function
function updateProperties<R extends RemoteRoot>(
context: TreeContext<R>,
component: RemoteComponent<SupportedBy<R>, R>,
properties: Unknown
) {
const componentData = context.components.get(component)!
const normalized: Unknown = {}
const records: FunctionProxyUpdate[] = []
let changed = false
for (const key of keysOf(properties).filter(notReserved)) {
const oldOriginal = componentData.properties.original[key]
const newOriginal = properties[key]
const oldSerializable = componentData.properties.serializable[key]
const newSerializable = serializeProperty(newOriginal)
if (oldSerializable === newSerializable && (newSerializable == null || typeof newSerializable !== 'object')) {
continue
}
const [value, record, skip] = prepareProxies(oldSerializable, newSerializable)
records.push(...record)
if (!skip) {
normalized[key] = value
changed = true
if (isRemoteFragment(oldOriginal)) {
context.detach(oldOriginal)
}
if (isRemoteFragment(newOriginal)) {
context.attach(component, newOriginal)
}
}
}
return context.update(component, (channel) => {
if (changed) {
channel(ACTION_UPDATE_PROPERTIES, component.id, normalized)
}
}, () => {
componentData.properties.original = capture({ ...componentData.properties.original, ...properties }, context.strict)
componentData.properties.serializable = { ...componentData.properties.serializable, ...normalized }
updateProxies(records)
})
}
function serializeProperty (property: unknown) {
return isRemoteFragment(property)
? property.serialize()
: property
}
function _print <R extends RemoteRoot, T extends SupportedBy<R>>(
id: string,
type: T | RemoteComponentDescriptor<T>,
_properties: PropertiesOf<T> | null | undefined,
children: ReadonlyArray<
| RemoteComment<R>
| RemoteComponent<ChildrenOf<T>, R>
| RemoteText<R>
| string
>
) {
const _head = `${typeof type === 'string' ? type : type.type}:${id}`
const _children = children.map(c => typeof c === 'string' ? c : c.print())
const _body = _children.length > 0 ? `\n${_indent(_children.join(',\n'))}\n` : ''
return `${_head}[${_body}]`
}
function _indent (text: string) {
return text
.split('\n')
.map(line => ` ${line}`)
.join('\n')
}