@cxxi-lab/hub-sdk 0.0.0-stage → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/frame/index.ts +8 -0
- package/frame/module.tsx +56 -0
- package/frame/runtime.css +16 -0
- package/frame/sdk.ts +113 -0
- package/package.json +36 -5
- package/protocol.ts +40 -0
- package/shared.json +10 -0
- package/types/index.d.ts +1089 -0
- package/version.json +3 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Louis-Antoine Lumet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/frame/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @cxxi-lab/hub-sdk/frame inside a hubby's frame (docs/spec-module.md § 6.3): what a hubby imports. Everything
|
|
2
|
+
// goes to the host by message; the frame has no token, no cookie, no access to the app.
|
|
3
|
+
// the base stylesheet, extracted into runtime.css at build (the frame page links it)
|
|
4
|
+
import './runtime.css'
|
|
5
|
+
|
|
6
|
+
export { call, collection, nav, notifications, settings, useInit, usePath, type Doc } from './sdk'
|
|
7
|
+
export { defineModule, start, type HubbyDefinition, type HubbyPageDefinition } from './module'
|
|
8
|
+
export { useBackHandler, useMediaQuery } from '@cxxi-lab/hub-ui'
|
package/frame/module.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createElement, StrictMode, type ComponentType } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import type { HubbyPage } from '../protocol'
|
|
4
|
+
import { connect, register, usePath } from './sdk'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A hubby's front (docs/spec-module.md § 6): its pages, each a component; those with a `nav`
|
|
8
|
+
* section make its navigation (built by the host: the column on desktop, the bottom bar on phones).
|
|
9
|
+
*/
|
|
10
|
+
export interface HubbyPageDefinition {
|
|
11
|
+
/** relative to the hubby: '/', '/settings', '/:id' */
|
|
12
|
+
path: string
|
|
13
|
+
component: ComponentType<{ params: Record<string, string> }>
|
|
14
|
+
title?: string
|
|
15
|
+
icon?: unknown
|
|
16
|
+
nav?: { section?: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HubbyDefinition {
|
|
20
|
+
pages: HubbyPageDefinition[]
|
|
21
|
+
/** the sections' icons in the navigation, by title */
|
|
22
|
+
navSections?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defineModule(definition: HubbyDefinition): HubbyDefinition {
|
|
26
|
+
return definition
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The page matching a path, with its parameters (`:name` segments). */
|
|
30
|
+
export function match(pages: HubbyPageDefinition[], path: string): { page: HubbyPageDefinition, params: Record<string, string> } | null {
|
|
31
|
+
const parts = path.split('/').filter(Boolean)
|
|
32
|
+
for (const page of pages) {
|
|
33
|
+
const pattern = page.path.split('/').filter(Boolean)
|
|
34
|
+
if (pattern.length !== parts.length) continue
|
|
35
|
+
const params: Record<string, string> = {}
|
|
36
|
+
if (pattern.every((p, i) => p.startsWith(':') ? (params[p.slice(1)] = decodeURIComponent(parts[i]), true) : p === parts[i])) {
|
|
37
|
+
return { page, params }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Router({ definition }: { definition: HubbyDefinition }) {
|
|
44
|
+
const path = usePath()
|
|
45
|
+
const found = match(definition.pages, path)
|
|
46
|
+
if (!found) return createElement('p', { className: 'p-6 text-sm text-steel-400' }, `Nothing at ${path}`)
|
|
47
|
+
return createElement(found.page.component, { params: found.params })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs a hubby in its frame: announces its pages to the host, renders the one it is on. */
|
|
51
|
+
export function start(definition: HubbyDefinition): void {
|
|
52
|
+
const pages: HubbyPage[] = definition.pages.map(p => ({ path: p.path, title: p.title, icon: p.icon, section: p.nav ? (p.nav.section ?? '') : undefined }))
|
|
53
|
+
createRoot(document.getElementById('root')!).render(createElement(StrictMode, null, createElement(Router, { definition })))
|
|
54
|
+
connect()
|
|
55
|
+
register(pages, definition.navSections ?? {})
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* A hubby frame's base styles: the design tokens, the base, the design system's utilities (the
|
|
2
|
+
hubby's own utilities come with it, in its style.css). */
|
|
3
|
+
@layer theme, base, components, utilities;
|
|
4
|
+
@import "@cxxi-lab/hub-ui/theme.css";
|
|
5
|
+
@import "tailwindcss/preflight.css" layer(base);
|
|
6
|
+
@import "tailwindcss/utilities.css" layer(utilities) source(none);
|
|
7
|
+
@source "../../../node_modules/@cxxi-lab/hub-ui/src";
|
|
8
|
+
@source "./";
|
|
9
|
+
@import "@cxxi-lab/hub-ui/fonts.css";
|
|
10
|
+
|
|
11
|
+
html, body {
|
|
12
|
+
background: var(--color-steel-900);
|
|
13
|
+
color: var(--color-white);
|
|
14
|
+
font-family: var(--font-display), sans-serif;
|
|
15
|
+
font-size: 14px;
|
|
16
|
+
}
|
package/frame/sdk.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { onBackDepth, handleBack } from '@cxxi-lab/hub-ui'
|
|
3
|
+
import { PROTOCOL, type Envelope, type GuestMessage, type HostMessage, type HubbyPage } from '../protocol'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The hubby SDK inside its frame (prototype): every call goes to the host by message and comes
|
|
7
|
+
* back as a promise. The frame has no token, no cookie, no access to the app.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let nextId = 1
|
|
11
|
+
const pending = new Map<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
|
|
12
|
+
const initListeners = new Set<(init: Extract<HostMessage, { type: 'init' }>) => void>()
|
|
13
|
+
const pathListeners = new Set<(path: string) => void>()
|
|
14
|
+
let init: Extract<HostMessage, { type: 'init' }> | null = null
|
|
15
|
+
|
|
16
|
+
function post(message: GuestMessage) {
|
|
17
|
+
const envelope: Envelope<GuestMessage> = { protocol: PROTOCOL, message }
|
|
18
|
+
// the host's origin can't be checked from an opaque origin: '*', the host checks the source
|
|
19
|
+
window.parent.postMessage(envelope, '*')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
window.addEventListener('message', event => {
|
|
23
|
+
if (event.source !== window.parent) return
|
|
24
|
+
const data = event.data as Envelope<HostMessage> | undefined
|
|
25
|
+
if (data?.protocol !== PROTOCOL) return
|
|
26
|
+
const m = data.message
|
|
27
|
+
if (m.type === 'result') {
|
|
28
|
+
const p = pending.get(m.id)
|
|
29
|
+
pending.delete(m.id)
|
|
30
|
+
if (!p) return
|
|
31
|
+
if (m.ok) p.resolve(m.value)
|
|
32
|
+
else p.reject(new Error(m.error))
|
|
33
|
+
}
|
|
34
|
+
else if (m.type === 'init') {
|
|
35
|
+
init = m
|
|
36
|
+
initListeners.forEach(l => l(m))
|
|
37
|
+
}
|
|
38
|
+
else if (m.type === 'back') handleBack()
|
|
39
|
+
else if (m.type === 'path') pathListeners.forEach(l => l(m.path))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** A call to the host (the platform API, through it). */
|
|
43
|
+
export function call<T>(method: string, ...args: unknown[]): Promise<T> {
|
|
44
|
+
const id = nextId++
|
|
45
|
+
return new Promise<T>((resolve, reject) => {
|
|
46
|
+
pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
47
|
+
post({ type: 'call', id, method, args })
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useInit() {
|
|
52
|
+
const [value, setValue] = useState(init)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
initListeners.add(setValue)
|
|
55
|
+
setValue(init) // it may have come before this component listened
|
|
56
|
+
return () => { initListeners.delete(setValue) }
|
|
57
|
+
}, [])
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function usePath(): string {
|
|
62
|
+
const [path, setPath] = useState(init?.path ?? '/')
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
pathListeners.add(setPath)
|
|
65
|
+
const onInit = (m: { path: string }) => setPath(m.path)
|
|
66
|
+
initListeners.add(onInit)
|
|
67
|
+
if (init) setPath(init.path)
|
|
68
|
+
return () => { pathListeners.delete(setPath); initListeners.delete(onInit) }
|
|
69
|
+
}, [])
|
|
70
|
+
return path
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const nav = {
|
|
74
|
+
navigate(path: string) {
|
|
75
|
+
post({ type: 'navigate', path })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Doc { id: string, [field: string]: unknown }
|
|
80
|
+
|
|
81
|
+
/** A collection of the module (the generic API, through the host). */
|
|
82
|
+
export function collection(name: string) {
|
|
83
|
+
return {
|
|
84
|
+
find: () => call<Doc[]>('collection.find', name),
|
|
85
|
+
create: (doc: Record<string, unknown>) => call<Doc>('collection.create', name, doc),
|
|
86
|
+
remove: (id: string) => call<void>('collection.remove', name, id)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const settings = {
|
|
91
|
+
/** a real API call made by the host (measures a network round trip) */
|
|
92
|
+
account: () => call<Record<string, unknown>>('settings.account')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const notifications = {
|
|
96
|
+
upsert: (n: { key: string, title: string, body?: string }) => call<void>('notifications.upsert', n)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Announces the hubby's pages to its host (it builds the navigation). */
|
|
100
|
+
export function register(pages: HubbyPage[], sections: Record<string, unknown>) {
|
|
101
|
+
post({ type: 'register', pages, sections })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Wires the frame to its host: ready, back depth, keys the app listens to (Ctrl+Alt…). */
|
|
105
|
+
export function connect() {
|
|
106
|
+
onBackDepth(depth => post({ type: 'back-depth', depth }))
|
|
107
|
+
window.addEventListener('keydown', e => {
|
|
108
|
+
if (e.ctrlKey || e.altKey || e.metaKey) {
|
|
109
|
+
post({ type: 'key', key: e.key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey })
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
post({ type: 'ready', at: performance.now() })
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@cxxi-lab/hub-sdk",
|
|
3
|
+
"version": "1.12.0",
|
|
4
|
+
"description": "The contract of hub's modules: the SDK a module builds on (provided by the host app at run time), and the SDK of the modules running isolated in their frame.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types/index.d.ts"
|
|
10
|
+
},
|
|
11
|
+
"./frame": "./frame/index.ts",
|
|
12
|
+
"./protocol": "./protocol.ts",
|
|
13
|
+
"./version.json": "./version.json",
|
|
14
|
+
"./shared.json": "./shared.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"types",
|
|
18
|
+
"frame",
|
|
19
|
+
"protocol.ts",
|
|
20
|
+
"version.json",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"shared.json"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/cxxi-lab/hub-apps.git",
|
|
27
|
+
"directory": "packages/hub-sdk"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@cxxi-lab/hub-ui": "^0.1.0",
|
|
34
|
+
"react": "^19.2.4",
|
|
35
|
+
"react-dom": "^19.2.4"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/protocol.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The messages between the app (host) and a hubby's frame (guest), both ways over postMessage.
|
|
3
|
+
* (docs/plan-module-architecture.md § 4, 4 ter): the guest runs in a sandboxed iframe with an
|
|
4
|
+
* opaque origin; it never gets a token, the host makes the API calls for it, within its scopes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A page of the hubby, as it announces it (its navigation is built by the host). */
|
|
8
|
+
export interface HubbyPage {
|
|
9
|
+
/** relative to the hubby ('/', '/:id'…) */
|
|
10
|
+
path: string
|
|
11
|
+
title?: string
|
|
12
|
+
/** a FontAwesome icon definition (plain data) */
|
|
13
|
+
icon?: unknown
|
|
14
|
+
/** in the navigation, under this section (no title: not in the navigation) */
|
|
15
|
+
section?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Guest → host. */
|
|
19
|
+
export type GuestMessage =
|
|
20
|
+
| { type: 'ready', at: number }
|
|
21
|
+
| { type: 'register', pages: HubbyPage[], sections: Record<string, unknown> }
|
|
22
|
+
| { type: 'call', id: number, method: string, args: unknown[] }
|
|
23
|
+
| { type: 'back-depth', depth: number }
|
|
24
|
+
| { type: 'key', key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean }
|
|
25
|
+
| { type: 'navigate', path: string }
|
|
26
|
+
|
|
27
|
+
/** Host → guest. */
|
|
28
|
+
export type HostMessage =
|
|
29
|
+
| { type: 'init', module: string, path: string, phone: boolean, scopes: string[] }
|
|
30
|
+
| { type: 'result', id: number, ok: true, value: unknown }
|
|
31
|
+
| { type: 'result', id: number, ok: false, error: string }
|
|
32
|
+
| { type: 'back' }
|
|
33
|
+
| { type: 'path', path: string }
|
|
34
|
+
|
|
35
|
+
export const PROTOCOL = 'hubby/1'
|
|
36
|
+
|
|
37
|
+
export interface Envelope<M> {
|
|
38
|
+
protocol: typeof PROTOCOL
|
|
39
|
+
message: M
|
|
40
|
+
}
|
package/shared.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"react": "react",
|
|
3
|
+
"react/jsx-runtime": "react-jsx-runtime",
|
|
4
|
+
"react-dom": "react-dom",
|
|
5
|
+
"react-router": "react-router",
|
|
6
|
+
"@fortawesome/react-fontawesome": "fontawesome-react",
|
|
7
|
+
"@fortawesome/fontawesome-svg-core": "fontawesome-core",
|
|
8
|
+
"@cxxi-lab/hub-sdk": "sdk",
|
|
9
|
+
"@cxxi-lab/hub-ui": "ui"
|
|
10
|
+
}
|