Loading files
7be0145b4This file is pinned to the selected release. Download the original to open it in your design tool.
import type { ScreenContext, ScreenDefinition, ScreenInstance } from 'stackchan/extensions/piu'
export const MINI_APP_BAR_HEIGHT = 44
// Internal viewport contract. AppSession supplies the owning SDK context to each factory.
export type MiniAppContext = Omit<ScreenContext, 'app'>
export type MiniAppInstance = ScreenInstance
export type MiniAppDefinition = Omit<ScreenDefinition, 'create'> & {
create(context: MiniAppContext): ReturnType<ScreenDefinition['create']>
}
export type RegisteredMiniApp = Readonly<Pick<MiniAppDefinition, 'id' | 'title' | 'icon'>>
export type MiniAppRegistryCapability = Readonly<{
register(definition: MiniAppDefinition): () => void
}>
type RegistryListener = () => void
const MINI_APP_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/
const MAX_MINI_APP_ID_LENGTH = 64
const MAX_MINI_APP_TITLE_LENGTH = 32
function validateDefinition(definition: MiniAppDefinition): MiniAppDefinition {
if (!definition || typeof definition !== 'object') throw new TypeError('mini app definition must be an object')
if (
typeof definition.id !== 'string' ||
definition.id.length === 0 ||
definition.id.length > MAX_MINI_APP_ID_LENGTH ||
!MINI_APP_ID_PATTERN.test(definition.id)
) {
throw new TypeError('mini app id must be 1-64 lowercase ASCII characters separated by ., _, or -')
}
const title = typeof definition.title === 'string' ? definition.title.trim() : ''
if (title.length === 0 || title.length > MAX_MINI_APP_TITLE_LENGTH) {
throw new TypeError('mini app title must be 1-32 characters')
}
if (typeof definition.create !== 'function') throw new TypeError('mini app create must be a function')
return Object.freeze({
id: definition.id,
title,
...(definition.icon ? { icon: definition.icon } : {}),
create: definition.create,
})
}
export class MiniAppRegistry implements MiniAppRegistryCapability {
#definitions = new Map<string, MiniAppDefinition>()
#listeners = new Set<RegistryListener>()
#closed = false
register(definition: MiniAppDefinition): () => void {
if (this.#closed) throw new Error('Mini app registry is closed')
const validated = validateDefinition(definition)
if (this.#definitions.has(validated.id)) throw new Error(`mini app id is already registered: ${validated.id}`)
this.#definitions.set(validated.id, validated)
try {
this.#notify()
} catch (error) {
this.#definitions.delete(validated.id)
try {
this.#notify()
} catch {
// Rollback must preserve the original publication failure.
}
throw error
}
let registered = true
return () => {
if (!registered) return
registered = false
if (this.#definitions.get(validated.id) !== validated) return
this.#definitions.delete(validated.id)
this.#notify()
}
}
get(id: string): MiniAppDefinition | undefined {
return this.#definitions.get(id)
}
list(): RegisteredMiniApp[] {
return [...this.#definitions.values()]
.map(({ id, title, icon }) => Object.freeze({ id, title, ...(icon ? { icon } : {}) }))
.sort((left, right) => left.title.localeCompare(right.title))
}
subscribe(listener: RegistryListener): () => void {
if (this.#closed) throw new Error('Mini app registry is closed')
this.#listeners.add(listener)
return () => this.#listeners.delete(listener)
}
#notify(): void {
for (const listener of this.#listeners) {
if (this.#closed) break
listener()
}
}
close(): void {
this.#closed = true
this.#listeners.clear()
this.#definitions.clear()
}
}